Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
acc1278b34
|
|||
|
bc678d26ad
|
|||
|
bb2a80a5e3
|
|||
|
b107358266
|
|||
|
02a53c8219
|
|||
|
15a02cac1c
|
|||
|
35a1748055
|
|||
|
28682bba41
|
|||
|
25484addfb
|
|||
|
728c7d2aa3
|
|||
|
b24949c117
|
|||
|
28e5878668
|
|||
|
b1e73533f6
|
|||
|
3a5b9459a9
|
|||
|
9c9729e268
|
|||
|
399cfbf108
|
|||
|
19f4d01015
|
|||
|
8c40322ff0
|
|||
|
782e2e7674
|
|||
|
89a9731797
|
|||
|
50badc7d54
|
|||
|
d4f86bb738
|
|||
|
b35f44f35b
|
|||
|
1a58ce8b54
|
|||
|
2dd84ec50a
|
|||
|
33651ca107
|
|||
|
ae307e3118
|
|||
|
3ab75bff92
|
|||
|
1753d648bd
|
|||
|
423b0bf1e1
|
|||
|
d713047970
|
|||
|
d59a6fad82
|
|||
|
8e47101401
|
|||
|
9431a90cd0
|
|||
|
3a10f36c39
|
19
.idea/deploymentTargetSelector.xml
generated
19
.idea/deploymentTargetSelector.xml
generated
@@ -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>
|
||||||
|
|||||||
@@ -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,12 +64,18 @@ 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")
|
||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
|
|
||||||
|
// ZXing core for QR code generation
|
||||||
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
|
|
||||||
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
|
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
|
||||||
implementation("androidx.camera:camera-core:1.4.2")
|
implementation("androidx.camera:camera-core:1.4.2")
|
||||||
implementation("androidx.camera:camera-camera2:1.4.2")
|
implementation("androidx.camera:camera-camera2:1.4.2")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.app.Application
|
|||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import sh.sar.basedbank.api.bml.BmlSession
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
@@ -17,33 +19,93 @@ 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
|
|
||||||
var mibProfiles: List<MibProfile> = emptyList()
|
/** Active MIB sessions keyed by loginId (= MIB username). */
|
||||||
/** Active BML sessions keyed by loginId (= BML username). */
|
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 profileId (a globally unique GUID per BML profile).
|
||||||
|
* Use [bmlSessionFor] to look up the session for an account.
|
||||||
|
*/
|
||||||
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
|
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
|
||||||
|
/** BML profiles per loginId (= BML username). */
|
||||||
|
val bmlProfilesMap: MutableMap<String, List<BmlProfile>> = mutableMapOf()
|
||||||
|
/** BML login flows per loginId — hold the web session (cookies) needed for profile activation. */
|
||||||
|
val bmlLoginFlows: MutableMap<String, BmlLoginFlow> = 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 BML session for the given account (matched via loginTag). */
|
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
||||||
fun bmlSessionFor(account: MibAccount): BmlSession? =
|
|
||||||
bmlSessions[account.loginTag.removePrefix("bml_")]
|
/** 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()
|
||||||
|
|
||||||
|
// ─── BML helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the BML session for the given account.
|
||||||
|
* Looks up by profileId first (multi-profile), falls back to loginId (legacy single-profile).
|
||||||
|
*/
|
||||||
|
fun bmlSessionFor(account: MibAccount): BmlSession? {
|
||||||
|
val byProfile = if (account.profileId.isNotBlank()) bmlSessions[account.profileId] else null
|
||||||
|
return byProfile ?: bmlSessions[account.loginTag.removePrefix("bml_")]
|
||||||
|
}
|
||||||
|
|
||||||
/** 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 any active BML session for the given loginId.
|
||||||
|
* Tries all profiles for that login; falls back to legacy loginId key.
|
||||||
|
*/
|
||||||
|
fun anyBmlSessionFor(loginId: String): BmlSession? {
|
||||||
|
val profiles = bmlProfilesMap[loginId]
|
||||||
|
if (!profiles.isNullOrEmpty()) {
|
||||||
|
return profiles.firstNotNullOfOrNull { bmlSessions[it.profileId] }
|
||||||
|
}
|
||||||
|
return bmlSessions[loginId]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the BmlLoginFlow for a given loginId, creating and caching it if needed. */
|
||||||
|
fun bmlFlowFor(loginId: String): BmlLoginFlow =
|
||||||
|
bmlLoginFlows.getOrPut(loginId) { BmlLoginFlow() }
|
||||||
|
|
||||||
|
// ─── Fahipay helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
|||||||
@@ -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) }
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ class BmlLoginFlow {
|
|||||||
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||||
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
|
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
|
||||||
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
||||||
private val APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)"
|
private val APP_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
|
||||||
private val APP_VERSION = "2.1.43.345"
|
private val APP_VERSION = "2.1.44.348"
|
||||||
private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
private val WEB_USER_AGENT = "Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
||||||
|
|
||||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||||
private val cookieJar = object : CookieJar {
|
private val cookieJar = object : CookieJar {
|
||||||
@@ -58,9 +58,27 @@ class BmlLoginFlow {
|
|||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/** Full login: returns a BmlSession and the account list. */
|
/** PKCE params — generated once per login and reused across all profile activations. */
|
||||||
fun login(username: String, password: String, otpSeed: String): Pair<BmlSession, List<MibAccount>> {
|
private var codeVerifier: String = ""
|
||||||
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies
|
private var codeChallenge: String = ""
|
||||||
|
private var deviceId: String = ""
|
||||||
|
|
||||||
|
/** Profiles returned by the last successful [login] call. */
|
||||||
|
var lastProfiles: List<BmlProfile> = emptyList()
|
||||||
|
private set
|
||||||
|
|
||||||
|
// ─── Login ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs web authentication (login + TOTP) and returns the list of available profiles.
|
||||||
|
* Call [activateProfile] for each profile to obtain an access token + accounts.
|
||||||
|
*/
|
||||||
|
fun login(username: String, password: String, otpSeed: String): List<BmlProfile> {
|
||||||
|
codeVerifier = generateCodeVerifier()
|
||||||
|
codeChallenge = generateCodeChallenge(codeVerifier)
|
||||||
|
deviceId = generateDeviceId()
|
||||||
|
|
||||||
|
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session
|
||||||
client.newCall(
|
client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/login")
|
Request.Builder().url("$BASE_URL/web/login")
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
@@ -82,15 +100,14 @@ class BmlLoginFlow {
|
|||||||
loginResp.close()
|
loginResp.close()
|
||||||
if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
|
if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
|
||||||
|
|
||||||
// Step 3: GET 2FA page (refreshes blaze_session)
|
// Step 3: GET 2FA page (refreshes session cookies)
|
||||||
client.newCall(
|
client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/login/2fa")
|
Request.Builder().url("$BASE_URL/web/login/2fa")
|
||||||
.header("X-XSRF-TOKEN", xsrf)
|
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute().close()
|
).execute().close()
|
||||||
val xsrf2 = xsrfToken() ?: xsrf
|
val xsrf2 = xsrfToken() ?: xsrf
|
||||||
|
|
||||||
// Step 4: POST OTP
|
// Step 4: POST TOTP
|
||||||
val otp = Totp.generate(otpSeed)
|
val otp = Totp.generate(otpSeed)
|
||||||
val twoFaBody = JSONObject().apply {
|
val twoFaBody = JSONObject().apply {
|
||||||
put("code", otp)
|
put("code", otp)
|
||||||
@@ -104,18 +121,161 @@ class BmlLoginFlow {
|
|||||||
twoFaResp.close()
|
twoFaResp.close()
|
||||||
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
|
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
|
||||||
|
|
||||||
// Step 5: GET /web/profile (sets blaze_identity cookie for profile selection)
|
// Step 5: GET /web/profile — multi-profile accounts return a 200 with a profile picker;
|
||||||
client.newCall(
|
// single-profile accounts skip the picker and redirect straight to /web/redirect with
|
||||||
|
// blaze_identity already set in the response cookies.
|
||||||
|
val profileResp = client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/profile")
|
Request.Builder().url("$BASE_URL/web/profile")
|
||||||
.header("X-XSRF-TOKEN", xsrf2)
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val profileCode = profileResp.code
|
||||||
|
val profileLocation = profileResp.header("Location") ?: ""
|
||||||
|
val profileBody = profileResp.body?.string() ?: ""
|
||||||
|
profileResp.close()
|
||||||
|
|
||||||
|
lastProfiles = if (profileCode == 302) {
|
||||||
|
// Any 302 from GET /web/profile means the server auto-activated the sole profile
|
||||||
|
// and blaze_identity is already set — no profile picker shown.
|
||||||
|
// Use username as a stable temporary profileId (unique per login); it will be
|
||||||
|
// replaced by the real BML customer ID after fetchUserInfo in finishBmlLogin().
|
||||||
|
listOf(BmlProfile(profileId = username, name = "Personal", type = "Profile", profileType = "default", autoActivated = true))
|
||||||
|
} else {
|
||||||
|
parseProfiles(profileBody)
|
||||||
|
}
|
||||||
|
return lastProfiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Profile activation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activates a profile in the current web session and returns the result.
|
||||||
|
*
|
||||||
|
* - Personal profiles (profile_type="default") succeed immediately and return [BmlActivationResult.Success].
|
||||||
|
* - Business profiles (profile_type="business") require SMS/email OTP; returns
|
||||||
|
* [BmlActivationResult.NeedsBusinessOtp] with available channels. Follow up with
|
||||||
|
* [requestBusinessOtp] + [submitBusinessOtp].
|
||||||
|
*/
|
||||||
|
fun activateProfile(profile: BmlProfile, loginTag: String): BmlActivationResult {
|
||||||
|
// Single-profile accounts: server already activated during login() and set blaze_identity.
|
||||||
|
// autoActivated=true is the sentinel for this case — skip the profile GET entirely.
|
||||||
|
if (profile.autoActivated) {
|
||||||
|
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||||
|
return BmlActivationResult.Success(session, accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
val xsrf = xsrfToken()
|
||||||
|
val reqBuilder = Request.Builder()
|
||||||
|
.url("$BASE_URL/web/profile/${profile.profileId}")
|
||||||
|
.header("User-Agent", WEB_USER_AGENT)
|
||||||
|
if (xsrf != null) reqBuilder.header("X-XSRF-TOKEN", xsrf)
|
||||||
|
|
||||||
|
val resp = client.newCall(reqBuilder.build()).execute()
|
||||||
|
val code = resp.code
|
||||||
|
val location = resp.header("Location") ?: ""
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
code == 409 || (code == 302 && "/web/profile/2fa/business" !in location) -> {
|
||||||
|
// Profile activated — blaze_identity cookie set in response headers.
|
||||||
|
// Any 302 that isn't to the business 2FA page means success.
|
||||||
|
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||||
|
BmlActivationResult.Success(session, accounts)
|
||||||
|
}
|
||||||
|
code == 302 && "/web/profile/2fa/business" in location -> {
|
||||||
|
// Business profile: server requires SMS/email OTP
|
||||||
|
val channels = fetchBusinessOtpChannels()
|
||||||
|
BmlActivationResult.NeedsBusinessOtp(channels)
|
||||||
|
}
|
||||||
|
else -> throw Exception("Profile activation failed (HTTP $code)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns available OTP channels for the business 2FA page.
|
||||||
|
* Also refreshes cookies so the subsequent POST has a valid XSRF token.
|
||||||
|
*/
|
||||||
|
private fun fetchBusinessOtpChannels(): List<BmlOtpChannel> {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
|
||||||
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val body = resp.body?.string() ?: ""
|
||||||
|
resp.close()
|
||||||
|
return parseBusinessOtpChannels(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an OTP to [channel] for business profile activation.
|
||||||
|
* Must be called before [submitBusinessOtp].
|
||||||
|
*/
|
||||||
|
fun requestBusinessOtp(channel: String) {
|
||||||
|
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("code", "")
|
||||||
|
put("channel", channel)
|
||||||
|
}.toString().toRequestBody("application/json".toMediaType())
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
|
||||||
|
.header("X-XSRF-TOKEN", xsrf)
|
||||||
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val respCode = resp.code
|
||||||
|
resp.close()
|
||||||
|
if (respCode != 302) throw Exception("Failed to request OTP (HTTP $respCode)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the OTP and activates the business profile.
|
||||||
|
* Returns a new [BmlSession] and accounts on success.
|
||||||
|
* @throws Exception if the OTP is invalid (retry is allowed).
|
||||||
|
*/
|
||||||
|
fun submitBusinessOtp(
|
||||||
|
channel: String,
|
||||||
|
code: String,
|
||||||
|
profile: BmlProfile,
|
||||||
|
loginTag: String
|
||||||
|
): Pair<BmlSession, List<MibAccount>> {
|
||||||
|
// Refresh XSRF token before submitting
|
||||||
|
client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute().close()
|
).execute().close()
|
||||||
|
|
||||||
// Step 6: PKCE OAuth authorize → extract auth code
|
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
|
||||||
val codeVerifier = generateCodeVerifier()
|
val body = JSONObject().apply {
|
||||||
val codeChallenge = generateCodeChallenge(codeVerifier)
|
put("code", code)
|
||||||
val deviceId = generateDeviceId()
|
put("channel", channel)
|
||||||
|
}.toString().toRequestBody("application/json".toMediaType())
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
|
||||||
|
.header("X-XSRF-TOKEN", xsrf)
|
||||||
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val respCode = resp.code
|
||||||
|
val location = resp.header("Location") ?: ""
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
respCode == 409 || (respCode == 302 && "/web/redirect" in location) ->
|
||||||
|
doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||||
|
respCode == 302 ->
|
||||||
|
throw Exception("Invalid OTP — please try again")
|
||||||
|
else ->
|
||||||
|
throw Exception("Business OTP verification failed (HTTP $respCode)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OAuth + account fetch ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes PKCE OAuth for the currently activated profile (blaze_identity cookie set).
|
||||||
|
* Returns a fresh [BmlSession] and the profile's accounts.
|
||||||
|
*/
|
||||||
|
private fun doOAuthAndFetchAccounts(
|
||||||
|
loginTag: String,
|
||||||
|
profileName: String,
|
||||||
|
profileId: String
|
||||||
|
): Pair<BmlSession, List<MibAccount>> {
|
||||||
val authorizeUrl = HttpUrl.Builder()
|
val authorizeUrl = HttpUrl.Builder()
|
||||||
.scheme("https").host("www.bankofmaldives.com.mv")
|
.scheme("https").host("www.bankofmaldives.com.mv")
|
||||||
.addPathSegments("internetbanking/oauth/authorize")
|
.addPathSegments("internetbanking/oauth/authorize")
|
||||||
@@ -135,14 +295,12 @@ class BmlLoginFlow {
|
|||||||
Request.Builder().url(authorizeUrl)
|
Request.Builder().url(authorizeUrl)
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute()
|
).execute()
|
||||||
|
val location = authorizeResp.header("Location")
|
||||||
authorizeResp.close()
|
authorizeResp.close()
|
||||||
|
|
||||||
val location = authorizeResp.header("Location")
|
val authCode = location?.let { Uri.parse(it).getQueryParameter("code") }
|
||||||
?: throw Exception("OAuth authorize did not redirect")
|
?: throw Exception("OAuth authorize did not return auth code")
|
||||||
val authCode = Uri.parse(location).getQueryParameter("code")
|
|
||||||
?: throw Exception("No auth code in OAuth redirect")
|
|
||||||
|
|
||||||
// Step 7: Exchange auth code for access token
|
|
||||||
val tokenBody = FormBody.Builder()
|
val tokenBody = FormBody.Builder()
|
||||||
.add("Device-ID", deviceId)
|
.add("Device-ID", deviceId)
|
||||||
.add("code", authCode)
|
.add("code", authCode)
|
||||||
@@ -161,22 +319,28 @@ class BmlLoginFlow {
|
|||||||
val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response")
|
val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response")
|
||||||
tokenResp.close()
|
tokenResp.close()
|
||||||
|
|
||||||
val tokenObj = JSONObject(tokenJson)
|
val accessToken = JSONObject(tokenJson).optString("access_token")
|
||||||
val accessToken = tokenObj.optString("access_token")
|
|
||||||
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
||||||
|
|
||||||
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
|
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
|
||||||
val accounts = fetchAccounts(session, "bml_$username")
|
val accounts = fetchAccounts(session, loginTag, profileName, profileId)
|
||||||
return Pair(session, accounts)
|
return Pair(session, accounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchAccounts(session: BmlSession, loginTag: String): List<MibAccount> {
|
// ─── API methods ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun fetchAccounts(
|
||||||
|
session: BmlSession,
|
||||||
|
loginTag: String,
|
||||||
|
profileName: String = "Personal",
|
||||||
|
profileId: String = ""
|
||||||
|
): List<MibAccount> {
|
||||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
|
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
|
||||||
val code = resp.code
|
val code = resp.code
|
||||||
val json = resp.body?.string()
|
val json = resp.body?.string()
|
||||||
resp.close()
|
resp.close()
|
||||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
return parseDashboard(json ?: return emptyList(), loginTag)
|
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
||||||
@@ -331,9 +495,6 @@ class BmlLoginFlow {
|
|||||||
return parseContacts(json, loginId)
|
return parseContacts(json, loginId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready).
|
|
||||||
*/
|
|
||||||
fun initiateTransfer(
|
fun initiateTransfer(
|
||||||
session: BmlSession,
|
session: BmlSession,
|
||||||
debitAccount: String,
|
debitAccount: String,
|
||||||
@@ -370,9 +531,6 @@ class BmlLoginFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 2 of BML transfer: POST with OTP + remarks. Returns BmlTransferResult.
|
|
||||||
*/
|
|
||||||
fun confirmTransfer(
|
fun confirmTransfer(
|
||||||
session: BmlSession,
|
session: BmlSession,
|
||||||
debitAccount: String,
|
debitAccount: String,
|
||||||
@@ -440,32 +598,6 @@ class BmlLoginFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
|
||||||
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
|
||||||
return try {
|
|
||||||
val parts = narrative1.split(" ")
|
|
||||||
if (parts.size < 2) null
|
|
||||||
else {
|
|
||||||
val timePart = parts[1].take(4)
|
|
||||||
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
|
|
||||||
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
|
|
||||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
|
|
||||||
private fun parseTransferNarrative1(narrative1: String): String? {
|
|
||||||
return try {
|
|
||||||
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
|
|
||||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches paginated transaction history for a BML CASA account.
|
|
||||||
* @return Pair of (transactions, totalPages)
|
|
||||||
*/
|
|
||||||
fun fetchAccountHistory(
|
fun fetchAccountHistory(
|
||||||
session: BmlSession,
|
session: BmlSession,
|
||||||
accountId: String,
|
accountId: String,
|
||||||
@@ -516,10 +648,6 @@ class BmlLoginFlow {
|
|||||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches card statement for a BML prepaid card for the given month ("YYYYMM").
|
|
||||||
* Returns combined outstanding authorizations + settled statement entries.
|
|
||||||
*/
|
|
||||||
fun fetchCardHistory(
|
fun fetchCardHistory(
|
||||||
session: BmlSession,
|
session: BmlSession,
|
||||||
cardId: String,
|
cardId: String,
|
||||||
@@ -546,7 +674,6 @@ class BmlLoginFlow {
|
|||||||
val payload = root.optJSONObject("payload") ?: return emptyList()
|
val payload = root.optJSONObject("payload") ?: return emptyList()
|
||||||
val result = mutableListOf<Transaction>()
|
val result = mutableListOf<Transaction>()
|
||||||
|
|
||||||
// Outstanding authorizations
|
|
||||||
val authDetails = payload.optJSONObject("outstanding")
|
val authDetails = payload.optJSONObject("outstanding")
|
||||||
?.optJSONArray("CardOutStdAuthDetails")
|
?.optJSONArray("CardOutStdAuthDetails")
|
||||||
if (authDetails != null) {
|
if (authDetails != null) {
|
||||||
@@ -567,7 +694,26 @@ class BmlLoginFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settled statement entries
|
val unbilled = payload.optJSONObject("unbilled")
|
||||||
|
?.optJSONArray("CardUnbillTxnDetails")
|
||||||
|
if (unbilled != null) {
|
||||||
|
for (i in 0 until unbilled.length()) {
|
||||||
|
val item = unbilled.getJSONObject(i)
|
||||||
|
result.add(Transaction(
|
||||||
|
id = "unbilled_${item.optString("TranApprCode")}_$i",
|
||||||
|
date = item.optString("DateTime"),
|
||||||
|
description = item.optString("TranDesc").trim(),
|
||||||
|
amount = item.optDouble("BillingAmount", 0.0),
|
||||||
|
currency = item.optString("BillingCcy", "MVR"),
|
||||||
|
counterpartyName = null,
|
||||||
|
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountDisplayName = accountDisplayName,
|
||||||
|
source = "BML_CARD"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val statement = payload.optJSONArray("cardstatement")
|
val statement = payload.optJSONArray("cardstatement")
|
||||||
if (statement != null) {
|
if (statement != null) {
|
||||||
for (i in 0 until statement.length()) {
|
for (i in 0 until statement.length()) {
|
||||||
@@ -590,14 +736,64 @@ class BmlLoginFlow {
|
|||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun apiRequest(session: BmlSession, url: String) =
|
// ─── Parsing ──────────────────────────────────────────────────────────────
|
||||||
Request.Builder().url(url)
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun parseDashboard(json: String, loginTag: String): List<MibAccount> {
|
/**
|
||||||
|
* BML web responses are Inertia.js pages — the data is embedded as HTML-escaped JSON
|
||||||
|
* in the `data-page="..."` attribute of the root div. This extracts and unescapes it.
|
||||||
|
*/
|
||||||
|
private fun extractInertiaJson(html: String): String? {
|
||||||
|
val match = Regex("""data-page="([^"]+)"""").find(html) ?: return null
|
||||||
|
return match.groupValues[1]
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseProfiles(html: String): List<BmlProfile> {
|
||||||
|
return try {
|
||||||
|
val json = extractInertiaJson(html) ?: html
|
||||||
|
val root = JSONObject(json)
|
||||||
|
val props = root.optJSONObject("props") ?: return emptyList()
|
||||||
|
val profiles = props.optJSONArray("profiles") ?: return emptyList()
|
||||||
|
(0 until profiles.length()).mapNotNull { i ->
|
||||||
|
val p = profiles.getJSONObject(i)
|
||||||
|
val profileObj = p.optJSONObject("profile") ?: return@mapNotNull null
|
||||||
|
BmlProfile(
|
||||||
|
profileId = p.optString("profile_id"),
|
||||||
|
name = p.optString("name"),
|
||||||
|
type = p.optString("type"),
|
||||||
|
profileType = profileObj.optString("profile_type", "default")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseBusinessOtpChannels(html: String): List<BmlOtpChannel> {
|
||||||
|
return try {
|
||||||
|
val json = extractInertiaJson(html) ?: html
|
||||||
|
val root = JSONObject(json)
|
||||||
|
val props = root.optJSONObject("props") ?: return emptyList()
|
||||||
|
val channels = props.optJSONArray("channels") ?: return emptyList()
|
||||||
|
(0 until channels.length()).map { i ->
|
||||||
|
val c = channels.getJSONObject(i)
|
||||||
|
BmlOtpChannel(
|
||||||
|
channel = c.optString("channel"),
|
||||||
|
description = c.optString("description"),
|
||||||
|
masked = c.optString("masked")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDashboard(
|
||||||
|
json: String,
|
||||||
|
loginTag: String,
|
||||||
|
profileName: String = "Personal",
|
||||||
|
profileId: String = ""
|
||||||
|
): List<MibAccount> {
|
||||||
val root = JSONObject(json)
|
val root = JSONObject(json)
|
||||||
if (!root.optBoolean("success")) return emptyList()
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
||||||
@@ -612,13 +808,13 @@ class BmlLoginFlow {
|
|||||||
val product = item.optString("product")
|
val product = item.optString("product")
|
||||||
val accountNumber = item.optString("account")
|
val accountNumber = item.optString("account")
|
||||||
val status = item.optString("account_status", "Active")
|
val status = item.optString("account_status", "Active")
|
||||||
|
|
||||||
val internalId = item.optString("id", "")
|
val internalId = item.optString("id", "")
|
||||||
|
|
||||||
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(
|
||||||
profileName = "Personal",
|
bank = "BML",
|
||||||
|
profileName = profileName,
|
||||||
profileType = "BML",
|
profileType = "BML",
|
||||||
accountNumber = accountNumber,
|
accountNumber = accountNumber,
|
||||||
accountBriefName = item.optString("alias"),
|
accountBriefName = item.optString("alias"),
|
||||||
@@ -631,17 +827,19 @@ class BmlLoginFlow {
|
|||||||
statusDesc = status,
|
statusDesc = status,
|
||||||
profileImageHash = null,
|
profileImageHash = null,
|
||||||
loginTag = loginTag,
|
loginTag = loginTag,
|
||||||
|
profileId = profileId,
|
||||||
internalId = internalId
|
internalId = internalId
|
||||||
))
|
))
|
||||||
} else if (accountType == "Card") {
|
} else if (accountType == "Card") {
|
||||||
val isVisible = item.optBoolean("account_visible", false)
|
val isVisible = item.optBoolean("account_visible", false)
|
||||||
if (!isVisible) continue // debit cards and other hidden cards — skip
|
if (!isVisible) continue
|
||||||
val isPrepaid = item.optBoolean("prepaid_card", false)
|
val isPrepaid = item.optBoolean("prepaid_card", false)
|
||||||
val cardBalance = item.optJSONObject("cardBalance")
|
val cardBalance = item.optJSONObject("cardBalance")
|
||||||
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(
|
||||||
profileName = "Personal",
|
bank = "BML",
|
||||||
|
profileName = profileName,
|
||||||
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
||||||
accountNumber = accountNumber,
|
accountNumber = accountNumber,
|
||||||
accountBriefName = item.optString("alias").ifBlank { product },
|
accountBriefName = item.optString("alias").ifBlank { product },
|
||||||
@@ -654,6 +852,7 @@ class BmlLoginFlow {
|
|||||||
statusDesc = status,
|
statusDesc = status,
|
||||||
profileImageHash = null,
|
profileImageHash = null,
|
||||||
loginTag = loginTag,
|
loginTag = loginTag,
|
||||||
|
profileId = profileId,
|
||||||
internalId = internalId
|
internalId = internalId
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -721,6 +920,15 @@ class BmlLoginFlow {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun apiRequest(session: BmlSession, url: String) =
|
||||||
|
Request.Builder().url(url)
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", APP_USER_AGENT)
|
||||||
|
.header("x-app-version", APP_VERSION)
|
||||||
|
.build()
|
||||||
|
|
||||||
private fun xsrfToken(): String? =
|
private fun xsrfToken(): String? =
|
||||||
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
|
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
|
||||||
|
|
||||||
@@ -746,4 +954,26 @@ class BmlLoginFlow {
|
|||||||
SecureRandom().nextBytes(bytes)
|
SecureRandom().nextBytes(bytes)
|
||||||
return bytes.joinToString("") { "%02x".format(it) }
|
return bytes.joinToString("") { "%02x".format(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
||||||
|
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
||||||
|
return try {
|
||||||
|
val parts = narrative1.split(" ")
|
||||||
|
if (parts.size < 2) null
|
||||||
|
else {
|
||||||
|
val timePart = parts[1].take(4)
|
||||||
|
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
|
||||||
|
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
|
||||||
|
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
|
||||||
|
private fun parseTransferNarrative1(narrative1: String): String? {
|
||||||
|
return try {
|
||||||
|
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
|
||||||
|
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,36 @@
|
|||||||
package sh.sar.basedbank.api.bml
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
|
||||||
data class BmlSession(
|
data class BmlSession(
|
||||||
val accessToken: String,
|
val accessToken: String,
|
||||||
val deviceId: String
|
val deviceId: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BmlProfile(
|
||||||
|
val profileId: String,
|
||||||
|
val name: String,
|
||||||
|
val type: String, // "Profile" (personal) or "Business"
|
||||||
|
val profileType: String, // "default" or "business"
|
||||||
|
val autoActivated: Boolean = false // true for single-profile accounts where server skips the picker
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BmlOtpChannel(
|
||||||
|
val channel: String,
|
||||||
|
val description: String,
|
||||||
|
val masked: String
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class BmlActivationResult {
|
||||||
|
data class Success(
|
||||||
|
val session: BmlSession,
|
||||||
|
val accounts: List<MibAccount>
|
||||||
|
) : BmlActivationResult()
|
||||||
|
data class NeedsBusinessOtp(
|
||||||
|
val channels: List<BmlOtpChannel>
|
||||||
|
) : BmlActivationResult()
|
||||||
|
}
|
||||||
|
|
||||||
data class BmlAccountValidation(
|
data class BmlAccountValidation(
|
||||||
val trnType: String, // IAT, QTR, DOT
|
val trnType: String, // IAT, QTR, DOT
|
||||||
val validationType: String, // BML, alias, MIB
|
val validationType: String, // BML, alias, MIB
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -35,6 +37,17 @@ class AccountHistoryAdapter(
|
|||||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||||
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||||
var onTransferClick: ((MibAccount) -> Unit)? = null
|
var onTransferClick: ((MibAccount) -> Unit)? = null
|
||||||
|
private var hideAmounts: Boolean = false
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyItemChanged(0) // refresh header card
|
||||||
|
// refresh all transaction rows
|
||||||
|
for (i in displayItems.indices) {
|
||||||
|
if (displayItems[i] is Item.Trx) notifyItemChanged(i + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||||
imageCache[counterpartyName] = bitmap
|
imageCache[counterpartyName] = bitmap
|
||||||
@@ -138,7 +151,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 +160,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 = if (hideAmounts) maskAmount(d.availableBalance) else d.availableBalance
|
||||||
else -> null
|
b.tvHeaderBalance.text = if (hideAmounts) maskAmount(d.workingBalance) else d.workingBalance
|
||||||
}
|
if (d.blockedBalance != null) {
|
||||||
b.tvHeaderPillType.text = friendlyType(acc.accountTypeName)
|
b.tvHeaderBlocked.text = if (hideAmounts) maskAmount(d.blockedBalance) else 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,12 +222,17 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
b.tvDate.text = formatTime(trx.date)
|
b.tvDate.text = formatTime(trx.date)
|
||||||
|
|
||||||
val sign = if (isCredit) "+" else "-"
|
if (hideAmounts) {
|
||||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
b.tvAmount.text = "${trx.currency} ••••••"
|
||||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
b.tvAmount.setTextColor(Color.parseColor("#888888"))
|
||||||
b.tvAmount.setTextColor(
|
} else {
|
||||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
val sign = if (isCredit) "+" else "-"
|
||||||
)
|
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||||
|
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||||
|
b.tvAmount.setTextColor(
|
||||||
|
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
b.root.setOnClickListener { showDetail(trx) }
|
b.root.setOnClickListener { showDetail(trx) }
|
||||||
}
|
}
|
||||||
@@ -297,6 +298,11 @@ class AccountHistoryAdapter(
|
|||||||
return FULL_DATE_FMT.format(date)
|
return FULL_DATE_FMT.format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun maskAmount(formatted: String): String {
|
||||||
|
val currency = formatted.substringBefore(' ', formatted)
|
||||||
|
return "$currency ••••••"
|
||||||
|
}
|
||||||
|
|
||||||
fun sourceColor(source: String) = when (source) {
|
fun sourceColor(source: String) = when (source) {
|
||||||
"MIB" -> "#FE860E"
|
"MIB" -> "#FE860E"
|
||||||
"BML", "BML_CARD" -> "#0066A1"
|
"BML", "BML_CARD" -> "#0066A1"
|
||||||
|
|||||||
@@ -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,15 +68,29 @@ 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 ->
|
||||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
|
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
|
||||||
}
|
}
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
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 +107,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 +135,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 +146,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 +163,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 +172,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 +190,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 +217,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) {
|
||||||
|
|||||||
@@ -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,21 @@ 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 var hideAmounts: Boolean = false
|
||||||
|
|
||||||
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()
|
||||||
@@ -34,39 +37,45 @@ class AccountsAdapter(
|
|||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
|
fun setHideAmounts(hide: Boolean) {
|
||||||
val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
if (hideAmounts == hide) return
|
||||||
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
// Group non-prepaid accounts by their derived section title, preserving order
|
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
|
||||||
val groups = LinkedHashMap<String, MutableList<MibAccount>>()
|
val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
|
||||||
for (acc in nonPrepaid) {
|
val nonCards = displayed.filter { !it.second.isCard }
|
||||||
|
val cards = displayed.filter { it.second.isCard }
|
||||||
|
|
||||||
|
val groups = LinkedHashMap<String, MutableList<Pair<MibAccount, AccountListDisplay>>>()
|
||||||
|
for ((acc, display) in nonCards) {
|
||||||
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 +87,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 +112,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 = if (hideAmounts) maskAmount(display.balance) else 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 +128,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 = if (hideAmounts) maskAmount(display.balance) else 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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,35 +153,15 @@ class AccountsAdapter(
|
|||||||
private const val TYPE_ACCOUNT = 1
|
private const val TYPE_ACCOUNT = 1
|
||||||
private const val TYPE_CARD = 2
|
private const val TYPE_CARD = 2
|
||||||
|
|
||||||
|
fun maskAmount(formatted: String): String {
|
||||||
|
val currency = formatted.substringBefore(' ', formatted)
|
||||||
|
return "$currency ••••••"
|
||||||
|
}
|
||||||
|
|
||||||
private fun copyToClipboard(context: Context, accountNumber: String) {
|
private fun copyToClipboard(context: Context, accountNumber: String) {
|
||||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +28,23 @@ 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) }
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|||||||
108
app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesAdapter.kt
Normal file
108
app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesAdapter.kt
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class ActivitiesAdapter(
|
||||||
|
private val onItemClick: (ReceiptStore.Entry) -> Unit
|
||||||
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
private sealed class Item {
|
||||||
|
data class DateHeader(val label: String) : Item()
|
||||||
|
data class ReceiptItem(val entry: ReceiptStore.Entry) : Item()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val displayItems = mutableListOf<Item>()
|
||||||
|
|
||||||
|
fun setEntries(entries: List<ReceiptStore.Entry>) {
|
||||||
|
displayItems.clear()
|
||||||
|
var lastDateKey = ""
|
||||||
|
for (entry in entries) {
|
||||||
|
val dateKey = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(entry.savedAt))
|
||||||
|
if (dateKey != lastDateKey) {
|
||||||
|
displayItems.add(Item.DateHeader(formatDateHeader(entry.savedAt)))
|
||||||
|
lastDateKey = dateKey
|
||||||
|
}
|
||||||
|
displayItems.add(Item.ReceiptItem(entry))
|
||||||
|
}
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = displayItems.size
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int) =
|
||||||
|
if (displayItems[position] is Item.DateHeader) TYPE_DATE_HEADER else TYPE_RECEIPT
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
return if (viewType == TYPE_DATE_HEADER)
|
||||||
|
DateHeaderVH(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||||
|
else
|
||||||
|
ReceiptVH(ItemTransactionBinding.inflate(inflater, parent, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
when (holder) {
|
||||||
|
is DateHeaderVH -> holder.bind((displayItems[position] as Item.DateHeader).label)
|
||||||
|
is ReceiptVH -> holder.bind((displayItems[position] as Item.ReceiptItem).entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class DateHeaderVH(private val b: ItemDateHeaderBinding) :
|
||||||
|
RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(label: String) { b.tvDateHeader.text = label }
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class ReceiptVH(private val b: ItemTransactionBinding) :
|
||||||
|
RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(entry: ReceiptStore.Entry) {
|
||||||
|
val d = entry.data
|
||||||
|
val colorHex = d.fromColorHex.takeIf { it.isNotBlank() } ?: "#607D8B"
|
||||||
|
val initial = d.toLabel.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
|
||||||
|
b.fvAvatar.background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY })
|
||||||
|
}
|
||||||
|
b.tvInitial.visibility = android.view.View.VISIBLE
|
||||||
|
b.tvInitial.text = initial
|
||||||
|
|
||||||
|
b.tvCounterparty.text = d.toLabel
|
||||||
|
b.tvCounterparty.visibility = android.view.View.VISIBLE
|
||||||
|
b.tvDescription.text = buildString {
|
||||||
|
append(d.fromLabel)
|
||||||
|
if (d.toBank.isNotBlank()) append(" · ${d.toBank}")
|
||||||
|
}
|
||||||
|
b.tvDate.text = formatTime(entry.savedAt)
|
||||||
|
|
||||||
|
b.tvAmount.text = "- ${d.currency} ${d.amount}"
|
||||||
|
b.tvAmount.setTextColor(Color.parseColor("#FF7043"))
|
||||||
|
|
||||||
|
b.root.setOnClickListener { onItemClick(entry) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDateHeader(millis: Long): String {
|
||||||
|
val sdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US)
|
||||||
|
return sdf.format(Date(millis))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(millis: Long): String {
|
||||||
|
val sdf = SimpleDateFormat("HH:mm", Locale.US)
|
||||||
|
return sdf.format(Date(millis))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TYPE_DATE_HEADER = 0
|
||||||
|
private const val TYPE_RECEIPT = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentActivitiesBinding
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
|
|
||||||
|
class ActivitiesFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentActivitiesBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private lateinit var adapter: ActivitiesAdapter
|
||||||
|
private val allEntries = mutableListOf<ReceiptStore.Entry>()
|
||||||
|
private var searchQuery = ""
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentActivitiesBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
adapter = ActivitiesAdapter { entry ->
|
||||||
|
(activity as? HomeActivity)?.showWithBackStack(
|
||||||
|
TransferReceiptFragment.newInstance(entry.data, null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext()
|
||||||
|
.getSharedPreferences("prefs", 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.etSearch.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
searchQuery = s?.toString()?.trim() ?: ""
|
||||||
|
filterAndDisplay()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.nav_activities)
|
||||||
|
// Reload in case a new receipt was added while we were away
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadEntries() {
|
||||||
|
allEntries.clear()
|
||||||
|
allEntries.addAll(ReceiptStore.loadAll(requireContext()))
|
||||||
|
filterAndDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterAndDisplay() {
|
||||||
|
val filtered = if (searchQuery.isBlank()) allEntries
|
||||||
|
else allEntries.filter { entry ->
|
||||||
|
entry.data.toLabel.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.fromLabel.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.toAccount.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.toBank.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.mibReferenceNo.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.bmlReference.contains(searchQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
adapter.setEntries(filtered)
|
||||||
|
binding.emptyView.visibility = if (filtered.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,52 @@ 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, profiles) in app.bmlProfilesMap) {
|
||||||
|
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||||
|
for (profile in profiles) {
|
||||||
|
if (app.bmlSessions.containsKey(profile.profileId)) {
|
||||||
|
list.add(DestinationOption("BML · ${fullName ?: profile.name}", isBml = true, bmlLoginId = profile.profileId, subtitle = profile.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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 +169,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 +223,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 +231,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 +242,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 +255,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 +272,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 +399,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 +411,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 +436,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 +450,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 +487,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 +496,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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -29,21 +32,49 @@ class DashboardFragment : Fragment() {
|
|||||||
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
|
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
|
||||||
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
|
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
|
||||||
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
|
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||||
|
updateBalances(viewModel.accounts.value ?: emptyList())
|
||||||
|
updatePendingFinances(viewModel.financing.value ?: emptyList())
|
||||||
|
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
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>) {
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
if (hide) {
|
||||||
|
binding.tvMvrBalance.text = "MVR ••••••"
|
||||||
|
binding.tvUsdBalance.text = "USD ••••••"
|
||||||
|
return
|
||||||
|
}
|
||||||
val mvrTotal = accounts
|
val mvrTotal = accounts
|
||||||
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
|
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
|
||||||
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||||
@@ -56,31 +87,40 @@ class DashboardFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
|
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
binding.containerForeignLimits.removeAllViews()
|
binding.containerForeignLimits.removeAllViews()
|
||||||
for (entry in entries) {
|
for (entry in entries) {
|
||||||
for (limit in entry.limits) {
|
for (limit in entry.limits) {
|
||||||
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
|
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
|
||||||
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" }
|
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" }
|
||||||
card.tvLimitType.text = limit.type
|
card.tvLimitType.text = limit.type
|
||||||
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
|
if (hide) {
|
||||||
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
|
card.tvLimitGeneral.text = "USD ••••••"
|
||||||
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
|
card.tvLimitMedical.text = "USD ••••••"
|
||||||
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
|
card.tvLimitAtm.text = if (!limit.isAtmEnabled) "USD •••••• · Disabled" else "USD ••••••"
|
||||||
else
|
card.tvLimitEcom.text = "USD ••••••"
|
||||||
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
|
card.tvLimitPos.text = if (!limit.isPosEnabled) "USD •••••• · Disabled" else "USD ••••••"
|
||||||
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
|
} else {
|
||||||
card.tvLimitPos.text = if (!limit.isPosEnabled)
|
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
|
||||||
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
|
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
|
||||||
else
|
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
|
||||||
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
|
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
|
||||||
|
else
|
||||||
|
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
|
||||||
|
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
|
||||||
|
card.tvLimitPos.text = if (!limit.isPosEnabled)
|
||||||
|
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
|
||||||
|
else
|
||||||
|
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
|
||||||
|
}
|
||||||
binding.containerForeignLimits.addView(card.root)
|
binding.containerForeignLimits.addView(card.root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePendingFinances(deals: List<MibFinanceDeal>) {
|
private fun updatePendingFinances(deals: List<MibFinanceDeal>) {
|
||||||
val total = deals.sumOf { it.outstandingAmount }
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
binding.tvPendingFinances.text = "MVR %,.2f".format(total)
|
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(deals.sumOf { it.outstandingAmount })
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ import java.util.Locale
|
|||||||
class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
||||||
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() {
|
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
private var hideAmounts: Boolean = false
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
private val expandedPositions = mutableSetOf<Int>()
|
private val expandedPositions = mutableSetOf<Int>()
|
||||||
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
|
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
|
||||||
minimumFractionDigits = 2
|
minimumFractionDigits = 2
|
||||||
@@ -52,19 +60,20 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
|||||||
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
|
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
|
||||||
val ctx = binding.root.context
|
val ctx = binding.root.context
|
||||||
val currency = deal.currency
|
val currency = deal.currency
|
||||||
|
val hide = hideAmounts
|
||||||
|
|
||||||
binding.tvProductName.text = deal.productDesc
|
binding.tvProductName.text = deal.productDesc
|
||||||
binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo)
|
binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo)
|
||||||
binding.tvStatus.text = deal.statusDesc
|
binding.tvStatus.text = deal.statusDesc
|
||||||
binding.tvTotal.text = "$currency ${amountFmt.format(deal.dealAmount)}"
|
binding.tvTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.dealAmount)}"
|
||||||
binding.tvPaid.text = "$currency ${amountFmt.format(deal.paidAmount)}"
|
binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
|
||||||
binding.tvUnpaid.text = "$currency ${amountFmt.format(deal.outstandingAmount)}"
|
binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
|
||||||
|
|
||||||
// Progress bar
|
// Progress bar
|
||||||
val progress = if (deal.dealAmount > 0)
|
val progress = if (deal.dealAmount > 0)
|
||||||
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
|
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
|
||||||
else 0
|
else 0
|
||||||
binding.progressBar.progress = progress
|
binding.progressBar.progress = if (hide) 0 else progress
|
||||||
|
|
||||||
// Completion estimate
|
// Completion estimate
|
||||||
binding.tvCompletion.text = completionText(deal, ctx)
|
binding.tvCompletion.text = completionText(deal, ctx)
|
||||||
@@ -76,14 +85,14 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
|||||||
|
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
binding.tvDealDate.text = formatDate(deal.dealDate)
|
binding.tvDealDate.text = formatDate(deal.dealDate)
|
||||||
binding.tvInstallment.text = "$currency ${amountFmt.format(deal.installmentAmount)}"
|
binding.tvInstallment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.installmentAmount)}"
|
||||||
binding.tvNumInstallments.text = deal.noOfInstallments.toString()
|
binding.tvNumInstallments.text = deal.noOfInstallments.toString()
|
||||||
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate)
|
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate)
|
||||||
binding.tvLastPayAmount.text = "$currency ${amountFmt.format(deal.lastPayAmount)}"
|
binding.tvLastPayAmount.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.lastPayAmount)}"
|
||||||
|
|
||||||
if (deal.overdueAmount > 0) {
|
if (deal.overdueAmount > 0) {
|
||||||
binding.rowOverdue.visibility = View.VISIBLE
|
binding.rowOverdue.visibility = View.VISIBLE
|
||||||
binding.tvOverdue.text = "$currency ${amountFmt.format(deal.overdueAmount)}"
|
binding.tvOverdue.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.overdueAmount)}"
|
||||||
} else {
|
} else {
|
||||||
binding.rowOverdue.visibility = View.GONE
|
binding.rowOverdue.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +29,22 @@ 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
|
||||||
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE
|
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE
|
||||||
binding.loadingView.visibility = View.GONE
|
binding.loadingView.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|||||||
@@ -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,18 @@ 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.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
@@ -29,7 +36,9 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
|||||||
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.AuthExpiredException
|
import sh.sar.basedbank.api.bml.AuthExpiredException
|
||||||
|
import sh.sar.basedbank.api.bml.BmlActivationResult
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import sh.sar.basedbank.api.bml.BmlSession
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||||
@@ -64,24 +73,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(
|
||||||
@@ -91,16 +113,31 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
binding.drawerLayout.addDrawerListener(toggle)
|
binding.drawerLayout.addDrawerListener(toggle)
|
||||||
toggle.syncState()
|
toggle.syncState()
|
||||||
|
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.navigationView) { v, insets ->
|
||||||
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.updatePadding(top = bars.top, bottom = bars.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
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_pay_mv_qr -> PayMvQrFragment()
|
||||||
else -> false
|
R.id.nav_more -> MoreFragment()
|
||||||
|
R.id.nav_activities -> ActivitiesFragment()
|
||||||
|
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 +150,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,36 +187,41 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hideAmounts is always false on launch; eye feature just needs to be enabled
|
||||||
|
viewModel.hideAmounts.value = false
|
||||||
|
|
||||||
// Show dashboard on first create
|
// Show dashboard on first create
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
show(DashboardFragment())
|
show(DashboardFragment())
|
||||||
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 +235,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,25 +252,72 @@ 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)
|
||||||
|
// Restore selection to current destination after menu rebuild
|
||||||
|
val currentId = binding.navigationView.checkedItem?.itemId
|
||||||
|
if (currentId != null) {
|
||||||
|
val bottomNavIds = (0 until menu.size()).map { menu.getItem(it).itemId }.toSet()
|
||||||
|
val selectId = if (currentId in bottomNavIds) currentId
|
||||||
|
else if (R.id.nav_more in bottomNavIds) R.id.nav_more
|
||||||
|
else null
|
||||||
|
if (selectId != null) {
|
||||||
|
suppressBottomNavCallback = true
|
||||||
|
binding.bottomNavigation.selectedItemId = selectId
|
||||||
|
suppressBottomNavCallback = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
R.id.nav_accounts -> AccountsFragment()
|
R.id.nav_accounts -> AccountsFragment()
|
||||||
R.id.nav_contacts -> ContactsFragment()
|
R.id.nav_contacts -> ContactsFragment()
|
||||||
R.id.nav_transfer -> TransferFragment()
|
R.id.nav_transfer -> TransferFragment()
|
||||||
|
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||||
|
R.id.nav_activities -> ActivitiesFragment()
|
||||||
R.id.nav_transfer_history -> TransferHistoryFragment()
|
R.id.nav_transfer_history -> TransferHistoryFragment()
|
||||||
R.id.nav_finances -> FinancingFragment()
|
R.id.nav_finances -> FinancingFragment()
|
||||||
R.id.nav_otp -> OtpFragment()
|
R.id.nav_otp -> OtpFragment()
|
||||||
R.id.nav_settings -> SettingsFragment()
|
R.id.nav_settings -> SettingsFragment()
|
||||||
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
||||||
}
|
}
|
||||||
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 +334,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 +348,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,13 +414,23 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
menuInflater.inflate(R.menu.toolbar_menu, menu)
|
menuInflater.inflate(R.menu.toolbar_menu, menu)
|
||||||
|
val eyeEnabled = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_sensitive_info", false)
|
||||||
|
val eyeItem = menu.findItem(R.id.action_hide_amounts)
|
||||||
|
eyeItem?.isVisible = eyeEnabled
|
||||||
|
val hidden = viewModel.hideAmounts.value ?: false
|
||||||
|
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
|
if (item.itemId == R.id.action_hide_amounts) {
|
||||||
|
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.value = newHidden
|
||||||
|
invalidateOptionsMenu()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return super.onOptionsItemSelected(item)
|
return super.onOptionsItemSelected(item)
|
||||||
@@ -326,50 +439,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" } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,98 +487,187 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
|
val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
|
||||||
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
|
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
|
||||||
loginId to async(Dispatchers.IO) {
|
loginId to async(Dispatchers.IO) {
|
||||||
val bmlFlow = BmlLoginFlow()
|
|
||||||
val loginTag = "bml_$loginId"
|
val loginTag = "bml_$loginId"
|
||||||
val savedToken = store.loadBmlSession(loginId)
|
val app = application as BasedBankApp
|
||||||
|
val savedProfiles = store.loadBmlProfiles(loginId)
|
||||||
|
val allAccounts = mutableListOf<MibAccount>()
|
||||||
|
var anyExpired = savedProfiles.isEmpty()
|
||||||
|
|
||||||
if (savedToken != null) {
|
// Try each saved profile's cached session
|
||||||
try {
|
for (profile in savedProfiles) {
|
||||||
val session = BmlSession(savedToken.first, savedToken.second)
|
val saved = store.loadBmlProfileSession(profile.profileId)
|
||||||
val accounts = bmlFlow.fetchAccounts(session, loginTag)
|
if (saved != null) {
|
||||||
val app = application as BasedBankApp
|
try {
|
||||||
app.bmlSessions[loginId] = session
|
val session = BmlSession(saved.first, saved.second)
|
||||||
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
val accounts = BmlLoginFlow().fetchAccounts(session, loginTag, profile.name, profile.profileId)
|
||||||
return@async Pair(session, accounts)
|
app.bmlSessions[profile.profileId] = session
|
||||||
} catch (_: AuthExpiredException) {
|
allAccounts += accounts
|
||||||
} catch (_: Exception) {
|
} catch (_: AuthExpiredException) { anyExpired = true
|
||||||
|
} catch (_: Exception) { anyExpired = true }
|
||||||
|
} else {
|
||||||
|
anyExpired = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
||||||
val (session, accounts) = bmlFlow.login(creds.username, creds.password, creds.otpSeed)
|
|
||||||
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
|
// Also try legacy single-profile session token (pre-multi-profile installs)
|
||||||
val app = application as BasedBankApp
|
if (savedProfiles.isEmpty()) {
|
||||||
app.bmlSessions[loginId] = session
|
val legacyToken = store.loadBmlSession(loginId)
|
||||||
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
if (legacyToken != null) {
|
||||||
Pair(session, accounts)
|
try {
|
||||||
} catch (_: Exception) {
|
val session = BmlSession(legacyToken.first, legacyToken.second)
|
||||||
Pair(null, AccountCache.loadBml(this@HomeActivity, loginId))
|
val accounts = BmlLoginFlow().fetchAccounts(session, loginTag)
|
||||||
|
app.bmlSessions[loginId] = session
|
||||||
|
allAccounts += accounts
|
||||||
|
anyExpired = false
|
||||||
|
} catch (_: AuthExpiredException) { anyExpired = true
|
||||||
|
} catch (_: Exception) { anyExpired = true }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (anyExpired || allAccounts.isEmpty()) {
|
||||||
|
// Re-authenticate to refresh personal profile sessions
|
||||||
|
try {
|
||||||
|
val flow = app.bmlFlowFor(loginId)
|
||||||
|
val profiles = flow.login(creds.username, creds.password, creds.otpSeed)
|
||||||
|
store.saveBmlProfiles(loginId, profiles)
|
||||||
|
app.bmlProfilesMap[loginId] = profiles
|
||||||
|
|
||||||
|
for (profile in profiles) {
|
||||||
|
if (profile.profileType == "business") {
|
||||||
|
// Can't activate business profiles without user OTP — use cached
|
||||||
|
val cached = AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
|
.filter { it.profileId == profile.profileId }
|
||||||
|
if (allAccounts.none { it.profileId == profile.profileId })
|
||||||
|
allAccounts += cached
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val result = flow.activateProfile(profile, loginTag)
|
||||||
|
if (result is BmlActivationResult.Success) {
|
||||||
|
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
|
||||||
|
app.bmlSessions[profile.profileId] = result.session
|
||||||
|
allAccounts.removeAll { it.profileId == profile.profileId }
|
||||||
|
allAccounts += result.accounts
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
if (allAccounts.none { it.profileId == profile.profileId }) {
|
||||||
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
|
.filter { it.profileId == profile.profileId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
if (allAccounts.isEmpty())
|
||||||
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allAccounts.isNotEmpty()) AccountCache.saveBml(this@HomeActivity, loginId, allAccounts)
|
||||||
|
allAccounts as List<MibAccount>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 bmlResults = bmlJobs.map { (_, job) -> job.await() }
|
val mibAccounts = mibResults.flatMap { it.second }
|
||||||
val bmlAccounts = bmlResults.flatMap { it.second }
|
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
|
||||||
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 accounts whose profileId the user has hidden in settings. */
|
||||||
|
private fun List<MibAccount>.filterVisibleAccounts(): List<MibAccount> {
|
||||||
|
val store = CredentialStore(this@HomeActivity)
|
||||||
|
return filter { acc ->
|
||||||
|
when (acc.bank) {
|
||||||
|
"MIB" -> {
|
||||||
|
val loginId = acc.loginTag.removePrefix("mib_")
|
||||||
|
val hidden = store.getHiddenMibProfileIds(loginId)
|
||||||
|
hidden.isEmpty() || acc.profileId !in hidden
|
||||||
|
}
|
||||||
|
"BML" -> {
|
||||||
|
val loginId = acc.loginTag.removePrefix("bml_")
|
||||||
|
val hidden = store.getHiddenBmlProfileIds(loginId)
|
||||||
|
hidden.isEmpty() || acc.profileId !in hidden
|
||||||
|
}
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
@@ -488,10 +687,12 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun refreshBmlContacts(app: BasedBankApp) {
|
private fun refreshBmlContacts(app: BasedBankApp) {
|
||||||
if (app.bmlSessions.isEmpty()) return
|
if (app.bmlSessions.isEmpty()) return
|
||||||
|
val store = CredentialStore(this)
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
val allBmlContacts = withContext(Dispatchers.IO) {
|
val allBmlContacts = withContext(Dispatchers.IO) {
|
||||||
app.bmlSessions.flatMap { (loginId, session) ->
|
store.getBmlLoginIds().flatMap { loginId ->
|
||||||
|
val session = app.anyBmlSessionFor(loginId) ?: return@flatMap emptyList()
|
||||||
val contacts = BmlLoginFlow().fetchContacts(session, loginId)
|
val contacts = BmlLoginFlow().fetchContacts(session, loginId)
|
||||||
if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts)
|
if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts)
|
||||||
contacts
|
contacts
|
||||||
@@ -523,9 +724,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 +765,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 +792,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 +807,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 +817,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 +837,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 {
|
||||||
|
|||||||
@@ -16,4 +16,6 @@ class HomeViewModel : ViewModel() {
|
|||||||
|
|
||||||
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
||||||
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
||||||
|
|
||||||
|
val hideAmounts = MutableLiveData<Boolean>(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
420
app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt
Normal file
420
app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.*
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.*
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
class PayMvQrFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentPayMvQrBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private var selectedAccount: MibAccount? = null
|
||||||
|
private var generatedBitmap: Bitmap? = null
|
||||||
|
private var generateJob: Job? = null
|
||||||
|
|
||||||
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
val qr = PaymvQrParser.parse(raw)
|
||||||
|
if (qr == null || qr.accountNumber == null) {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
activity.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
||||||
|
accountNumber = qr.accountNumber,
|
||||||
|
displayName = qr.merchantName ?: qr.accountNumber,
|
||||||
|
amount = qr.amount,
|
||||||
|
remarks = qr.purpose
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentPayMvQrBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val basePaddingBottom = view.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.updatePadding(bottom = basePaddingBottom + navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
setupDropdown()
|
||||||
|
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
||||||
|
binding.btnShare.isEnabled = false
|
||||||
|
binding.btnSave.isEnabled = false
|
||||||
|
binding.btnShare.setOnClickListener { shareQr() }
|
||||||
|
binding.btnSave.setOnClickListener { saveQr() }
|
||||||
|
binding.btnScanQr.setOnClickListener {
|
||||||
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupDropdown() {
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
|
val eligible = accounts.filter {
|
||||||
|
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT"
|
||||||
|
}
|
||||||
|
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||||
|
binding.actvAccount.setAdapter(adapter)
|
||||||
|
binding.actvAccount.setOnItemClickListener { _, _, position, _ ->
|
||||||
|
val picked = adapter.getAccount(position) ?: return@setOnItemClickListener
|
||||||
|
selectedAccount = picked
|
||||||
|
scheduleGenerate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleGenerate() {
|
||||||
|
generateJob?.cancel()
|
||||||
|
generateJob = viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
delay(300)
|
||||||
|
generateQr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun generateQr() {
|
||||||
|
val account = selectedAccount ?: return
|
||||||
|
val acquirer = when (account.bank) {
|
||||||
|
"BML" -> "MALBMVMV"
|
||||||
|
"MIB" -> "MADVMVMV"
|
||||||
|
"FAHIPAY" -> "FAHIMVMV"
|
||||||
|
else -> "MADVMVMV"
|
||||||
|
}
|
||||||
|
val amountFormatted = binding.etAmount.text?.toString()?.trim()
|
||||||
|
?.replace(",", "")
|
||||||
|
?.toDoubleOrNull()
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
?.let { "%.2f".format(it) }
|
||||||
|
|
||||||
|
val ctx = requireContext()
|
||||||
|
val bmp = withContext(Dispatchers.Default) {
|
||||||
|
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted)
|
||||||
|
renderQrCard(ctx, account, payload, amountFormatted)
|
||||||
|
}
|
||||||
|
if (_binding == null) return
|
||||||
|
generatedBitmap = bmp
|
||||||
|
binding.tvQrPlaceholder.visibility = View.GONE
|
||||||
|
binding.ivQrCard.setImageBitmap(bmp)
|
||||||
|
binding.ivQrCard.visibility = View.VISIBLE
|
||||||
|
binding.btnShare.isEnabled = true
|
||||||
|
binding.btnSave.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EMV MPQR payload ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun buildQrPayload(
|
||||||
|
accountNumber: String,
|
||||||
|
accountName: String,
|
||||||
|
acquirer: String,
|
||||||
|
amountStr: String?
|
||||||
|
): String {
|
||||||
|
fun tlv(tag: String, value: String): String {
|
||||||
|
val len = value.length
|
||||||
|
return tag + (if (len < 10) "0$len" else "$len") + value
|
||||||
|
}
|
||||||
|
val format = tlv("00", "01")
|
||||||
|
val poi = tlv("01", "11")
|
||||||
|
val sub00 = tlv("00", "mv.favara.mpqr")
|
||||||
|
val sub01 = tlv("01", acquirer)
|
||||||
|
val sub03 = tlv("03", accountNumber)
|
||||||
|
val sub10 = tlv("10", "IPAY")
|
||||||
|
val merchantAcct = tlv("26", sub00 + sub01 + sub03 + sub10)
|
||||||
|
val currency = tlv("53", "462")
|
||||||
|
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
|
||||||
|
val country = tlv("58", "MV")
|
||||||
|
val name = tlv("59", accountName.take(25))
|
||||||
|
val prefix = format + poi + merchantAcct + currency + amountTLV + country + name + "6304"
|
||||||
|
return prefix + crc16(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun crc16(data: String): String {
|
||||||
|
var crc = 0xFFFF
|
||||||
|
for (c in data) {
|
||||||
|
crc = crc xor ((c.code and 0xFF) shl 8)
|
||||||
|
repeat(8) {
|
||||||
|
crc = if (crc and 0x8000 != 0) ((crc shl 1) and 0xFFFF) xor 0x1021
|
||||||
|
else (crc shl 1) and 0xFFFF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crc.toString(16).uppercase().padStart(4, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QR card rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun renderQrCard(
|
||||||
|
ctx: Context,
|
||||||
|
account: MibAccount,
|
||||||
|
qrPayload: String,
|
||||||
|
amountStr: String?
|
||||||
|
): Bitmap {
|
||||||
|
val W = 900
|
||||||
|
val H = 1080
|
||||||
|
val outerCorner = 48f
|
||||||
|
val boxBlue = Color.parseColor("#2272B7")
|
||||||
|
val footerBlue = Color.parseColor("#1A5799")
|
||||||
|
val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f
|
||||||
|
|
||||||
|
val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bm)
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
// Clip to outer rounded card shape
|
||||||
|
val outerPath = Path()
|
||||||
|
outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW)
|
||||||
|
canvas.clipPath(outerPath)
|
||||||
|
canvas.drawColor(Color.WHITE)
|
||||||
|
|
||||||
|
// --- Bank logo top-left ---
|
||||||
|
val logoRes = when (account.bank) {
|
||||||
|
"BML" -> R.drawable.bml_logo_vector
|
||||||
|
"MIB" -> R.drawable.mib_faisanet_logo
|
||||||
|
else -> R.drawable.fahipay_logo_long
|
||||||
|
}
|
||||||
|
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
|
||||||
|
val nW = d.intrinsicWidth.coerceAtLeast(1)
|
||||||
|
val nH = d.intrinsicHeight.coerceAtLeast(1)
|
||||||
|
val maxW = 180f; val maxH = 76f
|
||||||
|
val scale = minOf(maxW / nW, maxH / nH)
|
||||||
|
val lW = (nW * scale).toInt()
|
||||||
|
val lH = (nH * scale).toInt()
|
||||||
|
val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10)
|
||||||
|
d.setBounds(24, lTop, 24 + lW, lTop + lH)
|
||||||
|
d.draw(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- "PayMV QR" top-right ---
|
||||||
|
paint.color = Color.parseColor("#1A1A2E")
|
||||||
|
paint.textSize = 36f
|
||||||
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
|
paint.textAlign = Paint.Align.RIGHT
|
||||||
|
canvas.drawText("PayMV QR", W - 28f, 66f, paint)
|
||||||
|
|
||||||
|
// --- Blue rounded box ---
|
||||||
|
paint.color = boxBlue
|
||||||
|
paint.textAlign = Paint.Align.LEFT
|
||||||
|
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
|
||||||
|
|
||||||
|
// Account name (white, bold, uppercase, auto-scaled to fit)
|
||||||
|
paint.color = Color.WHITE
|
||||||
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
|
paint.textAlign = Paint.Align.CENTER
|
||||||
|
val nameText = account.accountBriefName.uppercase()
|
||||||
|
paint.textSize = 36f
|
||||||
|
val maxNameW = boxR - boxL - 48f
|
||||||
|
if (paint.measureText(nameText) > maxNameW) {
|
||||||
|
paint.textSize = 36f * maxNameW / paint.measureText(nameText)
|
||||||
|
}
|
||||||
|
val nameBaseline = boxT + 68f
|
||||||
|
canvas.drawText(nameText, W / 2f, nameBaseline, paint)
|
||||||
|
|
||||||
|
// Optional amount below name
|
||||||
|
val qrTopY: Float
|
||||||
|
if (!amountStr.isNullOrBlank()) {
|
||||||
|
paint.textSize = 28f
|
||||||
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
|
||||||
|
val amtBaseline = nameBaseline + 42f
|
||||||
|
canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint)
|
||||||
|
qrTopY = amtBaseline + 20f
|
||||||
|
} else {
|
||||||
|
qrTopY = nameBaseline + 26f
|
||||||
|
}
|
||||||
|
|
||||||
|
// QR code — white modules on the same blue as the box background
|
||||||
|
val availH = boxB - qrTopY - 24f
|
||||||
|
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200)
|
||||||
|
val qrLeft = ((W - qrPx) / 2).toFloat()
|
||||||
|
try {
|
||||||
|
val hints = mapOf(
|
||||||
|
EncodeHintType.MARGIN to 0,
|
||||||
|
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
||||||
|
)
|
||||||
|
val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints)
|
||||||
|
val pixels = IntArray(qrPx * qrPx)
|
||||||
|
for (y in 0 until qrPx) {
|
||||||
|
for (x in 0 until qrPx) {
|
||||||
|
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
||||||
|
canvas.drawBitmap(qrBm, qrLeft, qrTopY, null)
|
||||||
|
qrBm.recycle()
|
||||||
|
} catch (_: Exception) { /* skip if encoding fails */ }
|
||||||
|
|
||||||
|
// --- Dark blue footer ---
|
||||||
|
paint.color = footerBlue
|
||||||
|
paint.textAlign = Paint.Align.LEFT
|
||||||
|
canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint)
|
||||||
|
paint.color = Color.WHITE
|
||||||
|
paint.textSize = 32f
|
||||||
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
|
paint.textAlign = Paint.Align.CENTER
|
||||||
|
canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint)
|
||||||
|
|
||||||
|
return bm
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Share / Save ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun shareQr() {
|
||||||
|
val bmp = generatedBitmap ?: return
|
||||||
|
val account = selectedAccount ?: return
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val uri = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val dir = File(requireContext().cacheDir, "qr")
|
||||||
|
dir.mkdirs()
|
||||||
|
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||||
|
val file = File(dir, "${safeName}_paymv_qr.png")
|
||||||
|
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
requireContext(),
|
||||||
|
"${requireContext().packageName}.fileprovider",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
if (uri == null || _binding == null) return@launch
|
||||||
|
val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||||
|
type = "image/png"
|
||||||
|
putExtra(android.content.Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
startActivity(android.content.Intent.createChooser(intent, getString(R.string.paymvqr_share)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveQr() {
|
||||||
|
val bmp = generatedBitmap ?: return
|
||||||
|
val account = selectedAccount ?: return
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val saved = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||||
|
val filename = "${safeName}_PayMV_QR.png"
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
|
||||||
|
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
|
||||||
|
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
|
||||||
|
}
|
||||||
|
val uri = requireContext().contentResolver.insert(
|
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values
|
||||||
|
) ?: return@withContext false
|
||||||
|
requireContext().contentResolver.openOutputStream(uri)?.use {
|
||||||
|
bmp.compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
|
||||||
|
dir.mkdirs()
|
||||||
|
FileOutputStream(File(dir, filename)).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
Toast.makeText(
|
||||||
|
requireContext(),
|
||||||
|
if (saved) R.string.paymvqr_saved else R.string.paymvqr_save_failed,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.pay_mv_qr)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account dropdown adapter ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private inner class QrAccountAdapter(
|
||||||
|
private val context: Context,
|
||||||
|
private val accounts: List<MibAccount>
|
||||||
|
) : BaseAdapter(), Filterable {
|
||||||
|
|
||||||
|
fun getAccount(position: Int): MibAccount? = accounts.getOrNull(position)
|
||||||
|
|
||||||
|
override fun getCount() = accounts.size
|
||||||
|
override fun getItem(position: Int) = accounts.getOrNull(position)
|
||||||
|
override fun getItemId(position: Int) = position.toLong()
|
||||||
|
|
||||||
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||||
|
getDropDownView(position, convertView, parent)
|
||||||
|
|
||||||
|
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
|
val acc = accounts[position]
|
||||||
|
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
|
||||||
|
convertView.tag as ItemAccountDropdownBinding
|
||||||
|
} else {
|
||||||
|
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||||
|
.also { it.root.tag = it }
|
||||||
|
}
|
||||||
|
val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||||
|
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||||
|
b.tvDropdownAccountNumber.text = acc.accountNumber
|
||||||
|
b.tvDropdownBalance.text = ""
|
||||||
|
b.root.alpha = 1f
|
||||||
|
return b.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilter() = object : Filter() {
|
||||||
|
override fun performFiltering(c: CharSequence?) =
|
||||||
|
FilterResults().apply { values = accounts; count = accounts.size }
|
||||||
|
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
|
||||||
|
override fun convertResultToString(r: Any?) =
|
||||||
|
(r as? MibAccount)?.let {
|
||||||
|
val prefix = if (it.bank == "BML" && it.profileName.isNotBlank()) "${it.profileName} · " else ""
|
||||||
|
"$prefix${it.accountBriefName}"
|
||||||
|
} ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/*") }
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import android.widget.LinearLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.materialswitch.MaterialSwitch
|
||||||
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.BmlProfile
|
||||||
|
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,60 +61,32 @@ 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) } }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in bmlLoginIds) {
|
for (loginId in bmlLoginIds) {
|
||||||
val profile = store.loadBmlUserProfile(loginId)
|
val profile = store.loadBmlUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
||||||
val profileNames = AccountCache.loadBml(ctx, loginId).map { it.profileName }.filter { it.isNotBlank() }.distinct()
|
val bmlProfiles = store.loadBmlProfiles(loginId)
|
||||||
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
||||||
showLoginDetails(
|
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
||||||
title = getString(R.string.bml_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 (!profile?.customerId.isNullOrBlank()) appendLine("${getString(R.string.login_detail_customer_id)}: ${profile!!.customerId}")
|
|
||||||
if (!profile?.idCard.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.idCard}")
|
|
||||||
if (profileNames.isNotEmpty()) {
|
|
||||||
appendLine()
|
|
||||||
appendLine(getString(R.string.login_detail_profiles))
|
|
||||||
profileNames.forEach { appendLine(" • $it") }
|
|
||||||
}
|
|
||||||
}.trim(),
|
|
||||||
onLogout = { confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) } }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +97,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 +129,247 @@ 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 toggle rows — wired up after dialog.show() so we can reference the Save button
|
||||||
|
val toggleRows = 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 toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||||
|
row.addView(textCol)
|
||||||
|
row.addView(toggle)
|
||||||
|
container.addView(row)
|
||||||
|
p to toggle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
|
val visibleCount = mibProfiles.count { it.profileId !in hidden }
|
||||||
|
toggleRows.forEach { (p, toggle) ->
|
||||||
|
// Disable the sole remaining visible toggle so it can't be turned off
|
||||||
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
|
}
|
||||||
|
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
|
||||||
|
toggleRows.forEach { (p, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBtn.setOnClickListener {
|
||||||
|
store.setHiddenMibProfileIds(loginId, hidden)
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
dialog.dismiss()
|
||||||
|
(activity as? HomeActivity)?.relogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBmlLoginDetails(
|
||||||
|
store: CredentialStore,
|
||||||
|
loginId: String,
|
||||||
|
profile: CredentialStore.BmlUserProfile?,
|
||||||
|
bmlProfiles: List<BmlProfile>
|
||||||
|
) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val originalHidden = store.getHiddenBmlProfileIds(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)
|
||||||
|
|
||||||
|
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" },
|
||||||
|
profile?.customerId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_customer_id)}: $it" },
|
||||||
|
profile?.idCard?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: $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 (bmlProfiles.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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
val toggleRows = bmlProfiles.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.type.isNotBlank()) {
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = p.type
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.6f
|
||||||
|
})
|
||||||
|
}
|
||||||
|
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||||
|
row.addView(textCol)
|
||||||
|
row.addView(toggle)
|
||||||
|
container.addView(row)
|
||||||
|
p to toggle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
|
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
|
||||||
|
toggleRows.forEach { (_, toggle) ->
|
||||||
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
|
}
|
||||||
|
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(getString(R.string.bml_name))
|
||||||
|
.setView(scroll)
|
||||||
|
.setPositiveButton(R.string.save, null)
|
||||||
|
.setNeutralButton(R.string.close, null)
|
||||||
|
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||||
|
confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) }
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
|
||||||
|
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||||
|
saveBtn.isEnabled = false
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
|
||||||
|
toggleRows.forEach { (p, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBtn.setOnClickListener {
|
||||||
|
store.setHiddenBmlProfileIds(loginId, hidden)
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
dialog.dismiss()
|
||||||
|
(activity as? HomeActivity)?.applyProfileVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 +388,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()
|
||||||
@@ -185,20 +405,26 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
|
|
||||||
private fun logoutBml(store: CredentialStore, loginId: String) {
|
private fun logoutBml(store: CredentialStore, loginId: String) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
store.clearBmlCredentials(loginId); store.clearBmlSession(loginId)
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
app.bmlSessions.remove(loginId)
|
// Remove all per-profile sessions for this login from the in-memory map
|
||||||
|
val profiles = app.bmlProfilesMap[loginId] ?: emptyList()
|
||||||
|
profiles.forEach { app.bmlSessions.remove(it.profileId) }
|
||||||
|
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
|
||||||
|
store.clearBmlCredentials(loginId)
|
||||||
|
app.bmlProfilesMap.remove(loginId)
|
||||||
|
app.bmlLoginFlows.remove(loginId)
|
||||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
|
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
|
||||||
clearAllCaches(ctx)
|
clearAllCaches(ctx)
|
||||||
(activity as HomeActivity).relogin()
|
(activity as HomeActivity).relogin()
|
||||||
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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -77,6 +79,17 @@ class SettingsSecurityFragment : Fragment() {
|
|||||||
(activity as? HomeActivity)?.resetAutolockTimer()
|
(activity as? HomeActivity)?.resetAutolockTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide sensitive information (enables/disables the eye icon in toolbar)
|
||||||
|
val viewModel = (requireActivity() as HomeActivity).let {
|
||||||
|
androidx.lifecycle.ViewModelProvider(it)[HomeViewModel::class.java]
|
||||||
|
}
|
||||||
|
binding.switchHideAmounts.isChecked = prefs.getBoolean("hide_sensitive_info", false)
|
||||||
|
binding.switchHideAmounts.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("hide_sensitive_info", isChecked).apply()
|
||||||
|
if (!isChecked) viewModel.hideAmounts.value = false
|
||||||
|
requireActivity().invalidateOptionsMenu()
|
||||||
|
}
|
||||||
|
|
||||||
// Block screenshots
|
// Block screenshots
|
||||||
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
|
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
|
||||||
binding.switchBlockScreenshots.isChecked = blockScreenshots
|
binding.switchBlockScreenshots.isChecked = blockScreenshots
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
|||||||
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
||||||
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
||||||
|
|
||||||
/** Adapter for Transfer History — date-grouped, shows account name in secondary line. */
|
/** Adapter for Transaction History — date-grouped, shows account name in secondary line. */
|
||||||
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
private sealed class Item {
|
private sealed class Item {
|
||||||
@@ -27,6 +27,13 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||||||
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
||||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||||
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||||
|
private var hideAmounts: Boolean = false
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||||
imageCache[counterpartyName] = bitmap
|
imageCache[counterpartyName] = bitmap
|
||||||
@@ -134,18 +141,23 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||||||
}
|
}
|
||||||
b.tvDescription.text = trx.description
|
b.tvDescription.text = trx.description
|
||||||
|
|
||||||
// Show account name in secondary line for Transfer History
|
// Show account name in secondary line for Transaction History
|
||||||
b.tvCounterparty.text = trx.accountDisplayName
|
b.tvCounterparty.text = trx.accountDisplayName
|
||||||
b.tvCounterparty.visibility = View.VISIBLE
|
b.tvCounterparty.visibility = View.VISIBLE
|
||||||
|
|
||||||
b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date)
|
b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date)
|
||||||
|
|
||||||
val sign = if (isCredit) "+" else "-"
|
if (hideAmounts) {
|
||||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
b.tvAmount.text = "${trx.currency} ••••••"
|
||||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
b.tvAmount.setTextColor(Color.parseColor("#888888"))
|
||||||
b.tvAmount.setTextColor(
|
} else {
|
||||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
val sign = if (isCredit) "+" else "-"
|
||||||
)
|
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||||
|
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||||
|
b.tvAmount.setTextColor(
|
||||||
|
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
b.root.setOnClickListener { showDetail(trx) }
|
b.root.setOnClickListener { showDetail(trx) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 +50,13 @@ 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
|
||||||
import sh.sar.basedbank.util.RecentPick
|
import sh.sar.basedbank.util.RecentPick
|
||||||
import sh.sar.basedbank.util.RecentsCache
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
class TransferFragment : Fragment() {
|
class TransferFragment : Fragment() {
|
||||||
@@ -62,7 +66,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()
|
||||||
@@ -96,6 +102,8 @@ class TransferFragment : Fragment() {
|
|||||||
private const val ARG_COLOR = "contact_color"
|
private const val ARG_COLOR = "contact_color"
|
||||||
private const val ARG_IMAGE_HASH = "contact_image_hash"
|
private const val ARG_IMAGE_HASH = "contact_image_hash"
|
||||||
private const val ARG_FROM_ACCOUNT = "from_account"
|
private const val ARG_FROM_ACCOUNT = "from_account"
|
||||||
|
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
|
||||||
|
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
||||||
|
|
||||||
fun newInstanceFrom(account: MibAccount) = TransferFragment().apply {
|
fun newInstanceFrom(account: MibAccount) = TransferFragment().apply {
|
||||||
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
||||||
@@ -116,6 +124,22 @@ class TransferFragment : Fragment() {
|
|||||||
if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash)
|
if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun newInstanceFromQr(
|
||||||
|
accountNumber: String,
|
||||||
|
displayName: String,
|
||||||
|
amount: String?,
|
||||||
|
remarks: String?
|
||||||
|
) = TransferFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putString(ARG_ACCOUNT, accountNumber)
|
||||||
|
putString(ARG_NAME, displayName)
|
||||||
|
putString(ARG_SUBTITLE, accountNumber)
|
||||||
|
putString(ARG_COLOR, "#607D8B")
|
||||||
|
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
|
||||||
|
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
@@ -145,9 +169,12 @@ 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() }
|
||||||
|
|
||||||
// Pre-select contact if navigated from contacts page
|
binding.etAmount.addTextChangedListener { updateTransferButton() }
|
||||||
|
|
||||||
|
// Pre-select contact if navigated from contacts page or QR scan
|
||||||
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
||||||
prefillToDirectly(
|
prefillToDirectly(
|
||||||
accountNumber = account,
|
accountNumber = account,
|
||||||
@@ -157,6 +184,24 @@ class TransferFragment : Fragment() {
|
|||||||
imageHash = arguments?.getString(ARG_IMAGE_HASH)
|
imageHash = arguments?.getString(ARG_IMAGE_HASH)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
||||||
|
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
@@ -166,6 +211,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 +223,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 +233,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 +264,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 +274,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 +301,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 +313,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 +344,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 +360,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 +406,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 +426,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 +442,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 +469,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 +553,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 +601,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 +612,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 +637,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,7 +647,9 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
if (ok && receipt != null) {
|
if (ok && receipt != null) {
|
||||||
|
ReceiptStore.save(requireContext(), receipt)
|
||||||
clearForm()
|
clearForm()
|
||||||
val activity = requireActivity() as HomeActivity
|
val activity = requireActivity() as HomeActivity
|
||||||
activity.refreshBalances(src)
|
activity.refreshBalances(src)
|
||||||
@@ -606,7 +660,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 +741,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"
|
||||||
@@ -721,7 +777,7 @@ class TransferFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
val receipt = TransferReceiptData(
|
val receipt = TransferReceiptData(
|
||||||
isMib = true,
|
bank = "MIB",
|
||||||
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
|
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
|
||||||
currency = currency,
|
currency = currency,
|
||||||
fromLabel = src.accountBriefName,
|
fromLabel = src.accountBriefName,
|
||||||
@@ -805,7 +861,7 @@ class TransferFragment : Fragment() {
|
|||||||
val result = bmlFlow.confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
|
val result = bmlFlow.confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
val receipt = TransferReceiptData(
|
val receipt = TransferReceiptData(
|
||||||
isMib = false,
|
bank = "BML",
|
||||||
amount = "%.2f".format(amount),
|
amount = "%.2f".format(amount),
|
||||||
currency = currency,
|
currency = currency,
|
||||||
fromLabel = src.accountBriefName,
|
fromLabel = src.accountBriefName,
|
||||||
@@ -829,6 +885,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 +920,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 +1051,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +86,20 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
adapter = TransactionAdapter()
|
adapter = TransactionAdapter()
|
||||||
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
||||||
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
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 +155,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 +199,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 +218,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 +293,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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
data class TransferReceiptData(
|
data class TransferReceiptData(
|
||||||
val isMib: Boolean,
|
val bank: String, // "MIB", "BML", etc.
|
||||||
val amount: String,
|
val amount: String,
|
||||||
val currency: String,
|
val currency: String,
|
||||||
val fromLabel: String,
|
val fromLabel: String,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
@@ -46,7 +50,7 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
private val receiptCard get() = _receiptCard!!
|
private val receiptCard get() = _receiptCard!!
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val ARG_IS_MIB = "is_mib"
|
private const val ARG_BANK = "bank"
|
||||||
private const val ARG_AMOUNT = "amount"
|
private const val ARG_AMOUNT = "amount"
|
||||||
private const val ARG_CURRENCY = "currency"
|
private const val ARG_CURRENCY = "currency"
|
||||||
private const val ARG_FROM_LABEL = "from_label"
|
private const val ARG_FROM_LABEL = "from_label"
|
||||||
@@ -69,7 +73,7 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply {
|
fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply {
|
||||||
pendingToAvatarBitmap = toAvatarBitmap
|
pendingToAvatarBitmap = toAvatarBitmap
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
putBoolean(ARG_IS_MIB, data.isMib)
|
putString(ARG_BANK, data.bank)
|
||||||
putString(ARG_AMOUNT, data.amount)
|
putString(ARG_AMOUNT, data.amount)
|
||||||
putString(ARG_CURRENCY, data.currency)
|
putString(ARG_CURRENCY, data.currency)
|
||||||
putString(ARG_FROM_LABEL, data.fromLabel)
|
putString(ARG_FROM_LABEL, data.fromLabel)
|
||||||
@@ -90,8 +94,8 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
val isMib = arguments?.getBoolean(ARG_IS_MIB, true) ?: true
|
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||||
return if (isMib) {
|
return if (bank == "MIB") {
|
||||||
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
||||||
bindMib(binding)
|
bindMib(binding)
|
||||||
_receiptCard = binding.receiptCard
|
_receiptCard = binding.receiptCard
|
||||||
@@ -105,6 +109,8 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
receiptCard.setOnClickListener { showFullScreenReceipt() }
|
||||||
|
|
||||||
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
||||||
parentFragmentManager.popBackStack()
|
parentFragmentManager.popBackStack()
|
||||||
}
|
}
|
||||||
@@ -150,15 +156,21 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
|
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
|
||||||
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
|
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
|
||||||
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
||||||
|
|
||||||
|
copyOnLongClick(
|
||||||
|
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
|
||||||
|
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
|
||||||
|
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -201,6 +213,13 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
binding.remarksDivider.visibility = View.VISIBLE
|
binding.remarksDivider.visibility = View.VISIBLE
|
||||||
binding.remarksRow.visibility = View.VISIBLE
|
binding.remarksRow.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
copyOnLongClick(
|
||||||
|
binding.tvMessage, binding.tvMessageRow, binding.tvReference,
|
||||||
|
binding.tvTransactionDate, binding.tvFrom, binding.tvToName,
|
||||||
|
binding.tvToAccount, binding.tvAmountRow, binding.tvAmountValue,
|
||||||
|
binding.tvAmountCurrency, binding.tvRemarks
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Share / Save ──────────────────────────────────────────────────────────
|
// ── Share / Save ──────────────────────────────────────────────────────────
|
||||||
@@ -310,9 +329,63 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
return bm
|
return bm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showFullScreenReceipt() {
|
||||||
|
captureReceiptBitmap { bitmap ->
|
||||||
|
if (bitmap == null) return@captureReceiptBitmap
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
||||||
|
val iv = android.widget.ImageView(ctx).apply {
|
||||||
|
setImageBitmap(bitmap)
|
||||||
|
scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
setBackgroundColor(Color.BLACK)
|
||||||
|
}
|
||||||
|
iv.setOnClickListener { dialog.dismiss() }
|
||||||
|
dialog.setContentView(iv)
|
||||||
|
val actWin = requireActivity().window
|
||||||
|
val prevColor = actWin.statusBarColor
|
||||||
|
val insetsCtrl = androidx.core.view.WindowInsetsControllerCompat(actWin, actWin.decorView)
|
||||||
|
actWin.statusBarColor = Color.BLACK
|
||||||
|
insetsCtrl.isAppearanceLightStatusBars = false
|
||||||
|
dialog.setOnDismissListener {
|
||||||
|
actWin.statusBarColor = prevColor
|
||||||
|
val isLight = (resources.configuration.uiMode and
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
insetsCtrl.isAppearanceLightStatusBars = isLight
|
||||||
|
}
|
||||||
|
dialog.show()
|
||||||
|
dialog.window?.let { win ->
|
||||||
|
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
||||||
|
androidx.core.view.WindowInsetsControllerCompat(win, iv).apply {
|
||||||
|
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
|
||||||
|
systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
||||||
|
for (tv in views) {
|
||||||
|
tv.setOnLongClickListener {
|
||||||
|
val text = tv.text?.toString()?.trim() ?: return@setOnLongClickListener false
|
||||||
|
if (text.isBlank()) return@setOnLongClickListener false
|
||||||
|
val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
cm.setPrimaryClip(ClipData.newPlainText("receipt", text))
|
||||||
|
Toast.makeText(requireContext(), "Copied", Toast.LENGTH_SHORT).show()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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() {
|
||||||
|
|||||||
@@ -18,14 +18,21 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
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.BmlActivationResult
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
|
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||||
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
import sh.sar.basedbank.util.AccountCache
|
import sh.sar.basedbank.util.AccountCache
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
||||||
import sh.sar.basedbank.ui.home.HomeActivity
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
|
||||||
class CredentialsFragment : Fragment() {
|
class CredentialsFragment : Fragment() {
|
||||||
|
|
||||||
@@ -46,6 +53,15 @@ class CredentialsFragment : Fragment() {
|
|||||||
private var fahipayFlow: FahipayLoginFlow? = null
|
private var fahipayFlow: FahipayLoginFlow? = null
|
||||||
private var fahipayAwaitingTotp = false
|
private var fahipayAwaitingTotp = false
|
||||||
|
|
||||||
|
// BML multi-profile state
|
||||||
|
private var bmlFlow: BmlLoginFlow? = null
|
||||||
|
private var bmlLoginId: String = ""
|
||||||
|
private var bmlAccumulatedAccounts = mutableListOf<MibAccount>()
|
||||||
|
private var bmlPendingBusinessProfiles = ArrayDeque<Pair<BmlProfile, List<BmlOtpChannel>>>()
|
||||||
|
private var bmlCurrentBusinessProfile: BmlProfile? = null
|
||||||
|
private var bmlSelectedChannel: String? = null
|
||||||
|
private var bmlAwaitingBusinessOtp = false
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -54,7 +70,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 +83,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 +120,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,19 +161,23 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attemptLogin() {
|
private fun attemptLogin() {
|
||||||
when (bankType) {
|
when (bankType) {
|
||||||
"BML" -> { attemptBmlLogin(); return }
|
"BML" -> {
|
||||||
|
if (bmlAwaitingBusinessOtp) submitBmlBusinessOtp()
|
||||||
|
else attemptBmlLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +190,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 +199,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 +215,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 +240,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"
|
||||||
@@ -185,20 +252,186 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.progressBar.visibility = View.VISIBLE
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
binding.btnLogin.isEnabled = false
|
binding.btnLogin.isEnabled = false
|
||||||
|
|
||||||
val loginId = username
|
bmlLoginId = username
|
||||||
val flow = BmlLoginFlow()
|
bmlAccumulatedAccounts.clear()
|
||||||
|
bmlPendingBusinessProfiles.clear()
|
||||||
|
bmlCurrentBusinessProfile = null
|
||||||
|
bmlSelectedChannel = null
|
||||||
|
bmlAwaitingBusinessOtp = false
|
||||||
|
|
||||||
|
val flow = BmlLoginFlow().also { bmlFlow = it }
|
||||||
|
val loginTag = "bml_$username"
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val profiles = withContext(Dispatchers.IO) {
|
||||||
|
flow.login(username, password, otpSeed)
|
||||||
|
}
|
||||||
|
if (profiles.isEmpty()) throw Exception("No profiles found for this account")
|
||||||
|
|
||||||
|
// Activate each profile; personal profiles are immediate, business ones need OTP
|
||||||
|
for (profile in profiles) {
|
||||||
|
val result = withContext(Dispatchers.IO) { flow.activateProfile(profile, loginTag) }
|
||||||
|
when (result) {
|
||||||
|
is BmlActivationResult.Success -> {
|
||||||
|
bmlAccumulatedAccounts += result.accounts
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
app.bmlSessions[profile.profileId] = result.session
|
||||||
|
}
|
||||||
|
is BmlActivationResult.NeedsBusinessOtp -> {
|
||||||
|
bmlPendingBusinessProfiles.addLast(Pair(profile, result.channels))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save credentials and profile list now (before business OTP prompts)
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
store.saveBmlCredentials(bmlLoginId, username, password, otpSeed)
|
||||||
|
store.saveBmlProfiles(bmlLoginId, profiles)
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
app.bmlProfilesMap[bmlLoginId] = profiles
|
||||||
|
app.bmlLoginFlows[bmlLoginId] = flow
|
||||||
|
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
|
||||||
|
if (bmlPendingBusinessProfiles.isNotEmpty()) {
|
||||||
|
processNextBmlBusinessProfile()
|
||||||
|
} else {
|
||||||
|
finishBmlLogin()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
binding.tvError.text = e.message ?: "Login failed"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun processNextBmlBusinessProfile() {
|
||||||
|
val (profile, channels) = bmlPendingBusinessProfiles.removeFirstOrNull()
|
||||||
|
?: run { finishBmlLogin(); return }
|
||||||
|
|
||||||
|
bmlCurrentBusinessProfile = profile
|
||||||
|
|
||||||
|
// Show channel selection dialog
|
||||||
|
val selectedChannel = showBmlChannelDialog(profile.name, channels) ?: run {
|
||||||
|
// User skipped this profile — move on
|
||||||
|
processNextBmlBusinessProfile()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bmlSelectedChannel = selectedChannel
|
||||||
|
|
||||||
|
// Request OTP
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
binding.btnLogin.isEnabled = false
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
bmlFlow!!.requestBusinessOtp(selectedChannel)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
binding.tvError.text = e.message ?: "Failed to send OTP"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
processNextBmlBusinessProfile()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show OTP input — disable credential fields (same pattern as Fahipay TOTP step)
|
||||||
|
bmlAwaitingBusinessOtp = true
|
||||||
|
binding.etUsername.isEnabled = false
|
||||||
|
binding.etPassword.isEnabled = false
|
||||||
|
binding.etOtpSeed.isEnabled = false
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.tilTotpCode.hint = getString(R.string.bml_business_otp_hint, profile.name)
|
||||||
|
binding.tilTotpCode.helperText = getString(R.string.bml_business_otp_sent, selectedChannel)
|
||||||
|
binding.tilTotpCode.visibility = View.VISIBLE
|
||||||
|
binding.etTotpCode.text?.clear()
|
||||||
|
binding.btnLogin.text = getString(R.string.verify)
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
binding.tvError.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun submitBmlBusinessOtp() {
|
||||||
|
val code = binding.etTotpCode.text.toString().trim()
|
||||||
|
if (code.length != 6) {
|
||||||
|
binding.tvError.text = getString(R.string.fahipay_totp_hint)
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvError.visibility = View.GONE
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
binding.btnLogin.isEnabled = false
|
||||||
|
|
||||||
|
val profile = bmlCurrentBusinessProfile ?: return
|
||||||
|
val channel = bmlSelectedChannel ?: return
|
||||||
|
val loginTag = "bml_$bmlLoginId"
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
val (session, accounts) = withContext(Dispatchers.IO) {
|
val (session, accounts) = withContext(Dispatchers.IO) {
|
||||||
flow.login(username, password, otpSeed)
|
bmlFlow!!.submitBusinessOtp(channel, code, profile, loginTag)
|
||||||
}
|
}
|
||||||
|
bmlAccumulatedAccounts += accounts
|
||||||
val store = CredentialStore(requireContext())
|
val store = CredentialStore(requireContext())
|
||||||
store.saveBmlCredentials(loginId, username, password, otpSeed)
|
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
|
||||||
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
|
val app = requireActivity().application as BasedBankApp
|
||||||
withContext(Dispatchers.IO) {
|
app.bmlSessions[profile.profileId] = session
|
||||||
val info = flow.fetchUserInfo(session)
|
|
||||||
if (info != null) store.saveBmlUserProfile(
|
bmlAwaitingBusinessOtp = false
|
||||||
loginId,
|
binding.tilTotpCode.visibility = View.GONE
|
||||||
|
binding.btnLogin.text = getString(R.string.login)
|
||||||
|
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
|
||||||
|
if (bmlPendingBusinessProfiles.isNotEmpty()) {
|
||||||
|
processNextBmlBusinessProfile()
|
||||||
|
} else {
|
||||||
|
finishBmlLogin()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
binding.tvError.text = e.message ?: "OTP verification failed"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun showBmlChannelDialog(profileName: String, channels: List<BmlOtpChannel>): String? =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val options = channels.map { "${it.description} (${it.masked})" }.toTypedArray()
|
||||||
|
val dialog = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(getString(R.string.bml_business_otp_title, profileName))
|
||||||
|
.setItems(options) { _, which ->
|
||||||
|
if (cont.isActive) cont.resume(channels[which].channel)
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.bml_business_otp_skip) { _: android.content.DialogInterface, _: Int ->
|
||||||
|
if (cont.isActive) cont.resume(null as String?)
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
dialog.setOnCancelListener { if (cont.isActive) cont.resume(null as String?) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun finishBmlLogin() {
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
val accounts = bmlAccumulatedAccounts.toList()
|
||||||
|
|
||||||
|
// Fetch user profile info from any active session
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val anySession = app.anyBmlSessionFor(bmlLoginId)
|
||||||
|
if (anySession != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val info = BmlLoginFlow().fetchUserInfo(anySession)
|
||||||
|
if (info != null) {
|
||||||
|
store.saveBmlUserProfile(
|
||||||
|
bmlLoginId,
|
||||||
CredentialStore.BmlUserProfile(
|
CredentialStore.BmlUserProfile(
|
||||||
fullName = info.fullName,
|
fullName = info.fullName,
|
||||||
email = info.email,
|
email = info.email,
|
||||||
@@ -208,24 +441,44 @@ class CredentialsFragment : Fragment() {
|
|||||||
birthdate = info.birthdate
|
birthdate = info.birthdate
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
// Single-profile accounts used username as a temporary profileId.
|
||||||
|
// Replace it with the real BML customer ID so multi-login doesn't collide.
|
||||||
|
val customerId = info.customerId
|
||||||
|
if (customerId.isNotBlank()) {
|
||||||
|
val profiles = store.loadBmlProfiles(bmlLoginId)
|
||||||
|
val autoProfile = profiles.firstOrNull { it.autoActivated }
|
||||||
|
if (autoProfile != null && autoProfile.profileId != customerId) {
|
||||||
|
val oldId = autoProfile.profileId
|
||||||
|
// Re-key session in memory and storage
|
||||||
|
val session = app.bmlSessions.remove(oldId)
|
||||||
|
if (session != null) {
|
||||||
|
app.bmlSessions[customerId] = session
|
||||||
|
store.clearBmlProfileSession(oldId)
|
||||||
|
store.saveBmlProfileSession(customerId, session.accessToken, session.deviceId)
|
||||||
|
}
|
||||||
|
// Update stored profile list with the real ID
|
||||||
|
val updatedProfiles = profiles.map {
|
||||||
|
if (it.autoActivated) it.copy(profileId = customerId) else it
|
||||||
|
}
|
||||||
|
store.saveBmlProfiles(bmlLoginId, updatedProfiles)
|
||||||
|
app.bmlProfilesMap[bmlLoginId] = updatedProfiles
|
||||||
|
// Update accounts to use real profileId
|
||||||
|
bmlAccumulatedAccounts.replaceAll { acc ->
|
||||||
|
if (acc.profileId == oldId) acc.copy(profileId = customerId) else acc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
AccountCache.saveBml(requireContext(), loginId, accounts)
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
|
||||||
app.bmlSessions[loginId] = session
|
|
||||||
// Merge with any existing BML accounts from other logins
|
|
||||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } + accounts
|
|
||||||
app.accounts = app.accounts + accounts
|
|
||||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
startActivity(intent)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
binding.tvError.text = e.message ?: "Login failed"
|
|
||||||
binding.tvError.visibility = View.VISIBLE
|
|
||||||
} finally {
|
|
||||||
binding.progressBar.visibility = View.GONE
|
|
||||||
binding.btnLogin.isEnabled = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AccountCache.saveBml(requireContext(), bmlLoginId, accounts)
|
||||||
|
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
|
||||||
|
app.accounts = app.accounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
|
||||||
|
|
||||||
|
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attemptFahipayLogin() {
|
private fun attemptFahipayLogin() {
|
||||||
@@ -333,11 +586,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 +603,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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package sh.sar.basedbank.ui.onboarding
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
|
||||||
|
|
||||||
|
class OnboardingConfigureFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentOnboardingConfigureBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentOnboardingConfigureBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
// Navigation — default Drawer
|
||||||
|
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||||
|
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
||||||
|
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
|
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme — default System
|
||||||
|
val savedTheme = prefs.getString("theme", "system")
|
||||||
|
binding.themeToggle.check(when (savedTheme) {
|
||||||
|
"light" -> R.id.btnThemeLight
|
||||||
|
"dark" -> R.id.btnThemeDark
|
||||||
|
else -> R.id.btnThemeSystem
|
||||||
|
})
|
||||||
|
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
|
val (key, mode) = when (checkedId) {
|
||||||
|
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
|
||||||
|
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
|
||||||
|
else -> "system" to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||||
|
}
|
||||||
|
prefs.edit().putString("theme", key).apply()
|
||||||
|
AppCompatDelegate.setDefaultNightMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Biometrics
|
||||||
|
val canUseBiometrics = BiometricManager.from(requireContext())
|
||||||
|
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
|
if (canUseBiometrics) {
|
||||||
|
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||||
|
binding.switchBiometrics.isChecked = unlockEnabled
|
||||||
|
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||||
|
binding.switchBiometricsTransfer.isEnabled = unlockEnabled
|
||||||
|
|
||||||
|
binding.switchBiometrics.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("biometrics_enabled", isChecked).apply()
|
||||||
|
binding.switchBiometricsTransfer.isEnabled = isChecked
|
||||||
|
if (!isChecked) {
|
||||||
|
binding.switchBiometricsTransfer.isChecked = false
|
||||||
|
prefs.edit().putBoolean("biometrics_transfer_confirm", false).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
binding.tvBiometricsHint.visibility = View.VISIBLE
|
||||||
|
binding.switchBiometrics.isEnabled = false
|
||||||
|
binding.switchBiometricsTransfer.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block screenshots — default on
|
||||||
|
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
|
||||||
|
binding.switchBlockScreenshots.isChecked = blockScreenshots
|
||||||
|
applyFlagSecure(blockScreenshots)
|
||||||
|
binding.switchBlockScreenshots.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("block_screenshots", isChecked).apply()
|
||||||
|
applyFlagSecure(isChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyFlagSecure(enabled: Boolean) {
|
||||||
|
val win = activity?.window ?: return
|
||||||
|
if (enabled) win.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
else win.clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import android.os.Bundle
|
|||||||
import android.view.LayoutInflater
|
import android.view.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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
16
app/src/main/java/sh/sar/basedbank/util/AccountListParser.kt
Normal file
16
app/src/main/java/sh/sar/basedbank/util/AccountListParser.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/src/main/java/sh/sar/basedbank/util/ContactDisplay.kt
Normal file
22
app/src/main/java/sh/sar/basedbank/util/ContactDisplay.kt
Normal 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
|
||||||
|
)
|
||||||
18
app/src/main/java/sh/sar/basedbank/util/ContactListParser.kt
Normal file
18
app/src/main/java/sh/sar/basedbank/util/ContactListParser.kt
Normal 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) }
|
||||||
|
}
|
||||||
40
app/src/main/java/sh/sar/basedbank/util/ContactManager.kt
Normal file
40
app/src/main/java/sh/sar/basedbank/util/ContactManager.kt
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import android.security.keystore.KeyGenParameterSpec
|
|||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.KeyGenerator
|
import javax.crypto.KeyGenerator
|
||||||
@@ -21,104 +22,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() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,24 +194,85 @@ class CredentialStore(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clearBmlCredentials(loginId: String) {
|
fun clearBmlCredentials(loginId: String) {
|
||||||
|
loadBmlProfiles(loginId).forEach { clearBmlProfileSession(it.profileId) }
|
||||||
removeBmlLoginId(loginId)
|
removeBmlLoginId(loginId)
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove("bml_${loginId}_enc_username")
|
.remove("bml_${loginId}_enc_username")
|
||||||
.remove("bml_${loginId}_enc_password")
|
.remove("bml_${loginId}_enc_password")
|
||||||
.remove("bml_${loginId}_enc_otp_seed")
|
.remove("bml_${loginId}_enc_otp_seed")
|
||||||
|
.remove("bml_${loginId}_profiles")
|
||||||
|
.remove("bml_${loginId}_hidden_profile_ids")
|
||||||
|
// legacy single-profile session keys
|
||||||
|
.remove("bml_${loginId}_enc_token")
|
||||||
|
.remove("bml_${loginId}_enc_device_id")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BML session token (per loginId) ───────────────────────────────────────
|
// ── BML profiles (per loginId) ────────────────────────────────────────────
|
||||||
|
|
||||||
fun saveBmlSession(loginId: String, accessToken: String, deviceId: String) {
|
fun saveBmlProfiles(loginId: String, profiles: List<BmlProfile>) {
|
||||||
|
val arr = org.json.JSONArray()
|
||||||
|
for (p in profiles) arr.put(org.json.JSONObject().apply {
|
||||||
|
put("profileId", p.profileId)
|
||||||
|
put("name", p.name)
|
||||||
|
put("type", p.type)
|
||||||
|
put("profileType", p.profileType)
|
||||||
|
put("autoActivated", p.autoActivated)
|
||||||
|
})
|
||||||
|
prefs.edit().putString("bml_${loginId}_profiles", arr.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBmlProfiles(loginId: String): List<BmlProfile> {
|
||||||
|
val raw = prefs.getString("bml_${loginId}_profiles", null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = org.json.JSONArray(raw)
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
BmlProfile(
|
||||||
|
profileId = o.optString("profileId"),
|
||||||
|
name = o.optString("name"),
|
||||||
|
type = o.optString("type"),
|
||||||
|
profileType = o.optString("profileType", "default"),
|
||||||
|
autoActivated = o.optBoolean("autoActivated", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getHiddenBmlProfileIds(loginId: String): Set<String> =
|
||||||
|
prefs.getStringSet("bml_${loginId}_hidden_profile_ids", emptySet()) ?: emptySet()
|
||||||
|
|
||||||
|
fun setHiddenBmlProfileIds(loginId: String, ids: Set<String>) =
|
||||||
|
prefs.edit().putStringSet("bml_${loginId}_hidden_profile_ids", ids).apply()
|
||||||
|
|
||||||
|
// ── BML per-profile session token (keyed by profileId, a globally unique GUID) ──
|
||||||
|
|
||||||
|
fun saveBmlProfileSession(profileId: String, accessToken: String, deviceId: String) {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("bml_${loginId}_enc_token", encrypt(accessToken, key))
|
.putString("bml_profile_${profileId}_enc_token", encrypt(accessToken, key))
|
||||||
.putString("bml_${loginId}_enc_device_id", encrypt(deviceId, key))
|
.putString("bml_profile_${profileId}_enc_device_id", encrypt(deviceId, key))
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadBmlProfileSession(profileId: String): Pair<String, String>? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val encToken = prefs.getString("bml_profile_${profileId}_enc_token", null) ?: return null
|
||||||
|
val encDeviceId = prefs.getString("bml_profile_${profileId}_enc_device_id", null) ?: return null
|
||||||
|
return try {
|
||||||
|
Pair(decrypt(encToken, key), decrypt(encDeviceId, key))
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearBmlProfileSession(profileId: String) {
|
||||||
|
prefs.edit()
|
||||||
|
.remove("bml_profile_${profileId}_enc_token")
|
||||||
|
.remove("bml_profile_${profileId}_enc_device_id")
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Legacy single-profile BML session (kept for backward compat reading) ─
|
||||||
|
|
||||||
fun loadBmlSession(loginId: String): Pair<String, String>? {
|
fun loadBmlSession(loginId: String): Pair<String, String>? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
|
val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
|
||||||
@@ -186,65 +282,81 @@ class CredentialStore(context: Context) {
|
|||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearBmlSession(loginId: String) {
|
// ── Fahipay login credentials (multi-login, keyed by loginId = profileId) ──
|
||||||
|
|
||||||
|
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()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove("bml_${loginId}_enc_token")
|
.putString("fahipay_${loginId}_enc_id_card", encrypt(idCard, key))
|
||||||
.remove("bml_${loginId}_enc_device_id")
|
.putString("fahipay_${loginId}_enc_password", encrypt(password, key))
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fahipay login credentials ─────────────────────────────────────────────
|
fun loadFahipayCredentials(loginId: String): FahipayCredentials? {
|
||||||
|
|
||||||
fun saveFahipayCredentials(idCard: String, password: String) {
|
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
prefs.edit()
|
val encId = prefs.getString("fahipay_${loginId}_enc_id_card", null) ?: return null
|
||||||
.putString("fahipay_enc_id_card", encrypt(idCard, key))
|
val encPw = prefs.getString("fahipay_${loginId}_enc_password", null) ?: return null
|
||||||
.putString("fahipay_enc_password", encrypt(password, key))
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadFahipayCredentials(): FahipayCredentials? {
|
|
||||||
val key = getOrCreateKey()
|
|
||||||
val encId = prefs.getString("fahipay_enc_id_card", null) ?: return null
|
|
||||||
val encPw = prefs.getString("fahipay_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 +370,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 +382,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 +393,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 +413,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 +490,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 +547,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 +595,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 {
|
||||||
|
|||||||
117
app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt
Normal file
117
app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt
Normal file
83
app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Persistent (non-cache) store for completed transfer receipts shown in Recent Transfers. */
|
||||||
|
object ReceiptStore {
|
||||||
|
|
||||||
|
private const val FILE_NAME = "activities.json"
|
||||||
|
|
||||||
|
data class Entry(val data: TransferReceiptData, val savedAt: Long)
|
||||||
|
|
||||||
|
fun save(context: Context, receipt: TransferReceiptData) {
|
||||||
|
val existing = loadAll(context).toMutableList()
|
||||||
|
existing.add(0, Entry(receipt, System.currentTimeMillis()))
|
||||||
|
writeAll(context, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadAll(context: Context): List<Entry> {
|
||||||
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
if (!file.exists()) return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(CacheEncryption.decrypt(file.readText()))
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
Entry(
|
||||||
|
data = TransferReceiptData(
|
||||||
|
bank = o.optString("bank", "MIB"),
|
||||||
|
amount = o.optString("amount"),
|
||||||
|
currency = o.optString("currency"),
|
||||||
|
fromLabel = o.optString("fromLabel"),
|
||||||
|
fromColorHex = o.optString("fromColorHex"),
|
||||||
|
fromProfileImageHash = o.optString("fromProfileImageHash").takeIf { it.isNotBlank() },
|
||||||
|
toLabel = o.optString("toLabel"),
|
||||||
|
toAccount = o.optString("toAccount"),
|
||||||
|
toBank = o.optString("toBank"),
|
||||||
|
remarks = o.optString("remarks"),
|
||||||
|
mibReferenceNo = o.optString("mibReferenceNo"),
|
||||||
|
mibTransactionDate = o.optString("mibTransactionDate"),
|
||||||
|
bmlFromName = o.optString("bmlFromName"),
|
||||||
|
bmlReference = o.optString("bmlReference"),
|
||||||
|
bmlTimestamp = o.optString("bmlTimestamp"),
|
||||||
|
bmlMessage = o.optString("bmlMessage")
|
||||||
|
),
|
||||||
|
savedAt = o.optLong("savedAt", 0L)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll(context: Context) {
|
||||||
|
File(context.filesDir, FILE_NAME).delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeAll(context: Context, items: List<Entry>) {
|
||||||
|
try {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for ((d, ts) in items) arr.put(JSONObject().apply {
|
||||||
|
put("bank", d.bank)
|
||||||
|
put("amount", d.amount)
|
||||||
|
put("currency", d.currency)
|
||||||
|
put("fromLabel", d.fromLabel)
|
||||||
|
put("fromColorHex", d.fromColorHex)
|
||||||
|
put("fromProfileImageHash", d.fromProfileImageHash ?: "")
|
||||||
|
put("toLabel", d.toLabel)
|
||||||
|
put("toAccount", d.toAccount)
|
||||||
|
put("toBank", d.toBank)
|
||||||
|
put("remarks", d.remarks)
|
||||||
|
put("mibReferenceNo", d.mibReferenceNo)
|
||||||
|
put("mibTransactionDate", d.mibTransactionDate)
|
||||||
|
put("bmlFromName", d.bmlFromName)
|
||||||
|
put("bmlReference", d.bmlReference)
|
||||||
|
put("bmlTimestamp", d.bmlTimestamp)
|
||||||
|
put("bmlMessage", d.bmlMessage)
|
||||||
|
put("savedAt", ts)
|
||||||
|
})
|
||||||
|
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(arr.toString()))
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/src/main/java/sh/sar/basedbank/util/TransferNetwork.kt
Normal file
10
app/src/main/java/sh/sar/basedbank/util/TransferNetwork.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
10
app/src/main/res/drawable/americanexpress.xml
Normal file
10
app/src/main/res/drawable/americanexpress.xml
Normal 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>
|
||||||
BIN
app/src/main/res/drawable/bml_logo_long.png
Normal file
BIN
app/src/main/res/drawable/bml_logo_long.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
11
app/src/main/res/drawable/ic_check_circle.xml
Normal file
11
app/src/main/res/drawable/ic_check_circle.xml
Normal 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>
|
||||||
10
app/src/main/res/drawable/ic_drag_handle.xml
Normal file
10
app/src/main/res/drawable/ic_drag_handle.xml
Normal 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>
|
||||||
13
app/src/main/res/drawable/ic_save.xml
Normal file
13
app/src/main/res/drawable/ic_save.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="?attr/colorPrimary"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M12,3v13M7,11l5,5 5,-5M5,21h14" />
|
||||||
|
</vector>
|
||||||
13
app/src/main/res/drawable/ic_share.xml
Normal file
13
app/src/main/res/drawable/ic_share.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:strokeColor="?attr/colorPrimary"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81c1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3s-3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65c0,1.61 1.31,2.92 2.92,2.92s2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z" />
|
||||||
|
</vector>
|
||||||
11
app/src/main/res/drawable/ic_visibility.xml
Normal file
11
app/src/main/res/drawable/ic_visibility.xml
Normal 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,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39-6,-7.5-11,-7.5zM12,17c-2.76,0-5,-2.24-5,-5s2.24,-5 5,-5 5,2.24 5,5-2.24,5-5,5zM12,9c-1.66,0-3,1.34-3,3s1.34,3 3,3 3,-1.34 3,-3-1.34,-3-3,-3z" />
|
||||||
|
</vector>
|
||||||
11
app/src/main/res/drawable/ic_visibility_off.xml
Normal file
11
app/src/main/res/drawable/ic_visibility_off.xml
Normal 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,7c2.76,0 5,2.24 5,5 0,0.65-0.13,1.26-0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75-1.73,-4.39-6,-7.5-11,-7.5-1.4,0-2.74,0.25-3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21-0.08,0.43-0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33-1.41,0.53-2.2,0.53-2.76,0-5,-2.24-5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66-1.34,-3-3,-3l-0.17,0.01z" />
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/mastercard.xml
Normal file
10
app/src/main/res/drawable/mastercard.xml
Normal 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>
|
||||||
10
app/src/main/res/drawable/visa.xml
Normal file
10
app/src/main/res/drawable/visa.xml
Normal 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>
|
||||||
@@ -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,7 +52,8 @@
|
|||||||
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"
|
||||||
app:menu="@menu/bottom_nav_menu" />
|
android:fitsSystemWindows="true"
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
59
app/src/main/res/layout/fragment_activities.xml
Normal file
59
app/src/main/res/layout/fragment_activities.xml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="?attr/colorSurface">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="12dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||||
|
app:startIconDrawable="@android:drawable/ic_menu_search"
|
||||||
|
app:boxCornerRadiusTopStart="24dp"
|
||||||
|
app:boxCornerRadiusTopEnd="24dp"
|
||||||
|
app:boxCornerRadiusBottomStart="24dp"
|
||||||
|
app:boxCornerRadiusBottomEnd="24dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etSearch"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="Search"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:imeOptions="actionSearch" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recyclerView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="16dp"
|
||||||
|
android:clipToPadding="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/emptyView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="No recent transfers"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
218
app/src/main/res/layout/fragment_onboarding_configure.xml
Normal file
218
app/src/main/res/layout/fragment_onboarding_configure.xml
Normal 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>
|
||||||
@@ -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>
|
|
||||||
|
|||||||
118
app/src/main/res/layout/fragment_pay_mv_qr.xml
Normal file
118
app/src/main/res/layout/fragment_pay_mv_qr.xml
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:paddingTop="16dp"
|
||||||
|
android:paddingBottom="16dp"
|
||||||
|
android:background="?attr/colorSurface">
|
||||||
|
|
||||||
|
<!-- QR card fills all available space above the inputs -->
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginBottom="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvQrPlaceholder"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:text="Select an account"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivQrCard"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:contentDescription="@string/pay_mv_qr" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<!-- Account dropdown -->
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilAccount"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="10dp"
|
||||||
|
android:hint="@string/paymvqr_select_account">
|
||||||
|
|
||||||
|
<AutoCompleteTextView
|
||||||
|
android:id="@+id/actvAccount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="none"
|
||||||
|
android:focusable="false"
|
||||||
|
android:focusableInTouchMode="false" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<!-- Amount (optional) -->
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilAmount"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:hint="@string/paymvqr_amount_hint"
|
||||||
|
app:helperText="@string/paymvqr_amount_helper"
|
||||||
|
app:prefixText="MVR ">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etAmount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="numberDecimal"
|
||||||
|
android:maxLines="1" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<!-- Action buttons — always visible; share/save disabled until QR is rendered -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutActions"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnShare"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:enabled="false"
|
||||||
|
android:text="@string/paymvqr_share"
|
||||||
|
app:icon="@drawable/ic_share" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSave"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="4dp"
|
||||||
|
android:enabled="false"
|
||||||
|
android:text="@string/paymvqr_save_image"
|
||||||
|
app:icon="@drawable/ic_save" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnScanQr"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:text="@string/transfer_scan_qr"
|
||||||
|
app:icon="@drawable/ic_qr_scan" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout
|
<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"
|
||||||
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 +207,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 +214,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">
|
||||||
@@ -231,7 +226,8 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginEnd="4dp"
|
android:layout_marginEnd="4dp"
|
||||||
android:text="Share" />
|
android:text="Share"
|
||||||
|
app:icon="@drawable/ic_share" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnSave"
|
android:id="@+id/btnSave"
|
||||||
@@ -240,7 +236,8 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginHorizontal="4dp"
|
android:layout_marginHorizontal="4dp"
|
||||||
android:text="Save" />
|
android:text="Save"
|
||||||
|
app:icon="@drawable/ic_save" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnDone"
|
android:id="@+id/btnDone"
|
||||||
|
|||||||
@@ -253,7 +253,8 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginEnd="4dp"
|
android:layout_marginEnd="4dp"
|
||||||
android:text="Share" />
|
android:text="Share"
|
||||||
|
app:icon="@drawable/ic_share" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnSave"
|
android:id="@+id/btnSave"
|
||||||
@@ -262,7 +263,8 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginHorizontal="4dp"
|
android:layout_marginHorizontal="4dp"
|
||||||
android:text="Save" />
|
android:text="Save"
|
||||||
|
app:icon="@drawable/ic_save" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnDone"
|
android:id="@+id/btnDone"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -153,6 +162,44 @@
|
|||||||
android:layout_marginTop="24dp"
|
android:layout_marginTop="24dp"
|
||||||
android:layout_marginBottom="8dp" />
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowHideAmounts"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="4dp">
|
||||||
|
|
||||||
|
<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_hide_amounts"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/settings_hide_amounts_desc"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.materialswitch.MaterialSwitch
|
||||||
|
android:id="@+id/switchHideAmounts"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/rowBlockScreenshots"
|
android:id="@+id/rowBlockScreenshots"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
28
app/src/main/res/layout/item_nav_slot.xml
Normal file
28
app/src/main/res/layout/item_nav_slot.xml
Normal 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>
|
||||||
@@ -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" />
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
<menu 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">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_hide_amounts"
|
||||||
|
android:icon="@drawable/ic_visibility"
|
||||||
|
android:title="@string/action_hide_amounts"
|
||||||
|
app:showAsAction="always" />
|
||||||
|
|
||||||
<item
|
<item
|
||||||
android:id="@+id/action_lock"
|
android:id="@+id/action_lock"
|
||||||
android:icon="@drawable/ic_lock"
|
android:icon="@drawable/ic_lock"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -68,7 +70,7 @@
|
|||||||
<string name="nav_accounts">އެކައުންޓްތައް</string>
|
<string name="nav_accounts">އެކައުންޓްތައް</string>
|
||||||
<string name="nav_contacts">ކޮންޓެކްޓްތައް</string>
|
<string name="nav_contacts">ކޮންޓެކްޓްތައް</string>
|
||||||
<string name="nav_activities">ހަރަކާތްތައް</string>
|
<string name="nav_activities">ހަރަކާތްތައް</string>
|
||||||
<string name="nav_transfer_history">ޓްރާންސްފަ ތާރީހް</string>
|
<string name="nav_transfer_history">ޓްރާންސެކްޝަން ތާރީހް</string>
|
||||||
<string name="nav_finances">ފައިނޭންސް</string>
|
<string name="nav_finances">ފައިނޭންސް</string>
|
||||||
<string name="nav_card_settings">ކާޑް ސެޓިންގ</string>
|
<string name="nav_card_settings">ކާޑް ސެޓިންގ</string>
|
||||||
<string name="nav_settings">ސެޓިންގ</string>
|
<string name="nav_settings">ސެޓިންގ</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>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user