Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ea227bf3b9
|
|||
|
6b3131069e
|
|||
|
8037ce3f02
|
|||
|
cecf0bedfc
|
|||
|
256f216da4
|
|||
|
0a27de4a34
|
|||
|
a3f8852163
|
|||
|
8e345746ed
|
|||
|
473e051282
|
|||
|
f9c182fe9a
|
|||
|
339dae8a37
|
|||
|
a6a1f28144
|
|||
|
523d1248bd
|
|||
|
ee9f98b720
|
|||
|
219ca9bf00
|
|||
|
e9f0cec698
|
|||
|
268f3dada0
|
|||
|
e0a554c769
|
|||
|
94b280a177
|
|||
|
88c9f153e5
|
|||
|
eb7da01b2e
|
|||
|
27270f1b7a
|
|||
|
fd7fcb41a6
|
|||
|
c9ae614fc7
|
|||
|
b784085605
|
|||
|
01e5c17284
|
|||
|
6d3c7036b5
|
|||
|
804712d22d
|
|||
|
f208ee6ad1
|
|||
|
51dbed94d4
|
|||
|
0b5a452046
|
|||
|
00297da71e
|
|||
|
1602d061c1
|
|||
|
ddd64e8624
|
|||
|
77f367844d
|
|||
|
e2729b1d1a
|
|||
|
105518e147
|
|||
|
38570615dd
|
|||
|
e82218e897
|
|||
|
50150b826f
|
|||
|
2d705457f8
|
|||
|
f03e23062b
|
@@ -1,11 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
release:
|
release:
|
||||||
# image: git.shihaam.dev/dockerfiles/android-builder
|
|
||||||
image: git.shihaam.dev/dockerfiles/runners/gradle
|
image: git.shihaam.dev/dockerfiles/runners/gradle
|
||||||
hostname: isodroid
|
|
||||||
network_mode: host
|
network_mode: host
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./release:/release
|
- ./release:/release
|
||||||
- ../../:/source
|
- ../../:/source
|
||||||
# - /root/.cache/cache-runners/gradle:/root/.gradle
|
- /root/.cache/cache-runners/gradle:/root/.gradle
|
||||||
|
|||||||
8
.idea/deploymentTargetSelector.xml
generated
@@ -3,11 +3,11 @@
|
|||||||
<component name="deploymentTargetSelector">
|
<component name="deploymentTargetSelector">
|
||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DIALOG" />
|
||||||
<DropdownSelection timestamp="2026-05-18T20:24:18.550107339Z">
|
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<targets>
|
<targets>
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
|
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=67d022c2" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
|
|||||||
8
.idea/markdown.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="MarkdownSettings">
|
||||||
|
<option name="previewPanelProviderInfo">
|
||||||
|
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 4
|
versionCode = 7
|
||||||
versionName = "1.0.5"
|
versionName = "1.0.8"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
app/src/main/assets/cards/bml/amex_credit_gold.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
app/src/main/assets/cards/bml/amex_credit_green.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
app/src/main/assets/cards/bml/amex_debit_gold.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
app/src/main/assets/cards/bml/amex_debit_green.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
app/src/main/assets/cards/bml/amex_platinum.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
app/src/main/assets/cards/bml/defaultcard.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
app/src/main/assets/cards/bml/master.png
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
app/src/main/assets/cards/bml/master_business_debit.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
app/src/main/assets/cards/bml/master_gold.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
app/src/main/assets/cards/bml/master_islamic.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
app/src/main/assets/cards/bml/master_masveriyaa.png
Normal file
|
After Width: | Height: | Size: 332 KiB |
BIN
app/src/main/assets/cards/bml/master_odiveriyaa.png
Normal file
|
After Width: | Height: | Size: 390 KiB |
BIN
app/src/main/assets/cards/bml/master_passport.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
app/src/main/assets/cards/bml/master_platinum.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
app/src/main/assets/cards/bml/master_prepaid.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
app/src/main/assets/cards/bml/master_prepaid_business.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
app/src/main/assets/cards/bml/master_prepaid_travel.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
app/src/main/assets/cards/bml/master_world.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
app/src/main/assets/cards/bml/visa_corporate.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
app/src/main/assets/cards/bml/visa_credit.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
app/src/main/assets/cards/bml/visa_debit.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
app/src/main/assets/cards/bml/visa_debit_generic.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
app/src/main/assets/cards/bml/visa_debit_islamic.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
app/src/main/assets/cards/bml/visa_debit_platinum.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
app/src/main/assets/cards/bml/visa_gold.png
Normal file
|
After Width: | Height: | Size: 295 KiB |
BIN
app/src/main/assets/cards/bml/visa_infinite.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/assets/cards/bml/visa_platinum.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
app/src/main/assets/cards/bml/visa_student_black.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
app/src/main/assets/cards/bml/visa_student_blue.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
app/src/main/assets/cards/mib/visa_black_platinum.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
app/src/main/assets/cards/mib/visa_blue_everyday.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
app/src/main/assets/cards/mib/visa_business.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
@@ -32,6 +32,8 @@ 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 autoUnlockPin = false
|
||||||
|
private var pinLength = 4
|
||||||
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)
|
||||||
@@ -61,6 +63,8 @@ class LockActivity : AppCompatActivity() {
|
|||||||
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)
|
||||||
|
autoUnlockPin = prefs.getBoolean("auto_unlock_pin", false)
|
||||||
|
pinLength = prefs.getInt("pin_length", 4)
|
||||||
|
|
||||||
val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
|
val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
|
||||||
salt = stored.first
|
salt = stored.first
|
||||||
@@ -134,13 +138,18 @@ class LockActivity : AppCompatActivity() {
|
|||||||
when (key) {
|
when (key) {
|
||||||
"⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
|
"⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
|
||||||
"✓" -> if (pinDigits.size >= 4) verifyPin()
|
"✓" -> if (pinDigits.size >= 4) verifyPin()
|
||||||
else -> if (pinDigits.size < 8) { pinDigits.add(key.toInt()); updateDots() }
|
else -> if (pinDigits.size < 8) {
|
||||||
|
pinDigits.add(key.toInt())
|
||||||
|
updateDots()
|
||||||
|
if (autoUnlockPin && pinDigits.size == pinLength) verifyPin()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateDots() {
|
private fun updateDots() {
|
||||||
val n = pinDigits.size
|
val n = pinDigits.size
|
||||||
binding.tvLockPinDots.text = "●".repeat(n) + "○".repeat(maxOf(4 - n, 0))
|
val total = if (autoUnlockPin) pinLength else maxOf(n, 4)
|
||||||
|
binding.tvLockPinDots.text = "●".repeat(n) + "○".repeat(maxOf(total - n, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun verifyPin() {
|
private fun verifyPin() {
|
||||||
@@ -194,15 +203,15 @@ class LockActivity : AppCompatActivity() {
|
|||||||
if (remaining <= 0) return false
|
if (remaining <= 0) return false
|
||||||
val secs = ((remaining + 999L) / 1000L).toInt()
|
val secs = ((remaining + 999L) / 1000L).toInt()
|
||||||
val msg = getString(R.string.unlock_locked_out, secs)
|
val msg = getString(R.string.unlock_locked_out, secs)
|
||||||
binding.tvLockPinDots.text = msg
|
binding.tvPinHint.text = msg
|
||||||
binding.root.postDelayed({ updateDots() }, remaining)
|
binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, remaining)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showFailure() {
|
private fun showFailure() {
|
||||||
val msg = failureMessage()
|
val msg = failureMessage()
|
||||||
binding.tvLockPinDots.text = msg
|
binding.tvPinHint.text = msg
|
||||||
binding.root.postDelayed({ updateDots() }, 1200)
|
binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, 1200)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun failureMessage(): String {
|
private fun failureMessage(): String {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sh.sar.basedbank.api.bml
|
|||||||
|
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
|
|
||||||
data class BmlUserInfo(
|
data class BmlUserInfo(
|
||||||
val fullName: String,
|
val fullName: String,
|
||||||
@@ -27,9 +28,19 @@ class BmlAccountClient {
|
|||||||
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()
|
||||||
|
if (code in 500..599) throw BankServerException("BML")
|
||||||
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
|
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lightweight call to verify the session is alive. Throws [AuthExpiredException] on 401/419. */
|
||||||
|
fun checkProfile(session: BmlSession) {
|
||||||
|
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/profile")).execute()
|
||||||
|
val code = resp.code
|
||||||
|
resp.close()
|
||||||
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
|
if (code in 500..599) throw BankServerException("BML")
|
||||||
|
}
|
||||||
|
|
||||||
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
|
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
|
||||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/userinfo")).execute()
|
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/userinfo")).execute()
|
||||||
val json = resp.body?.string() ?: return null
|
val json = resp.body?.string() ?: return null
|
||||||
@@ -49,6 +60,51 @@ class BmlAccountClient {
|
|||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun fetchLoanDetail(session: BmlSession, internalId: String): BmlLoanDetail? {
|
||||||
|
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/account/$internalId")).execute()
|
||||||
|
val code = resp.code
|
||||||
|
val json = resp.body?.string() ?: return null
|
||||||
|
resp.close()
|
||||||
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
if (!root.optBoolean("success")) return null
|
||||||
|
val p = root.optJSONObject("payload") ?: return null
|
||||||
|
BmlLoanDetail(
|
||||||
|
loanAmount = p.optDouble("loanAmount", 0.0),
|
||||||
|
outstandingAmt = p.optDouble("outstandingAmt", 0.0),
|
||||||
|
repayAmount = p.optDouble("repayAmount", 0.0),
|
||||||
|
intRate = p.optDouble("intRate", 0.0),
|
||||||
|
loanStatus = p.optString("loanStatus"),
|
||||||
|
startDate = p.optString("startDate"),
|
||||||
|
endDate = p.optString("endDate"),
|
||||||
|
noOfRepayOverdue = p.optInt("noOfRepayOverdue", 0),
|
||||||
|
overdueAmount = p.optDouble("overdueAmount", 0.0)
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchTransferChannels(session: BmlSession): List<BmlOtpChannel> {
|
||||||
|
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/transfer")).execute()
|
||||||
|
val json = resp.body?.string() ?: run { resp.close(); return emptyList() }
|
||||||
|
resp.close()
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
|
val arr = root.optJSONObject("payload")
|
||||||
|
?.optJSONObject("transfer")
|
||||||
|
?.optJSONArray("otpChannel") ?: return emptyList()
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val ch = arr.getJSONObject(i)
|
||||||
|
BmlOtpChannel(
|
||||||
|
channel = ch.optString("channel"),
|
||||||
|
description = ch.optString("description"),
|
||||||
|
masked = ch.optString("masked")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseDashboard(
|
private fun parseDashboard(
|
||||||
json: String,
|
json: String,
|
||||||
loginTag: String,
|
loginTag: String,
|
||||||
@@ -61,6 +117,7 @@ class BmlAccountClient {
|
|||||||
|
|
||||||
val casaAccounts = mutableListOf<BankAccount>()
|
val casaAccounts = mutableListOf<BankAccount>()
|
||||||
val prepaidCards = mutableListOf<BankAccount>()
|
val prepaidCards = mutableListOf<BankAccount>()
|
||||||
|
val loanAccounts = mutableListOf<BankAccount>()
|
||||||
|
|
||||||
for (i in 0 until dashboard.length()) {
|
for (i in 0 until dashboard.length()) {
|
||||||
val item = dashboard.getJSONObject(i)
|
val item = dashboard.getJSONObject(i)
|
||||||
@@ -91,17 +148,43 @@ class BmlAccountClient {
|
|||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
internalId = internalId
|
internalId = internalId
|
||||||
))
|
))
|
||||||
|
} else if (accountType == "Loan") {
|
||||||
|
val outstanding = Math.abs(item.optDouble("availableBalance", 0.0))
|
||||||
|
loanAccounts.add(BankAccount(
|
||||||
|
bank = "BML",
|
||||||
|
profileName = profileName,
|
||||||
|
profileType = "BML_LOAN",
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountBriefName = item.optString("alias"),
|
||||||
|
currencyName = currency,
|
||||||
|
accountTypeName = product,
|
||||||
|
availableBalance = "%.2f".format(outstanding),
|
||||||
|
currentBalance = "%.2f".format(outstanding),
|
||||||
|
blockedAmount = "0.00",
|
||||||
|
mvrBalance = "0.00",
|
||||||
|
statusDesc = status,
|
||||||
|
profileImageHash = null,
|
||||||
|
loginTag = loginTag,
|
||||||
|
profileId = profileId,
|
||||||
|
internalId = internalId
|
||||||
|
))
|
||||||
} else if (accountType == "Card") {
|
} else if (accountType == "Card") {
|
||||||
val isVisible = item.optBoolean("account_visible", false)
|
|
||||||
if (!isVisible) continue
|
|
||||||
val isPrepaid = item.optBoolean("prepaid_card", false)
|
val isPrepaid = item.optBoolean("prepaid_card", false)
|
||||||
|
val productCode = item.optString("product_code", "")
|
||||||
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
|
||||||
|
val isVisible = item.optBoolean("account_visible", false)
|
||||||
|
val cardProfileType = when {
|
||||||
|
isPrepaid -> "BML_PREPAID"
|
||||||
|
isVisible -> "BML_CREDIT" // non-prepaid, visible = credit card
|
||||||
|
else -> "BML_DEBIT" // non-prepaid, not visible = debit card
|
||||||
|
}
|
||||||
prepaidCards.add(BankAccount(
|
prepaidCards.add(BankAccount(
|
||||||
bank = "BML",
|
bank = "BML",
|
||||||
profileName = profileName,
|
profileName = profileName,
|
||||||
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
profileType = cardProfileType,
|
||||||
|
productCode = productCode,
|
||||||
accountNumber = accountNumber,
|
accountNumber = accountNumber,
|
||||||
accountBriefName = item.optString("alias").ifBlank { product },
|
accountBriefName = item.optString("alias").ifBlank { product },
|
||||||
currencyName = currency,
|
currencyName = currency,
|
||||||
@@ -119,6 +202,6 @@ class BmlAccountClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return casaAccounts + prepaidCards
|
return casaAccounts + prepaidCards + loanAccounts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package sh.sar.basedbank.api.bml
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||||
internal const val BML_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
|
internal val BML_USER_AGENT = "bml-mobile-banking/348 (${Build.MANUFACTURER}; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
|
||||||
internal const val BML_APP_VERSION = "2.1.44.348"
|
internal const val BML_APP_VERSION = "2.1.44.348"
|
||||||
|
|
||||||
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
||||||
|
|||||||
@@ -25,9 +25,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/348 (POCO; Android 14; 22101320I)"
|
private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
|
||||||
private val APP_VERSION = "2.1.44.348"
|
private val APP_VERSION = "2.1.44.348"
|
||||||
private val WEB_USER_AGENT = "Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
private val WEB_USER_AGENT = "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; 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 {
|
||||||
@@ -310,13 +310,47 @@ 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 accessToken = JSONObject(tokenJson).optString("access_token")
|
val tokenObj = JSONObject(tokenJson)
|
||||||
|
val accessToken = tokenObj.optString("access_token")
|
||||||
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
||||||
|
val refreshToken = tokenObj.optString("refresh_token", "")
|
||||||
|
val expiresIn = tokenObj.optLong("expires_in", 0L)
|
||||||
|
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
|
||||||
|
|
||||||
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
|
val session = BmlSession(accessToken = accessToken, deviceId = deviceId, refreshToken = refreshToken, expiresAt = expiresAt)
|
||||||
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profileName, profileId)
|
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profileName, profileId)
|
||||||
return Pair(session, accounts)
|
return Pair(session, accounts)
|
||||||
}
|
}
|
||||||
|
// ─── Token refresh ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the saved refresh token to obtain a new access token without re-login.
|
||||||
|
* Returns a new [BmlSession] with updated tokens.
|
||||||
|
*/
|
||||||
|
fun refreshSession(session: BmlSession): BmlSession {
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
.add("grant_type", "refresh_token")
|
||||||
|
.add("refresh_token", session.refreshToken)
|
||||||
|
.add("client_id", CLIENT_ID)
|
||||||
|
.add("Device-ID", session.deviceId)
|
||||||
|
.add("User-Agent", APP_USER_AGENT)
|
||||||
|
.add("x-app-version", APP_VERSION)
|
||||||
|
.build()
|
||||||
|
val resp = newBmlApiClient().newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/oauth/token").post(body)
|
||||||
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: throw Exception("Empty refresh response")
|
||||||
|
resp.close()
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
val newAccess = obj.optString("access_token").takeIf { it.isNotBlank() }
|
||||||
|
?: throw Exception("Token refresh failed")
|
||||||
|
val newRefresh = obj.optString("refresh_token", "").ifBlank { session.refreshToken }
|
||||||
|
val expiresIn = obj.optLong("expires_in", 0L)
|
||||||
|
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
|
||||||
|
return BmlSession(accessToken = newAccess, deviceId = session.deviceId, refreshToken = newRefresh, expiresAt = expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Parsing ──────────────────────────────────────────────────────────────
|
// ─── Parsing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import sh.sar.basedbank.api.models.BankAccount
|
|||||||
|
|
||||||
data class BmlSession(
|
data class BmlSession(
|
||||||
val accessToken: String,
|
val accessToken: String,
|
||||||
val deviceId: String
|
val deviceId: String,
|
||||||
)
|
val refreshToken: String = "",
|
||||||
|
val expiresAt: Long = 0L // Unix millis; 0 = unknown
|
||||||
|
) {
|
||||||
|
fun isExpired() = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
data class BmlProfile(
|
data class BmlProfile(
|
||||||
val profileId: String,
|
val profileId: String,
|
||||||
@@ -50,6 +54,18 @@ data class BmlTransferResult(
|
|||||||
val errorMessage: String = ""
|
val errorMessage: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BmlLoanDetail(
|
||||||
|
val loanAmount: Double,
|
||||||
|
val outstandingAmt: Double, // negative as returned by API
|
||||||
|
val repayAmount: Double,
|
||||||
|
val intRate: Double,
|
||||||
|
val loanStatus: String,
|
||||||
|
val startDate: String, // ISO8601 e.g. "2023-10-26T00:00:00+05:00"
|
||||||
|
val endDate: String,
|
||||||
|
val noOfRepayOverdue: Int,
|
||||||
|
val overdueAmount: Double
|
||||||
|
)
|
||||||
|
|
||||||
data class BmlForeignLimit(
|
data class BmlForeignLimit(
|
||||||
val type: String,
|
val type: String,
|
||||||
val used: Double,
|
val used: Double,
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ class BmlTransferClient {
|
|||||||
amount: Double,
|
amount: Double,
|
||||||
transferType: String,
|
transferType: String,
|
||||||
currency: String,
|
currency: String,
|
||||||
bank: String? = null
|
bank: String? = null,
|
||||||
|
channel: String = "token"
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val jo = JSONObject().apply {
|
val jo = JSONObject().apply {
|
||||||
put("debitAccount", debitAccount)
|
put("debitAccount", debitAccount)
|
||||||
@@ -25,7 +26,7 @@ class BmlTransferClient {
|
|||||||
put("debitAmount", amount)
|
put("debitAmount", amount)
|
||||||
put("transfertype", transferType)
|
put("transfertype", transferType)
|
||||||
put("currency", currency)
|
put("currency", currency)
|
||||||
put("channel", "token")
|
put("channel", channel)
|
||||||
if (bank != null) put("bank", bank)
|
if (bank != null) put("bank", bank)
|
||||||
}
|
}
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
@@ -55,7 +56,8 @@ class BmlTransferClient {
|
|||||||
currency: String,
|
currency: String,
|
||||||
otp: String,
|
otp: String,
|
||||||
remarks: String = "",
|
remarks: String = "",
|
||||||
bank: String? = null
|
bank: String? = null,
|
||||||
|
channel: String = "token"
|
||||||
): BmlTransferResult {
|
): BmlTransferResult {
|
||||||
val jo = JSONObject().apply {
|
val jo = JSONObject().apply {
|
||||||
put("debitAccount", debitAccount)
|
put("debitAccount", debitAccount)
|
||||||
@@ -63,7 +65,7 @@ class BmlTransferClient {
|
|||||||
put("debitAmount", amount)
|
put("debitAmount", amount)
|
||||||
put("transfertype", transferType)
|
put("transfertype", transferType)
|
||||||
put("currency", currency)
|
put("currency", currency)
|
||||||
put("channel", "token")
|
put("channel", channel)
|
||||||
put("otp", otp)
|
put("otp", otp)
|
||||||
if (remarks.isNotBlank()) put("remarks", remarks)
|
if (remarks.isNotBlank()) put("remarks", remarks)
|
||||||
if (bank != null) put("bank", bank)
|
if (bank != null) put("bank", bank)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import okhttp3.OkHttpClient
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class FahipayAccountClient {
|
class FahipayAccountClient {
|
||||||
@@ -27,8 +28,10 @@ class FahipayAccountClient {
|
|||||||
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
|
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
|
||||||
.auth(session).build()
|
.auth(session).build()
|
||||||
).execute()
|
).execute()
|
||||||
|
val code = resp.code
|
||||||
val json = resp.body?.string() ?: throw Exception("Empty profile response")
|
val json = resp.body?.string() ?: throw Exception("Empty profile response")
|
||||||
resp.close()
|
resp.close()
|
||||||
|
if (code in 500..599) throw BankServerException("Fahipay")
|
||||||
val obj = JSONObject(json)
|
val obj = JSONObject(json)
|
||||||
val props = obj.optJSONObject("props") ?: JSONObject()
|
val props = obj.optJSONObject("props") ?: JSONObject()
|
||||||
return FahipayUserProfile(
|
return FahipayUserProfile(
|
||||||
@@ -47,8 +50,10 @@ class FahipayAccountClient {
|
|||||||
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
|
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
|
||||||
.auth(session).build()
|
.auth(session).build()
|
||||||
).execute()
|
).execute()
|
||||||
|
val code = resp.code
|
||||||
val json = resp.body?.string() ?: return 0.0
|
val json = resp.body?.string() ?: return 0.0
|
||||||
resp.close()
|
resp.close()
|
||||||
|
if (code in 500..599) throw BankServerException("Fahipay")
|
||||||
return try {
|
return try {
|
||||||
val obj = JSONObject(json)
|
val obj = JSONObject(json)
|
||||||
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
|
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import java.util.concurrent.TimeUnit
|
|||||||
class FahipayLoginFlow {
|
class FahipayLoginFlow {
|
||||||
|
|
||||||
private val BASE_URL = "https://fahipay.mv"
|
private val BASE_URL = "https://fahipay.mv"
|
||||||
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||||
|
|
||||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||||
private val cookieJar = object : CookieJar {
|
private val cookieJar = object : CookieJar {
|
||||||
|
|||||||
63
app/src/main/java/sh/sar/basedbank/api/mib/MibCardsClient.kt
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class MibCardsClient {
|
||||||
|
|
||||||
|
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(20, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun cookieHeader(session: MibSession) =
|
||||||
|
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
|
||||||
|
"mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||||
|
|
||||||
|
fun fetchCards(session: MibSession, loginTag: String): List<MibCard> {
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
.add("name", "")
|
||||||
|
.add("start", "1")
|
||||||
|
.add("end", "50")
|
||||||
|
.add("includeCount", "1")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
|
||||||
|
.post(body)
|
||||||
|
.header("Cookie", cookieHeader(session))
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
|
||||||
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.header("Accept", "*/*")
|
||||||
|
.header("Origin", BASE_WV_URL)
|
||||||
|
.header("Referer", "$BASE_WV_URL//debitCards?dashurl=1")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
val bodyStr = response.body?.string() ?: return emptyList()
|
||||||
|
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return emptyList() }
|
||||||
|
if (!json.optBoolean("success")) return emptyList()
|
||||||
|
val data = json.optJSONArray("data") ?: return emptyList()
|
||||||
|
(0 until data.length()).map { i ->
|
||||||
|
val item = data.getJSONObject(i)
|
||||||
|
MibCard(
|
||||||
|
cardId = item.optString("cardId"),
|
||||||
|
maskedCardNumber = item.optString("maskedCardNumber"),
|
||||||
|
cardStatus = item.optString("cardStatus"),
|
||||||
|
cardType = item.optString("cardType"),
|
||||||
|
cardTypeDesc = item.optString("cardTypeDesc"),
|
||||||
|
customerId = item.optString("customerId"),
|
||||||
|
phoneNumber = item.optString("phoneNumber"),
|
||||||
|
cardHolderName = item.optString("cardHolderName"),
|
||||||
|
loginTag = loginTag
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.api.mib
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -24,7 +25,7 @@ class MibContactsClient {
|
|||||||
.header("Cookie", cookieHeader(session))
|
.header("Cookie", cookieHeader(session))
|
||||||
.header(
|
.header(
|
||||||
"User-Agent",
|
"User-Agent",
|
||||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||||
)
|
)
|
||||||
.header("X-Requested-With", "XMLHttpRequest")
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
.header("Accept", "*/*")
|
.header("Accept", "*/*")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.api.mib
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -27,7 +28,7 @@ class MibFinancingClient {
|
|||||||
.header("Cookie", cookieHeader)
|
.header("Cookie", cookieHeader)
|
||||||
.header(
|
.header(
|
||||||
"User-Agent",
|
"User-Agent",
|
||||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||||
)
|
)
|
||||||
.header("X-Requested-With", "mv.com.mib.faisamobilex")
|
.header("X-Requested-With", "mv.com.mib.faisamobilex")
|
||||||
.get()
|
.get()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.api.mib
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -50,7 +51,7 @@ class MibHistoryClient {
|
|||||||
.header("Cookie", cookieHeader(session))
|
.header("Cookie", cookieHeader(session))
|
||||||
.header(
|
.header(
|
||||||
"User-Agent",
|
"User-Agent",
|
||||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||||
)
|
)
|
||||||
.header("X-Requested-With", "XMLHttpRequest")
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
.header("Accept", "*/*")
|
.header("Accept", "*/*")
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
bank = "MIB",
|
bank = "MIB",
|
||||||
profileName = profile.name,
|
profileName = profile.name,
|
||||||
profileType = profile.profileType,
|
profileType = profile.profileType,
|
||||||
cifType = profile.cifType,
|
productCode = 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"),
|
||||||
@@ -318,7 +318,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
bank = "MIB",
|
bank = "MIB",
|
||||||
profileName = profile.name,
|
profileName = profile.name,
|
||||||
profileType = profile.profileType,
|
profileType = profile.profileType,
|
||||||
cifType = profile.cifType,
|
productCode = 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"),
|
||||||
@@ -373,6 +373,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
.build()
|
.build()
|
||||||
val response = client.newCall(request).execute()
|
val response = client.newCall(request).execute()
|
||||||
if (response.code == 419) throw SessionExpiredException()
|
if (response.code == 419) throw SessionExpiredException()
|
||||||
|
if (response.code in 500..599) throw sh.sar.basedbank.api.models.BankServerException("MIB")
|
||||||
return response.body?.string() ?: throw IllegalStateException("Empty response body")
|
return response.body?.string() ?: throw IllegalStateException("Empty response body")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ data class MibIpsAccountInfo(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
data class MibCard(
|
||||||
|
val cardId: String,
|
||||||
|
val maskedCardNumber: String,
|
||||||
|
val cardStatus: String,
|
||||||
|
val cardType: String,
|
||||||
|
val cardTypeDesc: String,
|
||||||
|
val customerId: String,
|
||||||
|
val phoneNumber: String,
|
||||||
|
val cardHolderName: String,
|
||||||
|
val loginTag: String
|
||||||
|
)
|
||||||
|
|
||||||
data class MibFinanceDeal(
|
data class MibFinanceDeal(
|
||||||
val dealNo: String,
|
val dealNo: String,
|
||||||
val productDesc: String,
|
val productDesc: String,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.api.mib
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -26,7 +27,7 @@ class MibTransferClient {
|
|||||||
.header("Cookie", cookieHeader(session))
|
.header("Cookie", cookieHeader(session))
|
||||||
.header(
|
.header(
|
||||||
"User-Agent",
|
"User-Agent",
|
||||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||||
)
|
)
|
||||||
.header("X-Requested-With", "XMLHttpRequest")
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
.header("Accept", "*/*")
|
.header("Accept", "*/*")
|
||||||
@@ -68,6 +69,7 @@ class MibTransferClient {
|
|||||||
.withWvHeaders(session)
|
.withWvHeaders(session)
|
||||||
.build()
|
.build()
|
||||||
return client.newCall(request).execute().use { response ->
|
return client.newCall(request).execute().use { response ->
|
||||||
|
if (response.code == 419) throw SessionExpiredException()
|
||||||
val bodyStr = response.body?.string() ?: ""
|
val bodyStr = response.body?.string() ?: ""
|
||||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
|
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
|
||||||
if (json == null || !json.optBoolean("success")) {
|
if (json == null || !json.optBoolean("success")) {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package sh.sar.basedbank.api.models
|
package sh.sar.basedbank.api.models
|
||||||
|
|
||||||
|
/** Thrown by a bank API client when the server returns an HTTP 5xx response. */
|
||||||
|
class BankServerException(val bankName: String) : Exception("Server error from $bankName")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified account model used across all banks (MIB, BML, Fahipay, ...).
|
* Unified account model used across all banks (MIB, BML, Fahipay, ...).
|
||||||
* The [bank] field identifies which bank owns this account.
|
* The [bank] field identifies which bank owns this account.
|
||||||
@@ -8,7 +11,7 @@ data class BankAccount(
|
|||||||
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
|
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 productCode: String = "", // bank-specific product/subtype code: MIB: CIF type label ("Individual", "Sole Propr"); BML: card product code ("C8201", "C1007")
|
||||||
val accountNumber: String,
|
val accountNumber: String,
|
||||||
val accountBriefName: String,
|
val accountBriefName: String,
|
||||||
val currencyName: String,
|
val currencyName: String,
|
||||||
|
|||||||
@@ -118,6 +118,14 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
(activity as? HomeActivity)?.setRefreshing(true)
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
loadNextPage()
|
loadNextPage()
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
if (isLoading) {
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
} else {
|
||||||
|
resetAndReload()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
@@ -135,6 +143,17 @@ 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 resetAndReload() {
|
||||||
|
allTransactions.clear()
|
||||||
|
pendingImageNames.clear()
|
||||||
|
pendingIconUrls.clear()
|
||||||
|
firstPageDone = false
|
||||||
|
fetcher = HistoryFetcher(account)
|
||||||
|
adapter.setTransactions(emptyList())
|
||||||
|
binding.emptyView.visibility = View.GONE
|
||||||
|
loadNextPage()
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadNextPage() {
|
private fun loadNextPage() {
|
||||||
if (isLoading || !fetcher.hasMore()) return
|
if (isLoading || !fetcher.hasMore()) return
|
||||||
isLoading = true
|
isLoading = true
|
||||||
@@ -153,6 +172,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
if (!firstPageDone) {
|
if (!firstPageDone) {
|
||||||
firstPageDone = true
|
firstPageDone = true
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transactions.isNotEmpty()) {
|
if (transactions.isNotEmpty()) {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class AccountsAdapter(
|
|||||||
else -> account.bank
|
else -> account.bank
|
||||||
}
|
}
|
||||||
val profileLabel = when (account.bank) {
|
val profileLabel = when (account.bank) {
|
||||||
"MIB" -> account.cifType.ifBlank { account.profileName }
|
"MIB" -> account.productCode.ifBlank { account.profileName }
|
||||||
else -> account.profileName
|
else -> account.profileName
|
||||||
}
|
}
|
||||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ class AccountsFragment : Fragment() {
|
|||||||
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
|
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentCardSettingsBinding
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
|
|
||||||
|
class CardSettingsFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentCardSettingsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentCardSettingsBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val adapter = CardSettingsAdapter(emptyList(), requireContext())
|
||||||
|
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.swipeRefresh.setOnRefreshListener {
|
||||||
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
val updateCardList = {
|
||||||
|
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
|
||||||
|
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||||
|
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
|
||||||
|
.map { CardItem.Bml(it) }
|
||||||
|
val all = mibItems + bmlItems
|
||||||
|
adapter.update(all)
|
||||||
|
binding.loadingView.visibility = View.GONE
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
binding.emptyView.visibility = if (all.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
binding.recyclerView.visibility = if (all.isEmpty()) View.GONE else View.VISIBLE
|
||||||
|
}
|
||||||
|
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
|
||||||
|
|
||||||
|
if (viewModel.mibCards.value == null) {
|
||||||
|
binding.loadingView.visibility = View.VISIBLE
|
||||||
|
(activity as? HomeActivity)?.triggerRefreshCards()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.nav_card_settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class CardSettingsAdapter(
|
||||||
|
private var cards: List<CardItem>,
|
||||||
|
private val context: Context
|
||||||
|
) : RecyclerView.Adapter<CardSettingsAdapter.VH>() {
|
||||||
|
|
||||||
|
fun update(newCards: List<CardItem>) {
|
||||||
|
cards = newCards
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
|
||||||
|
VH(LayoutInflater.from(context).inflate(R.layout.item_card_settings_entry, parent, false))
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
|
||||||
|
override fun getItemCount() = cards.size
|
||||||
|
|
||||||
|
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
|
||||||
|
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
|
||||||
|
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
|
||||||
|
private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
|
||||||
|
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
|
||||||
|
private val btnChangePin: View = view.findViewById(R.id.btnChangePin)
|
||||||
|
private val btnFreeze: View = view.findViewById(R.id.btnFreeze)
|
||||||
|
private val btnBlock: View = view.findViewById(R.id.btnBlock)
|
||||||
|
|
||||||
|
fun bind(item: CardItem) {
|
||||||
|
when (item) {
|
||||||
|
is CardItem.Mib -> {
|
||||||
|
tvCardOwner.text = item.card.cardHolderName
|
||||||
|
tvCardNumber.text = PayWithCardFragment.formatMasked(item.card.maskedCardNumber)
|
||||||
|
tvCardType.text = item.card.cardTypeDesc
|
||||||
|
val assetPath = PayWithCardFragment.cardImageAsset(item.card)
|
||||||
|
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
|
||||||
|
else ivCardImage.setImageDrawable(null)
|
||||||
|
PayWithCardFragment.bindCardStatus(tvCardStatus, PayWithCardFragment.mibCardStatusLabel(item.card.cardStatus))
|
||||||
|
itemView.alpha = 1f
|
||||||
|
}
|
||||||
|
is CardItem.Bml -> {
|
||||||
|
tvCardOwner.text = item.account.accountBriefName
|
||||||
|
tvCardNumber.text = PayWithCardFragment.formatMasked(item.account.accountNumber)
|
||||||
|
tvCardType.text = item.account.accountTypeName
|
||||||
|
PayWithCardFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||||
|
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
val bmlStatus = item.account.statusDesc.takeUnless { isActive }
|
||||||
|
PayWithCardFragment.bindCardStatus(tvCardStatus, bmlStatus)
|
||||||
|
itemView.alpha = if (isActive) 1f else 0.45f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val wip = View.OnClickListener {
|
||||||
|
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
btnChangePin.setOnClickListener(wip)
|
||||||
|
btnFreeze.setOnClickListener(wip)
|
||||||
|
btnBlock.setOnClickListener(wip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,6 +145,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
viewModel.contacts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
viewModel.contacts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||||
|
|
||||||
(activity as? HomeActivity)?.loadAllContacts()
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
}
|
}
|
||||||
@@ -183,6 +184,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
private fun buildPageItems(tabTag: String?): List<ContactPickerAdapter.PickerItem> {
|
private fun buildPageItems(tabTag: String?): List<ContactPickerAdapter.PickerItem> {
|
||||||
val search = binding.etSheetSearch.text?.toString()?.trim() ?: ""
|
val search = binding.etSheetSearch.text?.toString()?.trim() ?: ""
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
val items = mutableListOf<ContactPickerAdapter.PickerItem>()
|
val items = mutableListOf<ContactPickerAdapter.PickerItem>()
|
||||||
|
|
||||||
if (tabTag == RECENTS_TAG) {
|
if (tabTag == RECENTS_TAG) {
|
||||||
@@ -209,11 +211,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
|
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
|
||||||
val fromCurrency = fromAccount?.currencyName ?: ""
|
val fromCurrency = fromAccount?.currencyName ?: ""
|
||||||
val fromLoginTag = fromAccount?.loginTag ?: ""
|
val fromLoginTag = fromAccount?.loginTag ?: ""
|
||||||
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT"
|
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT"
|
||||||
|
|
||||||
if (tabTag == MY_ACCOUNTS_TAG) {
|
if (tabTag == MY_ACCOUNTS_TAG) {
|
||||||
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" }
|
||||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
|
||||||
|
|
||||||
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
|
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
|
||||||
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
|
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
|
||||||
@@ -223,10 +225,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
for (acc in filteredRegular) {
|
for (acc in filteredRegular) {
|
||||||
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
|
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
|
||||||
val isSame = acc.accountNumber == fromAccountNumber
|
val isSame = acc.accountNumber == fromAccountNumber
|
||||||
|
val accBal = if (hide) "••••••" else acc.availableBalance
|
||||||
items.add(ContactPickerAdapter.PickerItem.Row(
|
items.add(ContactPickerAdapter.PickerItem.Row(
|
||||||
accountNumber = acc.accountNumber,
|
accountNumber = acc.accountNumber,
|
||||||
displayName = acc.accountBriefName,
|
displayName = acc.accountBriefName,
|
||||||
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
|
subtitle = "${acc.accountNumber} · ${acc.currencyName} $accBal",
|
||||||
colorHex = "#FE860E",
|
colorHex = "#FE860E",
|
||||||
isSameAsFrom = isSame,
|
isSameAsFrom = isSame,
|
||||||
imageHash = acc.profileImageHash,
|
imageHash = acc.profileImageHash,
|
||||||
@@ -246,10 +249,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
|
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
|
||||||
val isSame = acc.accountNumber == fromAccountNumber
|
val isSame = acc.accountNumber == fromAccountNumber
|
||||||
val isActive = acc.statusDesc.equals("Active", ignoreCase = true)
|
val isActive = acc.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
val cardBal = if (hide) "••••••" else acc.availableBalance
|
||||||
items.add(ContactPickerAdapter.PickerItem.Row(
|
items.add(ContactPickerAdapter.PickerItem.Row(
|
||||||
accountNumber = acc.accountNumber,
|
accountNumber = acc.accountNumber,
|
||||||
displayName = acc.accountBriefName,
|
displayName = acc.accountBriefName,
|
||||||
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
|
subtitle = "${acc.accountNumber} · ${acc.currencyName} $cardBal",
|
||||||
colorHex = "#FE860E",
|
colorHex = "#FE860E",
|
||||||
isSameAsFrom = isSame,
|
isSameAsFrom = isSame,
|
||||||
imageHash = acc.profileImageHash,
|
imageHash = acc.profileImageHash,
|
||||||
@@ -306,6 +310,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun maskAmount(formatted: String): String {
|
||||||
|
val currency = formatted.substringBefore(' ', formatted)
|
||||||
|
return "$currency ••••••"
|
||||||
|
}
|
||||||
|
|
||||||
private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? =
|
private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? =
|
||||||
if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null
|
if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class ContactsFragment : Fragment() {
|
|||||||
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
|
||||||
|
private var contactsRefreshing = false
|
||||||
|
|
||||||
private data class TabPage(val categoryId: String?, val label: String)
|
private data class TabPage(val categoryId: String?, val label: String)
|
||||||
|
|
||||||
@@ -134,6 +135,11 @@ class ContactsFragment : Fragment() {
|
|||||||
|
|
||||||
(activity as? HomeActivity)?.loadAllContacts()
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
contactsRefreshing = true
|
||||||
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
||||||
rebuildPager(cats)
|
rebuildPager(cats)
|
||||||
}
|
}
|
||||||
@@ -143,6 +149,10 @@ class ContactsFragment : Fragment() {
|
|||||||
pagerAdapter.updateContacts(allContacts)
|
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
|
||||||
|
if (contactsRefreshing) {
|
||||||
|
contactsRefreshing = false
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,24 @@ 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.TextView
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.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.LinearSnapHelper
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
|
import kotlin.math.abs
|
||||||
import sh.sar.basedbank.databinding.FragmentDashboardBinding
|
import sh.sar.basedbank.databinding.FragmentDashboardBinding
|
||||||
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
|
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
|
||||||
|
|
||||||
@@ -30,14 +39,41 @@ class DashboardFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
|
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
|
||||||
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
|
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances() }
|
||||||
|
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { updatePendingFinances() }
|
||||||
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
|
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||||
updateBalances(viewModel.accounts.value ?: emptyList())
|
updateBalances(viewModel.accounts.value ?: emptyList())
|
||||||
updatePendingFinances(viewModel.financing.value ?: emptyList())
|
updatePendingFinances()
|
||||||
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
|
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.cardPendingFinances.setOnClickListener {
|
||||||
|
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
||||||
|
}
|
||||||
|
|
||||||
|
val cardAdapter = DashboardCardAdapter()
|
||||||
|
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||||
|
binding.rvCards.adapter = cardAdapter
|
||||||
|
LinearSnapHelper().attachToRecyclerView(binding.rvCards)
|
||||||
|
|
||||||
|
val updateCardList = {
|
||||||
|
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
|
||||||
|
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||||
|
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) }
|
||||||
|
.map { CardItem.Bml(it) }
|
||||||
|
val all = mibItems + bmlItems
|
||||||
|
cardAdapter.update(all)
|
||||||
|
binding.sectionCards.visibility = if (all.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
|
||||||
|
|
||||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
|
||||||
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
@@ -70,61 +106,195 @@ class DashboardFragment : Fragment() {
|
|||||||
|
|
||||||
private fun updateBalances(accounts: List<BankAccount>) {
|
private fun updateBalances(accounts: List<BankAccount>) {
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
|
||||||
|
val nonCreditAccounts = accounts.filter { it.profileType != "BML_CREDIT" && it.profileType != "BML_LOAN" }
|
||||||
|
val creditAccounts = accounts.filter { it.profileType == "BML_CREDIT" }
|
||||||
|
|
||||||
if (hide) {
|
if (hide) {
|
||||||
binding.tvMvrBalance.text = "MVR ••••••"
|
binding.tvMvrBalance.text = "MVR ••••••"
|
||||||
binding.tvUsdBalance.text = "USD ••••••"
|
binding.tvUsdBalance.text = "USD ••••••"
|
||||||
|
if (creditAccounts.isNotEmpty()) {
|
||||||
|
binding.rowCreditCards.visibility = View.VISIBLE
|
||||||
|
val hasMvrCredit = creditAccounts.any { it.currencyName.equals("MVR", ignoreCase = true) }
|
||||||
|
val hasUsdCredit = creditAccounts.any { it.currencyName.equals("USD", ignoreCase = true) }
|
||||||
|
binding.cardMvrCredit.visibility = if (hasMvrCredit) View.VISIBLE else View.GONE
|
||||||
|
binding.cardUsdCredit.visibility = if (hasUsdCredit) View.VISIBLE else View.GONE
|
||||||
|
binding.tvMvrCredit.text = "MVR ••••••"
|
||||||
|
binding.tvUsdCredit.text = "USD ••••••"
|
||||||
|
} else {
|
||||||
|
binding.rowCreditCards.visibility = View.GONE
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val mvrTotal = accounts
|
|
||||||
|
val mvrTotal = nonCreditAccounts
|
||||||
.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 }
|
||||||
val usdTotal = accounts
|
val usdTotal = nonCreditAccounts
|
||||||
.filter { it.currencyName.equals("USD", ignoreCase = true) }
|
.filter { it.currencyName.equals("USD", ignoreCase = true) }
|
||||||
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||||
|
|
||||||
binding.tvMvrBalance.text = "MVR %,.2f".format(mvrTotal)
|
binding.tvMvrBalance.text = "MVR %,.2f".format(mvrTotal)
|
||||||
binding.tvUsdBalance.text = "USD %,.2f".format(usdTotal)
|
binding.tvUsdBalance.text = "USD %,.2f".format(usdTotal)
|
||||||
|
|
||||||
|
val mvrCredit = creditAccounts
|
||||||
|
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
|
||||||
|
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||||
|
val usdCredit = creditAccounts
|
||||||
|
.filter { it.currencyName.equals("USD", ignoreCase = true) }
|
||||||
|
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||||
|
|
||||||
|
if (creditAccounts.isNotEmpty()) {
|
||||||
|
binding.rowCreditCards.visibility = View.VISIBLE
|
||||||
|
binding.cardMvrCredit.visibility = if (mvrCredit > 0) View.VISIBLE else View.GONE
|
||||||
|
binding.cardUsdCredit.visibility = if (usdCredit > 0) View.VISIBLE else View.GONE
|
||||||
|
binding.tvMvrCredit.text = "MVR %,.2f".format(mvrCredit)
|
||||||
|
binding.tvUsdCredit.text = "USD %,.2f".format(usdCredit)
|
||||||
|
} else {
|
||||||
|
binding.rowCreditCards.visibility = View.GONE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val expandedLimits = mutableSetOf<Int>()
|
||||||
|
|
||||||
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
|
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
binding.containerForeignLimits.removeAllViews()
|
binding.containerForeignLimits.removeAllViews()
|
||||||
|
var cardIndex = 0
|
||||||
for (entry in entries) {
|
for (entry in entries) {
|
||||||
for (limit in entry.limits) {
|
for (limit in entry.limits) {
|
||||||
|
val idx = cardIndex++
|
||||||
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
|
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
|
||||||
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" }
|
bindLimitCard(card, entry.userName, limit, hide, idx in expandedLimits)
|
||||||
card.tvLimitType.text = limit.type
|
card.root.setOnClickListener {
|
||||||
if (hide) {
|
if (idx in expandedLimits) expandedLimits.remove(idx) else expandedLimits.add(idx)
|
||||||
card.tvLimitGeneral.text = "USD ••••••"
|
updateForeignLimits(entries)
|
||||||
card.tvLimitMedical.text = "USD ••••••"
|
|
||||||
card.tvLimitAtm.text = if (!limit.isAtmEnabled) "USD •••••• · Disabled" else "USD ••••••"
|
|
||||||
card.tvLimitEcom.text = "USD ••••••"
|
|
||||||
card.tvLimitPos.text = if (!limit.isPosEnabled) "USD •••••• · Disabled" else "USD ••••••"
|
|
||||||
} else {
|
|
||||||
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
|
|
||||||
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
|
|
||||||
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
|
|
||||||
"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 bindLimitCard(
|
||||||
|
card: ItemForeignLimitBinding,
|
||||||
|
userName: String,
|
||||||
|
limit: BmlForeignLimit,
|
||||||
|
hide: Boolean,
|
||||||
|
expanded: Boolean
|
||||||
|
) {
|
||||||
|
card.tvLimitUserName.text = userName.ifBlank { "BML" }
|
||||||
|
card.tvLimitType.text = limit.type
|
||||||
|
|
||||||
|
// ECOM (always visible)
|
||||||
|
card.tvLimitEcom.text = if (hide) "USD ••••••"
|
||||||
|
else "USD %,.2f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
|
||||||
|
card.progressEcom.progress = if (hide || limit.ecomLimit <= 0) 0
|
||||||
|
else ((limit.ecomRemaining / limit.ecomLimit) * 100).toInt().coerceIn(0, 100)
|
||||||
|
|
||||||
|
// General (always visible)
|
||||||
|
card.tvLimitGeneral.text = if (hide) "USD ••••••"
|
||||||
|
else "USD %,.2f / %,.0f".format(limit.generalRemaining, limit.generalCap)
|
||||||
|
card.progressGeneral.progress = if (hide || limit.generalCap <= 0) 0
|
||||||
|
else ((limit.generalRemaining / limit.generalCap) * 100).toInt().coerceIn(0, 100)
|
||||||
|
|
||||||
|
// Expanded section
|
||||||
|
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
|
||||||
|
card.dividerLimitDetails.visibility = detailsVisible
|
||||||
|
card.detailsGroup.visibility = detailsVisible
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
// ATM
|
||||||
|
if (!limit.isAtmEnabled) card.tvAtmLabel.append(" (Disabled)")
|
||||||
|
card.tvLimitAtm.text = if (hide) "USD ••••••"
|
||||||
|
else "USD %,.2f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
|
||||||
|
card.progressAtm.progress = if (hide || limit.atmLimit <= 0) 0
|
||||||
|
else ((limit.atmRemaining / limit.atmLimit) * 100).toInt().coerceIn(0, 100)
|
||||||
|
|
||||||
|
// POS
|
||||||
|
if (!limit.isPosEnabled) card.tvPosLabel.append(" (Disabled)")
|
||||||
|
card.tvLimitPos.text = if (hide) "USD ••••••"
|
||||||
|
else "USD %,.2f / %,.0f".format(limit.posRemaining, limit.posLimit)
|
||||||
|
card.progressPos.progress = if (hide || limit.posLimit <= 0) 0
|
||||||
|
else ((limit.posRemaining / limit.posLimit) * 100).toInt().coerceIn(0, 100)
|
||||||
|
|
||||||
|
// Medical
|
||||||
|
card.tvLimitMedical.text = if (hide) "USD ••••••"
|
||||||
|
else "USD %,.2f / %,.0f".format(limit.medicalRemaining, limit.totalLimit)
|
||||||
|
card.progressMedical.progress = if (hide || limit.totalLimit <= 0) 0
|
||||||
|
else ((limit.medicalRemaining / limit.totalLimit) * 100).toInt().coerceIn(0, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePendingFinances() {
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(deals.sumOf { it.outstandingAmount })
|
val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
|
||||||
|
val bmlLoanDetails = viewModel.bmlLoanDetails.value ?: emptyMap()
|
||||||
|
val bmlTotal = bmlLoanDetails.values.sumOf { abs(it.outstandingAmt) }
|
||||||
|
val total = mibTotal + bmlTotal
|
||||||
|
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private inner class DashboardCardAdapter : RecyclerView.Adapter<DashboardCardAdapter.VH>() {
|
||||||
|
private var cards: List<CardItem> = emptyList()
|
||||||
|
|
||||||
|
fun update(newCards: List<CardItem>) {
|
||||||
|
cards = newCards
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||||
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.item_card_dashboard, parent, false)
|
||||||
|
return VH(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
|
||||||
|
override fun getItemCount() = cards.size
|
||||||
|
|
||||||
|
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
|
||||||
|
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
|
||||||
|
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
|
||||||
|
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
|
||||||
|
private val btnPayQr: View = view.findViewById(R.id.btnPayQr)
|
||||||
|
private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc)
|
||||||
|
|
||||||
|
fun bind(item: CardItem) {
|
||||||
|
when (item) {
|
||||||
|
is CardItem.Mib -> {
|
||||||
|
tvCardOwner.text = item.card.cardHolderName
|
||||||
|
tvCardNumber.text = PayWithCardFragment.formatMasked(item.card.maskedCardNumber)
|
||||||
|
val assetPath = PayWithCardFragment.cardImageAsset(item.card)
|
||||||
|
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
|
||||||
|
else ivCardImage.setImageDrawable(null)
|
||||||
|
PayWithCardFragment.bindCardStatus(tvCardStatus, PayWithCardFragment.mibCardStatusLabel(item.card.cardStatus))
|
||||||
|
}
|
||||||
|
is CardItem.Bml -> {
|
||||||
|
tvCardOwner.text = item.account.accountBriefName
|
||||||
|
tvCardNumber.text = PayWithCardFragment.formatMasked(item.account.accountNumber)
|
||||||
|
PayWithCardFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||||
|
PayWithCardFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
btnPayQr.setOnClickListener {
|
||||||
|
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(requireContext())
|
||||||
|
val nfcSupported = nfcAdapter != null
|
||||||
|
btnPayNfc.isEnabled = nfcSupported
|
||||||
|
if (nfcSupported) {
|
||||||
|
btnPayNfc.setOnClickListener {
|
||||||
|
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
btnPayNfc.setOnClickListener(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,56 +5,97 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
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.bml.BmlLoanDetail
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
import sh.sar.basedbank.api.mib.MibFinancingClient
|
import sh.sar.basedbank.api.mib.MibFinancingClient
|
||||||
|
import sh.sar.basedbank.databinding.ItemBmlLoanBinding
|
||||||
import sh.sar.basedbank.databinding.ItemFinanceDealBinding
|
import sh.sar.basedbank.databinding.ItemFinanceDealBinding
|
||||||
import java.text.NumberFormat
|
import java.text.NumberFormat
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.ceil
|
||||||
|
|
||||||
class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
|
||||||
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() {
|
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
private sealed class Item {
|
||||||
|
data class Mib(val deal: MibFinanceDeal) : Item()
|
||||||
|
data class Bml(val account: BankAccount, val detail: BmlLoanDetail?) : Item()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var items: List<Item> = mibDeals.map { Item.Mib(it) }
|
||||||
private var hideAmounts: Boolean = false
|
private var hideAmounts: Boolean = false
|
||||||
|
|
||||||
|
private val expandedPositions = mutableSetOf<Int>()
|
||||||
|
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
|
||||||
|
minimumFractionDigits = 2
|
||||||
|
maximumFractionDigits = 2
|
||||||
|
}
|
||||||
|
private val mibDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||||
|
private val isoDateFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
|
||||||
|
private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US)
|
||||||
|
|
||||||
fun setHideAmounts(hide: Boolean) {
|
fun setHideAmounts(hide: Boolean) {
|
||||||
if (hideAmounts == hide) return
|
if (hideAmounts == hide) return
|
||||||
hideAmounts = hide
|
hideAmounts = hide
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val expandedPositions = mutableSetOf<Int>()
|
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
|
||||||
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
|
|
||||||
minimumFractionDigits = 2
|
|
||||||
maximumFractionDigits = 2
|
|
||||||
}
|
|
||||||
private val inputDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
|
||||||
private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US)
|
|
||||||
|
|
||||||
fun updateDeals(newDeals: List<MibFinanceDeal>) {
|
|
||||||
deals = newDeals
|
|
||||||
expandedPositions.clear()
|
expandedPositions.clear()
|
||||||
|
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
// Legacy compatibility — used on initial empty construction
|
||||||
val binding = ItemFinanceDealBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
fun updateDeals(newDeals: List<MibFinanceDeal>) {
|
||||||
return ViewHolder(binding)
|
expandedPositions.clear()
|
||||||
|
val bmlItems = items.filterIsInstance<Item.Bml>()
|
||||||
|
items = newDeals.map { Item.Mib(it) } + bmlItems
|
||||||
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
fun updateBmlLoans(loans: List<Pair<BankAccount, BmlLoanDetail?>>) {
|
||||||
holder.bind(deals[position], position in expandedPositions)
|
expandedPositions.clear()
|
||||||
holder.binding.root.setOnClickListener {
|
val mibItems = items.filterIsInstance<Item.Mib>()
|
||||||
|
items = mibItems + loans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||||
|
is Item.Mib -> TYPE_MIB
|
||||||
|
is Item.Bml -> TYPE_BML
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
return when (viewType) {
|
||||||
|
TYPE_BML -> BmlViewHolder(ItemBmlLoanBinding.inflate(inflater, parent, false))
|
||||||
|
else -> MibViewHolder(ItemFinanceDealBinding.inflate(inflater, parent, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
val expanded = position in expandedPositions
|
||||||
|
when (val item = items[position]) {
|
||||||
|
is Item.Mib -> (holder as MibViewHolder).bind(item.deal, expanded)
|
||||||
|
is Item.Bml -> (holder as BmlViewHolder).bind(item.account, item.detail, expanded)
|
||||||
|
}
|
||||||
|
holder.itemView.setOnClickListener {
|
||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos)
|
if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos)
|
||||||
notifyItemChanged(pos)
|
notifyItemChanged(pos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount() = deals.size
|
override fun getItemCount() = items.size
|
||||||
|
|
||||||
inner class ViewHolder(val binding: ItemFinanceDealBinding) :
|
// ── MIB ViewHolder ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
inner class MibViewHolder(val binding: ItemFinanceDealBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
|
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
|
||||||
@@ -69,25 +110,22 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
|||||||
binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
|
binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
|
||||||
binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
|
binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
|
||||||
|
|
||||||
// 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 = if (hide) 0 else progress
|
binding.progressBar.progress = if (hide) 0 else progress
|
||||||
|
|
||||||
// Completion estimate
|
binding.tvCompletion.text = mibCompletionText(deal, ctx)
|
||||||
binding.tvCompletion.text = completionText(deal, ctx)
|
|
||||||
|
|
||||||
// Expanded details
|
|
||||||
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
|
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
|
||||||
binding.dividerDetails.visibility = detailsVisible
|
binding.dividerDetails.visibility = detailsVisible
|
||||||
binding.detailsGroup.visibility = detailsVisible
|
binding.detailsGroup.visibility = detailsVisible
|
||||||
|
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
binding.tvDealDate.text = formatDate(deal.dealDate)
|
binding.tvDealDate.text = formatMibDate(deal.dealDate)
|
||||||
binding.tvInstallment.text = if (hide) "$currency ••••••" else "$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 = formatMibDate(deal.lastPaidDate)
|
||||||
binding.tvLastPayAmount.text = if (hide) "$currency ••••••" else "$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) {
|
||||||
@@ -99,7 +137,7 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun completionText(deal: MibFinanceDeal, ctx: android.content.Context): String {
|
private fun mibCompletionText(deal: MibFinanceDeal, ctx: android.content.Context): String {
|
||||||
if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done)
|
if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done)
|
||||||
val remaining = MibFinancingClient.remainingMonths(deal)
|
val remaining = MibFinancingClient.remainingMonths(deal)
|
||||||
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
|
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
|
||||||
@@ -109,12 +147,84 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
|||||||
return ctx.getString(R.string.financing_completion_fmt, month)
|
return ctx.getString(R.string.financing_completion_fmt, month)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatDate(raw: String): String {
|
private fun formatMibDate(raw: String): String {
|
||||||
return try {
|
return try {
|
||||||
outputDateFmt.format(inputDateFmt.parse(raw)!!)
|
outputDateFmt.format(mibDateFmt.parse(raw)!!)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) { raw.take(10) }
|
||||||
raw.take(10)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── BML ViewHolder ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
inner class BmlViewHolder(val binding: ItemBmlLoanBinding) :
|
||||||
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
|
fun bind(account: BankAccount, detail: BmlLoanDetail?, expanded: Boolean) {
|
||||||
|
val ctx = binding.root.context
|
||||||
|
val currency = account.currencyName
|
||||||
|
val hide = hideAmounts
|
||||||
|
|
||||||
|
binding.tvLoanProduct.text = account.accountTypeName
|
||||||
|
.trim().lowercase().split(" ")
|
||||||
|
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercaseChar() } }
|
||||||
|
binding.tvLoanAccount.text = account.accountNumber
|
||||||
|
binding.tvLoanStatus.text = detail?.loanStatus?.ifBlank { account.statusDesc } ?: account.statusDesc
|
||||||
|
|
||||||
|
val loanAmt = detail?.loanAmount ?: 0.0
|
||||||
|
val outstanding = if (detail != null) abs(detail.outstandingAmt) else account.availableBalance.toDoubleOrNull() ?: 0.0
|
||||||
|
val paid = (loanAmt - outstanding).coerceAtLeast(0.0)
|
||||||
|
|
||||||
|
binding.tvLoanTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(loanAmt)}"
|
||||||
|
binding.tvLoanPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(paid)}"
|
||||||
|
binding.tvLoanOutstanding.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(outstanding)}"
|
||||||
|
|
||||||
|
val progress = if (loanAmt > 0) ((paid / loanAmt) * 100).toInt().coerceIn(0, 100) else 0
|
||||||
|
binding.loanProgressBar.progress = if (hide) 0 else progress
|
||||||
|
|
||||||
|
binding.tvLoanCompletion.text = bmlCompletionText(detail, ctx)
|
||||||
|
|
||||||
|
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
|
||||||
|
binding.loanDividerDetails.visibility = detailsVisible
|
||||||
|
binding.loanDetailsGroup.visibility = detailsVisible
|
||||||
|
|
||||||
|
if (expanded && detail != null) {
|
||||||
|
binding.tvLoanRepayment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(detail.repayAmount)}"
|
||||||
|
binding.tvLoanIntRate.text = ctx.getString(R.string.loan_rate_fmt, detail.intRate)
|
||||||
|
binding.tvLoanStartDate.text = formatIsoDate(detail.startDate)
|
||||||
|
binding.tvLoanEndDate.text = formatIsoDate(detail.endDate)
|
||||||
|
|
||||||
|
if (detail.overdueAmount > 0) {
|
||||||
|
binding.loanRowOverdue.visibility = View.VISIBLE
|
||||||
|
binding.tvLoanOverdue.text = if (hide) "$currency ••••••"
|
||||||
|
else "$currency ${amountFmt.format(detail.overdueAmount)} (${detail.noOfRepayOverdue})"
|
||||||
|
} else {
|
||||||
|
binding.loanRowOverdue.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bmlCompletionText(detail: BmlLoanDetail?, ctx: android.content.Context): String {
|
||||||
|
if (detail == null) return ""
|
||||||
|
val outstanding = abs(detail.outstandingAmt)
|
||||||
|
if (outstanding <= 0.0 || detail.repayAmount <= 0.0)
|
||||||
|
return ctx.getString(R.string.financing_completion_done)
|
||||||
|
val remaining = ceil(outstanding / detail.repayAmount).toInt()
|
||||||
|
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
cal.add(Calendar.MONTH, remaining)
|
||||||
|
val month = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(cal.time)
|
||||||
|
return ctx.getString(R.string.financing_completion_fmt, month)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatIsoDate(raw: String): String {
|
||||||
|
return try {
|
||||||
|
outputDateFmt.format(isoDateFmt.parse(raw)!!)
|
||||||
|
} catch (_: Exception) { raw.take(10) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TYPE_MIB = 0
|
||||||
|
private const val TYPE_BML = 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ 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
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||||
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
||||||
|
|
||||||
class FinancingFragment : Fragment() {
|
class FinancingFragment : Fragment() {
|
||||||
@@ -18,6 +21,10 @@ class FinancingFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
private lateinit var adapter: FinancingAdapter
|
private lateinit var adapter: FinancingAdapter
|
||||||
|
private var financingRefreshing = false
|
||||||
|
|
||||||
|
private var latestMibDeals: List<MibFinanceDeal> = emptyList()
|
||||||
|
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentFinancingBinding.inflate(inflater, container, false)
|
_binding = FragmentFinancingBinding.inflate(inflater, container, false)
|
||||||
@@ -38,15 +45,41 @@ class FinancingFragment : Fragment() {
|
|||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
financingRefreshing = true
|
||||||
|
(activity as? HomeActivity)?.triggerRefreshFinancing()
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
|
||||||
viewModel.financing.observe(viewLifecycleOwner) { deals ->
|
viewModel.financing.observe(viewLifecycleOwner) { deals ->
|
||||||
adapter.updateDeals(deals)
|
latestMibDeals = deals
|
||||||
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE
|
rebuildAdapter()
|
||||||
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE
|
}
|
||||||
binding.loadingView.visibility = View.GONE
|
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { details ->
|
||||||
|
latestBmlLoanDetails = details
|
||||||
|
rebuildAdapter()
|
||||||
}
|
}
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun rebuildAdapter() {
|
||||||
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val loanAccounts = accounts.filter { it.profileType == "BML_LOAN" }
|
||||||
|
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
||||||
|
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
||||||
|
|
||||||
|
adapter.update(latestMibDeals, bmlLoans)
|
||||||
|
|
||||||
|
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
||||||
|
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||||
|
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
|
||||||
|
binding.loadingView.visibility = View.GONE
|
||||||
|
if (financingRefreshing) {
|
||||||
|
financingRefreshing = false
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
requireActivity().title = getString(R.string.nav_finances)
|
requireActivity().title = getString(R.string.nav_finances)
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import android.widget.Toast
|
|||||||
import sh.sar.basedbank.ui.home.NavCustomization
|
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.OnBackPressedCallback
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.ActionBarDrawerToggle
|
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||||
|
import androidx.core.view.GravityCompat
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
@@ -36,10 +38,12 @@ 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.models.BankServerException
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||||
import sh.sar.basedbank.api.bml.BmlActivationResult
|
|
||||||
import sh.sar.basedbank.api.bml.BmlContactsClient
|
import sh.sar.basedbank.api.bml.BmlContactsClient
|
||||||
import sh.sar.basedbank.api.bml.BmlForeignLimitsClient
|
import sh.sar.basedbank.api.bml.BmlForeignLimitsClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||||
import sh.sar.basedbank.api.bml.BmlProfile
|
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.FahipayAccountClient
|
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
|
||||||
@@ -53,6 +57,7 @@ import sh.sar.basedbank.ui.login.LoginActivity
|
|||||||
import sh.sar.basedbank.api.models.BankContact
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
import sh.sar.basedbank.api.models.BankContactCategory
|
import sh.sar.basedbank.api.models.BankContactCategory
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
|
import sh.sar.basedbank.api.mib.MibCardsClient
|
||||||
import sh.sar.basedbank.api.mib.MibFinancingClient
|
import sh.sar.basedbank.api.mib.MibFinancingClient
|
||||||
import sh.sar.basedbank.api.mib.MibProfile
|
import sh.sar.basedbank.api.mib.MibProfile
|
||||||
import sh.sar.basedbank.api.mib.MibSession
|
import sh.sar.basedbank.api.mib.MibSession
|
||||||
@@ -60,6 +65,7 @@ import sh.sar.basedbank.api.mib.MibFinanceDeal
|
|||||||
import sh.sar.basedbank.util.AccountCache
|
import sh.sar.basedbank.util.AccountCache
|
||||||
import sh.sar.basedbank.util.ContactsCache
|
import sh.sar.basedbank.util.ContactsCache
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.CardsCache
|
||||||
import sh.sar.basedbank.util.FinancingCache
|
import sh.sar.basedbank.util.FinancingCache
|
||||||
import sh.sar.basedbank.util.ForeignLimitsCache
|
import sh.sar.basedbank.util.ForeignLimitsCache
|
||||||
|
|
||||||
@@ -70,6 +76,10 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
private lateinit var toggle: ActionBarDrawerToggle
|
private lateinit var toggle: ActionBarDrawerToggle
|
||||||
private var suppressBottomNavCallback = false
|
private var suppressBottomNavCallback = false
|
||||||
|
|
||||||
|
private var backPressedOnce = false
|
||||||
|
private val backPressHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val resetBackPress = Runnable { backPressedOnce = false }
|
||||||
|
|
||||||
private val autolockHandler = Handler(Looper.getMainLooper())
|
private val autolockHandler = Handler(Looper.getMainLooper())
|
||||||
private var warningDialog: AlertDialog? = null
|
private var warningDialog: AlertDialog? = null
|
||||||
private var countdownTimer: CountDownTimer? = null
|
private var countdownTimer: CountDownTimer? = null
|
||||||
@@ -137,6 +147,8 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
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()
|
||||||
|
R.id.nav_pay_with_card -> PayWithCardFragment()
|
||||||
|
R.id.nav_card_settings -> CardSettingsFragment()
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (frag != null) show(frag)
|
if (frag != null) show(frag)
|
||||||
@@ -168,8 +180,12 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
|
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val cachedCards = CardsCache.load(this)
|
||||||
|
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||||
val cachedFinancing = FinancingCache.load(this)
|
val cachedFinancing = FinancingCache.load(this)
|
||||||
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
||||||
|
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
|
||||||
|
if (cachedBmlLoans.isNotEmpty()) viewModel.bmlLoanDetails.value = cachedBmlLoans
|
||||||
val cachedLimits = ForeignLimitsCache.load(this)
|
val cachedLimits = ForeignLimitsCache.load(this)
|
||||||
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
|
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
|
||||||
|
|
||||||
@@ -178,6 +194,8 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
|
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
}
|
}
|
||||||
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
|
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
|
||||||
|
refreshBmlLoanDetails()
|
||||||
|
triggerRefreshCards()
|
||||||
} 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)
|
||||||
@@ -186,8 +204,12 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
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 cachedCards = CardsCache.load(this)
|
||||||
|
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||||
val cachedFinancing = FinancingCache.load(this)
|
val cachedFinancing = FinancingCache.load(this)
|
||||||
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
||||||
|
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
|
||||||
|
if (cachedBmlLoans.isNotEmpty()) viewModel.bmlLoanDetails.value = cachedBmlLoans
|
||||||
val cachedLimits = ForeignLimitsCache.load(this)
|
val cachedLimits = ForeignLimitsCache.load(this)
|
||||||
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
|
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
|
||||||
|
|
||||||
@@ -202,6 +224,42 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
// Close drawer if open (drawer-nav mode)
|
||||||
|
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
|
||||||
|
binding.drawerLayout.closeDrawers()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
|
||||||
|
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||||
|
supportFragmentManager.popBackStack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// In bottom nav mode, pressing back navigates up the hierarchy
|
||||||
|
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
||||||
|
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
||||||
|
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
|
||||||
|
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
|
||||||
|
show(MoreFragment())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.bottomNavigation.selectedItemId = R.id.nav_dashboard
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// At top level — require double-tap to exit
|
||||||
|
if (backPressedOnce) {
|
||||||
|
backPressHandler.removeCallbacks(resetBackPress)
|
||||||
|
finish()
|
||||||
|
} else {
|
||||||
|
backPressedOnce = true
|
||||||
|
Toast.makeText(this@HomeActivity, R.string.press_back_to_exit, Toast.LENGTH_SHORT).show()
|
||||||
|
backPressHandler.postDelayed(resetBackPress, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Keep all MIB sessions 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) {
|
||||||
@@ -301,6 +359,8 @@ fun applyNavLabelVisibility() {
|
|||||||
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()
|
||||||
|
R.id.nav_pay_with_card -> PayWithCardFragment()
|
||||||
|
R.id.nav_card_settings -> CardSettingsFragment()
|
||||||
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)
|
||||||
@@ -324,6 +384,19 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun triggerRefresh() {
|
||||||
|
autoRefresh(CredentialStore(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun triggerRefreshFinancing() {
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
for ((loginId, session) in app.mibSessions) {
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
|
}
|
||||||
|
refreshBmlLoanDetails()
|
||||||
|
}
|
||||||
|
|
||||||
fun setRefreshing(visible: Boolean) {
|
fun setRefreshing(visible: Boolean) {
|
||||||
binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE
|
binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
@@ -337,11 +410,12 @@ fun applyNavLabelVisibility() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// Returning from LockActivity — skip the elapsed check and reset state.
|
// Returning from LockActivity — refresh sessions since they may have expired.
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
isLocked = false
|
isLocked = false
|
||||||
pauseTime = 0L
|
pauseTime = 0L
|
||||||
resetAutolockTimer()
|
resetAutolockTimer()
|
||||||
|
autoRefresh(CredentialStore(this))
|
||||||
return
|
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
|
||||||
@@ -354,6 +428,9 @@ fun applyNavLabelVisibility() {
|
|||||||
lock()
|
lock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (elapsed > 45_000L) {
|
||||||
|
autoRefresh(CredentialStore(this))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
resetAutolockTimer()
|
resetAutolockTimer()
|
||||||
}
|
}
|
||||||
@@ -427,14 +504,24 @@ fun applyNavLabelVisibility() {
|
|||||||
|
|
||||||
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) {
|
||||||
lock()
|
val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
|
||||||
|
if (avd != null) {
|
||||||
|
item.icon = avd
|
||||||
|
avd.start()
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({ lock() }, 200)
|
||||||
|
} else {
|
||||||
|
lock()
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (item.itemId == R.id.action_hide_amounts) {
|
if (item.itemId == R.id.action_hide_amounts) {
|
||||||
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
||||||
viewModel.hideAmounts.value = newHidden
|
viewModel.hideAmounts.value = newHidden
|
||||||
getSharedPreferences("prefs", MODE_PRIVATE).edit().putBoolean("hide_amounts", newHidden).apply()
|
getSharedPreferences("prefs", MODE_PRIVATE).edit().putBoolean("hide_amounts", newHidden).apply()
|
||||||
invalidateOptionsMenu()
|
val avd = getDrawable(if (newHidden) R.drawable.avd_hide_amounts else R.drawable.avd_show_amounts)
|
||||||
|
as? android.graphics.drawable.AnimatedVectorDrawable
|
||||||
|
item.icon = avd
|
||||||
|
avd?.start()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return super.onOptionsItemSelected(item)
|
return super.onOptionsItemSelected(item)
|
||||||
@@ -462,14 +549,25 @@ fun applyNavLabelVisibility() {
|
|||||||
autoRefresh(store)
|
autoRefresh(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showConnectivityBanner(message: String) {
|
||||||
|
binding.connectivityBanner.text = message
|
||||||
|
binding.connectivityBanner.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideConnectivityBanner() {
|
||||||
|
binding.connectivityBanner.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
private fun autoRefresh(store: CredentialStore) {
|
private fun autoRefresh(store: CredentialStore) {
|
||||||
val mibLoginIds = store.getMibLoginIds()
|
val mibLoginIds = store.getMibLoginIds()
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
val bmlLoginIds = store.getBmlLoginIds()
|
||||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
|
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
|
||||||
binding.refreshIndicator.visibility = View.VISIBLE
|
binding.refreshIndicator.visibility = View.VISIBLE
|
||||||
|
hideConnectivityBanner()
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
|
val refreshErrors = ConcurrentLinkedQueue<String>()
|
||||||
// One async job per MIB login, all run in parallel
|
// One async job per MIB login, all run in parallel
|
||||||
val mibJobs = mibLoginIds.mapNotNull { loginId ->
|
val mibJobs = mibLoginIds.mapNotNull { loginId ->
|
||||||
val creds = store.loadMibCredentials(loginId) ?: return@mapNotNull null
|
val creds = store.loadMibCredentials(loginId) ?: return@mapNotNull null
|
||||||
@@ -483,39 +581,84 @@ fun applyNavLabelVisibility() {
|
|||||||
app.mibLoginFlows[loginId] = flow
|
app.mibLoginFlows[loginId] = flow
|
||||||
store.saveMibProfiles(loginId, flow.lastProfiles)
|
store.saveMibProfiles(loginId, flow.lastProfiles)
|
||||||
accounts
|
accounts
|
||||||
} catch (_: Exception) { AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" } }
|
} catch (e: java.io.IOException) {
|
||||||
|
refreshErrors.add("NO_INTERNET")
|
||||||
|
AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" }
|
||||||
|
} catch (e: BankServerException) {
|
||||||
|
refreshErrors.add("SERVER:${e.bankName}")
|
||||||
|
AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One async job per BML login, all run in parallel
|
// One async job per BML login, all run in parallel
|
||||||
val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
|
val bmlJobs = bmlLoginIds.map { loginId ->
|
||||||
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
|
|
||||||
loginId to async(Dispatchers.IO) {
|
loginId to async(Dispatchers.IO) {
|
||||||
val loginTag = "bml_$loginId"
|
val loginTag = "bml_$loginId"
|
||||||
val app = application as BasedBankApp
|
val app = application as BasedBankApp
|
||||||
val savedProfiles = store.loadBmlProfiles(loginId)
|
val savedProfiles = store.loadBmlProfiles(loginId)
|
||||||
val allAccounts = mutableListOf<BankAccount>()
|
val allAccounts = mutableListOf<BankAccount>()
|
||||||
var anyExpired = savedProfiles.isEmpty()
|
|
||||||
|
|
||||||
// Try each saved profile's cached session
|
|
||||||
for (profile in savedProfiles) {
|
|
||||||
val saved = store.loadBmlProfileSession(profile.profileId)
|
|
||||||
if (saved != null) {
|
|
||||||
try {
|
|
||||||
val session = BmlSession(saved.first, saved.second)
|
|
||||||
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profile.name, profile.profileId)
|
|
||||||
app.bmlSessions[profile.profileId] = session
|
|
||||||
allAccounts += accounts
|
|
||||||
} catch (_: AuthExpiredException) { anyExpired = true
|
|
||||||
} catch (_: Exception) { anyExpired = true }
|
|
||||||
} else {
|
|
||||||
anyExpired = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
||||||
|
|
||||||
// Also try legacy single-profile session token (pre-multi-profile installs)
|
val bmlClient = BmlAccountClient()
|
||||||
|
for (profile in savedProfiles) {
|
||||||
|
val saved = store.loadBmlProfileSession(profile.profileId)
|
||||||
|
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
|
||||||
|
if (saved == null) {
|
||||||
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
|
.filter { it.profileId == profile.profileId }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
|
||||||
|
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
||||||
|
|
||||||
|
suspend fun fetchWithSession(session: BmlSession) {
|
||||||
|
bmlClient.checkProfile(session)
|
||||||
|
val accounts = bmlClient.fetchAccounts(session, loginTag, profile.name, profile.profileId)
|
||||||
|
app.bmlSessions[profile.profileId] = session
|
||||||
|
allAccounts += accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun tryRefresh() {
|
||||||
|
if (refreshToken == null) throw Exception("No refresh token")
|
||||||
|
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
|
||||||
|
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
|
||||||
|
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
|
||||||
|
if (newSession.refreshToken.isNotBlank())
|
||||||
|
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
|
||||||
|
if (newSession.expiresAt > 0)
|
||||||
|
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
|
||||||
|
fetchWithSession(newSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (tokenKnownExpired) {
|
||||||
|
tryRefresh()
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
fetchWithSession(BmlSession(saved.first, saved.second))
|
||||||
|
} catch (_: AuthExpiredException) {
|
||||||
|
tryRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: java.io.IOException) {
|
||||||
|
refreshErrors.add("NO_INTERNET")
|
||||||
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
|
.filter { it.profileId == profile.profileId }
|
||||||
|
} catch (e: BankServerException) {
|
||||||
|
refreshErrors.add("SERVER:${e.bankName}")
|
||||||
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
|
.filter { it.profileId == profile.profileId }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
|
.filter { it.profileId == profile.profileId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy single-profile session (pre-multi-profile installs)
|
||||||
if (savedProfiles.isEmpty()) {
|
if (savedProfiles.isEmpty()) {
|
||||||
val legacyToken = store.loadBmlSession(loginId)
|
val legacyToken = store.loadBmlSession(loginId)
|
||||||
if (legacyToken != null) {
|
if (legacyToken != null) {
|
||||||
@@ -524,47 +667,17 @@ fun applyNavLabelVisibility() {
|
|||||||
val accounts = BmlAccountClient().fetchAccounts(session, loginTag)
|
val accounts = BmlAccountClient().fetchAccounts(session, loginTag)
|
||||||
app.bmlSessions[loginId] = session
|
app.bmlSessions[loginId] = session
|
||||||
allAccounts += accounts
|
allAccounts += accounts
|
||||||
anyExpired = false
|
} catch (e: java.io.IOException) {
|
||||||
} catch (_: AuthExpiredException) { anyExpired = true
|
refreshErrors.add("NO_INTERNET")
|
||||||
} 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)
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
|
} catch (e: BankServerException) {
|
||||||
|
refreshErrors.add("SERVER:${e.bankName}")
|
||||||
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,6 +724,12 @@ fun applyNavLabelVisibility() {
|
|||||||
app.fahipaySessions[loginId] = session
|
app.fahipaySessions[loginId] = session
|
||||||
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
|
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
|
||||||
accounts
|
accounts
|
||||||
|
} catch (e: java.io.IOException) {
|
||||||
|
refreshErrors.add("NO_INTERNET")
|
||||||
|
AccountCache.loadFahipay(this@HomeActivity, loginId)
|
||||||
|
} catch (e: BankServerException) {
|
||||||
|
refreshErrors.add("SERVER:${e.bankName}")
|
||||||
|
AccountCache.loadFahipay(this@HomeActivity, loginId)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
AccountCache.loadFahipay(this@HomeActivity, loginId)
|
AccountCache.loadFahipay(this@HomeActivity, loginId)
|
||||||
}
|
}
|
||||||
@@ -630,11 +749,32 @@ fun applyNavLabelVisibility() {
|
|||||||
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
|
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
|
||||||
binding.refreshIndicator.visibility = View.GONE
|
binding.refreshIndicator.visibility = View.GONE
|
||||||
|
|
||||||
|
val noInternet = refreshErrors.any { it == "NO_INTERNET" }
|
||||||
|
val serverErrors = refreshErrors.filter { it.startsWith("SERVER:") }
|
||||||
|
.map { it.removePrefix("SERVER:") }.distinct()
|
||||||
|
when {
|
||||||
|
noInternet -> showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||||
|
serverErrors.isNotEmpty() -> showConnectivityBanner(
|
||||||
|
getString(R.string.connectivity_server_error, serverErrors.joinToString(", "))
|
||||||
|
)
|
||||||
|
else -> hideConnectivityBanner()
|
||||||
|
}
|
||||||
|
|
||||||
|
val errors = mutableSetOf<String>()
|
||||||
|
if (noInternet) errors.add("NO_INTERNET")
|
||||||
|
serverErrors.forEach { errors.add(it.uppercase()) }
|
||||||
|
viewModel.connectivityErrors.postValue(errors)
|
||||||
|
|
||||||
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
|
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
|
||||||
for ((loginId, session) in app.mibSessions) {
|
for ((loginId, session) in app.mibSessions) {
|
||||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
|
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
}
|
}
|
||||||
|
refreshBmlLoanDetails()
|
||||||
|
for ((loginId, session) in app.mibSessions) {
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
refreshMibCards(loginId, session, profiles)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,6 +1012,70 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun refreshBmlLoanDetails() {
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
val loanAccounts = app.bmlAccounts.filter { it.profileType == "BML_LOAN" }
|
||||||
|
if (loanAccounts.isEmpty()) return
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val details = withContext(Dispatchers.IO) {
|
||||||
|
val map = mutableMapOf<String, BmlLoanDetail>()
|
||||||
|
for (acc in loanAccounts) {
|
||||||
|
val session = app.bmlSessionFor(acc) ?: continue
|
||||||
|
try {
|
||||||
|
val detail = BmlAccountClient().fetchLoanDetail(session, acc.internalId)
|
||||||
|
if (detail != null) map[acc.internalId] = detail
|
||||||
|
} catch (_: Exception) { /* keep existing */ }
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
if (details.isNotEmpty()) {
|
||||||
|
val merged = (viewModel.bmlLoanDetails.value ?: emptyMap()) + details
|
||||||
|
FinancingCache.saveBmlLoans(this@HomeActivity, merged)
|
||||||
|
viewModel.bmlLoanDetails.postValue(merged)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { /* keep cached data */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun triggerRefreshCards() {
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
for ((loginId, session) in app.mibSessions) {
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
refreshMibCards(loginId, session, profiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshMibCards(loginId: String, session: MibSession, profiles: List<MibProfile>) {
|
||||||
|
if (profiles.isEmpty()) return
|
||||||
|
val flow = (application as BasedBankApp).mibFlowFor(loginId)
|
||||||
|
val client = MibCardsClient()
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val cards = withContext(Dispatchers.IO) {
|
||||||
|
val result = mutableListOf<sh.sar.basedbank.api.mib.MibCard>()
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
for (profile in profiles) {
|
||||||
|
try {
|
||||||
|
flow.switchProfile(session, profile)
|
||||||
|
for (card in client.fetchCards(session, "mib_$loginId")) {
|
||||||
|
if (seen.add(card.cardId)) result += card
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
if (cards.isNotEmpty()) {
|
||||||
|
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
|
||||||
|
existing.removeAll { it.loginTag == "mib_$loginId" }
|
||||||
|
existing += cards
|
||||||
|
viewModel.mibCards.postValue(existing)
|
||||||
|
CardsCache.save(this@HomeActivity, existing)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshFinancing(loginId: String, session: MibSession, profiles: List<MibProfile>) {
|
private fun refreshFinancing(loginId: String, session: MibSession, profiles: List<MibProfile>) {
|
||||||
if (profiles.isEmpty()) return
|
if (profiles.isEmpty()) return
|
||||||
val flow = (application as BasedBankApp).mibFlowFor(loginId)
|
val flow = (application as BasedBankApp).mibFlowFor(loginId)
|
||||||
|
|||||||
@@ -3,19 +3,37 @@ package sh.sar.basedbank.ui.home
|
|||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||||
|
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.models.BankContact
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
import sh.sar.basedbank.api.models.BankContactCategory
|
import sh.sar.basedbank.api.models.BankContactCategory
|
||||||
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
|
|
||||||
|
sealed class CardItem {
|
||||||
|
data class Mib(val card: MibCard) : CardItem()
|
||||||
|
data class Bml(val account: BankAccount) : CardItem()
|
||||||
|
}
|
||||||
|
|
||||||
class HomeViewModel : ViewModel() {
|
class HomeViewModel : ViewModel() {
|
||||||
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
|
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
|
||||||
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
||||||
|
/** BML loan details keyed by account internalId. */
|
||||||
|
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
||||||
val contacts = MutableLiveData<List<BankContact>>(emptyList())
|
val contacts = MutableLiveData<List<BankContact>>(emptyList())
|
||||||
val contactCategories = MutableLiveData<List<BankContactCategory>>(emptyList())
|
val contactCategories = MutableLiveData<List<BankContactCategory>>(emptyList())
|
||||||
|
|
||||||
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
||||||
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
||||||
|
|
||||||
|
val mibCards = MutableLiveData<List<MibCard>?>(null)
|
||||||
|
|
||||||
val hideAmounts = MutableLiveData<Boolean>(false)
|
val hideAmounts = MutableLiveData<Boolean>(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set of connectivity error keys from the last refresh.
|
||||||
|
* Contains "NO_INTERNET" for no network, or uppercase bank names ("MIB", "BML", "FAHIPAY")
|
||||||
|
* for HTTP 5xx server errors from specific banks.
|
||||||
|
*/
|
||||||
|
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class MoreFragment : Fragment() {
|
|||||||
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.iconRes)
|
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
|
||||||
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
|
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
|
||||||
|
row.findViewById<TextView>(R.id.tvDescription).setText(item.descriptionRes)
|
||||||
row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) }
|
row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) }
|
||||||
list.addView(row)
|
list.addView(row)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,21 +10,23 @@ object NavCustomization {
|
|||||||
data class NavItemDef(
|
data class NavItemDef(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
@DrawableRes val iconRes: Int,
|
@DrawableRes val iconRes: Int,
|
||||||
@StringRes val titleRes: Int
|
@StringRes val titleRes: Int,
|
||||||
|
@StringRes val descriptionRes: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
||||||
val ALL_SWAPPABLE = listOf(
|
val ALL_SWAPPABLE = listOf(
|
||||||
NavItemDef(R.id.nav_accounts, R.drawable.ic_nav_accounts, R.string.nav_accounts),
|
NavItemDef(R.id.nav_accounts, R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
|
||||||
NavItemDef(R.id.nav_contacts, R.drawable.ic_contacts, R.string.nav_contacts),
|
NavItemDef(R.id.nav_contacts, R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
|
||||||
NavItemDef(R.id.nav_transfer, R.drawable.ic_send, R.string.transfer),
|
NavItemDef(R.id.nav_transfer, R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
|
||||||
NavItemDef(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr),
|
NavItemDef(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
|
||||||
NavItemDef(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities),
|
NavItemDef(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities, R.string.nav_desc_activities),
|
||||||
NavItemDef(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history),
|
NavItemDef(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history, R.string.nav_desc_transfer_history),
|
||||||
NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances),
|
NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances),
|
||||||
NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings),
|
NavItemDef(R.id.nav_pay_with_card, R.drawable.ic_nav_card, R.string.nav_pay_with_card, R.string.nav_desc_pay_with_card),
|
||||||
NavItemDef(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp),
|
NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings, R.string.nav_desc_card_settings),
|
||||||
NavItemDef(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings),
|
NavItemDef(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp, R.string.nav_desc_otp),
|
||||||
|
NavItemDef(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings, R.string.nav_desc_settings),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||||
|
|||||||
@@ -77,8 +77,10 @@ class PayMvQrFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val basePaddingBottom = view.paddingBottom
|
val basePaddingBottom = view.paddingBottom
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
|
||||||
v.updatePadding(bottom = basePaddingBottom + navBar.bottom)
|
val navBarBottom = if (bottomNav?.visibility == View.VISIBLE) 0
|
||||||
|
else insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
|
||||||
|
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
|
||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
setupDropdown()
|
setupDropdown()
|
||||||
@@ -95,7 +97,7 @@ class PayMvQrFragment : Fragment() {
|
|||||||
private fun setupDropdown() {
|
private fun setupDropdown() {
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
val eligible = accounts.filter {
|
val eligible = accounts.filter {
|
||||||
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT"
|
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN"
|
||||||
}
|
}
|
||||||
val adapter = QrAccountAdapter(requireContext(), eligible)
|
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||||
binding.actvAccount.setAdapter(adapter)
|
binding.actvAccount.setAdapter(adapter)
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
|
import sh.sar.basedbank.databinding.FragmentPayWithCardBinding
|
||||||
|
import sh.sar.basedbank.util.CardsCache
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
|
|
||||||
|
class PayWithCardFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentPayWithCardBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentPayWithCardBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val adapter = CardWalletAdapter(emptyList(), requireContext())
|
||||||
|
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.swipeRefresh.setOnRefreshListener {
|
||||||
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
val updateCardList = {
|
||||||
|
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
|
||||||
|
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||||
|
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) }
|
||||||
|
.map { CardItem.Bml(it) }
|
||||||
|
val all = mibItems + bmlItems
|
||||||
|
adapter.update(all)
|
||||||
|
binding.loadingView.visibility = View.GONE
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
binding.emptyView.visibility = if (all.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
binding.recyclerView.visibility = if (all.isEmpty()) View.GONE else View.VISIBLE
|
||||||
|
}
|
||||||
|
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
|
||||||
|
|
||||||
|
val cached = CardsCache.load(requireContext())
|
||||||
|
if (cached.isNotEmpty()) {
|
||||||
|
viewModel.mibCards.value = cached
|
||||||
|
} else {
|
||||||
|
binding.loadingView.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
(activity as? HomeActivity)?.triggerRefreshCards()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.nav_pay_with_card)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class CardWalletAdapter(
|
||||||
|
private var cards: List<CardItem>,
|
||||||
|
private val context: Context
|
||||||
|
) : RecyclerView.Adapter<CardWalletAdapter.VH>() {
|
||||||
|
|
||||||
|
fun update(newCards: List<CardItem>) {
|
||||||
|
cards = newCards
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
|
||||||
|
VH(LayoutInflater.from(context).inflate(R.layout.item_card_wallet, parent, false))
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
|
||||||
|
override fun getItemCount() = cards.size
|
||||||
|
|
||||||
|
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
|
||||||
|
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
|
||||||
|
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
|
||||||
|
private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
|
||||||
|
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
|
||||||
|
private val btnPayQr: View = view.findViewById(R.id.btnPayQr)
|
||||||
|
private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc)
|
||||||
|
|
||||||
|
fun bind(item: CardItem) {
|
||||||
|
when (item) {
|
||||||
|
is CardItem.Mib -> {
|
||||||
|
tvCardOwner.text = item.card.cardHolderName
|
||||||
|
tvCardNumber.text = formatMasked(item.card.maskedCardNumber)
|
||||||
|
tvCardType.text = item.card.cardTypeDesc
|
||||||
|
val assetPath = cardImageAsset(item.card)
|
||||||
|
if (assetPath != null) loadCardImage(ivCardImage, assetPath)
|
||||||
|
else ivCardImage.setImageDrawable(null)
|
||||||
|
bindCardStatus(tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
|
||||||
|
}
|
||||||
|
is CardItem.Bml -> {
|
||||||
|
tvCardOwner.text = item.account.accountBriefName
|
||||||
|
tvCardNumber.text = formatMasked(item.account.accountNumber)
|
||||||
|
tvCardType.text = item.account.accountTypeName
|
||||||
|
loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||||
|
val bmlStatus = item.account.statusDesc.takeUnless { it.equals("Active", ignoreCase = true) }
|
||||||
|
bindCardStatus(tvCardStatus, bmlStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
btnPayQr.setOnClickListener {
|
||||||
|
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(context)
|
||||||
|
val nfcSupported = nfcAdapter != null
|
||||||
|
btnPayNfc.isEnabled = nfcSupported
|
||||||
|
if (nfcSupported) {
|
||||||
|
btnPayNfc.setOnClickListener {
|
||||||
|
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
btnPayNfc.setOnClickListener(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
|
||||||
|
"53" -> "cards/mib/visa_black_platinum.png"
|
||||||
|
"57" -> "cards/mib/visa_blue_everyday.png"
|
||||||
|
"70" -> "cards/mib/visa_business.png"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadCardImage(imageView: ImageView, assetPath: String) {
|
||||||
|
try {
|
||||||
|
val bitmap = imageView.context.assets.open(assetPath).use {
|
||||||
|
BitmapFactory.decodeStream(it)
|
||||||
|
}
|
||||||
|
imageView.setImageBitmap(bitmap)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
imageView.setImageDrawable(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mibCardStatusLabel(cardStatus: String): String? = when (cardStatus) {
|
||||||
|
"CHST0" -> null // Active — no badge
|
||||||
|
else -> cardStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bindCardStatus(tv: TextView, statusLabel: String?) {
|
||||||
|
if (statusLabel == null) { tv.visibility = View.GONE; return }
|
||||||
|
tv.visibility = View.VISIBLE
|
||||||
|
tv.text = statusLabel
|
||||||
|
val dp = tv.context.resources.displayMetrics.density
|
||||||
|
tv.background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.RECTANGLE
|
||||||
|
cornerRadius = 12 * dp
|
||||||
|
setColor(0xCC212121.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatMasked(masked: String): String {
|
||||||
|
if (masked.length < 4) return masked
|
||||||
|
return "\u2022\u2022\u2022\u2022 ${masked.takeLast(4)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,14 +17,15 @@ class SettingsFragment : Fragment() {
|
|||||||
private data class SettingsItem(
|
private data class SettingsItem(
|
||||||
@DrawableRes val icon: Int,
|
@DrawableRes val icon: Int,
|
||||||
@StringRes val title: Int,
|
@StringRes val title: Int,
|
||||||
|
@StringRes val description: Int,
|
||||||
val dest: () -> Fragment
|
val dest: () -> Fragment
|
||||||
)
|
)
|
||||||
|
|
||||||
private val items = listOf(
|
private val items = listOf(
|
||||||
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins) { SettingsLoginsFragment() },
|
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins, R.string.settings_desc_logins) { SettingsLoginsFragment() },
|
||||||
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance) { SettingsAppearanceFragment() },
|
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() },
|
||||||
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security) { SettingsSecurityFragment() },
|
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
|
||||||
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage) { SettingsStorageFragment() },
|
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||||
@@ -37,6 +38,7 @@ class SettingsFragment : Fragment() {
|
|||||||
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.icon)
|
||||||
row.findViewById<TextView>(R.id.tvLabel).setText(item.title)
|
row.findViewById<TextView>(R.id.tvLabel).setText(item.title)
|
||||||
|
row.findViewById<TextView>(R.id.tvDescription).setText(item.description)
|
||||||
row.setOnClickListener {
|
row.setOnClickListener {
|
||||||
(requireActivity() as HomeActivity).showWithBackStack(item.dest())
|
(requireActivity() as HomeActivity).showWithBackStack(item.dest())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -433,6 +433,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
return when (activationResult) {
|
return when (activationResult) {
|
||||||
is BmlActivationResult.Success -> {
|
is BmlActivationResult.Success -> {
|
||||||
store.saveBmlProfileSession(profile.profileId, activationResult.session.accessToken, activationResult.session.deviceId)
|
store.saveBmlProfileSession(profile.profileId, activationResult.session.accessToken, activationResult.session.deviceId)
|
||||||
|
if (activationResult.session.refreshToken.isNotBlank())
|
||||||
|
store.saveBmlProfileRefreshToken(profile.profileId, activationResult.session.refreshToken)
|
||||||
|
if (activationResult.session.expiresAt > 0)
|
||||||
|
store.saveBmlProfileExpiresAt(profile.profileId, activationResult.session.expiresAt)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
is BmlActivationResult.NeedsBusinessOtp ->
|
is BmlActivationResult.NeedsBusinessOtp ->
|
||||||
@@ -475,6 +479,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
verifyProgress.dismiss()
|
verifyProgress.dismiss()
|
||||||
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
|
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
|
||||||
|
if (session.refreshToken.isNotBlank())
|
||||||
|
store.saveBmlProfileRefreshToken(profile.profileId, session.refreshToken)
|
||||||
|
if (session.expiresAt > 0)
|
||||||
|
store.saveBmlProfileExpiresAt(profile.profileId, session.expiresAt)
|
||||||
return true
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
verifyProgress.dismiss()
|
verifyProgress.dismiss()
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ class SettingsSecurityFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto unlock on correct PIN (only for pin method)
|
||||||
|
if (prefs.getString("security_method", null) == "pin") {
|
||||||
|
binding.rowAutoUnlockPin.visibility = View.VISIBLE
|
||||||
|
binding.switchAutoUnlockPin.isChecked = prefs.getBoolean("auto_unlock_pin", false)
|
||||||
|
binding.switchAutoUnlockPin.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("auto_unlock_pin", isChecked).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Biometrics
|
// Biometrics
|
||||||
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
|
||||||
@@ -60,7 +69,6 @@ class SettingsSecurityFragment : Fragment() {
|
|||||||
|
|
||||||
// Auto-lock
|
// Auto-lock
|
||||||
binding.autolockToggle.check(when (prefs.getLong("autolock_timeout", 60_000L)) {
|
binding.autolockToggle.check(when (prefs.getLong("autolock_timeout", 60_000L)) {
|
||||||
0L -> R.id.btnAutolockOff
|
|
||||||
30_000L -> R.id.btnAutolock30s
|
30_000L -> R.id.btnAutolock30s
|
||||||
180_000L -> R.id.btnAutolock3m
|
180_000L -> R.id.btnAutolock3m
|
||||||
300_000L -> R.id.btnAutolock5m
|
300_000L -> R.id.btnAutolock5m
|
||||||
@@ -69,7 +77,6 @@ class SettingsSecurityFragment : Fragment() {
|
|||||||
binding.autolockToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
binding.autolockToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
val timeout = when (checkedId) {
|
val timeout = when (checkedId) {
|
||||||
R.id.btnAutolockOff -> 0L
|
|
||||||
R.id.btnAutolock30s -> 30_000L
|
R.id.btnAutolock30s -> 30_000L
|
||||||
R.id.btnAutolock3m -> 180_000L
|
R.id.btnAutolock3m -> 180_000L
|
||||||
R.id.btnAutolock5m -> 300_000L
|
R.id.btnAutolock5m -> 300_000L
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import android.widget.BaseAdapter
|
|||||||
import android.widget.Filter
|
import android.widget.Filter
|
||||||
import android.widget.Filterable
|
import android.widget.Filterable
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -36,6 +38,8 @@ 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.BmlAccountClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||||
import sh.sar.basedbank.api.bml.BmlTransferClient
|
import sh.sar.basedbank.api.bml.BmlTransferClient
|
||||||
import sh.sar.basedbank.api.bml.BmlTransferResult
|
import sh.sar.basedbank.api.bml.BmlTransferResult
|
||||||
import sh.sar.basedbank.api.bml.BmlValidateClient
|
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||||
@@ -83,6 +87,29 @@ class TransferFragment : Fragment() {
|
|||||||
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
||||||
private var selectedFahipayService: String? = null
|
private var selectedFahipayService: String? = null
|
||||||
|
|
||||||
|
// BML business profile OTP flow state
|
||||||
|
private enum class BmlOtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||||
|
private var bmlOtpState = BmlOtpState.NONE
|
||||||
|
private var bmlOtpChannel: String? = null
|
||||||
|
|
||||||
|
private data class PendingBmlTransfer(
|
||||||
|
val src: BankAccount,
|
||||||
|
val debitAccount: String,
|
||||||
|
val creditAccount: String,
|
||||||
|
val amount: Double,
|
||||||
|
val amountStr: String,
|
||||||
|
val remarks: String,
|
||||||
|
val transferType: String,
|
||||||
|
val currency: String,
|
||||||
|
val bank: String?,
|
||||||
|
val destDisplay: String,
|
||||||
|
val destAccount: String,
|
||||||
|
val toBank: String,
|
||||||
|
val toAvatar: Bitmap?
|
||||||
|
)
|
||||||
|
private var pendingBmlTransfer: PendingBmlTransfer? = null
|
||||||
|
private var accountDropdownAdapter: AccountDropdownAdapter? = null
|
||||||
|
|
||||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
@@ -152,6 +179,11 @@ class TransferFragment : Fragment() {
|
|||||||
setupFromDropdown()
|
setupFromDropdown()
|
||||||
setupAccountLookup()
|
setupAccountLookup()
|
||||||
|
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||||
|
accountDropdownAdapter?.notifyDataSetChanged()
|
||||||
|
selectedAccount?.let { showFromCard(it) }
|
||||||
|
}
|
||||||
|
|
||||||
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
||||||
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
|
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
|
||||||
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
|
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
|
||||||
@@ -170,8 +202,13 @@ class TransferFragment : Fragment() {
|
|||||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewModel.connectivityErrors.observe(viewLifecycleOwner) { updateTransferButton() }
|
||||||
|
|
||||||
binding.btnTransfer.isEnabled = false
|
binding.btnTransfer.isEnabled = false
|
||||||
binding.btnTransfer.setOnClickListener { initiateTransfer() }
|
binding.btnTransfer.setOnClickListener {
|
||||||
|
if (bmlOtpState == BmlOtpState.AWAITING_OTP) verifyBmlOtp()
|
||||||
|
else initiateTransfer()
|
||||||
|
}
|
||||||
|
|
||||||
binding.etAmount.addTextChangedListener { updateTransferButton() }
|
binding.etAmount.addTextChangedListener { updateTransferButton() }
|
||||||
|
|
||||||
@@ -205,6 +242,13 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilTo.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
|
binding.tilTo.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sensitive masking helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun maskAmount(formatted: String): String {
|
||||||
|
val currency = formatted.substringBefore(' ', formatted)
|
||||||
|
return "$currency ••••••"
|
||||||
|
}
|
||||||
|
|
||||||
private fun setupFromDropdown() {
|
private fun setupFromDropdown() {
|
||||||
binding.btnClearFromInfo.setOnClickListener {
|
binding.btnClearFromInfo.setOnClickListener {
|
||||||
selectedAccount = null
|
selectedAccount = null
|
||||||
@@ -216,11 +260,11 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
val adapter = AccountDropdownAdapter(requireContext(), accounts)
|
accountDropdownAdapter = AccountDropdownAdapter(requireContext(), accounts)
|
||||||
binding.actvFrom.setAdapter(adapter)
|
binding.actvFrom.setAdapter(accountDropdownAdapter)
|
||||||
|
|
||||||
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
|
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
|
||||||
val picked = adapter.getAccount(position) ?: return@setOnItemClickListener
|
val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener
|
||||||
selectedAccount = picked
|
selectedAccount = picked
|
||||||
updateAmountPrefix(picked)
|
updateAmountPrefix(picked)
|
||||||
showFromCard(picked)
|
showFromCard(picked)
|
||||||
@@ -254,13 +298,16 @@ class TransferFragment : Fragment() {
|
|||||||
val typeLabel = when {
|
val typeLabel = when {
|
||||||
account.profileType == "BML_PREPAID" -> "Prepaid Card"
|
account.profileType == "BML_PREPAID" -> "Prepaid Card"
|
||||||
account.profileType == "BML_CREDIT" -> "Credit Card"
|
account.profileType == "BML_CREDIT" -> "Credit Card"
|
||||||
|
account.profileType == "BML_DEBIT" -> "Debit Card"
|
||||||
account.accountTypeName.isNotBlank() -> account.accountTypeName
|
account.accountTypeName.isNotBlank() -> account.accountTypeName
|
||||||
else -> account.profileType
|
else -> account.profileType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
val balancePart = "${account.currencyName} ${account.availableBalance}"
|
||||||
binding.tvFromAccountName.text = account.accountBriefName
|
binding.tvFromAccountName.text = account.accountBriefName
|
||||||
binding.tvFromAccountNumber.text = account.accountNumber
|
binding.tvFromAccountNumber.text = account.accountNumber
|
||||||
binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel, "${account.currencyName} ${account.availableBalance}").joinToString(" · ")
|
binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel, if (hide) maskAmount(balancePart) else balancePart).joinToString(" · ")
|
||||||
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, colorHex))
|
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, colorHex))
|
||||||
binding.tilFrom.visibility = View.GONE
|
binding.tilFrom.visibility = View.GONE
|
||||||
binding.cardFromInfo.visibility = View.VISIBLE
|
binding.cardFromInfo.visibility = View.VISIBLE
|
||||||
@@ -602,7 +649,8 @@ class TransferFragment : Fragment() {
|
|||||||
val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
|
val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
|
||||||
|
|
||||||
val isSrcBml = src.bank == "BML"
|
val isSrcBml = src.bank == "BML"
|
||||||
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT"
|
val isBmlBusiness = isSrcBml && isBusinessProfile(src) // to test on personal accounts: use `isSrcBml`
|
||||||
|
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT" || src.profileType == "BML_DEBIT"
|
||||||
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" }
|
||||||
val allAccounts = viewModel.accounts.value ?: emptyList()
|
val allAccounts = viewModel.accounts.value ?: emptyList()
|
||||||
@@ -636,26 +684,34 @@ class TransferFragment : Fragment() {
|
|||||||
val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}"
|
val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}"
|
||||||
|
|
||||||
val doTransfer: () -> Unit = {
|
val doTransfer: () -> Unit = {
|
||||||
binding.btnTransfer.isEnabled = false
|
if (isBmlBusiness) {
|
||||||
(activity as? HomeActivity)?.setRefreshing(true)
|
// Business profile: async OTP channel selection flow
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
startBmlBusinessOtpFlow(
|
||||||
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks,
|
||||||
if (!isSrcBml) {
|
isSrcCard, isDestMib, currency, allAccounts, allContacts, capturedToAvatar
|
||||||
doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture)
|
)
|
||||||
} else {
|
} else {
|
||||||
doBmlTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
binding.btnTransfer.isEnabled = false
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
||||||
|
if (!isSrcBml) {
|
||||||
|
doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture)
|
||||||
|
} else {
|
||||||
|
doBmlTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
if (ok && receipt != null) {
|
||||||
|
ReceiptStore.save(requireContext(), receipt)
|
||||||
|
clearForm()
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
activity.triggerRefresh()
|
||||||
|
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
||||||
|
} else if (!ok) {
|
||||||
|
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
binding.btnTransfer.isEnabled = true
|
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
|
||||||
if (ok && receipt != null) {
|
|
||||||
ReceiptStore.save(requireContext(), receipt)
|
|
||||||
clearForm()
|
|
||||||
val activity = requireActivity() as HomeActivity
|
|
||||||
activity.refreshBalances(src)
|
|
||||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
|
||||||
} else if (!ok) {
|
|
||||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -742,12 +798,6 @@ class TransferFragment : Fragment() {
|
|||||||
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_")
|
val loginId = src.loginTag.removePrefix("mib_")
|
||||||
// Switch to the profile that owns the source account
|
|
||||||
if (src.profileId.isNotBlank()) {
|
|
||||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
|
||||||
val profile = profiles.firstOrNull { it.profileId == src.profileId }
|
|
||||||
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
|
|
||||||
}
|
|
||||||
val otp = CredentialStore(requireContext()).loadMibCredentials(loginId)?.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)
|
||||||
@@ -764,6 +814,12 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return try {
|
return try {
|
||||||
|
// Switch to the profile that owns the source account
|
||||||
|
if (src.profileId.isNotBlank()) {
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
val profile = profiles.firstOrNull { it.profileId == src.profileId }
|
||||||
|
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
|
||||||
|
}
|
||||||
val result = MibTransferClient().transfer(
|
val result = MibTransferClient().transfer(
|
||||||
session = sess,
|
session = sess,
|
||||||
fromAccount = src.accountNumber,
|
fromAccount = src.accountNumber,
|
||||||
@@ -822,7 +878,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine type + credit account
|
// Determine type + credit account
|
||||||
val isDestMyCard = allAccounts.any { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount }
|
val isDestMyCard = allAccounts.any { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount }
|
||||||
val (transferType, creditAccount, bank) = when {
|
val (transferType, creditAccount, bank) = when {
|
||||||
isSrcCard -> {
|
isSrcCard -> {
|
||||||
// CAD: card → own BML account
|
// CAD: card → own BML account
|
||||||
@@ -831,7 +887,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
isDestMyCard -> {
|
isDestMyCard -> {
|
||||||
// CPA: BML CASA → own card top-up
|
// CPA: BML CASA → own card top-up
|
||||||
val card = allAccounts.first { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount }
|
val card = allAccounts.first { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount }
|
||||||
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
|
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
|
||||||
}
|
}
|
||||||
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
|
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
|
||||||
@@ -884,12 +940,308 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── BML business profile OTP flow ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun isBusinessProfile(account: BankAccount): Boolean {
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginId = account.loginTag.removePrefix("bml_")
|
||||||
|
val profiles = app.bmlProfilesMap[loginId] ?: return false
|
||||||
|
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startBmlBusinessOtpFlow(
|
||||||
|
src: BankAccount,
|
||||||
|
destAccount: String,
|
||||||
|
destDisplay: String,
|
||||||
|
amount: Double,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String,
|
||||||
|
isSrcCard: Boolean,
|
||||||
|
isDestMib: Boolean,
|
||||||
|
currency: String,
|
||||||
|
allAccounts: List<BankAccount>,
|
||||||
|
allContacts: List<BankContact>,
|
||||||
|
toAvatar: Bitmap?
|
||||||
|
) {
|
||||||
|
val debitAccount = src.internalId.ifBlank {
|
||||||
|
Toast.makeText(requireContext(), getString(R.string.transfer_missing_internal_id), Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val isDestMyCard = allAccounts.any {
|
||||||
|
(it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount
|
||||||
|
}
|
||||||
|
val (transferType, creditAccount, bank) = when {
|
||||||
|
isSrcCard -> {
|
||||||
|
val destBml = allAccounts.firstOrNull { it.accountNumber == destAccount && it.profileType == "BML" }
|
||||||
|
Triple("CAD", destBml?.internalId?.ifBlank { destAccount } ?: destAccount, null as String?)
|
||||||
|
}
|
||||||
|
isDestMyCard -> {
|
||||||
|
val card = allAccounts.first {
|
||||||
|
(it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount
|
||||||
|
}
|
||||||
|
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
|
||||||
|
}
|
||||||
|
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
|
||||||
|
isDestMib -> {
|
||||||
|
val contact = allContacts.firstOrNull { it.benefCategoryId == "BML" && it.benefAccount == destAccount }
|
||||||
|
if (contact == null) {
|
||||||
|
Toast.makeText(requireContext(), "BML contact not found for this account", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Triple("DOT", contact.benefNo.removePrefix("bml_"), null as String?)
|
||||||
|
}
|
||||||
|
else -> Triple("IAT", destAccount, null as String?)
|
||||||
|
}
|
||||||
|
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||||
|
|
||||||
|
pendingBmlTransfer = PendingBmlTransfer(
|
||||||
|
src = src,
|
||||||
|
debitAccount = debitAccount,
|
||||||
|
creditAccount = creditAccount,
|
||||||
|
amount = amount,
|
||||||
|
amountStr = amountStr,
|
||||||
|
remarks = remarks,
|
||||||
|
transferType = transferType,
|
||||||
|
currency = currency,
|
||||||
|
bank = bank,
|
||||||
|
destDisplay = destDisplay,
|
||||||
|
destAccount = destAccount,
|
||||||
|
toBank = toBank,
|
||||||
|
toAvatar = toAvatar
|
||||||
|
)
|
||||||
|
|
||||||
|
bmlOtpState = BmlOtpState.SELECTING_CHANNEL
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val sess = bmlSessionFor(src)
|
||||||
|
val channels = if (sess != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try { BmlAccountClient().fetchTransferChannels(sess) }
|
||||||
|
catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
} else emptyList<BmlOtpChannel>()
|
||||||
|
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (channels.isEmpty()) {
|
||||||
|
Toast.makeText(requireContext(), "Could not load OTP channels", Toast.LENGTH_SHORT).show()
|
||||||
|
resetBmlOtpState()
|
||||||
|
updateTransferButton()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
showBmlChannelSelection(channels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBmlChannelSelection(channels: List<BmlOtpChannel>) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
binding.containerBmlChannels.removeAllViews()
|
||||||
|
|
||||||
|
for (channel in channels) {
|
||||||
|
val iconRes = when (channel.channel) {
|
||||||
|
"email" -> R.drawable.ic_channel_email
|
||||||
|
"mobile" -> R.drawable.ic_channel_sms
|
||||||
|
else -> R.drawable.ic_channel_sms
|
||||||
|
}
|
||||||
|
val iconSize = (24 * dp).toInt()
|
||||||
|
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||||
|
marginStart = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = channel.description
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||||
|
})
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = channel.masked
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.6f
|
||||||
|
})
|
||||||
|
|
||||||
|
val row = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||||
|
background = ta.getDrawable(0); ta.recycle()
|
||||||
|
isClickable = true; isFocusable = true
|
||||||
|
val hp = (16 * dp).toInt(); val vp = (12 * dp).toInt()
|
||||||
|
setPadding(hp, vp, hp, vp)
|
||||||
|
}
|
||||||
|
row.addView(ImageView(ctx).apply { setImageResource(iconRes) },
|
||||||
|
LinearLayout.LayoutParams(iconSize, iconSize))
|
||||||
|
row.addView(textCol)
|
||||||
|
row.setOnClickListener { selectBmlOtpChannel(channel) }
|
||||||
|
binding.containerBmlChannels.addView(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
disableTransferFields()
|
||||||
|
binding.layoutBmlChannelSelection.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectBmlOtpChannel(channel: BmlOtpChannel) {
|
||||||
|
bmlOtpChannel = channel.channel
|
||||||
|
binding.layoutBmlChannelSelection.visibility = View.GONE
|
||||||
|
|
||||||
|
val pending = pendingBmlTransfer ?: return
|
||||||
|
val sess = bmlSessionFor(pending.src) ?: run {
|
||||||
|
Toast.makeText(requireContext(), getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||||
|
resetBmlOtpState()
|
||||||
|
updateTransferButton()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val initiated = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
BmlTransferClient().initiateTransfer(
|
||||||
|
sess, pending.debitAccount, pending.creditAccount,
|
||||||
|
pending.amount, pending.transferType, pending.currency,
|
||||||
|
pending.bank, channel.channel
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (!initiated) {
|
||||||
|
Toast.makeText(requireContext(), "Failed to initiate transfer — check your session", Toast.LENGTH_SHORT).show()
|
||||||
|
resetBmlOtpState()
|
||||||
|
updateTransferButton()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
bmlOtpState = BmlOtpState.AWAITING_OTP
|
||||||
|
binding.tvBmlOtpSentVia.text = "OTP code sent via: ${channel.description} (${channel.masked})"
|
||||||
|
binding.tvBmlOtpSentVia.visibility = View.VISIBLE
|
||||||
|
binding.tilBmlOtp.visibility = View.VISIBLE
|
||||||
|
binding.etBmlOtp.requestFocus()
|
||||||
|
binding.btnTransfer.text = getString(R.string.transfer_verify_payment)
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifyBmlOtp() {
|
||||||
|
val otp = binding.etBmlOtp.text?.toString()?.trim() ?: ""
|
||||||
|
if (otp.isEmpty()) {
|
||||||
|
binding.tilBmlOtp.error = "Enter the verification code"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tilBmlOtp.error = null
|
||||||
|
val pending = pendingBmlTransfer ?: return
|
||||||
|
val channel = bmlOtpChannel ?: return
|
||||||
|
val sess = bmlSessionFor(pending.src) ?: run {
|
||||||
|
Toast.makeText(requireContext(), getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
|
val capturedToAvatar = pending.toAvatar
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val result = BmlTransferClient().confirmTransfer(
|
||||||
|
sess, pending.debitAccount, pending.creditAccount,
|
||||||
|
pending.amount, pending.transferType, pending.currency,
|
||||||
|
otp, pending.remarks, pending.bank, channel
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
val r = TransferReceiptData(
|
||||||
|
bank = "BML",
|
||||||
|
amount = "%.2f".format(pending.amount),
|
||||||
|
currency = pending.currency,
|
||||||
|
fromLabel = pending.src.accountBriefName,
|
||||||
|
fromColorHex = "#0066A1",
|
||||||
|
toLabel = pending.destDisplay.ifBlank { pending.destAccount },
|
||||||
|
toAccount = pending.destAccount,
|
||||||
|
toBank = pending.toBank,
|
||||||
|
remarks = pending.remarks,
|
||||||
|
bmlFromName = pending.src.accountBriefName,
|
||||||
|
bmlReference = result.reference,
|
||||||
|
bmlTimestamp = result.timestamp,
|
||||||
|
bmlMessage = result.message
|
||||||
|
)
|
||||||
|
Triple(true, "", r)
|
||||||
|
} else {
|
||||||
|
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null as TransferReceiptData?)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Triple(false, e.message ?: "Transfer failed", null as TransferReceiptData?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (ok && receipt != null) {
|
||||||
|
ReceiptStore.save(requireContext(), receipt)
|
||||||
|
resetBmlOtpState()
|
||||||
|
clearForm()
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
activity.triggerRefresh()
|
||||||
|
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
||||||
|
} else {
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
binding.tilBmlOtp.error = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableTransferFields() {
|
||||||
|
binding.tilAmount.isEnabled = false
|
||||||
|
binding.tilRemarks.isEnabled = false
|
||||||
|
binding.cardFromInfo.alpha = 0.5f
|
||||||
|
binding.btnClearFromInfo.isEnabled = false
|
||||||
|
binding.cardToInfo.alpha = 0.5f
|
||||||
|
binding.btnClearToInfo.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableTransferFields() {
|
||||||
|
binding.tilAmount.isEnabled = true
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.cardFromInfo.alpha = 1f
|
||||||
|
binding.btnClearFromInfo.isEnabled = true
|
||||||
|
binding.cardToInfo.alpha = 1f
|
||||||
|
binding.btnClearToInfo.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetBmlOtpState() {
|
||||||
|
bmlOtpState = BmlOtpState.NONE
|
||||||
|
bmlOtpChannel = null
|
||||||
|
pendingBmlTransfer = null
|
||||||
|
val b = _binding ?: return
|
||||||
|
b.layoutBmlChannelSelection.visibility = View.GONE
|
||||||
|
b.tvBmlOtpSentVia.visibility = View.GONE
|
||||||
|
b.tilBmlOtp.visibility = View.GONE
|
||||||
|
b.etBmlOtp.setText("")
|
||||||
|
b.tilBmlOtp.error = null
|
||||||
|
enableTransferFields()
|
||||||
|
b.btnTransfer.text = getString(R.string.transfer)
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateTransferButton() {
|
private fun updateTransferButton() {
|
||||||
|
if (bmlOtpState != BmlOtpState.NONE) return
|
||||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||||
binding.btnTransfer.isEnabled = selectedAccount != null && resolvedAccountNumber.isNotBlank() && amount > 0
|
val hasAll = selectedAccount != null && resolvedAccountNumber.isNotBlank() && amount > 0
|
||||||
|
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
||||||
|
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
||||||
|
val bankOffline = "NO_INTERNET" in errors ||
|
||||||
|
selectedAccount?.bank?.uppercase()?.let { it in errors } == true
|
||||||
|
binding.btnTransfer.isEnabled = !bankOffline
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearForm() {
|
private fun clearForm() {
|
||||||
|
resetBmlOtpState()
|
||||||
selectedAccount = null
|
selectedAccount = null
|
||||||
binding.actvFrom.setText("", false)
|
binding.actvFrom.setText("", false)
|
||||||
binding.cardFromInfo.visibility = View.GONE
|
binding.cardFromInfo.visibility = View.GONE
|
||||||
@@ -1008,8 +1360,8 @@ class TransferFragment : Fragment() {
|
|||||||
) : BaseAdapter(), Filterable {
|
) : BaseAdapter(), Filterable {
|
||||||
|
|
||||||
private val items: List<Any> = buildList {
|
private val items: List<Any> = buildList {
|
||||||
val regular = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
val regular = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" }
|
||||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
|
||||||
addAll(regular)
|
addAll(regular)
|
||||||
if (cards.isNotEmpty()) {
|
if (cards.isNotEmpty()) {
|
||||||
add(getString(R.string.cards))
|
add(getString(R.string.cards))
|
||||||
@@ -1018,7 +1370,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getAccount(position: Int): BankAccount? = (items.getOrNull(position) as? BankAccount)
|
fun getAccount(position: Int): BankAccount? = (items.getOrNull(position) as? BankAccount)
|
||||||
?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && !it.statusDesc.equals("Active", ignoreCase = true) }
|
?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && !it.statusDesc.equals("Active", ignoreCase = true) }
|
||||||
|
|
||||||
override fun getCount() = items.size
|
override fun getCount() = items.size
|
||||||
override fun getItem(position: Int) = items[position]
|
override fun getItem(position: Int) = items[position]
|
||||||
@@ -1049,12 +1401,14 @@ class TransferFragment : Fragment() {
|
|||||||
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
|
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||||
.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.profileType == "BML_DEBIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
|
||||||
val isBmlAccount = acc.bank == "BML"
|
val isBmlAccount = acc.bank == "BML"
|
||||||
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
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 = AccountListParser.from(acc)?.balance ?: ""
|
val balance = AccountListParser.from(acc)?.balance ?: ""
|
||||||
|
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
|
||||||
b.root.alpha = if (inactive) 0.4f else 1f
|
b.root.alpha = if (inactive) 0.4f else 1f
|
||||||
b.root
|
b.root
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
) {
|
) {
|
||||||
fun hasMore(): Boolean = when {
|
fun hasMore(): Boolean = when {
|
||||||
account.bank == "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" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2
|
||||||
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||||
}
|
}
|
||||||
@@ -138,6 +138,14 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
(activity as? HomeActivity)?.setRefreshing(true)
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
loadNextPages()
|
loadNextPages()
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
if (isLoading) {
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
} else {
|
||||||
|
resetAndReload()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
@@ -145,6 +153,19 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
requireActivity().title = getString(R.string.nav_transfer_history)
|
requireActivity().title = getString(R.string.nav_transfer_history)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resetAndReload() {
|
||||||
|
allTransactions.clear()
|
||||||
|
pendingImageNames.clear()
|
||||||
|
pendingIconUrls.clear()
|
||||||
|
firstBatchDone = false
|
||||||
|
val accounts = accountStates.map { it.account }
|
||||||
|
accountStates.clear()
|
||||||
|
accounts.forEach { accountStates.add(AccountState(it)) }
|
||||||
|
adapter.setTransactions(emptyList())
|
||||||
|
binding.emptyView.visibility = View.GONE
|
||||||
|
loadNextPages()
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadNextPages() {
|
private fun loadNextPages() {
|
||||||
val activeStates = accountStates.filter { it.hasMore() }
|
val activeStates = accountStates.filter { it.hasMore() }
|
||||||
if (isLoading || activeStates.isEmpty()) return
|
if (isLoading || activeStates.isEmpty()) return
|
||||||
@@ -166,7 +187,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
async {
|
async {
|
||||||
try {
|
try {
|
||||||
when {
|
when {
|
||||||
state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" -> {
|
state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" || state.account.profileType == "BML_DEBIT" -> {
|
||||||
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
||||||
val cal = Calendar.getInstance()
|
val cal = Calendar.getInstance()
|
||||||
cal.add(Calendar.MONTH, -state.cardMonthOffset)
|
cal.add(Calendar.MONTH, -state.cardMonthOffset)
|
||||||
@@ -250,6 +271,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
if (!firstBatchDone) {
|
if (!firstBatchDone) {
|
||||||
firstBatchDone = true
|
firstBatchDone = true
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newTransactions.isNotEmpty()) {
|
if (newTransactions.isNotEmpty()) {
|
||||||
|
|||||||
@@ -330,36 +330,48 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showFullScreenReceipt() {
|
private fun showFullScreenReceipt() {
|
||||||
captureReceiptBitmap { bitmap ->
|
val ctx = requireContext()
|
||||||
if (bitmap == null) return@captureReceiptBitmap
|
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||||
val ctx = requireContext()
|
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
||||||
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
|
||||||
val iv = android.widget.ImageView(ctx).apply {
|
val scrollView = android.widget.ScrollView(ctx).apply {
|
||||||
setImageBitmap(bitmap)
|
setBackgroundColor(Color.BLACK)
|
||||||
scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
}
|
||||||
setBackgroundColor(Color.BLACK)
|
|
||||||
}
|
val cardView = if (bank == "MIB") {
|
||||||
iv.setOnClickListener { dialog.dismiss() }
|
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
|
||||||
dialog.setContentView(iv)
|
bindMib(binding)
|
||||||
val actWin = requireActivity().window
|
binding.receiptCard
|
||||||
val prevColor = actWin.statusBarColor
|
} else {
|
||||||
val insetsCtrl = androidx.core.view.WindowInsetsControllerCompat(actWin, actWin.decorView)
|
val binding = FragmentReceiptBmlBinding.inflate(layoutInflater)
|
||||||
actWin.statusBarColor = Color.BLACK
|
bindBml(binding)
|
||||||
insetsCtrl.isAppearanceLightStatusBars = false
|
binding.receiptCard
|
||||||
dialog.setOnDismissListener {
|
}
|
||||||
actWin.statusBarColor = prevColor
|
(cardView.parent as? ViewGroup)?.removeView(cardView)
|
||||||
val isLight = (resources.configuration.uiMode and
|
cardView.setOnClickListener { dialog.dismiss() }
|
||||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
scrollView.addView(cardView)
|
||||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
scrollView.setOnTouchListener { _, _ -> dialog.dismiss(); true }
|
||||||
insetsCtrl.isAppearanceLightStatusBars = isLight
|
|
||||||
}
|
dialog.setContentView(scrollView)
|
||||||
dialog.show()
|
|
||||||
dialog.window?.let { win ->
|
val actWin = requireActivity().window
|
||||||
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
val prevColor = actWin.statusBarColor
|
||||||
androidx.core.view.WindowInsetsControllerCompat(win, iv).apply {
|
val insetsCtrl = androidx.core.view.WindowInsetsControllerCompat(actWin, actWin.decorView)
|
||||||
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
|
actWin.statusBarColor = Color.BLACK
|
||||||
systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
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, scrollView).apply {
|
||||||
|
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
|
||||||
|
systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,10 @@ class CredentialsFragment : Fragment() {
|
|||||||
bmlAccumulatedAccounts += result.accounts
|
bmlAccumulatedAccounts += result.accounts
|
||||||
val store = CredentialStore(requireContext())
|
val store = CredentialStore(requireContext())
|
||||||
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
|
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
|
||||||
|
if (result.session.refreshToken.isNotBlank())
|
||||||
|
store.saveBmlProfileRefreshToken(profile.profileId, result.session.refreshToken)
|
||||||
|
if (result.session.expiresAt > 0)
|
||||||
|
store.saveBmlProfileExpiresAt(profile.profileId, result.session.expiresAt)
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
app.bmlSessions[profile.profileId] = result.session
|
app.bmlSessions[profile.profileId] = result.session
|
||||||
}
|
}
|
||||||
@@ -326,8 +330,16 @@ class CredentialsFragment : Fragment() {
|
|||||||
val session = app.bmlSessions.remove(oldId)
|
val session = app.bmlSessions.remove(oldId)
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
app.bmlSessions[customerId] = session
|
app.bmlSessions[customerId] = session
|
||||||
|
val savedRefresh = store.loadBmlProfileRefreshToken(oldId)
|
||||||
|
val savedExpiry = store.loadBmlProfileExpiresAt(oldId)
|
||||||
store.clearBmlProfileSession(oldId)
|
store.clearBmlProfileSession(oldId)
|
||||||
store.saveBmlProfileSession(customerId, session.accessToken, session.deviceId)
|
store.saveBmlProfileSession(customerId, session.accessToken, session.deviceId)
|
||||||
|
if (session.refreshToken.isNotBlank())
|
||||||
|
store.saveBmlProfileRefreshToken(customerId, session.refreshToken)
|
||||||
|
else if (savedRefresh != null)
|
||||||
|
store.saveBmlProfileRefreshToken(customerId, savedRefresh)
|
||||||
|
val expiryToSave = if (session.expiresAt > 0) session.expiresAt else savedExpiry
|
||||||
|
if (expiryToSave > 0) store.saveBmlProfileExpiresAt(customerId, expiryToSave)
|
||||||
}
|
}
|
||||||
// Update stored profile list with the real ID
|
// Update stored profile list with the real ID
|
||||||
val updatedProfiles = profiles.map {
|
val updatedProfiles = profiles.map {
|
||||||
|
|||||||
@@ -11,19 +11,19 @@ class OnboardingPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(
|
|||||||
OnboardingSlide(
|
OnboardingSlide(
|
||||||
titleRes = R.string.onboarding_title_1,
|
titleRes = R.string.onboarding_title_1,
|
||||||
descRes = R.string.onboarding_desc_1,
|
descRes = R.string.onboarding_desc_1,
|
||||||
iconRes = R.drawable.ic_launcher_foreground,
|
iconRes = R.drawable.ic_logo,
|
||||||
isFirst = true
|
isFirst = true
|
||||||
),
|
),
|
||||||
OnboardingSlide(
|
OnboardingSlide(
|
||||||
titleRes = R.string.onboarding_title_2,
|
titleRes = R.string.onboarding_title_2,
|
||||||
descRes = R.string.onboarding_desc_2,
|
descRes = R.string.onboarding_desc_2,
|
||||||
iconRes = R.drawable.ic_launcher_foreground,
|
iconRes = R.drawable.ic_logo,
|
||||||
isFirst = false
|
isFirst = false
|
||||||
),
|
),
|
||||||
OnboardingSlide(
|
OnboardingSlide(
|
||||||
titleRes = R.string.onboarding_title_3,
|
titleRes = R.string.onboarding_title_3,
|
||||||
descRes = R.string.onboarding_desc_3,
|
descRes = R.string.onboarding_desc_3,
|
||||||
iconRes = R.drawable.ic_launcher_foreground,
|
iconRes = R.drawable.ic_logo,
|
||||||
isFirst = false,
|
isFirst = false,
|
||||||
isLast = true
|
isLast = true
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -215,9 +215,10 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||||
val saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP)
|
val saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP)
|
||||||
val hash = pbkdf2(input, salt)
|
val hash = pbkdf2(input, salt)
|
||||||
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
|
val edit = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
|
||||||
.putString("security_method", method)
|
.putString("security_method", method)
|
||||||
.apply()
|
if (method == "pin") edit.putInt("pin_length", input.length)
|
||||||
|
edit.apply()
|
||||||
CredentialStore(requireContext()).saveSecurityHash(saltB64, hash)
|
CredentialStore(requireContext()).saveSecurityHash(saltB64, hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ object AccountCache {
|
|||||||
put("bank", acc.bank)
|
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("productCode", acc.productCode)
|
||||||
put("accountNumber", acc.accountNumber)
|
put("accountNumber", acc.accountNumber)
|
||||||
put("accountBriefName", acc.accountBriefName)
|
put("accountBriefName", acc.accountBriefName)
|
||||||
put("currencyName", acc.currencyName)
|
put("currencyName", acc.currencyName)
|
||||||
@@ -44,6 +44,7 @@ object AccountCache {
|
|||||||
arr.put(JSONObject().apply {
|
arr.put(JSONObject().apply {
|
||||||
put("profileName", acc.profileName)
|
put("profileName", acc.profileName)
|
||||||
put("profileType", acc.profileType)
|
put("profileType", acc.profileType)
|
||||||
|
put("productCode", acc.productCode)
|
||||||
put("accountNumber", acc.accountNumber)
|
put("accountNumber", acc.accountNumber)
|
||||||
put("accountBriefName", acc.accountBriefName)
|
put("accountBriefName", acc.accountBriefName)
|
||||||
put("currencyName", acc.currencyName)
|
put("currencyName", acc.currencyName)
|
||||||
@@ -55,6 +56,7 @@ object AccountCache {
|
|||||||
put("statusDesc", acc.statusDesc)
|
put("statusDesc", acc.statusDesc)
|
||||||
put("loginTag", acc.loginTag)
|
put("loginTag", acc.loginTag)
|
||||||
put("internalId", acc.internalId)
|
put("internalId", acc.internalId)
|
||||||
|
put("profileId", acc.profileId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
@@ -72,6 +74,7 @@ object AccountCache {
|
|||||||
bank = "BML",
|
bank = "BML",
|
||||||
profileName = o.optString("profileName"),
|
profileName = o.optString("profileName"),
|
||||||
profileType = o.optString("profileType"),
|
profileType = o.optString("profileType"),
|
||||||
|
productCode = o.optString("productCode", ""),
|
||||||
accountNumber = o.optString("accountNumber"),
|
accountNumber = o.optString("accountNumber"),
|
||||||
accountBriefName = o.optString("accountBriefName"),
|
accountBriefName = o.optString("accountBriefName"),
|
||||||
currencyName = o.optString("currencyName"),
|
currencyName = o.optString("currencyName"),
|
||||||
@@ -83,7 +86,8 @@ object AccountCache {
|
|||||||
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", ""),
|
||||||
|
profileId = o.optString("profileId", "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
@@ -162,7 +166,7 @@ object AccountCache {
|
|||||||
bank = o.optString("bank", "MIB"),
|
bank = o.optString("bank", "MIB"),
|
||||||
profileName = o.optString("profileName"),
|
profileName = o.optString("profileName"),
|
||||||
profileType = o.optString("profileType"),
|
profileType = o.optString("profileType"),
|
||||||
cifType = o.optString("cifType", ""),
|
productCode = o.optString("productCode", ""),
|
||||||
accountNumber = o.optString("accountNumber"),
|
accountNumber = o.optString("accountNumber"),
|
||||||
accountBriefName = o.optString("accountBriefName"),
|
accountBriefName = o.optString("accountBriefName"),
|
||||||
currencyName = o.optString("currencyName"),
|
currencyName = o.optString("currencyName"),
|
||||||
|
|||||||
57
app/src/main/java/sh/sar/basedbank/util/CardsCache.kt
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
|
|
||||||
|
object CardsCache {
|
||||||
|
|
||||||
|
private const val PREFS = "cards_cache"
|
||||||
|
private const val KEY_MIB_CARDS = "mib_cards"
|
||||||
|
|
||||||
|
fun save(context: Context, cards: List<MibCard>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for (c in cards) {
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("cardId", c.cardId)
|
||||||
|
put("maskedCardNumber", c.maskedCardNumber)
|
||||||
|
put("cardStatus", c.cardStatus)
|
||||||
|
put("cardType", c.cardType)
|
||||||
|
put("cardTypeDesc", c.cardTypeDesc)
|
||||||
|
put("customerId", c.customerId)
|
||||||
|
put("phoneNumber", c.phoneNumber)
|
||||||
|
put("cardHolderName", c.cardHolderName)
|
||||||
|
put("loginTag", c.loginTag)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit().putString(KEY_MIB_CARDS, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(context: Context): List<MibCard> {
|
||||||
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getString(KEY_MIB_CARDS, null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
MibCard(
|
||||||
|
cardId = o.optString("cardId"),
|
||||||
|
maskedCardNumber = o.optString("maskedCardNumber"),
|
||||||
|
cardStatus = o.optString("cardStatus"),
|
||||||
|
cardType = o.optString("cardType"),
|
||||||
|
cardTypeDesc = o.optString("cardTypeDesc"),
|
||||||
|
customerId = o.optString("customerId"),
|
||||||
|
phoneNumber = o.optString("phoneNumber"),
|
||||||
|
cardHolderName = o.optString("cardHolderName"),
|
||||||
|
loginTag = o.optString("loginTag")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(context: Context) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -264,10 +264,30 @@ class CredentialStore(context: Context) {
|
|||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveBmlProfileExpiresAt(profileId: String, expiresAt: Long) {
|
||||||
|
prefs.edit().putLong("bml_profile_${profileId}_expires_at", expiresAt).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBmlProfileExpiresAt(profileId: String): Long =
|
||||||
|
prefs.getLong("bml_profile_${profileId}_expires_at", 0L)
|
||||||
|
|
||||||
|
fun saveBmlProfileRefreshToken(profileId: String, refreshToken: String) {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit().putString("bml_profile_${profileId}_enc_refresh_token", encrypt(refreshToken, key)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBmlProfileRefreshToken(profileId: String): String? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val enc = prefs.getString("bml_profile_${profileId}_enc_refresh_token", null) ?: return null
|
||||||
|
return try { decrypt(enc, key) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
fun clearBmlProfileSession(profileId: String) {
|
fun clearBmlProfileSession(profileId: String) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove("bml_profile_${profileId}_enc_token")
|
.remove("bml_profile_${profileId}_enc_token")
|
||||||
.remove("bml_profile_${profileId}_enc_device_id")
|
.remove("bml_profile_${profileId}_enc_device_id")
|
||||||
|
.remove("bml_profile_${profileId}_enc_refresh_token")
|
||||||
|
.remove("bml_profile_${profileId}_expires_at")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package sh.sar.basedbank.util
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
|
|
||||||
object FinancingCache {
|
object FinancingCache {
|
||||||
|
|
||||||
private const val PREFS = "financing_cache"
|
private const val PREFS = "financing_cache"
|
||||||
private const val KEY_MIB = "mib_financing"
|
private const val KEY_MIB = "mib_financing"
|
||||||
|
private const val KEY_BML_LOANS = "bml_loans"
|
||||||
|
|
||||||
fun save(context: Context, deals: List<MibFinanceDeal>) {
|
fun save(context: Context, deals: List<MibFinanceDeal>) {
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
@@ -34,6 +36,52 @@ object FinancingCache {
|
|||||||
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
|
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveBmlLoans(context: Context, loans: Map<String, BmlLoanDetail>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for ((internalId, d) in loans) {
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("internalId", internalId)
|
||||||
|
put("loanAmount", d.loanAmount)
|
||||||
|
put("outstandingAmt", d.outstandingAmt)
|
||||||
|
put("repayAmount", d.repayAmount)
|
||||||
|
put("intRate", d.intRate)
|
||||||
|
put("loanStatus", d.loanStatus)
|
||||||
|
put("startDate", d.startDate)
|
||||||
|
put("endDate", d.endDate)
|
||||||
|
put("noOfRepayOverdue", d.noOfRepayOverdue)
|
||||||
|
put("overdueAmount", d.overdueAmount)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit().putString(KEY_BML_LOANS, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBmlLoans(context: Context): Map<String, BmlLoanDetail> {
|
||||||
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getString(KEY_BML_LOANS, null) ?: return emptyMap()
|
||||||
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
buildMap {
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
val id = o.optString("internalId")
|
||||||
|
if (id.isNotBlank()) put(id, BmlLoanDetail(
|
||||||
|
loanAmount = o.optDouble("loanAmount", 0.0),
|
||||||
|
outstandingAmt = o.optDouble("outstandingAmt", 0.0),
|
||||||
|
repayAmount = o.optDouble("repayAmount", 0.0),
|
||||||
|
intRate = o.optDouble("intRate", 0.0),
|
||||||
|
loanStatus = o.optString("loanStatus"),
|
||||||
|
startDate = o.optString("startDate"),
|
||||||
|
endDate = o.optString("endDate"),
|
||||||
|
noOfRepayOverdue = o.optInt("noOfRepayOverdue", 0),
|
||||||
|
overdueAmount = o.optDouble("overdueAmount", 0.0)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyMap() }
|
||||||
|
}
|
||||||
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import java.util.Locale
|
|||||||
class HistoryFetcher(private val account: BankAccount) {
|
class HistoryFetcher(private val account: BankAccount) {
|
||||||
|
|
||||||
private val isMib get() = account.bank == "MIB"
|
private val isMib get() = account.bank == "MIB"
|
||||||
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||||
|
private val isBmlLoan get() = account.profileType == "BML_LOAN"
|
||||||
private val isFahipay get() = account.bank == "FAHIPAY"
|
private val isFahipay get() = account.bank == "FAHIPAY"
|
||||||
|
|
||||||
// MIB pagination
|
// MIB pagination
|
||||||
@@ -40,6 +41,7 @@ class HistoryFetcher(private val account: BankAccount) {
|
|||||||
private var fahipayTotal = -1
|
private var fahipayTotal = -1
|
||||||
|
|
||||||
fun hasMore(): Boolean = when {
|
fun hasMore(): Boolean = when {
|
||||||
|
isBmlLoan -> false
|
||||||
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||||
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||||
isBmlCard -> cardMonthOffset < 3
|
isBmlCard -> cardMonthOffset < 3
|
||||||
@@ -47,6 +49,7 @@ class HistoryFetcher(private val account: BankAccount) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
|
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
|
||||||
|
isBmlLoan -> emptyList()
|
||||||
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
|
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
|
||||||
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
|
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
|
||||||
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
|
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package sh.sar.basedbank.util.bmlapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
|
||||||
|
object BmlCardParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the asset path for the card image.
|
||||||
|
* The product code is stored in [BankAccount.productCode] for BML Card accounts.
|
||||||
|
*/
|
||||||
|
fun cardImageAsset(account: BankAccount): String =
|
||||||
|
productCodeToAsset(account.productCode)
|
||||||
|
|
||||||
|
fun productCodeToAsset(productCode: String): String = when (productCode) {
|
||||||
|
"C8201", "C8001", "C8009" -> "cards/bml/master_prepaid.png"
|
||||||
|
"C8205", "C8005", "C8008" -> "cards/bml/master_prepaid_travel.png"
|
||||||
|
"C3007", "C3017", "C3097", "C3095", "C3077", "C3177" -> "cards/bml/amex_debit_green.png"
|
||||||
|
"C3003", "C3013", "C3053", "C3023", "C3033", "C3052" -> "cards/bml/amex_debit_gold.png"
|
||||||
|
"C3009", "C3019", "C3029", "C3099", "C3088", "C3188" -> "cards/bml/amex_credit_gold.png"
|
||||||
|
"C3001", "C3011", "C3050", "C3051", "C3031" -> "cards/bml/amex_credit_green.png"
|
||||||
|
"C3005", "C3015", "C3055", "C3054" -> "cards/bml/amex_platinum.png"
|
||||||
|
"C1003", "C1013", "C1083", "C1084", "C1103", "C1113", "C1183", "C1184" -> "cards/bml/visa_gold.png"
|
||||||
|
"C1007", "C1027", "C1097", "C1107", "C1197", "C1077", "C1177" -> "cards/bml/visa_debit.png"
|
||||||
|
"C1020", "C1021" -> "cards/bml/visa_debit_platinum.png"
|
||||||
|
"C8020", "C8022" -> "cards/bml/master_gold.png"
|
||||||
|
"C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png"
|
||||||
|
"C8101" -> "cards/bml/master_masveriyaa.png"
|
||||||
|
"C8102" -> "cards/bml/master_odiveriyaa.png"
|
||||||
|
"C8010", "C8011" -> "cards/bml/master_platinum.png"
|
||||||
|
"C8040", "C8044" -> "cards/bml/master_world.png"
|
||||||
|
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
|
||||||
|
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
|
||||||
|
"C1030", "C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
||||||
|
"C8905", "C8995" -> "cards/bml/visa_credit.png"
|
||||||
|
"C1001", "C1011", "C1082", "C1081", "C1101", "C1111", "C1181", "C1182" -> "cards/bml/visa_debit_generic.png"
|
||||||
|
"C1005", "C1006", "C1089" -> "cards/bml/visa_debit_islamic.png"
|
||||||
|
"C1017" -> "cards/bml/visa_infinite.png"
|
||||||
|
"C1009", "C1019", "C1085", "C1086", "C1109", "C1119", "C1185", "C1186" -> "cards/bml/visa_platinum.png"
|
||||||
|
"C1050", "C1051", "C1087", "C1088", "C1150", "C1151", "C1187", "C1188", "C1040", "C1041", "C1047", "C1048", "C1140", "C1141", "C1147", "C1148" -> "cards/bml/visa_student_black.png"
|
||||||
|
"C8925", "C8926" -> "cards/bml/visa_student_blue.png"
|
||||||
|
"C1071", "C1073", "C1061", "C1063", "C1161", "C1163" -> "cards/bml/master.png"
|
||||||
|
"C1070", "C1072", "C1059", "C1062", "C1159", "C1162" -> "cards/bml/master_prepaid_business.png"
|
||||||
|
else -> "cards/bml/defaultcard.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ object BmlDashboardParser {
|
|||||||
* Returns all display fields for an account/card row in the accounts list.
|
* Returns all display fields for an account/card row in the accounts list.
|
||||||
* Handles both BML CASA accounts and BML prepaid/credit cards.
|
* Handles both BML CASA accounts and BML prepaid/credit cards.
|
||||||
*/
|
*/
|
||||||
fun displayData(account: BankAccount): AccountListDisplay {
|
fun displayData(account: BankAccount): AccountListDisplay? {
|
||||||
|
if (account.profileType == "BML_LOAN") return null // Loans shown on financing page only
|
||||||
|
if (account.profileType == "BML_DEBIT") return null // Debit cards shown on card screens only
|
||||||
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
||||||
return if (isCard) {
|
return if (isCard) {
|
||||||
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
|
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
|||||||
58
app/src/main/res/drawable/avd_hide_amounts.xml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<animated-vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt">
|
||||||
|
|
||||||
|
<aapt:attr name="android:drawable">
|
||||||
|
<vector
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:width="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:name="strike_through"
|
||||||
|
android:pathData="M3.27,4.27 L19.74,20.74"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="square"
|
||||||
|
android:strokeWidth="1.8"
|
||||||
|
android:trimPathEnd="0"/>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<clip-path
|
||||||
|
android:name="eye_mask"
|
||||||
|
android:pathData="M2,4.27 L2,4.27 L4.54,1.73 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"/>
|
||||||
|
<path
|
||||||
|
android:name="eye"
|
||||||
|
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"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
</vector>
|
||||||
|
</aapt:attr>
|
||||||
|
|
||||||
|
<target android:name="eye_mask">
|
||||||
|
<aapt:attr name="android:animation">
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="320"
|
||||||
|
android:interpolator="@android:interpolator/fast_out_slow_in"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:valueFrom="M2,4.27 L2,4.27 L4.54,1.73 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
|
||||||
|
android:valueTo="M2,4.27 L19.73,22 L22.27,19.46 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
|
||||||
|
android:valueType="pathType"/>
|
||||||
|
</aapt:attr>
|
||||||
|
</target>
|
||||||
|
|
||||||
|
<target android:name="strike_through">
|
||||||
|
<aapt:attr name="android:animation">
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="320"
|
||||||
|
android:interpolator="@android:interpolator/fast_out_slow_in"
|
||||||
|
android:propertyName="trimPathEnd"
|
||||||
|
android:valueFrom="0"
|
||||||
|
android:valueTo="1"/>
|
||||||
|
</aapt:attr>
|
||||||
|
</target>
|
||||||
|
|
||||||
|
</animated-vector>
|
||||||
52
app/src/main/res/drawable/avd_lock.xml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<animated-vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt">
|
||||||
|
|
||||||
|
<aapt:attr name="android:drawable">
|
||||||
|
<vector
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:width="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Shackle drawn first (behind body) so it appears to slot into the body.
|
||||||
|
Starts translateY=-4 (open/raised), animates to 0 (locked).
|
||||||
|
-->
|
||||||
|
<group
|
||||||
|
android:name="shackle"
|
||||||
|
android:translateY="-4">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:pathData="M8.9,10V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1V10"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeWidth="2.2"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Body on top — covers the shackle legs once they slide inside.
|
||||||
|
Even-odd fill cuts out the keyhole.
|
||||||
|
-->
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M6,8H18c1.1,0 2,0.9 2,2V20c0,1.1-0.9,2-2,2H6c-1.1,0-2,-0.9-2,-2V10c0,-1.1 0.9,-2 2,-2zM12,15c-1.1,0-2,0.9-2,2s0.9,2 2,2 2,-0.9 2,-2-0.9,-2-2,-2z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
|
</aapt:attr>
|
||||||
|
|
||||||
|
<target android:name="shackle">
|
||||||
|
<aapt:attr name="android:animation">
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="320"
|
||||||
|
android:interpolator="@android:interpolator/overshoot"
|
||||||
|
android:propertyName="translateY"
|
||||||
|
android:valueFrom="-4"
|
||||||
|
android:valueTo="0"/>
|
||||||
|
</aapt:attr>
|
||||||
|
</target>
|
||||||
|
|
||||||
|
</animated-vector>
|
||||||
57
app/src/main/res/drawable/avd_show_amounts.xml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<animated-vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt">
|
||||||
|
|
||||||
|
<aapt:attr name="android:drawable">
|
||||||
|
<vector
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:width="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:name="strike_through"
|
||||||
|
android:pathData="M3.27,4.27 L19.74,20.74"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="square"
|
||||||
|
android:strokeWidth="1.8"/>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<clip-path
|
||||||
|
android:name="eye_mask"
|
||||||
|
android:pathData="M2,4.27 L19.73,22 L22.27,19.46 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"/>
|
||||||
|
<path
|
||||||
|
android:name="eye"
|
||||||
|
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"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
</vector>
|
||||||
|
</aapt:attr>
|
||||||
|
|
||||||
|
<target android:name="eye_mask">
|
||||||
|
<aapt:attr name="android:animation">
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="200"
|
||||||
|
android:interpolator="@android:interpolator/fast_out_linear_in"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:valueFrom="M2,4.27 L19.73,22 L22.27,19.46 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
|
||||||
|
android:valueTo="M2,4.27 L2,4.27 L4.54,1.73 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
|
||||||
|
android:valueType="pathType"/>
|
||||||
|
</aapt:attr>
|
||||||
|
</target>
|
||||||
|
|
||||||
|
<target android:name="strike_through">
|
||||||
|
<aapt:attr name="android:animation">
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="200"
|
||||||
|
android:interpolator="@android:interpolator/fast_out_linear_in"
|
||||||
|
android:propertyName="trimPathEnd"
|
||||||
|
android:valueFrom="1"
|
||||||
|
android:valueTo="0"/>
|
||||||
|
</aapt:attr>
|
||||||
|
</target>
|
||||||
|
|
||||||
|
</animated-vector>
|
||||||
7
app/src/main/res/drawable/bg_card_overlay_gradient.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<gradient
|
||||||
|
android:startColor="#00000000"
|
||||||
|
android:endColor="#CC000000"
|
||||||
|
android:angle="270"/>
|
||||||
|
</shape>
|
||||||
10
app/src/main/res/drawable/ic_block.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/colorOnSurface"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM4,12c0,-4.42 3.58,-8 8,-8 1.85,0 3.55,0.63 4.9,1.68L5.68,16.9C4.63,15.55 4,13.85 4,12zM12,20c-1.85,0 -3.55,-0.63 -4.9,-1.68L18.32,7.1C19.37,8.45 20,10.15 20,12c0,4.42 -3.58,8 -8,8z"/>
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/ic_freeze.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/colorOnSurface"
|
||||||
|
android:pathData="M22,11h-4.17l3.24,-3.24 -1.41,-1.42L15,11h-2V9l4.66,-4.66 -1.42,-1.41L13,6.17V2h-2v4.17L7.76,2.93 6.34,4.34 11,9v2H9L4.34,6.34 2.93,7.76 6.17,11H2v2h4.17l-3.24,3.24 1.41,1.42L9,13h2v2l-4.66,4.66 1.42,1.41L11,17.83V22h2v-4.17l3.24,3.24 1.42,-1.41L13,15v-2h2l4.66,4.66 1.41,-1.42L17.83,13H22z"/>
|
||||||
|
</vector>
|
||||||
@@ -5,166 +5,6 @@
|
|||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#3DDC84"
|
android:fillColor="#E8B547"
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M9,0L9,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,0L19,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,0L29,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,0L39,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,0L49,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,0L59,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,0L69,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,0L79,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M89,0L89,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M99,0L99,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,9L108,9"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,19L108,19"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,29L108,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,39L108,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,49L108,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,59L108,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,69L108,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,79L108,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,89L108,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,99L108,99"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,29L89,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,39L89,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,49L89,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,59L89,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,69L89,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,79L89,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,19L29,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,19L39,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,19L49,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,19L59,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,19L69,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,19L79,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,30 +1,24 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="432"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="432">
|
||||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
|
||||||
<aapt:attr name="android:fillColor">
|
<!--
|
||||||
<gradient
|
Rufiyaa symbol centered on canvas.
|
||||||
android:endX="85.84757"
|
Original SVG bounding box: 276.85 × 175.60
|
||||||
android:endY="92.4963"
|
Scale 0.85 → 235.3 × 149.3, centered at (216, 216):
|
||||||
android:startX="42.9492"
|
translateX = 216 − 235.3/2 = 98
|
||||||
android:startY="49.59793"
|
translateY = 216 − 149.3/2 = 141
|
||||||
android:type="linear">
|
-->
|
||||||
<item
|
<group
|
||||||
android:color="#44000000"
|
android:scaleX="0.85"
|
||||||
android:offset="0.0" />
|
android:scaleY="0.85"
|
||||||
<item
|
android:translateX="98"
|
||||||
android:color="#00000000"
|
android:translateY="141">
|
||||||
android:offset="1.0" />
|
<path
|
||||||
</gradient>
|
android:fillColor="#000000"
|
||||||
</aapt:attr>
|
android:pathData="m 0.01444349,146.48136 c 0.292039,-6.44722 3.89308401,-9.12968 9.18909001,-9.48871 1.2523995,-0.0849 2.7807995,-0.0849 4.8186995,0 7.677319,0.0719 15.575535,2.04677 23.196,0.32719 3.328697,-0.83215 38.545925,-17.3522 71.890297,-40.525298 -6.38484,-3.92558 -9.558207,-9.227296 -9.06617,-16.8414 0.1618,-1.45617 2.26605,-16.30132 20.6841,-14.74007 2.82949,0.2398 11.06017,2.89394 24.6766,7.89527 4.8434,-4.011342 9.86993,-7.792775 14.86,-11.6179 -8.69961,-3.530822 -11.46541,-11.291021 -10.04177,-20.1135 1.42405,-8.825035 8.66169,-13.705029 19.0329,-11.88805 9.23153,1.617223 19.00984,5.215984 22.8004,6.7845 20.70075,-11.189327 54.77683,-28.8770828 65.3541,-34.0729998 5.70626,-3.35254 12.71464,-3.19857098 16.5862,2.73179 4.21557,6.6454798 3.9892,13.2813598 -2.19151,17.9821998 -8.64301,6.573585 -19.44036,9.920412 -29.2398,14.5974 -6.93317,3.490262 -13.93101,6.864024 -20.6089,10.8298 21.25669,6.267982 27.73865,10.456642 28.27166,22.5229 0.0826,1.868798 -0.20263,3.745 -0.64544,5.3661 -1.21907,4.462958 -5.06847,10.963331 -17.1941,9.7415 -5.09154,-0.513098 -25.58805,-6.353594 -42.9433,-12.0531 -4.15228,3.73688 -8.78178,6.907135 -13.2389,10.267 9.60154,3.066049 21.58993,6.56711 24.4216,17.486898 0.24324,1.08936 0.96989,5.15913 -0.58539,9.4564 -2.62403,7.6132 -9.24819,10.55389 -16.7664,10.1919 -3.54102,-0.12568 -18.38167,-3.16212 -42.9144,-12.18824 -26.82393,18.68149 -64.679357,43.44745 -92.643497,57.009 -5.389878,2.61389 -21.064916,10.09114 -30.0955,9.4114 -5.029255,-0.37856 -10.4541355,-5.68673 -13.6177995,-11.67795 -4.32270801,-8.18616 -4.01566901,-16.80012 -3.98877001,-17.39403 z" />
|
||||||
</path>
|
</group>
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
</vector>
|
||||||
android:fillType="nonZero"
|
|
||||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
|
||||||
android:strokeWidth="1"
|
|
||||||
android:strokeColor="#00000000" />
|
|
||||||
</vector>
|
|
||||||
|
|||||||
@@ -5,7 +5,19 @@
|
|||||||
android:viewportWidth="24"
|
android:viewportWidth="24"
|
||||||
android:viewportHeight="24"
|
android:viewportHeight="24"
|
||||||
android:tint="?attr/colorControlNormal">
|
android:tint="?attr/colorControlNormal">
|
||||||
|
|
||||||
|
<!-- Shackle (behind body) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:pathData="M8.9,10V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1V10"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeWidth="2.2"/>
|
||||||
|
|
||||||
|
<!-- Body + keyhole cutout -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="@android:color/white"
|
android:fillColor="@android:color/white"
|
||||||
android:pathData="M18,8h-1V6c0-2.76-2.24-5-5-5S7,3.24 7,6v2H6c-1.1,0-2,0.9-2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V10c0,-1.1-0.9,-2-2,-2zm-6,9c-1.1,0-2,-0.9-2,-2s0.9,-2 2,-2 2,0.9 2,2-0.9,2-2,2zm3.1,-9H8.9V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2z" />
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M6,8H18c1.1,0 2,0.9 2,2V20c0,1.1-0.9,2-2,2H6c-1.1,0-2,-0.9-2,-2V10c0,-1.1 0.9,-2 2,-2zM12,15c-1.1,0-2,0.9-2,2s0.9,2 2,2 2,-0.9 2,-2-0.9,-2-2,-2z"/>
|
||||||
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
9
app/src/main/res/drawable/ic_logo.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item>
|
||||||
|
<shape android:shape="oval">
|
||||||
|
<solid android:color="#E8B547" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</layer-list>
|
||||||
10
app/src/main/res/drawable/ic_nfc.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/colorOnSurface"
|
||||||
|
android:pathData="M20,2L4,2C2.9,2 2,2.9 2,4l0,16c0,1.1 0.9,2 2,2l16,0c1.1,0 2,-0.9 2,-2L22,4C22,2.9 21.1,2 20,2zM13,18l-2,0 0,-1c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5l-2,0c0,-1.65 -1.35,-3 -3,-3s-3,1.35 -3,3 1.35,3 3,3l0,-2 2,3zM19,12l-2,0c0,-2.76 -2.24,-5 -5,-5l0,-2C15.87,5 19,8.13 19,12z"/>
|
||||||
|
</vector>
|
||||||
@@ -19,19 +19,40 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:fitsSystemWindows="true">
|
android:fitsSystemWindows="true">
|
||||||
|
|
||||||
<com.google.android.material.appbar.MaterialToolbar
|
<FrameLayout
|
||||||
android:id="@+id/toolbar"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="?attr/actionBarSize"
|
android:layout_height="wrap_content">
|
||||||
app:titleTextAppearance="?attr/textAppearanceTitleLarge" />
|
|
||||||
|
|
||||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
android:id="@+id/refreshIndicator"
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/actionBarSize"
|
||||||
|
app:titleTextAppearance="?attr/textAppearanceTitleLarge" />
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||||
|
android:id="@+id/refreshIndicator"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:indeterminate="true"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:trackCornerRadius="0dp" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/connectivityBanner"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:indeterminate="true"
|
android:background="#C62828"
|
||||||
android:visibility="gone"
|
android:textColor="#FFFFFF"
|
||||||
app:trackCornerRadius="0dp" />
|
android:gravity="center"
|
||||||
|
android:paddingTop="6dp"
|
||||||
|
android:paddingBottom="6dp"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,16 @@
|
|||||||
android:textSize="26sp"
|
android:textSize="26sp"
|
||||||
android:letterSpacing="0.3"
|
android:letterSpacing="0.3"
|
||||||
android:textColor="?attr/colorPrimary"
|
android:textColor="?attr/colorPrimary"
|
||||||
android:layout_marginBottom="24dp" />
|
android:layout_marginBottom="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvPinHint"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:gravity="center"
|
||||||
|
android:layout_marginBottom="20dp" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/lockNumpadContainer"
|
android:id="@+id/lockNumpadContainer"
|
||||||
|
|||||||
@@ -31,28 +31,35 @@
|
|||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
<FrameLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
android:id="@+id/swipeRefresh"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1">
|
android:layout_weight="1">
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<FrameLayout
|
||||||
android:id="@+id/recyclerView"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent">
|
||||||
android:paddingBottom="16dp"
|
|
||||||
android:clipToPadding="false" />
|
|
||||||
|
|
||||||
<TextView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/emptyView"
|
android:id="@+id/recyclerView"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:gravity="center"
|
android:paddingBottom="16dp"
|
||||||
android:text="No transactions found"
|
android:clipToPadding="false" />
|
||||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
|
||||||
android:textColor="?attr/colorOnSurfaceVariant"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
</FrameLayout>
|
<TextView
|
||||||
|
android:id="@+id/emptyView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="No transactions found"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:id="@+id/recyclerView"
|
android:id="@+id/swipeRefresh"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:paddingHorizontal="16dp"
|
android:background="?attr/colorSurface">
|
||||||
android:paddingTop="8dp"
|
|
||||||
android:paddingBottom="16dp"
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:clipToPadding="false"
|
android:id="@+id/recyclerView"
|
||||||
android:background="?attr/colorSurface" />
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="16dp"
|
||||||
|
android:clipToPadding="false" />
|
||||||
|
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|||||||
48
app/src/main/res/layout/fragment_card_settings.xml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/swipeRefresh"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="?attr/colorSurface">
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recyclerView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="16dp"
|
||||||
|
android:clipToPadding="false"/>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/loadingView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/emptyView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/cards_empty"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone"/>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
@@ -41,34 +41,41 @@
|
|||||||
app:tabMode="scrollable"
|
app:tabMode="scrollable"
|
||||||
app:tabGravity="start" />
|
app:tabGravity="start" />
|
||||||
|
|
||||||
<FrameLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
android:id="@+id/swipeRefresh"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1">
|
android:layout_weight="1">
|
||||||
|
|
||||||
<androidx.viewpager2.widget.ViewPager2
|
<FrameLayout
|
||||||
android:id="@+id/viewPager"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<ProgressBar
|
<androidx.viewpager2.widget.ViewPager2
|
||||||
android:id="@+id/loadingView"
|
android:id="@+id/viewPager"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent" />
|
||||||
android:layout_gravity="center"
|
|
||||||
android:visibility="visible" />
|
|
||||||
|
|
||||||
<TextView
|
<ProgressBar
|
||||||
android:id="@+id/emptyView"
|
android:id="@+id/loadingView"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:text="@string/contacts_empty"
|
android:visibility="visible" />
|
||||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
|
||||||
android:textColor="?attr/colorOnSurfaceVariant"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
</FrameLayout>
|
<TextView
|
||||||
|
android:id="@+id/emptyView"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:text="@string/contacts_empty"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||