Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc072484a8
|
@@ -1,9 +1,11 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -87,24 +87,3 @@ jobs:
|
|||||||
--data-binary "@${ASSET_PATH}"
|
--data-binary "@${ASSET_PATH}"
|
||||||
|
|
||||||
echo "Uploaded asset: $ASSET_NAME"
|
echo "Uploaded asset: $ASSET_NAME"
|
||||||
|
|
||||||
- name: Send APK to Telegram
|
|
||||||
env:
|
|
||||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
|
||||||
TG_CHAT_ID: ${{ vars.TG_CHAT_ID }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$TG_BOT_TOKEN" ] || [ -z "$TG_CHAT_ID" ]; then
|
|
||||||
echo "TG_BOT_TOKEN or TG_CHAT_ID not set, skipping Telegram upload."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
APP_NAME="${{ gitea.repository }}"
|
|
||||||
APP_NAME="${APP_NAME##*/}"
|
|
||||||
TAG="${{ gitea.ref_name }}"
|
|
||||||
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
|
|
||||||
CAPTION="${APP_NAME} ${TAG}"
|
|
||||||
|
|
||||||
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
|
|
||||||
-F "chat_id=${TG_CHAT_ID}" \
|
|
||||||
-F "document=@${ASSET_PATH}" \
|
|
||||||
-F "caption=${CAPTION}"
|
|
||||||
|
|||||||
@@ -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-28T18:41:19.777722821Z">
|
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=4254e2f" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Thijooree
|
# BasedBank
|
||||||
|
|
||||||
A native Android client for Maldivian banking services. It is a pure client: requests go directly from your device to the banks' own servers using the same protocols as their official apps. No proxy, no backend, no middleman.
|
A unified Android banking app for Maldivians that combines MIB (Faisanet), BML (Bank of Maldives), and Fahipay into a single interface — with no analytics, no tracking, and no phone-home behaviour outside the banks themselves.
|
||||||
|
|
||||||
[](https://sladge.net)
|
[](https://sladge.net)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
@@ -8,29 +8,65 @@ A native Android client for Maldivian banking services. It is a pure client: req
|
|||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Multi-bank dashboard** — view balances across all your MIB, BML, and Fahipay accounts in one place, with a combined MVR and USD total
|
||||||
|
- **Transaction history** — paginated, searchable transaction history per account for MIB CASA, BML CASA, BML prepaid cards, and Fahipay wallet
|
||||||
|
- **Transfers** — send money between accounts and to saved contacts; supports MIB-to-MIB, BML-to-BML, and cross-bank (MIB↔BML via FAVARA)
|
||||||
|
- **Contacts** — manage saved beneficiaries across all banks; validates Dhiraagu and Ooredoo numbers and shows the account owner name before you add
|
||||||
|
- **Fahipay** — full wallet support including balance, history with merchant icons, and Fahipay favourites (Raastas, Reload, Ooredoo Bill, Dhiraagu Bill)
|
||||||
|
- **QR payments** — scan PayMV QR codes to pre-fill transfers
|
||||||
|
- **BML foreign limits** — view your foreign currency spending allowances and breakdowns by ATM / POS / ECOM
|
||||||
|
- **MIB financing** — view active financing deals
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The app requires your existing credentials for each bank — the same username/password/OTP seed you use with the official apps. It stores them encrypted using AES-256-GCM backed by the Android Keystore (hardware secure enclave).
|
||||||
|
|
||||||
|
Each bank's 2FA uses TOTP, so you need to have your OTP seed (the same secret used by your authenticator app).
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- All credentials encrypted at rest with **AES-256-GCM** (Android Keystore)
|
||||||
|
- Lock screen protected by **PBKDF2-HMAC-SHA256** (100,000 iterations) with optional biometric unlock
|
||||||
|
- **FLAG_SECURE** on by default — content hidden in app switcher and screenshots blocked
|
||||||
|
- All sensitive data excluded from Android cloud backup
|
||||||
|
- Zero analytics, crash reporters, or third-party SDKs — network traffic goes only to MIB, BML, Fahipay, and the Maldivian telecoms for number validation
|
||||||
|
|
||||||
|
See [`docs/AI_SECURITY_CHECK.md`](docs/AI_SECURITY_CHECK.md) for the full security audit.
|
||||||
|
|
||||||
|
## Supported banks
|
||||||
|
|
||||||
|
| Bank | Login | Accounts | History | Transfers | Contacts |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| MIB (Faisanet) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| BML (Bank of Maldives) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| Fahipay | national ID + password + TOTP | ✓ | ✓ | — | ✓ (favourites) |
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Android 8.0+ (API 26)
|
- Android 8.0+ (API 26)
|
||||||
- Existing accounts with MIB, BML, or Fahipay
|
- Existing accounts with MIB, BML, or Fahipay
|
||||||
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
||||||
|
|
||||||
## Download APK
|
## Building
|
||||||
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
|
|
||||||
[Telegram Channel](https://t.me/s/thijooreeapks)
|
Open in Android Studio and run. No API keys or secrets required — all protocol constants are derived from the official apps and are included in the source.
|
||||||
|
|
||||||
|
The release signing config reads from environment variables (`KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`).
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
BasedBank talks directly to each bank's existing mobile API using the same protocol as their official apps, reverse-engineered from the APKs. It does not use any intermediary server — requests go straight from your device to the bank.
|
||||||
|
|
||||||
|
- **MIB**: Blowfish/ECB encrypted JSON over HTTPS with a Diffie-Hellman session key exchange
|
||||||
|
- **BML**: PKCE OAuth 2.0 flow via the BML web login, exchanged for a Bearer token used on the mobile API
|
||||||
|
- **Fahipay**: multipart form login with TOTP, session maintained via `__Secure-sess` cookie and `authid` header
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/thijooree/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
|
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
API reverse-engineering notes and app internals are in [`docs/`](docs/README.md).
|
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials.
|
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials.
|
||||||
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
GNU General Public License v3.0 - See [LICENSE](LICENSE) file for details
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 14
|
versionCode = 7
|
||||||
versionName = "1.0.15"
|
versionName = "1.0.8"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -27,10 +27,6 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
|
||||||
applicationIdSuffix = ".debug"
|
|
||||||
versionNameSuffix = "-debug"
|
|
||||||
}
|
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
@@ -91,9 +87,6 @@ dependencies {
|
|||||||
// Biometric authentication
|
// Biometric authentication
|
||||||
implementation("androidx.biometric:biometric:1.1.0")
|
implementation("androidx.biometric:biometric:1.1.0")
|
||||||
|
|
||||||
// Encrypted SharedPreferences (HCE token store)
|
|
||||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#CC0000"
|
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">Thijooree Debug</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<shortcut
|
|
||||||
android:shortcutId="transfer"
|
|
||||||
android:enabled="true"
|
|
||||||
android:icon="@drawable/ic_shortcut_transfer"
|
|
||||||
android:shortcutShortLabel="@string/transfer"
|
|
||||||
android:shortcutLongLabel="@string/transfer">
|
|
||||||
<intent
|
|
||||||
android:action="sh.sar.basedbank.OPEN_TRANSFER"
|
|
||||||
android:targetPackage="sh.sar.basedbank.debug"
|
|
||||||
android:targetClass="sh.sar.basedbank.MainActivity" />
|
|
||||||
<categories android:name="android.shortcut.conversation" />
|
|
||||||
</shortcut>
|
|
||||||
|
|
||||||
<shortcut
|
|
||||||
android:shortcutId="scan_qr"
|
|
||||||
android:enabled="true"
|
|
||||||
android:icon="@drawable/ic_shortcut_scan_qr"
|
|
||||||
android:shortcutShortLabel="@string/transfer_scan_qr"
|
|
||||||
android:shortcutLongLabel="@string/transfer_scan_qr">
|
|
||||||
<intent
|
|
||||||
android:action="sh.sar.basedbank.OPEN_SCAN_QR"
|
|
||||||
android:targetPackage="sh.sar.basedbank.debug"
|
|
||||||
android:targetClass="sh.sar.basedbank.MainActivity" />
|
|
||||||
<categories android:name="android.shortcut.conversation" />
|
|
||||||
</shortcut>
|
|
||||||
|
|
||||||
<shortcut
|
|
||||||
android:shortcutId="tap_to_pay"
|
|
||||||
android:enabled="true"
|
|
||||||
android:icon="@drawable/ic_shortcut_pay_card"
|
|
||||||
android:shortcutShortLabel="@string/card_pay_nfc"
|
|
||||||
android:shortcutLongLabel="@string/card_pay_nfc">
|
|
||||||
<intent
|
|
||||||
android:action="sh.sar.basedbank.TAP_TO_PAY"
|
|
||||||
android:targetPackage="sh.sar.basedbank.debug"
|
|
||||||
android:targetClass="sh.sar.basedbank.MainActivity" />
|
|
||||||
<categories android:name="android.shortcut.conversation" />
|
|
||||||
</shortcut>
|
|
||||||
|
|
||||||
</shortcuts>
|
|
||||||
@@ -7,13 +7,10 @@
|
|||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.NFC" />
|
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".BasedBankApp"
|
android:name=".BasedBankApp"
|
||||||
android:allowBackup="false"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -33,9 +30,6 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data
|
|
||||||
android:name="android.app.shortcuts"
|
|
||||||
android:resource="@xml/shortcuts" />
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
@@ -62,39 +56,6 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:screenOrientation="portrait" />
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".nfc.BmlTapToPayActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:launchMode="singleTop"
|
|
||||||
android:theme="@style/Theme.BasedBank" />
|
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".nfc.BmlHostCardEmulatorService"
|
|
||||||
android:exported="true"
|
|
||||||
android:permission="android.permission.BIND_NFC_SERVICE">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
</intent-filter>
|
|
||||||
<meta-data
|
|
||||||
android:name="android.nfc.cardemulation.host_apdu_service"
|
|
||||||
android:resource="@xml/bml_aid_list" />
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<!-- Share-sheet alias: "Scan to Pay" receives shared images and decodes their QR code -->
|
|
||||||
<activity-alias
|
|
||||||
android:name=".ScanToPayActivity"
|
|
||||||
android:targetActivity=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:label="@string/transfer_scan_qr"
|
|
||||||
android:icon="@drawable/ic_qr_scan">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.SEND" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:mimeType="image/*" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity-alias>
|
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 196 KiB |
@@ -1 +0,0 @@
|
|||||||
visa_bingaa.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
visa_bingaa.png
|
|
||||||
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 41 KiB |
@@ -16,13 +16,6 @@ import sh.sar.basedbank.util.CredentialStore
|
|||||||
|
|
||||||
class BasedBankApp : Application() {
|
class BasedBankApp : Application() {
|
||||||
|
|
||||||
/**
|
|
||||||
* Set to true only after the user passes LockActivity or completes fresh login.
|
|
||||||
* Resets to false on every process restart so direct ADB/root activity launches
|
|
||||||
* cannot reach HomeActivity without re-authenticating.
|
|
||||||
*/
|
|
||||||
var isUnlocked = false
|
|
||||||
|
|
||||||
// Held in memory after successful login; cleared on logout
|
// Held in memory after successful login; cleared on logout
|
||||||
var accounts: List<BankAccount> = emptyList()
|
var accounts: List<BankAccount> = emptyList()
|
||||||
var fullName: String = ""
|
var fullName: String = ""
|
||||||
@@ -115,11 +108,7 @@ class BasedBankApp : Application() {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
// Only apply wallpaper-based dynamic colors in system theme mode.
|
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||||
// Light/dark modes use content-based accent colors applied per-activity via ThemeHelper.
|
|
||||||
DynamicColors.applyToActivitiesIfAvailable(this) { _, _ ->
|
|
||||||
getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system") == "system"
|
|
||||||
}
|
|
||||||
|
|
||||||
val theme = getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system")
|
val theme = getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system")
|
||||||
AppCompatDelegate.setDefaultNightMode(when (theme) {
|
AppCompatDelegate.setDefaultNightMode(when (theme) {
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.databinding.ActivityLockBinding
|
import sh.sar.basedbank.databinding.ActivityLockBinding
|
||||||
import sh.sar.basedbank.ui.home.HomeActivity
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.ThemeHelper
|
|
||||||
import sh.sar.basedbank.BasedBankApp
|
|
||||||
import javax.crypto.SecretKeyFactory
|
import javax.crypto.SecretKeyFactory
|
||||||
import javax.crypto.spec.PBEKeySpec
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
|
||||||
@@ -47,7 +45,6 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
ThemeHelper.applyAccent(this)
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityLockBinding.inflate(layoutInflater)
|
binding = ActivityLockBinding.inflate(layoutInflater)
|
||||||
@@ -57,9 +54,6 @@ class LockActivity : AppCompatActivity() {
|
|||||||
isAppearanceLightStatusBars = isLight
|
isAppearanceLightStatusBars = isLight
|
||||||
isAppearanceLightNavigationBars = isLight
|
isAppearanceLightNavigationBars = isLight
|
||||||
}
|
}
|
||||||
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
|
||||||
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
|
||||||
ta.recycle()
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
|
||||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
||||||
@@ -265,27 +259,10 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun proceed() {
|
private fun proceed() {
|
||||||
(application as BasedBankApp).isUnlocked = true
|
|
||||||
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
|
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
|
||||||
finish()
|
finish()
|
||||||
} else {
|
} else {
|
||||||
val store = CredentialStore(this)
|
startActivity(Intent(this, HomeActivity::class.java))
|
||||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
|
||||||
if (!hasCredentials) {
|
|
||||||
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
|
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val navDest = intent.getIntExtra("nav_destination", -1)
|
|
||||||
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
|
||||||
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
|
||||||
val shareQrText = intent.getStringExtra("share_qr_text")
|
|
||||||
startActivity(Intent(this, HomeActivity::class.java).apply {
|
|
||||||
if (navDest != -1) putExtra("nav_destination", navDest)
|
|
||||||
if (autoScan) putExtra("auto_scan", true)
|
|
||||||
if (autoTapMode) putExtra("auto_tap_mode", true)
|
|
||||||
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
|
|
||||||
})
|
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,80 +7,24 @@ import sh.sar.basedbank.ui.home.HomeActivity
|
|||||||
import sh.sar.basedbank.ui.login.LoginActivity
|
import sh.sar.basedbank.ui.login.LoginActivity
|
||||||
import sh.sar.basedbank.ui.onboarding.OnboardingActivity
|
import sh.sar.basedbank.ui.onboarding.OnboardingActivity
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.BasedBankApp
|
|
||||||
import sh.sar.basedbank.R
|
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private fun decodeQrFromSharedImage(uri: android.net.Uri): String? {
|
|
||||||
return try {
|
|
||||||
val bitmap = contentResolver.openInputStream(uri)?.use {
|
|
||||||
android.graphics.BitmapFactory.decodeStream(it)
|
|
||||||
} ?: return null
|
|
||||||
val opts = de.markusfisch.android.zxingcpp.ZxingCpp.ReaderOptions(
|
|
||||||
tryHarder = true, tryRotate = true, tryInvert = true,
|
|
||||||
tryDownscale = true, maxNumberOfSymbols = 1,
|
|
||||||
textMode = de.markusfisch.android.zxingcpp.ZxingCpp.TextMode.PLAIN
|
|
||||||
)
|
|
||||||
val result = (de.markusfisch.android.zxingcpp.ZxingCpp.readBitmap(
|
|
||||||
bitmap, 0, 0, bitmap.width, bitmap.height, 0,
|
|
||||||
opts.apply { binarizer = de.markusfisch.android.zxingcpp.ZxingCpp.Binarizer.LOCAL_AVERAGE }
|
|
||||||
) ?: de.markusfisch.android.zxingcpp.ZxingCpp.readBitmap(
|
|
||||||
bitmap, 0, 0, bitmap.width, bitmap.height, 0,
|
|
||||||
opts.apply { binarizer = de.markusfisch.android.zxingcpp.ZxingCpp.Binarizer.GLOBAL_HISTOGRAM }
|
|
||||||
))?.firstOrNull()?.text
|
|
||||||
bitmap.recycle()
|
|
||||||
result
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
||||||
val securitySet = prefs.getString("security_method", null) != null
|
val securitySet = prefs.getString("security_method", null) != null
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
||||||
|
|
||||||
// Image shared via "Scan to Pay" — decode QR here while we still hold the URI permission
|
|
||||||
val shareQrText: String? = if (intent?.action == Intent.ACTION_SEND &&
|
|
||||||
intent.type?.startsWith("image/") == true) {
|
|
||||||
val uri: android.net.Uri? =
|
|
||||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU)
|
|
||||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, android.net.Uri::class.java)
|
|
||||||
else
|
|
||||||
@Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
|
||||||
if (uri != null) decodeQrFromSharedImage(uri) else null
|
|
||||||
} else null
|
|
||||||
|
|
||||||
val navDestination = when (intent?.action) {
|
|
||||||
"sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer
|
|
||||||
"sh.sar.basedbank.OPEN_SCAN_QR" -> R.id.nav_transfer
|
|
||||||
"sh.sar.basedbank.OPEN_PAY_WITH_CARD" -> R.id.nav_pay_with_card
|
|
||||||
"sh.sar.basedbank.TAP_TO_PAY" -> R.id.nav_pay_with_card
|
|
||||||
else -> -1
|
|
||||||
}
|
|
||||||
val autoScan = intent?.action == "sh.sar.basedbank.OPEN_SCAN_QR"
|
|
||||||
val autoTapMode = intent?.action == "sh.sar.basedbank.TAP_TO_PAY"
|
|
||||||
|
|
||||||
val target = when {
|
val target = when {
|
||||||
!onboardingDone -> OnboardingActivity::class.java
|
!onboardingDone -> OnboardingActivity::class.java
|
||||||
!hasCredentials -> LoginActivity::class.java
|
!hasCredentials -> LoginActivity::class.java
|
||||||
securitySet -> LockActivity::class.java // proceed() → HomeActivity
|
securitySet -> LockActivity::class.java // proceed() → HomeActivity
|
||||||
else -> HomeActivity::class.java
|
else -> HomeActivity::class.java
|
||||||
}
|
}
|
||||||
// No lock screen configured — mark as unlocked so HomeActivity's guard passes
|
startActivity(Intent(this, target))
|
||||||
if (target == HomeActivity::class.java) {
|
|
||||||
(application as BasedBankApp).isUnlocked = true
|
|
||||||
}
|
|
||||||
|
|
||||||
startActivity(Intent(this, target).apply {
|
|
||||||
if (navDestination != -1) putExtra("nav_destination", navDestination)
|
|
||||||
if (autoScan) putExtra("auto_scan", true)
|
|
||||||
if (autoTapMode) putExtra("auto_tap_mode", true)
|
|
||||||
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
|
|
||||||
})
|
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import okhttp3.MediaType.Companion.toMediaType
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import sh.sar.basedbank.api.models.BankServerException
|
|
||||||
import sh.sar.basedbank.api.models.BankTransaction
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -31,7 +30,6 @@ class BmlHistoryClient {
|
|||||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||||
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 try {
|
return try {
|
||||||
val root = JSONObject(json)
|
val root = JSONObject(json)
|
||||||
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
||||||
@@ -84,7 +82,6 @@ class BmlHistoryClient {
|
|||||||
val json = resp.body?.string() ?: return emptyList()
|
val json = resp.body?.string() ?: return emptyList()
|
||||||
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 try {
|
return try {
|
||||||
val root = JSONObject(json)
|
val root = JSONObject(json)
|
||||||
if (!root.optBoolean("success")) return emptyList()
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
|
|||||||
@@ -66,31 +66,6 @@ data class BmlLoanDetail(
|
|||||||
val overdueAmount: Double
|
val overdueAmount: Double
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BmlQrPayInfo(
|
|
||||||
val requestId: String, // base64-encoded full QR URL (trxn_hash)
|
|
||||||
val merchantName: String, // narrative1
|
|
||||||
val merchantAddress: String, // narrative2 + narrative3
|
|
||||||
val amount: Double, // 0.0 for static QR
|
|
||||||
val currency: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class BmlQrPayResult(
|
|
||||||
val success: Boolean,
|
|
||||||
val merchant: String = "",
|
|
||||||
val amount: String = "",
|
|
||||||
val currency: String = "",
|
|
||||||
val errorMessage: String = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
data class BmlWalletToken(
|
|
||||||
val token: String,
|
|
||||||
val expiry: String,
|
|
||||||
val appCode: String, // AID hex, e.g. "A0000000031010"
|
|
||||||
val serviceCode: String,
|
|
||||||
val data: String,
|
|
||||||
val validUntil: String // "YYYY-MM-DD HH:mm:ss.SSS"
|
|
||||||
)
|
|
||||||
|
|
||||||
data class BmlForeignLimit(
|
data class BmlForeignLimit(
|
||||||
val type: String,
|
val type: String,
|
||||||
val used: Double,
|
val used: Double,
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
package sh.sar.basedbank.api.bml
|
|
||||||
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
class BmlQrPayClient {
|
|
||||||
|
|
||||||
private val client = newBmlApiClient()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves a BML QR URL to merchant details.
|
|
||||||
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
|
|
||||||
*/
|
|
||||||
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
|
|
||||||
val request = bmlApiRequest(session,
|
|
||||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
|
|
||||||
return client.newCall(request).execute().use { response ->
|
|
||||||
val body = response.body?.string() ?: throw Exception("No response")
|
|
||||||
val json = JSONObject(body)
|
|
||||||
if (!json.optBoolean("success"))
|
|
||||||
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
|
|
||||||
val payload = json.getJSONObject("payload")
|
|
||||||
val addr2 = payload.optString("narrative2").trim()
|
|
||||||
val addr3 = payload.optString("narrative3").trim()
|
|
||||||
val address = listOf(addr2, addr3).filter { it.isNotBlank() }.joinToString(", ")
|
|
||||||
BmlQrPayInfo(
|
|
||||||
requestId = payload.optString("trxn_hash"),
|
|
||||||
merchantName = payload.optString("narrative1").trim(),
|
|
||||||
merchantAddress = address,
|
|
||||||
amount = payload.optString("amount").toDoubleOrNull() ?: 0.0,
|
|
||||||
currency = payload.optString("currency").ifBlank { "MVR" }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-initiate step required for gateway QR (pay.bml.com.mv).
|
|
||||||
* POST without channel — expects code 99 (OTP channel selection required).
|
|
||||||
*/
|
|
||||||
fun preInitiatePayment(
|
|
||||||
session: BmlSession,
|
|
||||||
debitAccount: String,
|
|
||||||
requestId: String,
|
|
||||||
amount: Double,
|
|
||||||
currency: String
|
|
||||||
): Boolean {
|
|
||||||
val jo = JSONObject().apply {
|
|
||||||
put("action", "approve")
|
|
||||||
put("debitAccount", debitAccount)
|
|
||||||
put("requestId", requestId)
|
|
||||||
put("amount", amount)
|
|
||||||
put("currency", currency)
|
|
||||||
}
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
|
||||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", BML_USER_AGENT)
|
|
||||||
.header("x-app-version", BML_APP_VERSION)
|
|
||||||
.header("accept", "application/json")
|
|
||||||
.build()
|
|
||||||
return client.newCall(request).execute().use { response ->
|
|
||||||
val body = response.body?.string() ?: return@use false
|
|
||||||
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
|
|
||||||
json.optBoolean("success") && json.optInt("code") == 99
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 1 — initiate: POST with channel but no OTP.
|
|
||||||
* Returns true when server responds with code 22 (OTP generated).
|
|
||||||
*/
|
|
||||||
fun initiatePayment(
|
|
||||||
session: BmlSession,
|
|
||||||
debitAccount: String,
|
|
||||||
requestId: String,
|
|
||||||
amount: Double,
|
|
||||||
currency: String
|
|
||||||
): Boolean {
|
|
||||||
val jo = JSONObject().apply {
|
|
||||||
put("action", "approve")
|
|
||||||
put("debitAccount", debitAccount)
|
|
||||||
put("requestId", requestId)
|
|
||||||
put("amount", amount)
|
|
||||||
put("currency", currency)
|
|
||||||
put("channel", "token")
|
|
||||||
}
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
|
||||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", BML_USER_AGENT)
|
|
||||||
.header("x-app-version", BML_APP_VERSION)
|
|
||||||
.header("accept", "application/json")
|
|
||||||
.build()
|
|
||||||
return client.newCall(request).execute().use { response ->
|
|
||||||
val body = response.body?.string() ?: return@use false
|
|
||||||
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
|
|
||||||
json.optBoolean("success") && json.optInt("code") == 22
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 2 — confirm: POST with channel + OTP.
|
|
||||||
* Returns [BmlQrPayResult] with success/error.
|
|
||||||
*/
|
|
||||||
fun confirmPayment(
|
|
||||||
session: BmlSession,
|
|
||||||
debitAccount: String,
|
|
||||||
requestId: String,
|
|
||||||
amount: Double,
|
|
||||||
currency: String,
|
|
||||||
otp: String
|
|
||||||
): BmlQrPayResult {
|
|
||||||
val jo = JSONObject().apply {
|
|
||||||
put("action", "approve")
|
|
||||||
put("debitAccount", debitAccount)
|
|
||||||
put("requestId", requestId)
|
|
||||||
put("amount", amount)
|
|
||||||
put("currency", currency)
|
|
||||||
put("channel", "token")
|
|
||||||
put("otp", otp)
|
|
||||||
}
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
|
||||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", BML_USER_AGENT)
|
|
||||||
.header("x-app-version", BML_APP_VERSION)
|
|
||||||
.header("accept", "application/json")
|
|
||||||
.build()
|
|
||||||
return client.newCall(request).execute().use { response ->
|
|
||||||
val body = response.body?.string()
|
|
||||||
?: return@use BmlQrPayResult(false, errorMessage = "No response")
|
|
||||||
val json = try { JSONObject(body) } catch (_: Exception) {
|
|
||||||
return@use BmlQrPayResult(false, errorMessage = "Parse error")
|
|
||||||
}
|
|
||||||
if (!json.optBoolean("success")) {
|
|
||||||
BmlQrPayResult(false, errorMessage = json.optString("message").ifBlank { "Payment failed" })
|
|
||||||
} else {
|
|
||||||
val payload = json.optJSONObject("payload")
|
|
||||||
BmlQrPayResult(
|
|
||||||
success = true,
|
|
||||||
merchant = payload?.optString("merchant") ?: "",
|
|
||||||
amount = payload?.optString("amount") ?: "",
|
|
||||||
currency = payload?.optString("currency") ?: currency
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package sh.sar.basedbank.api.bml
|
|
||||||
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
class BmlTapToPayClient {
|
|
||||||
|
|
||||||
private val client = newBmlApiClient()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches up to [quantity] single-use payment tokens for [cardId].
|
|
||||||
* [otp] is a TOTP code generated from the stored BML OTP seed.
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. POST → code 99 (OTP required) or 0 (direct, unlikely)
|
|
||||||
* 2. POST with channel=token → code 22 (OTP generated on BML side, but we use TOTP)
|
|
||||||
* 3. POST with otp=TOTP → code 0, payload = token list
|
|
||||||
*/
|
|
||||||
fun fetchTokens(
|
|
||||||
session: BmlSession,
|
|
||||||
cardId: String,
|
|
||||||
otp: String,
|
|
||||||
quantity: Int = 3
|
|
||||||
): List<BmlWalletToken> {
|
|
||||||
val url = "$BML_BASE_URL/api/mobile/walletpayments/gettoken"
|
|
||||||
|
|
||||||
// Step 1: initiate
|
|
||||||
val base = JSONObject().apply {
|
|
||||||
put("type", "track2")
|
|
||||||
put("cardid", cardId)
|
|
||||||
put("quantity", quantity)
|
|
||||||
}
|
|
||||||
val step1 = post(session, url, base)
|
|
||||||
if (step1.optInt("code") == 0) return parseTokens(step1.optJSONArray("payload"))
|
|
||||||
if (step1.optInt("code") != 99) throw Exception(step1.optString("message", "Token request failed"))
|
|
||||||
|
|
||||||
// Step 2: request OTP channel (triggers BML to validate we can use TOTP)
|
|
||||||
val body2 = JSONObject(base.toString()).apply { put("channel", "token") }
|
|
||||||
val step2 = post(session, url, body2)
|
|
||||||
if (step2.optInt("code") != 22) throw Exception(step2.optString("message", "OTP channel request failed"))
|
|
||||||
|
|
||||||
// Step 3: submit TOTP
|
|
||||||
val body3 = JSONObject(body2.toString()).apply { put("otp", otp) }
|
|
||||||
val step3 = post(session, url, body3)
|
|
||||||
if (step3.optInt("code") != 0) throw Exception(step3.optString("message", "Token fetch failed"))
|
|
||||||
|
|
||||||
return parseTokens(step3.optJSONArray("payload"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun post(session: BmlSession, url: String, body: JSONObject): JSONObject {
|
|
||||||
val req = okhttp3.Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.post(body.toString().toRequestBody("application/json".toMediaType()))
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", BML_USER_AGENT)
|
|
||||||
.header("x-app-version", BML_APP_VERSION)
|
|
||||||
.build()
|
|
||||||
return client.newCall(req).execute().use { resp ->
|
|
||||||
JSONObject(resp.body?.string() ?: throw Exception("Empty response"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseTokens(arr: JSONArray?): List<BmlWalletToken> {
|
|
||||||
arr ?: return emptyList()
|
|
||||||
return (0 until arr.length()).map { i ->
|
|
||||||
val o = arr.getJSONObject(i)
|
|
||||||
BmlWalletToken(
|
|
||||||
token = o.getString("token"),
|
|
||||||
expiry = o.getString("expiry"),
|
|
||||||
appCode = o.getString("app_code"),
|
|
||||||
serviceCode = o.getString("service_code"),
|
|
||||||
data = o.optString("data", ""),
|
|
||||||
validUntil = o.optString("valid_until", "")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package sh.sar.basedbank.api.fahipay
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import sh.sar.basedbank.api.models.BankServerException
|
|
||||||
import sh.sar.basedbank.api.models.BankTransaction
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -33,10 +32,8 @@ class FahipayHistoryClient {
|
|||||||
.header("User-Agent", UA)
|
.header("User-Agent", UA)
|
||||||
.build()
|
.build()
|
||||||
).execute()
|
).execute()
|
||||||
val code = resp.code
|
|
||||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
val json = resp.body?.string() ?: return Pair(emptyList(), 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)
|
||||||
val total = obj.optInt("total", 0)
|
val total = obj.optInt("total", 0)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import okhttp3.FormBody
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import sh.sar.basedbank.api.models.BankServerException
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class MibHistoryClient {
|
class MibHistoryClient {
|
||||||
@@ -61,7 +60,6 @@ class MibHistoryClient {
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
return client.newCall(request).execute().use { response ->
|
return client.newCall(request).execute().use { response ->
|
||||||
if (response.code in 500..599) throw BankServerException("MIB")
|
|
||||||
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
|
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
|
||||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
|
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
|
||||||
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
|
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.json.JSONObject
|
|||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlin.math.abs
|
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
class SessionExpiredException : Exception("MIB session expired")
|
class SessionExpiredException : Exception("MIB session expired")
|
||||||
@@ -40,7 +39,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
.addNetworkInterceptor { chain ->
|
.addInterceptor { chain ->
|
||||||
val req = chain.request().newBuilder()
|
val req = chain.request().newBuilder()
|
||||||
.header("User-Agent", "android/1.0")
|
.header("User-Agent", "android/1.0")
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
@@ -169,7 +168,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
accountTypeName = a.optString("accountTypeName"),
|
accountTypeName = a.optString("accountTypeName"),
|
||||||
availableBalance = a.optString("availableBalance"),
|
availableBalance = a.optString("availableBalance"),
|
||||||
currentBalance = a.optString("currentBalance"),
|
currentBalance = a.optString("currentBalance"),
|
||||||
blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
|
blockedAmount = a.optString("blockedAmount"),
|
||||||
mvrBalance = a.optString("mvrBalance"),
|
mvrBalance = a.optString("mvrBalance"),
|
||||||
statusDesc = a.optString("statusDesc"),
|
statusDesc = a.optString("statusDesc"),
|
||||||
profileImageHash = profile.customerImage,
|
profileImageHash = profile.customerImage,
|
||||||
@@ -189,13 +188,6 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** MIB returns blockedAmount as a signed decimal where negative = funds held.
|
|
||||||
* Normalize to a positive magnitude so downstream code can treat it uniformly. */
|
|
||||||
private fun absBlockedAmount(raw: String): String {
|
|
||||||
val v = raw.toDoubleOrNull() ?: return raw
|
|
||||||
return "%.2f".format(abs(v))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initialKeyExchange(
|
private fun initialKeyExchange(
|
||||||
appId: String, encKey: String, sfunc: String, key2: String? = null
|
appId: String, encKey: String, sfunc: String, key2: String? = null
|
||||||
): Pair<MibSession, String> {
|
): Pair<MibSession, String> {
|
||||||
@@ -333,7 +325,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
accountTypeName = a.optString("accountTypeName"),
|
accountTypeName = a.optString("accountTypeName"),
|
||||||
availableBalance = a.optString("availableBalance"),
|
availableBalance = a.optString("availableBalance"),
|
||||||
currentBalance = a.optString("currentBalance"),
|
currentBalance = a.optString("currentBalance"),
|
||||||
blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
|
blockedAmount = a.optString("blockedAmount"),
|
||||||
mvrBalance = a.optString("mvrBalance"),
|
mvrBalance = a.optString("mvrBalance"),
|
||||||
statusDesc = a.optString("statusDesc"),
|
statusDesc = a.optString("statusDesc"),
|
||||||
profileImageHash = profile.customerImage,
|
profileImageHash = profile.customerImage,
|
||||||
@@ -374,34 +366,6 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
return resp.optString("profileImage").takeIf { it.isNotBlank() }
|
return resp.optString("profileImage").takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads a profile image via P40.
|
|
||||||
* [imageBase64] is a base64-encoded JPEG string.
|
|
||||||
* Returns the new imageHash on success, or null on failure.
|
|
||||||
*/
|
|
||||||
fun uploadProfileImage(session: MibSession, profile: MibProfile, imageBase64: String): String? {
|
|
||||||
val payload = baseData(session, "P40").apply {
|
|
||||||
put("profileId", profile.profileId)
|
|
||||||
put("profileImage", imageBase64)
|
|
||||||
}
|
|
||||||
val resp = doRequest(session, payload, "n")
|
|
||||||
if (!resp.optBoolean("success", false)) return null
|
|
||||||
return resp.optString("imageHash").takeIf { it.isNotBlank() }
|
|
||||||
?: resp.optString("customerImage").takeIf { it.isNotBlank() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the profile image via P42.
|
|
||||||
* Returns true on success.
|
|
||||||
*/
|
|
||||||
fun deleteProfileImage(session: MibSession, profile: MibProfile): Boolean {
|
|
||||||
val payload = baseData(session, "P42").apply {
|
|
||||||
put("profileId", profile.profileId)
|
|
||||||
}
|
|
||||||
val resp = doRequest(session, payload, "n")
|
|
||||||
return resp.optBoolean("success", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun post(body: FormBody): String {
|
private fun post(body: FormBody): String {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(BASE_URL)
|
.url(BASE_URL)
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
package sh.sar.basedbank.nfc
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.nfc.cardemulation.HostApduService
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.util.Log
|
|
||||||
import sh.sar.basedbank.api.bml.BmlWalletToken
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HCE service that emulates a BML contactless payment card.
|
|
||||||
*
|
|
||||||
* Implements the minimal EMV mag-stripe contactless flow:
|
|
||||||
* SELECT PPSE → SELECT AID → GET PROCESSING OPTIONS → READ RECORD
|
|
||||||
*
|
|
||||||
* Each BmlWalletToken is single-use and is set via [setToken] before tapping.
|
|
||||||
* After READ RECORD is sent the [onTransactionComplete] callback fires.
|
|
||||||
*/
|
|
||||||
class BmlHostCardEmulatorService : HostApduService() {
|
|
||||||
|
|
||||||
private var gpoSent = false
|
|
||||||
|
|
||||||
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
|
|
||||||
if (commandApdu == null) return SW_UNKNOWN_ERROR
|
|
||||||
val apdu = Apdu(commandApdu)
|
|
||||||
if (apdu.isError) return apdu.errorResponse()
|
|
||||||
|
|
||||||
return when (apdu.ins) {
|
|
||||||
INS_SELECT -> handleSelect(apdu)
|
|
||||||
INS_GPO -> handleGpo()
|
|
||||||
INS_READ -> handleReadRecord()
|
|
||||||
else -> SW_INS_NOT_SUPPORTED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDeactivated(reason: Int) {
|
|
||||||
if (!gpoSent) onTransactionComplete?.invoke(false)
|
|
||||||
gpoSent = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── APDU handlers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun handleSelect(apdu: Apdu): ByteArray {
|
|
||||||
val data = apdu.data ?: return SW_UNKNOWN_ERROR
|
|
||||||
|
|
||||||
if (data.contentEquals(PPSE_BYTES)) {
|
|
||||||
val token = activeToken ?: run { launchPromptActivity(); return SW_UNKNOWN_ERROR }
|
|
||||||
return hexToBytes(buildSelectPpseResponse(token.appCode, applicationLabel(token.appCode), "01"))
|
|
||||||
}
|
|
||||||
|
|
||||||
val token = activeToken ?: return SW_UNKNOWN_ERROR
|
|
||||||
return if (data.contentEquals(hexToBytes(token.appCode))) {
|
|
||||||
hexToBytes(buildSelectAidResponse(token.appCode, applicationLabel(token.appCode)))
|
|
||||||
} else {
|
|
||||||
SW_UNKNOWN_ERROR
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun launchPromptActivity() {
|
|
||||||
val intent = Intent(applicationContext, BmlTapToPayActivity::class.java).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
}
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleGpo(): ByteArray {
|
|
||||||
gpoSent = true
|
|
||||||
// AIP=0080 (mag-stripe mode), AFL=08010100 (SFI=1, record 1-1, offline 0)
|
|
||||||
val miscData = "008008010100"
|
|
||||||
val body = tlv("80", miscData)
|
|
||||||
return hexToBytes(body + SW_OK_HEX)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleReadRecord(): ByteArray {
|
|
||||||
val token = activeToken ?: return SW_UNKNOWN_ERROR
|
|
||||||
val track2 = buildTrack2(token)
|
|
||||||
val body = tlv("70", tlv("57", track2))
|
|
||||||
val response = hexToBytes(body + SW_OK_HEX)
|
|
||||||
onTransactionComplete?.invoke(true)
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── TLV / APDU response builders ───────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun buildSelectPpseResponse(aid: String, label: String, priority: String): String {
|
|
||||||
val priorityTlv = tlv("87", priority) // tag 87
|
|
||||||
val aidTlv = tlv("4F", aid) // tag 4F (ADF Name)
|
|
||||||
val appEntry = tlv("61", aidTlv + priorityTlv) // tag 61
|
|
||||||
val ppseTlv = tlv("84", PPSE_HEX) // tag 84 (DF Name)
|
|
||||||
val inner = tlv("BF0C", appEntry) // tag BF0C
|
|
||||||
val propTemplate = tlv("A5", inner) // tag A5
|
|
||||||
val fci = tlv("6F", ppseTlv + propTemplate) // tag 6F
|
|
||||||
return fci + SW_OK_HEX
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildSelectAidResponse(aid: String, label: String): String {
|
|
||||||
val aidTlv = tlv("84", aid) // tag 84
|
|
||||||
val labelTlv = tlv("50", asciiToHex(label)) // tag 50
|
|
||||||
val pdolTlv = tlv("9F38", "9F6602") // PDOL: TTQ 2 bytes
|
|
||||||
val propTemplate = tlv("A5", labelTlv + pdolTlv) // tag A5
|
|
||||||
val fci = tlv("6F", aidTlv + propTemplate) // tag 6F
|
|
||||||
return fci + SW_OK_HEX
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildTrack2(token: BmlWalletToken): String {
|
|
||||||
var t2 = "${token.token}D${token.expiry}${token.serviceCode}${token.data}"
|
|
||||||
if (t2.length % 2 != 0) t2 += "F"
|
|
||||||
return t2
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Build BER-TLV: tag (hex string, 1 or 2 bytes) + DER length + data (hex string). */
|
|
||||||
private fun tlv(tagHex: String, dataHex: String): String {
|
|
||||||
val lenBytes = dataHex.length / 2
|
|
||||||
val lenHex = when {
|
|
||||||
lenBytes <= 0x7F -> lenBytes.toHexByte()
|
|
||||||
lenBytes <= 0xFF -> "81" + lenBytes.toHexByte()
|
|
||||||
else -> "82" + (lenBytes shr 8).toHexByte() + (lenBytes and 0xFF).toHexByte()
|
|
||||||
}
|
|
||||||
return tagHex + lenHex + dataHex
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Int.toHexByte(): String = toString(16).padStart(2, '0').uppercase()
|
|
||||||
|
|
||||||
private fun asciiToHex(s: String): String =
|
|
||||||
s.toByteArray(Charsets.US_ASCII).joinToString("") { "%02X".format(it) }
|
|
||||||
|
|
||||||
private fun hexToBytes(hex: String): ByteArray {
|
|
||||||
val s = hex.uppercase()
|
|
||||||
return ByteArray(s.length / 2) { i ->
|
|
||||||
s.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── APDU parser ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private inner class Apdu(raw: ByteArray) {
|
|
||||||
val isError: Boolean
|
|
||||||
val ins: Int
|
|
||||||
val data: ByteArray?
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (raw.size < 5) {
|
|
||||||
isError = true; ins = -1; data = null
|
|
||||||
} else {
|
|
||||||
isError = false
|
|
||||||
ins = raw[1].toInt() and 0xFF
|
|
||||||
val lc = if (raw.size > 4) raw[4].toInt() and 0xFF else 0
|
|
||||||
data = if (lc > 0 && raw.size >= 5 + lc) raw.copyOfRange(5, 5 + lc) else null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun errorResponse() = SW_UNKNOWN_ERROR
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "BmlHCE"
|
|
||||||
|
|
||||||
private const val INS_SELECT = 0xA4
|
|
||||||
private const val INS_GPO = 0xA8
|
|
||||||
private const val INS_READ = 0xB2
|
|
||||||
|
|
||||||
private val PPSE_HEX = "325041592E5359532E4444463031" // "2PAY.SYS.DDF01"
|
|
||||||
private val PPSE_BYTES = byteArrayOf(
|
|
||||||
0x32,0x50,0x41,0x59,0x2E,0x53,0x59,0x53,0x2E,0x44,0x44,0x46,0x30,0x31
|
|
||||||
)
|
|
||||||
|
|
||||||
private const val SW_OK_HEX = "9000"
|
|
||||||
private val SW_UNKNOWN_ERROR = byteArrayOf(0x6F.toByte(), 0x00.toByte())
|
|
||||||
private val SW_INS_NOT_SUPPORTED = byteArrayOf(0x6D.toByte(), 0x00.toByte())
|
|
||||||
|
|
||||||
@Volatile var activeToken: BmlWalletToken? = null
|
|
||||||
@Volatile var onTransactionComplete: ((success: Boolean) -> Unit)? = null
|
|
||||||
|
|
||||||
fun setToken(token: BmlWalletToken) { activeToken = token }
|
|
||||||
fun clearToken() { activeToken = null }
|
|
||||||
|
|
||||||
fun applicationLabel(aidHex: String): String = when {
|
|
||||||
aidHex.startsWith("A0000000031010", ignoreCase = true) -> "VISA"
|
|
||||||
aidHex.startsWith("A0000000041010", ignoreCase = true) -> "MASTERCARD"
|
|
||||||
aidHex.startsWith("A000000025", ignoreCase = true) -> "AMEX"
|
|
||||||
else -> "BML"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package sh.sar.basedbank.nfc
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import sh.sar.basedbank.MainActivity
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback entry point — redirects to MainActivity which routes to the in-app tap-to-pay screen.
|
|
||||||
*/
|
|
||||||
class BmlTapToPayActivity : AppCompatActivity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
startActivity(Intent(this, MainActivity::class.java).apply {
|
|
||||||
action = "sh.sar.basedbank.TAP_TO_PAY"
|
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
|
||||||
})
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,12 +37,7 @@ class AccountHistoryAdapter(
|
|||||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||||
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||||
var onTransferClick: ((BankAccount) -> Unit)? = null
|
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||||
var onDefaultToggle: ((Boolean) -> Unit)? = null
|
|
||||||
private var hideAmounts: Boolean = false
|
private var hideAmounts: Boolean = false
|
||||||
var showDefaultToggle: Boolean = false
|
|
||||||
set(value) { if (field == value) return; field = value; notifyItemChanged(0) }
|
|
||||||
var isDefaultAccount: Boolean = false
|
|
||||||
set(value) { if (field == value) return; field = value; notifyItemChanged(0) }
|
|
||||||
|
|
||||||
fun setHideAmounts(hide: Boolean) {
|
fun setHideAmounts(hide: Boolean) {
|
||||||
if (hideAmounts == hide) return
|
if (hideAmounts == hide) return
|
||||||
@@ -179,20 +174,6 @@ class AccountHistoryAdapter(
|
|||||||
b.llHeaderBlocked.visibility = View.GONE
|
b.llHeaderBlocked.visibility = View.GONE
|
||||||
}
|
}
|
||||||
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||||
|
|
||||||
if (showDefaultToggle) {
|
|
||||||
b.dividerDefaultAccount.visibility = View.VISIBLE
|
|
||||||
b.llDefaultAccountRow.visibility = View.VISIBLE
|
|
||||||
b.switchDefaultAccount.setOnCheckedChangeListener(null)
|
|
||||||
b.switchDefaultAccount.isChecked = isDefaultAccount
|
|
||||||
b.switchDefaultAccount.setOnCheckedChangeListener { _, checked ->
|
|
||||||
isDefaultAccount = checked
|
|
||||||
onDefaultToggle?.invoke(checked)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
b.dividerDefaultAccount.visibility = View.GONE
|
|
||||||
b.llDefaultAccountRow.visibility = View.GONE
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,15 +23,12 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.models.BankServerException
|
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.models.BankTransaction
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
||||||
import sh.sar.basedbank.util.AccountHistoryParser
|
import sh.sar.basedbank.util.AccountHistoryParser
|
||||||
import sh.sar.basedbank.util.AccountListParser
|
|
||||||
import sh.sar.basedbank.util.ContactImageCache
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
|
||||||
import sh.sar.basedbank.util.HistoryFetcher
|
import sh.sar.basedbank.util.HistoryFetcher
|
||||||
import sh.sar.basedbank.util.MerchantIconCache
|
import sh.sar.basedbank.util.MerchantIconCache
|
||||||
|
|
||||||
@@ -82,23 +79,6 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
|
|
||||||
// Show default account toggle only for non-card accounts
|
|
||||||
val isCard = AccountListParser.from(account)?.isCard ?: false
|
|
||||||
if (!isCard) {
|
|
||||||
val store = CredentialStore(requireContext())
|
|
||||||
adapter.showDefaultToggle = true
|
|
||||||
adapter.isDefaultAccount = store.getDefaultAccountNumber() == account.accountNumber
|
|
||||||
adapter.onDefaultToggle = { isChecked ->
|
|
||||||
if (isChecked) {
|
|
||||||
store.setDefaultAccountNumber(account.accountNumber)
|
|
||||||
} else {
|
|
||||||
if (store.getDefaultAccountNumber() == account.accountNumber) {
|
|
||||||
store.setDefaultAccountNumber(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
@@ -150,12 +130,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
if (::account.isInitialized) {
|
if (::account.isInitialized) requireActivity().title = account.accountBriefName
|
||||||
requireActivity().title = account.accountBriefName
|
|
||||||
if (adapter.showDefaultToggle) {
|
|
||||||
adapter.isDefaultAccount = CredentialStore(requireContext()).getDefaultAccountNumber() == account.accountNumber
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun filterAndDisplay() {
|
private fun filterAndDisplay() {
|
||||||
@@ -174,15 +149,8 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
pendingIconUrls.clear()
|
pendingIconUrls.clear()
|
||||||
firstPageDone = false
|
firstPageDone = false
|
||||||
fetcher = HistoryFetcher(account)
|
fetcher = HistoryFetcher(account)
|
||||||
// Restore cache immediately so data stays visible while refreshing
|
adapter.setTransactions(emptyList())
|
||||||
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
binding.emptyView.visibility = View.GONE
|
||||||
if (cached.isNotEmpty()) {
|
|
||||||
allTransactions.addAll(cached)
|
|
||||||
filterAndDisplay()
|
|
||||||
} else {
|
|
||||||
adapter.setTransactions(emptyList())
|
|
||||||
binding.emptyView.visibility = View.GONE
|
|
||||||
}
|
|
||||||
loadNextPage()
|
loadNextPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,20 +165,9 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val transactions = try {
|
val transactions = fetcher.fetchNextPage(app, pageSize)
|
||||||
fetcher.fetchNextPage(app, pageSize)
|
|
||||||
} catch (e: java.io.IOException) {
|
|
||||||
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
|
||||||
null
|
|
||||||
} catch (e: BankServerException) {
|
|
||||||
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_server_error, e.bankName))
|
|
||||||
null
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
if (_binding == null) return@launch
|
|
||||||
|
|
||||||
if (!firstPageDone) {
|
if (!firstPageDone) {
|
||||||
firstPageDone = true
|
firstPageDone = true
|
||||||
@@ -218,13 +175,6 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
binding.swipeRefresh.isRefreshing = false
|
binding.swipeRefresh.isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transactions == null) {
|
|
||||||
adapter.showLoadingFooter = false
|
|
||||||
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
(activity as? HomeActivity)?.hideConnectivityBanner()
|
|
||||||
|
|
||||||
if (transactions.isNotEmpty()) {
|
if (transactions.isNotEmpty()) {
|
||||||
val existingIds = allTransactions.map { it.id }.toHashSet()
|
val existingIds = allTransactions.map { it.id }.toHashSet()
|
||||||
val newOnes = transactions.filter { it.id !in existingIds }
|
val newOnes = transactions.filter { it.id !in existingIds }
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ package sh.sar.basedbank.ui.home
|
|||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import sh.sar.basedbank.R
|
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.databinding.ItemAccountBinding
|
import sh.sar.basedbank.databinding.ItemAccountBinding
|
||||||
import sh.sar.basedbank.databinding.ItemCardBinding
|
import sh.sar.basedbank.databinding.ItemCardBinding
|
||||||
@@ -19,11 +17,7 @@ import sh.sar.basedbank.util.AccountListParser
|
|||||||
|
|
||||||
class AccountsAdapter(
|
class AccountsAdapter(
|
||||||
accounts: List<BankAccount>,
|
accounts: List<BankAccount>,
|
||||||
private val onAccountClick: (BankAccount) -> Unit = {},
|
private val onAccountClick: (BankAccount) -> Unit = {}
|
||||||
/** Optional loader for MIB per-profile images: (hash, onLoaded) */
|
|
||||||
private val profileImageLoader: ((String, (Bitmap) -> Unit) -> Unit)? = null,
|
|
||||||
/** Optional loader for local (BML/Fahipay) profile images: (loginTag, profileId, onLoaded) */
|
|
||||||
private val localProfileImageLoader: ((String, String, (Bitmap) -> Unit) -> Unit)? = null
|
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
var onTransferClick: ((BankAccount) -> Unit)? = null
|
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||||
@@ -118,51 +112,17 @@ class AccountsAdapter(
|
|||||||
|
|
||||||
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
private var boundHash: String? = null
|
|
||||||
|
|
||||||
fun bind(account: BankAccount, display: AccountListDisplay) {
|
fun bind(account: BankAccount, display: AccountListDisplay) {
|
||||||
binding.tvAccountName.text = display.name
|
binding.tvAccountName.text = display.name
|
||||||
binding.tvAccountNumber.text = display.number
|
binding.tvAccountNumber.text = display.number
|
||||||
binding.tvAccountType.text = display.typeLabel
|
binding.tvAccountType.text = display.typeLabel
|
||||||
binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
||||||
val blocked = display.blockedBalance
|
|
||||||
if (blocked != null) {
|
|
||||||
val shown = if (hideAmounts) maskAmount(blocked) else blocked
|
|
||||||
binding.tvBlocked.text = binding.root.context.getString(R.string.account_blocked_label, shown)
|
|
||||||
binding.tvBlocked.visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
binding.tvBlocked.visibility = View.GONE
|
|
||||||
}
|
|
||||||
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||||
binding.root.setOnClickListener { onAccountClick(account) }
|
binding.root.setOnClickListener { onAccountClick(account) }
|
||||||
binding.root.setOnLongClickListener {
|
binding.root.setOnLongClickListener {
|
||||||
copyToClipboard(it.context, display.number)
|
copyToClipboard(it.context, display.number)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
val staticLogo = when (account.bank) {
|
|
||||||
"BML" -> R.drawable.bml_logo_vector
|
|
||||||
"FAHIPAY" -> R.drawable.fahipay_logo
|
|
||||||
"MIB" -> R.drawable.mib_logo
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
|
|
||||||
else binding.ivBankLogo.setImageDrawable(null)
|
|
||||||
|
|
||||||
val hash = account.profileImageHash
|
|
||||||
boundHash = hash
|
|
||||||
when {
|
|
||||||
account.bank == "MIB" && hash != null && profileImageLoader != null -> {
|
|
||||||
profileImageLoader.invoke(hash) { bitmap ->
|
|
||||||
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(account.bank == "BML" || account.bank == "FAHIPAY") && localProfileImageLoader != null -> {
|
|
||||||
localProfileImageLoader.invoke(account.loginTag, account.profileId) { bitmap ->
|
|
||||||
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -11,15 +8,9 @@ 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.lifecycle.lifecycleScope
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import sh.sar.basedbank.BasedBankApp
|
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentAccountsBinding
|
import sh.sar.basedbank.databinding.FragmentAccountsBinding
|
||||||
import sh.sar.basedbank.util.ProfileImageStore
|
|
||||||
|
|
||||||
class AccountsFragment : Fragment() {
|
class AccountsFragment : Fragment() {
|
||||||
|
|
||||||
@@ -27,7 +18,6 @@ class AccountsFragment : 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: AccountsAdapter
|
private lateinit var adapter: AccountsAdapter
|
||||||
private val profileImageCache = mutableMapOf<String, Bitmap>()
|
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentAccountsBinding.inflate(inflater, container, false)
|
_binding = FragmentAccountsBinding.inflate(inflater, container, false)
|
||||||
@@ -35,49 +25,9 @@ class AccountsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val app = requireActivity().application as BasedBankApp
|
adapter = AccountsAdapter(emptyList()) { account ->
|
||||||
adapter = AccountsAdapter(
|
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
||||||
accounts = emptyList(),
|
}
|
||||||
onAccountClick = { account ->
|
|
||||||
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
|
||||||
},
|
|
||||||
profileImageLoader = { hash, onLoaded ->
|
|
||||||
profileImageCache[hash]?.let { onLoaded(it); return@AccountsAdapter }
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
val bitmap = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val session = app.anyMibSession() ?: return@withContext null
|
|
||||||
val b64 = app.anyMibFlow()?.fetchProfileImage(session, hash) ?: return@withContext null
|
|
||||||
val bytes = Base64.decode(b64, Base64.DEFAULT)
|
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
if (bitmap != null) {
|
|
||||||
profileImageCache[hash] = bitmap
|
|
||||||
onLoaded(bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
localProfileImageLoader = { loginTag, profileId, onLoaded ->
|
|
||||||
val cacheKey = "$loginTag|$profileId"
|
|
||||||
profileImageCache[cacheKey]?.let { onLoaded(it); return@AccountsAdapter }
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
val bitmap = withContext(Dispatchers.IO) {
|
|
||||||
val ctx = requireContext()
|
|
||||||
if (loginTag.startsWith("bml_") && profileId.isNotBlank()) {
|
|
||||||
ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(profileId))
|
|
||||||
} else if (loginTag.startsWith("fahipay_")) {
|
|
||||||
val loginId = ProfileImageStore.loginIdFromTag(loginTag)
|
|
||||||
ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
|
||||||
} else null
|
|
||||||
}
|
|
||||||
if (bitmap != null) {
|
|
||||||
profileImageCache[cacheKey] = bitmap
|
|
||||||
onLoaded(bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
adapter.onTransferClick = { account ->
|
adapter.onTransferClick = { account ->
|
||||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
|
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
|
||||||
}
|
}
|
||||||
@@ -93,10 +43,7 @@ class AccountsFragment : Fragment() {
|
|||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) {
|
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
|
||||||
adapter.updateAccounts(it)
|
|
||||||
binding.emptyView.visibility = if (it.isEmpty()) View.VISIBLE else View.GONE
|
|
||||||
}
|
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
|
|
||||||
binding.swipeRefresh.setOnRefreshListener {
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
|||||||
@@ -1,403 +0,0 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.util.Base64
|
|
||||||
import android.view.Gravity
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.BaseAdapter
|
|
||||||
import android.widget.Filter
|
|
||||||
import android.widget.Filterable
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.core.widget.addTextChangedListener
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.fragment.app.activityViewModels
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import sh.sar.basedbank.BasedBankApp
|
|
||||||
import sh.sar.basedbank.R
|
|
||||||
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
|
||||||
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
|
||||||
import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding
|
|
||||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
|
||||||
import sh.sar.basedbank.util.RecentPick
|
|
||||||
import sh.sar.basedbank.util.RecentsCache
|
|
||||||
import sh.sar.basedbank.util.Totp
|
|
||||||
|
|
||||||
class BmlQrPayFragment : Fragment() {
|
|
||||||
|
|
||||||
private var _binding: FragmentBmlQrPayBinding? = null
|
|
||||||
private val binding get() = _binding!!
|
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
|
||||||
|
|
||||||
private var merchantInfo: BmlQrPayInfo? = null
|
|
||||||
private var selectedAccount: BankAccount? = null
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val ARG_QR_URL = "qr_url"
|
|
||||||
private const val ARG_FROM_ACCOUNT = "from_account"
|
|
||||||
|
|
||||||
fun newInstance(qrUrl: String, fromAccountNumber: String? = null) = BmlQrPayFragment().apply {
|
|
||||||
arguments = Bundle().apply {
|
|
||||||
putString(ARG_QR_URL, qrUrl)
|
|
||||||
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
|
||||||
): View {
|
|
||||||
_binding = FragmentBmlQrPayBinding.inflate(inflater, container, false)
|
|
||||||
return binding.root
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
setupFromDropdown()
|
|
||||||
|
|
||||||
binding.etAmount.addTextChangedListener { updatePayButton() }
|
|
||||||
|
|
||||||
binding.btnClearFromInfo.setOnClickListener {
|
|
||||||
selectedAccount = null
|
|
||||||
binding.cardFromInfo.visibility = View.GONE
|
|
||||||
binding.tilFrom.visibility = View.VISIBLE
|
|
||||||
binding.actvFrom.setText("", false)
|
|
||||||
updatePayButton()
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.btnPay.setOnClickListener { initiatePay() }
|
|
||||||
|
|
||||||
val qrUrl = arguments?.getString(ARG_QR_URL) ?: run {
|
|
||||||
requireActivity().onBackPressedDispatcher.onBackPressed(); return
|
|
||||||
}
|
|
||||||
lookupMerchant(qrUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupFromDropdown() {
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
|
||||||
val bmlAccounts = accounts.filter {
|
|
||||||
it.bank == "BML" &&
|
|
||||||
it.profileType != "BML_LOAN" &&
|
|
||||||
it.profileType != "BML_CREDIT"
|
|
||||||
}
|
|
||||||
val adapter = BmlAccountAdapter(bmlAccounts)
|
|
||||||
binding.actvFrom.setAdapter(adapter)
|
|
||||||
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
|
|
||||||
val picked = adapter.getItem(position) as? BankAccount ?: return@setOnItemClickListener
|
|
||||||
selectedAccount = picked
|
|
||||||
showFromCard(picked)
|
|
||||||
updatePayButton()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-select card passed in from the card wallet/dashboard
|
|
||||||
val preselect = arguments?.getString(ARG_FROM_ACCOUNT)
|
|
||||||
if (preselect != null && selectedAccount == null) {
|
|
||||||
bmlAccounts.firstOrNull { it.accountNumber == preselect }?.let {
|
|
||||||
selectedAccount = it
|
|
||||||
showFromCard(it)
|
|
||||||
updatePayButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showFromCard(account: BankAccount) {
|
|
||||||
binding.tvFromAccountName.text = account.accountBriefName
|
|
||||||
binding.tvFromAccountNumber.text = account.accountNumber
|
|
||||||
val currency = account.currencyName.ifBlank { "MVR" }
|
|
||||||
binding.tvFromBalance.text = "$currency ${account.availableBalance}"
|
|
||||||
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, "#0066A1"))
|
|
||||||
binding.tilFrom.visibility = View.GONE
|
|
||||||
binding.cardFromInfo.visibility = View.VISIBLE
|
|
||||||
|
|
||||||
// Update amount prefix to match account currency
|
|
||||||
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun lookupMerchant(qrUrl: String) {
|
|
||||||
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
|
||||||
val session = app.anyBmlSession() ?: run {
|
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
|
||||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.tvLookingUp.visibility = View.VISIBLE
|
|
||||||
binding.cardMerchant.visibility = View.GONE
|
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
val info = withContext(Dispatchers.IO) {
|
|
||||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
|
||||||
catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
if (_binding == null) return@launch
|
|
||||||
binding.tvLookingUp.visibility = View.GONE
|
|
||||||
if (info == null) {
|
|
||||||
Toast.makeText(requireContext(), R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
|
|
||||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
merchantInfo = info
|
|
||||||
if (info.amount == 0.0) {
|
|
||||||
val qrUrl = arguments?.getString(ARG_QR_URL)
|
|
||||||
if (qrUrl != null) {
|
|
||||||
RecentsCache.save(requireContext(), RecentPick(
|
|
||||||
accountNumber = "bmlqr:$qrUrl",
|
|
||||||
displayName = info.merchantName,
|
|
||||||
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
|
|
||||||
colorHex = "#0066A1",
|
|
||||||
imageHash = null,
|
|
||||||
isProfileImage = false
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
populateMerchant(info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun populateMerchant(info: BmlQrPayInfo) {
|
|
||||||
binding.tvMerchantName.text = info.merchantName
|
|
||||||
binding.tvMerchantAddress.text = info.merchantAddress
|
|
||||||
binding.ivMerchantIcon.setImageBitmap(makeInitialsBitmap(info.merchantName, "#0066A1"))
|
|
||||||
binding.cardMerchant.visibility = View.VISIBLE
|
|
||||||
|
|
||||||
// Dynamic QR: pre-fill amount and lock the field
|
|
||||||
if (info.amount > 0.0) {
|
|
||||||
binding.etAmount.setText("%.2f".format(info.amount))
|
|
||||||
binding.tilAmount.isEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePayButton()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updatePayButton() {
|
|
||||||
val merchant = merchantInfo ?: run { binding.btnPay.isEnabled = false; return }
|
|
||||||
val account = selectedAccount ?: run { binding.btnPay.isEnabled = false; return }
|
|
||||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
|
||||||
binding.btnPay.isEnabled = amount > 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initiatePay() {
|
|
||||||
val info = merchantInfo ?: return
|
|
||||||
val account = selectedAccount ?: run {
|
|
||||||
Toast.makeText(requireContext(), R.string.bml_qr_select_account, Toast.LENGTH_SHORT).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
|
||||||
val amount = amountStr.toDoubleOrNull()
|
|
||||||
if (amount == null || amount <= 0) {
|
|
||||||
binding.tilAmount.error = "Enter a valid amount"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
binding.tilAmount.error = null
|
|
||||||
|
|
||||||
val debitAccount = account.internalId.ifBlank {
|
|
||||||
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val currency = info.currency
|
|
||||||
|
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
|
||||||
.setTitle(R.string.transfer)
|
|
||||||
.setMessage("Pay $currency ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${account.accountBriefName} · ${account.accountNumber}")
|
|
||||||
.setPositiveButton(R.string.transfer_confirm) { _, _ ->
|
|
||||||
executePay(account, debitAccount, info.requestId, amount, currency, info.merchantName)
|
|
||||||
}
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun executePay(
|
|
||||||
account: BankAccount,
|
|
||||||
debitAccount: String,
|
|
||||||
requestId: String,
|
|
||||||
amount: Double,
|
|
||||||
currency: String,
|
|
||||||
merchantName: String
|
|
||||||
) {
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
|
||||||
val loginId = account.loginTag.removePrefix("bml_")
|
|
||||||
val session = app.bmlSessionFor(account) ?: run {
|
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
|
||||||
?.let { Totp.generate(it) }
|
|
||||||
?: run {
|
|
||||||
Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.btnPay.isEnabled = false
|
|
||||||
(activity as? HomeActivity)?.setRefreshing(true)
|
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
val result = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val initiated = BmlQrPayClient().initiatePayment(session, debitAccount, requestId, amount, currency)
|
|
||||||
if (!initiated) return@withContext null
|
|
||||||
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
|
||||||
?.let { Totp.generate(it) } ?: otp
|
|
||||||
BmlQrPayClient().confirmPayment(session, debitAccount, requestId, amount, currency, confirmOtp)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
|
||||||
|
|
||||||
if (_binding == null) return@launch
|
|
||||||
|
|
||||||
if (result == null) {
|
|
||||||
binding.btnPay.isEnabled = true
|
|
||||||
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
showSuccessDialog(
|
|
||||||
merchant = result.merchant.ifBlank { merchantName },
|
|
||||||
amount = result.amount.ifBlank { "%.2f".format(amount) },
|
|
||||||
currency = result.currency.ifBlank { currency }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
binding.btnPay.isEnabled = true
|
|
||||||
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showSuccessDialog(merchant: String, amount: String, currency: String) {
|
|
||||||
val ctx = requireContext()
|
|
||||||
val dp = resources.displayMetrics.density
|
|
||||||
|
|
||||||
val container = LinearLayout(ctx).apply {
|
|
||||||
orientation = LinearLayout.VERTICAL
|
|
||||||
gravity = Gravity.CENTER_HORIZONTAL
|
|
||||||
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Green checkmark icon
|
|
||||||
container.addView(ImageView(ctx).apply {
|
|
||||||
setImageResource(R.drawable.ic_check_circle)
|
|
||||||
setColorFilter(Color.parseColor("#4CAF50"))
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
|
||||||
(64 * dp).toInt(), (64 * dp).toInt()
|
|
||||||
).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = (16 * dp).toInt() }
|
|
||||||
})
|
|
||||||
|
|
||||||
// Amount
|
|
||||||
container.addView(TextView(ctx).apply {
|
|
||||||
text = "$currency $amount"
|
|
||||||
textSize = 28f
|
|
||||||
setTypeface(null, android.graphics.Typeface.BOLD)
|
|
||||||
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
|
||||||
requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK))
|
|
||||||
gravity = Gravity.CENTER
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = (8 * dp).toInt() }
|
|
||||||
})
|
|
||||||
|
|
||||||
// Merchant name
|
|
||||||
container.addView(TextView(ctx).apply {
|
|
||||||
text = merchant
|
|
||||||
textSize = 14f
|
|
||||||
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
|
||||||
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY))
|
|
||||||
gravity = Gravity.CENTER
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
).apply { gravity = Gravity.CENTER_HORIZONTAL }
|
|
||||||
})
|
|
||||||
|
|
||||||
MaterialAlertDialogBuilder(ctx)
|
|
||||||
.setTitle(R.string.bml_qr_payment_success)
|
|
||||||
.setView(container)
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
|
||||||
}
|
|
||||||
.setCancelable(false)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
|
||||||
val sizePx = (resources.displayMetrics.density * 40).toInt()
|
|
||||||
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
|
|
||||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
|
||||||
val canvas = Canvas(bm)
|
|
||||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
||||||
paint.color = bgColor
|
|
||||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
|
||||||
paint.color = Color.WHITE
|
|
||||||
paint.textSize = sizePx * 0.42f
|
|
||||||
paint.textAlign = Paint.Align.CENTER
|
|
||||||
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
|
||||||
val metrics = paint.fontMetrics
|
|
||||||
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
|
||||||
return bm
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
requireActivity().title = "BML QR Pay"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
super.onDestroyView()
|
|
||||||
_binding = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class BmlAccountAdapter(private val accounts: List<BankAccount>) :
|
|
||||||
BaseAdapter(), Filterable {
|
|
||||||
|
|
||||||
override fun getCount() = accounts.size
|
|
||||||
override fun getItem(position: Int) = accounts[position]
|
|
||||||
override fun getItemId(position: Int) = position.toLong()
|
|
||||||
|
|
||||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
|
|
||||||
getDropDownView(position, convertView, parent)
|
|
||||||
|
|
||||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
|
||||||
val acc = accounts[position]
|
|
||||||
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
|
|
||||||
convertView.tag as ItemAccountDropdownBinding
|
|
||||||
} else {
|
|
||||||
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
|
|
||||||
.also { it.root.tag = it }
|
|
||||||
}
|
|
||||||
val ownerPrefix = if (acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
|
||||||
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
|
||||||
b.tvDropdownAccountNumber.text = acc.accountNumber
|
|
||||||
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
|
|
||||||
b.root.alpha = 1f
|
|
||||||
return b.root
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getFilter() = object : Filter() {
|
|
||||||
override fun performFiltering(c: CharSequence?) =
|
|
||||||
FilterResults().apply { values = accounts; count = accounts.size }
|
|
||||||
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
|
|
||||||
override fun convertResultToString(r: Any?) =
|
|
||||||
(r as? BankAccount)?.let {
|
|
||||||
val prefix = if (it.profileName.isNotBlank()) "${it.profileName} · " else ""
|
|
||||||
"$prefix${it.accountBriefName}"
|
|
||||||
} ?: ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,139 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
// Merged into CardsFragment
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,467 +0,0 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
|
||||||
|
|
||||||
import android.animation.ValueAnimator
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.*
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.util.TypedValue
|
|
||||||
import android.view.*
|
|
||||||
import android.view.animation.DecelerateInterpolator
|
|
||||||
import android.animation.AnimatorListenerAdapter
|
|
||||||
import android.animation.Animator
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import android.graphics.Typeface
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import androidx.appcompat.content.res.AppCompatResources
|
|
||||||
import androidx.appcompat.widget.Toolbar
|
|
||||||
import androidx.core.graphics.drawable.DrawableCompat
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import com.google.android.material.color.MaterialColors
|
|
||||||
import sh.sar.basedbank.R
|
|
||||||
import kotlin.math.*
|
|
||||||
|
|
||||||
class CircularNavFragment : Fragment() {
|
|
||||||
|
|
||||||
private var wheelView: CircularWheelView? = null
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View {
|
|
||||||
val ctx = requireContext()
|
|
||||||
val colorPrimary = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorPrimary, Color.RED)
|
|
||||||
val colorSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, Color.WHITE)
|
|
||||||
val colorOnSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
|
|
||||||
|
|
||||||
fun dp(v: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, ctx.resources.displayMetrics)
|
|
||||||
|
|
||||||
val root = android.widget.LinearLayout(ctx).apply {
|
|
||||||
orientation = android.widget.LinearLayout.VERTICAL
|
|
||||||
setBackgroundColor(colorSurface)
|
|
||||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wheel area (weight 1, fills remaining space)
|
|
||||||
val wheelContainer = FrameLayout(ctx).apply {
|
|
||||||
layoutParams = android.widget.LinearLayout.LayoutParams(
|
|
||||||
android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
|
||||||
wheelView = CircularWheelView(ctx).apply {
|
|
||||||
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
|
||||||
wheelAngle = prefs.getFloat("circular_wheel_angle", 0f)
|
|
||||||
val savedSlots = NavCustomization.getCircularSlots(prefs).map { id ->
|
|
||||||
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == id }!!
|
|
||||||
CircularWheelView.WheelItem(def.id, def.iconRes, ctx.getString(def.titleRes))
|
|
||||||
}
|
|
||||||
items = listOf(
|
|
||||||
savedSlots[3], // 4 o'clock (strip slot 3)
|
|
||||||
CircularWheelView.WheelItem(R.id.nav_dashboard, R.drawable.ic_nav_dashboard, ctx.getString(R.string.nav_dashboard)), // 6 o'clock
|
|
||||||
CircularWheelView.WheelItem(R.id.nav_more, R.drawable.ic_nav_more, ctx.getString(R.string.nav_more)), // 8 o'clock
|
|
||||||
savedSlots[0], // 10 o'clock (strip slot 0 — first in strip)
|
|
||||||
savedSlots[1], // 12 o'clock (strip slot 1)
|
|
||||||
savedSlots[2], // 2 o'clock (strip slot 2)
|
|
||||||
)
|
|
||||||
accentColor = colorPrimary
|
|
||||||
surfaceColor = colorSurface
|
|
||||||
labelColor = colorOnSurface
|
|
||||||
onItemClick = { navId -> (activity as? HomeActivity)?.navigateTo(navId) }
|
|
||||||
onCenterClick = { (activity as? HomeActivity)?.lockApp() }
|
|
||||||
}
|
|
||||||
wheelContainer.addView(wheelView)
|
|
||||||
|
|
||||||
// App icon centered at the bottom
|
|
||||||
val iconSz = dp(48f).toInt()
|
|
||||||
val footerIcon = android.widget.ImageView(ctx).apply {
|
|
||||||
setImageDrawable(ctx.packageManager.getApplicationIcon(ctx.packageName))
|
|
||||||
layoutParams = android.widget.LinearLayout.LayoutParams(iconSz, iconSz).also {
|
|
||||||
it.gravity = Gravity.CENTER_HORIZONTAL
|
|
||||||
it.topMargin = dp(12f).toInt()
|
|
||||||
it.bottomMargin = dp(16f).toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
root.addView(wheelContainer)
|
|
||||||
root.addView(footerIcon)
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
requireActivity().invalidateOptionsMenu()
|
|
||||||
val ctx = requireContext()
|
|
||||||
val toolbar = requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
|
|
||||||
requireActivity().title = ""
|
|
||||||
|
|
||||||
val textColor = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.DKGRAY)
|
|
||||||
|
|
||||||
val container = android.widget.TextView(ctx).apply {
|
|
||||||
text = getString(R.string.app_name)
|
|
||||||
setTextColor(textColor)
|
|
||||||
textSize = 20f
|
|
||||||
typeface = Typeface.DEFAULT_BOLD
|
|
||||||
tag = "wheel_title"
|
|
||||||
}
|
|
||||||
|
|
||||||
toolbar.addView(container, Toolbar.LayoutParams(
|
|
||||||
Toolbar.LayoutParams.WRAP_CONTENT,
|
|
||||||
Toolbar.LayoutParams.WRAP_CONTENT,
|
|
||||||
Gravity.CENTER
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
super.onPause()
|
|
||||||
wheelView?.let { wv ->
|
|
||||||
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
|
||||||
.edit().putFloat("circular_wheel_angle", wv.wheelAngle).apply()
|
|
||||||
}
|
|
||||||
val toolbar = requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
|
|
||||||
toolbar.findViewWithTag<android.view.View>("wheel_title")?.let { toolbar.removeView(it) }
|
|
||||||
requireActivity().invalidateOptionsMenu()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Custom wheel view
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class CircularWheelView @JvmOverloads constructor(
|
|
||||||
context: Context,
|
|
||||||
attrs: AttributeSet? = null
|
|
||||||
) : View(context, attrs) {
|
|
||||||
|
|
||||||
data class WheelItem(
|
|
||||||
val navId: Int,
|
|
||||||
@DrawableRes val iconRes: Int,
|
|
||||||
val label: String
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- public properties ------------------------------------------------
|
|
||||||
|
|
||||||
var items: List<WheelItem> = emptyList()
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
iconBitmaps = arrayOfNulls(value.size)
|
|
||||||
if (cx > 0f) reloadBitmaps()
|
|
||||||
invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
var accentColor: Int = Color.RED
|
|
||||||
set(value) { field = value; if (cx > 0f) reloadBitmaps(); invalidate() }
|
|
||||||
|
|
||||||
var surfaceColor: Int = Color.WHITE
|
|
||||||
set(value) { field = value; invalidate() }
|
|
||||||
|
|
||||||
var labelColor: Int = Color.DKGRAY
|
|
||||||
set(value) { field = value; invalidate() }
|
|
||||||
|
|
||||||
var onItemClick: ((Int) -> Unit)? = null
|
|
||||||
var onCenterClick: (() -> Unit)? = null
|
|
||||||
|
|
||||||
// ---- geometry ---------------------------------------------------------
|
|
||||||
|
|
||||||
private var cx = 0f
|
|
||||||
private var cy = 0f
|
|
||||||
private var outerRadius = 0f
|
|
||||||
private var innerRadius = 0f
|
|
||||||
|
|
||||||
// ---- paint ------------------------------------------------------------
|
|
||||||
|
|
||||||
private val discPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
||||||
private val accentRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
|
||||||
private val accentRing2Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
|
||||||
private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
|
||||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
textAlign = Paint.Align.CENTER
|
|
||||||
typeface = Typeface.DEFAULT_BOLD
|
|
||||||
}
|
|
||||||
private val centerFillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
||||||
private val centerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
|
||||||
private val bitmapPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
||||||
|
|
||||||
private var iconBitmaps: Array<Bitmap?> = emptyArray()
|
|
||||||
private var centerBitmap: Bitmap? = null
|
|
||||||
|
|
||||||
// ---- touch & fling ----------------------------------------------------
|
|
||||||
|
|
||||||
var wheelAngle = 0f
|
|
||||||
private var isDragging = false
|
|
||||||
private var snapAnimator: ValueAnimator? = null
|
|
||||||
|
|
||||||
// Incremental drag state
|
|
||||||
private var prevTouchAngle = 0f
|
|
||||||
private var touchDownX = 0f
|
|
||||||
private var touchDownY = 0f
|
|
||||||
|
|
||||||
// Velocity buffer: stores (cumulative wheel angle, timestamp) for last N samples
|
|
||||||
private val VEL_SAMPLES = 6
|
|
||||||
private val velAngles = FloatArray(VEL_SAMPLES)
|
|
||||||
private val velTimes = LongArray(VEL_SAMPLES)
|
|
||||||
private var velIdx = 0
|
|
||||||
private var velCount = 0
|
|
||||||
|
|
||||||
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
|
|
||||||
|
|
||||||
// ---- helpers ----------------------------------------------------------
|
|
||||||
|
|
||||||
private fun dp(v: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, resources.displayMetrics)
|
|
||||||
|
|
||||||
// ---- sizing -----------------------------------------------------------
|
|
||||||
|
|
||||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
||||||
cx = w / 2f
|
|
||||||
cy = h / 2f
|
|
||||||
val size = minOf(w, h)
|
|
||||||
outerRadius = size / 2f * 0.80f
|
|
||||||
innerRadius = outerRadius * 0.26f
|
|
||||||
|
|
||||||
textPaint.textSize = size * 0.034f
|
|
||||||
dividerPaint.strokeWidth = dp(0.7f)
|
|
||||||
accentRingPaint.strokeWidth = dp(5f)
|
|
||||||
accentRing2Paint.strokeWidth = dp(3f)
|
|
||||||
centerRingPaint.strokeWidth = dp(4f)
|
|
||||||
|
|
||||||
reloadBitmaps()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reloadBitmaps() {
|
|
||||||
val iconPx = (outerRadius * 0.24f).toInt().coerceAtLeast(1)
|
|
||||||
items.forEachIndexed { i, item ->
|
|
||||||
iconBitmaps[i] = tintedBitmap(item.iconRes, accentColor, iconPx)
|
|
||||||
}
|
|
||||||
val centerPx = (innerRadius * 0.64f).toInt().coerceAtLeast(1)
|
|
||||||
centerBitmap = tintedBitmap(R.drawable.ic_lock, accentColor, centerPx)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tintedBitmap(@DrawableRes resId: Int, tint: Int, sizePx: Int): Bitmap? {
|
|
||||||
if (sizePx <= 0) return null
|
|
||||||
return try {
|
|
||||||
val d = AppCompatResources.getDrawable(context, resId)!!.mutate()
|
|
||||||
DrawableCompat.setTint(DrawableCompat.wrap(d), tint)
|
|
||||||
val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
|
||||||
Canvas(bmp).also { d.setBounds(0, 0, sizePx, sizePx); d.draw(it) }
|
|
||||||
bmp
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- drawing ----------------------------------------------------------
|
|
||||||
|
|
||||||
override fun onDraw(canvas: Canvas) {
|
|
||||||
if (items.isEmpty()) return
|
|
||||||
|
|
||||||
val segCount = items.size
|
|
||||||
val segDeg = 360f / segCount
|
|
||||||
|
|
||||||
// Wheel disc
|
|
||||||
discPaint.color = surfaceColor
|
|
||||||
canvas.drawCircle(cx, cy, outerRadius, discPaint)
|
|
||||||
|
|
||||||
// Accent ring around wheel
|
|
||||||
accentRingPaint.color = accentColor
|
|
||||||
canvas.drawCircle(cx, cy, outerRadius + dp(20f), accentRingPaint)
|
|
||||||
|
|
||||||
// Rotatable layer
|
|
||||||
canvas.save()
|
|
||||||
canvas.rotate(wheelAngle, cx, cy)
|
|
||||||
|
|
||||||
// Divider lines between segments
|
|
||||||
dividerPaint.color = (labelColor and 0x00FFFFFF) or (100 shl 24)
|
|
||||||
for (i in 0 until segCount) {
|
|
||||||
val rad = Math.toRadians((i * segDeg).toDouble())
|
|
||||||
val cos = cos(rad).toFloat()
|
|
||||||
val sin = sin(rad).toFloat()
|
|
||||||
canvas.drawLine(
|
|
||||||
cx + cos * (innerRadius + dp(6f)), cy + sin * (innerRadius + dp(6f)),
|
|
||||||
cx + cos * (outerRadius - dp(12f)), cy + sin * (outerRadius - dp(12f)),
|
|
||||||
dividerPaint
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Segment content
|
|
||||||
for (i in 0 until segCount) {
|
|
||||||
val midDeg = i * segDeg + segDeg / 2f
|
|
||||||
drawSegment(canvas, i, midDeg)
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.restore()
|
|
||||||
|
|
||||||
// Center button — always upright
|
|
||||||
centerRingPaint.color = accentColor
|
|
||||||
canvas.drawCircle(cx, cy, innerRadius + dp(3f), centerRingPaint)
|
|
||||||
centerFillPaint.color = surfaceColor
|
|
||||||
canvas.drawCircle(cx, cy, innerRadius, centerFillPaint)
|
|
||||||
centerBitmap?.let {
|
|
||||||
canvas.drawBitmap(it, cx - it.width / 2f, cy - it.height / 2f, bitmapPaint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun drawSegment(canvas: Canvas, index: Int, midDeg: Float) {
|
|
||||||
val rad = Math.toRadians(midDeg.toDouble())
|
|
||||||
val cosA = cos(rad).toFloat()
|
|
||||||
val sinA = sin(rad).toFloat()
|
|
||||||
|
|
||||||
val iconX = cx + cosA * (outerRadius * 0.63f)
|
|
||||||
val iconY = cy + sinA * (outerRadius * 0.63f)
|
|
||||||
val textX = cx + cosA * (outerRadius * 0.84f)
|
|
||||||
val textY = cy + sinA * (outerRadius * 0.84f)
|
|
||||||
|
|
||||||
// Icon — radially oriented; top items are naturally upside-down
|
|
||||||
iconBitmaps.getOrNull(index)?.let { bmp ->
|
|
||||||
canvas.save()
|
|
||||||
canvas.translate(iconX, iconY)
|
|
||||||
canvas.rotate(midDeg - 90f)
|
|
||||||
canvas.drawBitmap(bmp, -bmp.width / 2f, -bmp.height / 2f, bitmapPaint)
|
|
||||||
canvas.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Label — same radial rotation
|
|
||||||
textPaint.color = labelColor
|
|
||||||
canvas.save()
|
|
||||||
canvas.translate(textX, textY)
|
|
||||||
canvas.rotate(midDeg - 90f)
|
|
||||||
canvas.drawText(items[index].label, 0f, textPaint.textSize * 0.36f, textPaint)
|
|
||||||
canvas.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- touch ------------------------------------------------------------
|
|
||||||
|
|
||||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
||||||
when (event.actionMasked) {
|
|
||||||
MotionEvent.ACTION_DOWN -> {
|
|
||||||
snapAnimator?.cancel()
|
|
||||||
prevTouchAngle = angleAt(event.x, event.y)
|
|
||||||
touchDownX = event.x
|
|
||||||
touchDownY = event.y
|
|
||||||
isDragging = false
|
|
||||||
velIdx = 0
|
|
||||||
velCount = 0
|
|
||||||
recordVelSample()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
MotionEvent.ACTION_MOVE -> {
|
|
||||||
val curr = angleAt(event.x, event.y)
|
|
||||||
// Incremental delta — normalised to [-180, 180] to survive the ±180° wrap
|
|
||||||
var dA = curr - prevTouchAngle
|
|
||||||
if (dA > 180f) dA -= 360f
|
|
||||||
if (dA < -180f) dA += 360f
|
|
||||||
prevTouchAngle = curr
|
|
||||||
|
|
||||||
val moved = hypot(event.x - touchDownX, event.y - touchDownY)
|
|
||||||
if (moved > touchSlop || isDragging) {
|
|
||||||
isDragging = true
|
|
||||||
wheelAngle += dA
|
|
||||||
recordVelSample()
|
|
||||||
invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
|
||||||
if (!isDragging) {
|
|
||||||
val dist = hypot(event.x - cx, event.y - cy)
|
|
||||||
when {
|
|
||||||
dist <= innerRadius -> onCenterClick?.invoke()
|
|
||||||
dist <= outerRadius -> {
|
|
||||||
val idx = segmentAt(event.x, event.y)
|
|
||||||
if (idx in items.indices) animateToSixOClock(idx) { onItemClick?.invoke(items[idx].navId) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val vel = computeVelocity() // degrees per millisecond
|
|
||||||
if (abs(vel) > 0.05f) fling(vel) else snapToNearest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun recordVelSample() {
|
|
||||||
val slot = velIdx % VEL_SAMPLES
|
|
||||||
velAngles[slot] = wheelAngle
|
|
||||||
velTimes[slot] = System.currentTimeMillis()
|
|
||||||
velIdx++
|
|
||||||
if (velCount < VEL_SAMPLES) velCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns angular velocity in degrees per millisecond, using the oldest available sample. */
|
|
||||||
private fun computeVelocity(): Float {
|
|
||||||
if (velCount < 2) return 0f
|
|
||||||
val newest = (velIdx - 1 + VEL_SAMPLES) % VEL_SAMPLES
|
|
||||||
// Use the sample that is ~100 ms old for a stable estimate
|
|
||||||
val oldest = (velIdx - velCount + VEL_SAMPLES) % VEL_SAMPLES
|
|
||||||
val dt = velTimes[newest] - velTimes[oldest]
|
|
||||||
if (dt <= 0L) return 0f
|
|
||||||
return (velAngles[newest] - velAngles[oldest]) / dt
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kick off a physics-based fling: uniform deceleration from [initialVel] to zero,
|
|
||||||
* then snap to the nearest segment.
|
|
||||||
* Formula: total_rotation = v0² / (2 * DECEL), duration = v0 / DECEL
|
|
||||||
* With DecelerateInterpolator(1) the initial animation velocity matches v0.
|
|
||||||
*/
|
|
||||||
private fun fling(initialVel: Float) {
|
|
||||||
val DECEL = 0.0008f // deg / ms² — tune for feel
|
|
||||||
val duration = (abs(initialVel) / DECEL).toLong().coerceIn(200, 3500)
|
|
||||||
val sign = if (initialVel >= 0f) 1f else -1f
|
|
||||||
val totalRot = sign * initialVel * initialVel / (2f * DECEL)
|
|
||||||
val startAngle = wheelAngle
|
|
||||||
val endAngle = startAngle + totalRot
|
|
||||||
|
|
||||||
snapAnimator = ValueAnimator.ofFloat(startAngle, endAngle).apply {
|
|
||||||
this.duration = duration
|
|
||||||
interpolator = DecelerateInterpolator() // matches v0 at t=0
|
|
||||||
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
|
|
||||||
addListener(object : AnimatorListenerAdapter() {
|
|
||||||
override fun onAnimationEnd(a: Animator) { snapToNearest() }
|
|
||||||
})
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun angleAt(x: Float, y: Float): Float =
|
|
||||||
Math.toDegrees(atan2((y - cy).toDouble(), (x - cx).toDouble())).toFloat()
|
|
||||||
|
|
||||||
private fun segmentAt(x: Float, y: Float): Int {
|
|
||||||
var a = angleAt(x, y) - wheelAngle
|
|
||||||
a = (a % 360f + 360f) % 360f
|
|
||||||
return (a / (360f / items.size)).toInt() % items.size
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun animateToSixOClock(index: Int, onDone: () -> Unit) {
|
|
||||||
val segDeg = 360f / items.size.coerceAtLeast(1)
|
|
||||||
val midDeg = index * segDeg + segDeg / 2f
|
|
||||||
// delta needed so this segment's midpoint lands at 90° (6 o'clock in math coords)
|
|
||||||
var delta = (90f - midDeg) - wheelAngle
|
|
||||||
// normalise to shortest path [-180, 180]
|
|
||||||
delta = ((delta % 360f) + 360f) % 360f
|
|
||||||
if (delta > 180f) delta -= 360f
|
|
||||||
val endAngle = wheelAngle + delta
|
|
||||||
|
|
||||||
snapAnimator?.cancel()
|
|
||||||
snapAnimator = ValueAnimator.ofFloat(wheelAngle, endAngle).apply {
|
|
||||||
duration = 350
|
|
||||||
interpolator = DecelerateInterpolator()
|
|
||||||
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
|
|
||||||
addListener(object : AnimatorListenerAdapter() {
|
|
||||||
private var cancelled = false
|
|
||||||
override fun onAnimationCancel(a: Animator) { cancelled = true }
|
|
||||||
override fun onAnimationEnd(a: Animator) { if (!cancelled) onDone() }
|
|
||||||
})
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun snapToNearest() {
|
|
||||||
val segDeg = 360f / items.size.coerceAtLeast(1)
|
|
||||||
val target = (wheelAngle / segDeg).roundToInt() * segDeg
|
|
||||||
snapAnimator = ValueAnimator.ofFloat(wheelAngle, target).apply {
|
|
||||||
duration = 300
|
|
||||||
interpolator = DecelerateInterpolator()
|
|
||||||
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,9 +31,7 @@ class ContactPickerAdapter(
|
|||||||
val isSameAsFrom: Boolean = false,
|
val isSameAsFrom: Boolean = false,
|
||||||
val isManualEntry: Boolean = false,
|
val isManualEntry: Boolean = false,
|
||||||
val imageHash: String? = null,
|
val imageHash: String? = null,
|
||||||
val inactiveReason: String? = null,
|
val inactiveReason: String? = null
|
||||||
val balance: String? = null,
|
|
||||||
val bankLogoRes: Int? = null
|
|
||||||
) : PickerItem()
|
) : PickerItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,31 +89,14 @@ class ContactPickerAdapter(
|
|||||||
binding.tvPrimary.text = item.displayName
|
binding.tvPrimary.text = item.displayName
|
||||||
binding.tvSecondary.text = item.subtitle
|
binding.tvSecondary.text = item.subtitle
|
||||||
|
|
||||||
if (item.balance != null) {
|
|
||||||
binding.tvBalance.text = item.balance
|
|
||||||
binding.tvBalance.visibility = android.view.View.VISIBLE
|
|
||||||
} else {
|
|
||||||
binding.tvBalance.visibility = android.view.View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
val cached = item.imageHash?.let { imageCache[it] }
|
val cached = item.imageHash?.let { imageCache[it] }
|
||||||
when {
|
if (cached != null) {
|
||||||
cached != null -> {
|
binding.ivIcon.setImageBitmap(cached)
|
||||||
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
} else {
|
||||||
binding.ivIcon.setImageBitmap(cached)
|
val iconChar = if (item.isManualEntry) "→" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
}
|
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex
|
||||||
item.bankLogoRes != null -> {
|
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
|
||||||
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
||||||
binding.ivIcon.setImageResource(item.bankLogoRes)
|
|
||||||
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
|
||||||
val iconChar = if (item.isManualEntry) "→" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
|
||||||
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex
|
|
||||||
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
|
|
||||||
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.root.alpha = if (item.isSameAsFrom || item.inactiveReason != null) 0.4f else 1.0f
|
binding.root.alpha = if (item.isSameAsFrom || item.inactiveReason != null) 0.4f else 1.0f
|
||||||
|
|||||||
@@ -24,11 +24,7 @@ import sh.sar.basedbank.BasedBankApp
|
|||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.databinding.SheetContactPickerBinding
|
import sh.sar.basedbank.databinding.SheetContactPickerBinding
|
||||||
import sh.sar.basedbank.util.AccountListParser
|
|
||||||
import sh.sar.basedbank.util.ProfileImageStore
|
|
||||||
import sh.sar.basedbank.util.RecentsCache
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
|
||||||
|
|
||||||
class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
@@ -151,7 +147,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||||
|
|
||||||
(activity as? HomeActivity)?.triggerRefresh()
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attachMediator(pages: List<TabDef>) {
|
private fun attachMediator(pages: List<TabDef>) {
|
||||||
@@ -168,10 +164,6 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
|
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
|
||||||
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
|
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
|
||||||
when {
|
when {
|
||||||
accountNumber.startsWith("bmlqr:") -> {
|
|
||||||
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
|
|
||||||
bundle.putString(KEY_COLOR, "#0066A1")
|
|
||||||
}
|
|
||||||
account != null -> {
|
account != null -> {
|
||||||
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
|
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
|
||||||
bundle.putString(KEY_COLOR, "#FE860E")
|
bundle.putString(KEY_COLOR, "#FE860E")
|
||||||
@@ -233,29 +225,17 @@ 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 parsedBalance = AccountListParser.from(acc)?.balance
|
val accBal = if (hide) "••••••" else acc.availableBalance
|
||||||
?: "${acc.currencyName} ${acc.availableBalance}"
|
|
||||||
val balance = if (hide) maskAmount(parsedBalance) else parsedBalance
|
|
||||||
val logoRes = when (acc.bank) {
|
|
||||||
"BML" -> R.drawable.bml_logo_vector
|
|
||||||
"FAHIPAY" -> R.drawable.fahipay_logo
|
|
||||||
"MIB" -> R.drawable.mib_logo
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
val localKey = localImageKeyFor(acc)
|
|
||||||
if (localKey != null) profileImageHashes.add("local:$localKey")
|
|
||||||
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,
|
subtitle = "${acc.accountNumber} · ${acc.currencyName} $accBal",
|
||||||
colorHex = "#FE860E",
|
colorHex = "#FE860E",
|
||||||
isSameAsFrom = isSame,
|
isSameAsFrom = isSame,
|
||||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
imageHash = acc.profileImageHash,
|
||||||
inactiveReason = if (isSame) null
|
inactiveReason = if (isSame) null
|
||||||
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
||||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
else currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||||
balance = balance,
|
|
||||||
bankLogoRes = logoRes
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,26 +249,18 @@ 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 isDebit = acc.profileType == "BML_DEBIT"
|
val cardBal = if (hide) "••••••" else acc.availableBalance
|
||||||
val parsedBalance = if (isDebit) null
|
|
||||||
else AccountListParser.from(acc)?.balance ?: "${acc.currencyName} ${acc.availableBalance}"
|
|
||||||
val balance = parsedBalance?.let { if (hide) maskAmount(it) else it }
|
|
||||||
val logoRes = BmlCardParser.cardNetworkIcon(acc) ?: R.drawable.bml_logo_vector
|
|
||||||
val localKey = localImageKeyFor(acc)
|
|
||||||
if (localKey != null) profileImageHashes.add("local:$localKey")
|
|
||||||
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,
|
subtitle = "${acc.accountNumber} · ${acc.currencyName} $cardBal",
|
||||||
colorHex = "#FE860E",
|
colorHex = "#FE860E",
|
||||||
isSameAsFrom = isSame,
|
isSameAsFrom = isSame,
|
||||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
imageHash = acc.profileImageHash,
|
||||||
inactiveReason = if (isSame) null
|
inactiveReason = if (isSame) null
|
||||||
else if (!isActive) acc.statusDesc
|
else if (!isActive) acc.statusDesc
|
||||||
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
||||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
else currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||||
balance = balance,
|
|
||||||
bankLogoRes = logoRes
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,17 +291,6 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
private fun fetchImage(hash: String) {
|
private fun fetchImage(hash: String) {
|
||||||
if (!pendingHashes.add(hash)) return
|
if (!pendingHashes.add(hash)) return
|
||||||
// Local image keys for BML/Fahipay (prefixed with "local:")
|
|
||||||
if (hash.startsWith("local:")) {
|
|
||||||
val key = hash.removePrefix("local:")
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
val bitmap = ProfileImageStore.load(requireContext(), key) ?: run {
|
|
||||||
pendingHashes.remove(hash); return@launch
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.Main) { pagerAdapter.updateImage(hash, bitmap) }
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val sess = session ?: return
|
val sess = session ?: return
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
@@ -357,16 +318,6 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
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
|
||||||
|
|
||||||
/** Returns the ProfileImageStore key for BML/Fahipay accounts, or null for MIB/others. */
|
|
||||||
private fun localImageKeyFor(acc: sh.sar.basedbank.api.models.BankAccount): String? = when (acc.bank) {
|
|
||||||
"BML" -> if (acc.profileId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId) else null
|
|
||||||
"FAHIPAY" -> {
|
|
||||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
|
|
||||||
if (loginId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId) else null
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
_binding = null
|
_binding = 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)
|
||||||
|
|
||||||
@@ -135,8 +136,8 @@ class ContactsFragment : Fragment() {
|
|||||||
(activity as? HomeActivity)?.loadAllContacts()
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
|
|
||||||
binding.swipeRefresh.setOnRefreshListener {
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
(activity as? HomeActivity)?.triggerRefresh()
|
contactsRefreshing = true
|
||||||
binding.swipeRefresh.isRefreshing = false
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
||||||
@@ -148,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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +183,7 @@ class ContactsFragment : Fragment() {
|
|||||||
colorHex = contact.bankColor,
|
colorHex = contact.bankColor,
|
||||||
imageHash = contact.imageHash
|
imageHash = contact.imageHash
|
||||||
)
|
)
|
||||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmDelete(contact: ContactDisplay) {
|
private fun confirmDelete(contact: ContactDisplay) {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -25,8 +22,6 @@ import sh.sar.basedbank.api.models.BankAccount
|
|||||||
import sh.sar.basedbank.api.mib.MibCard
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
|
||||||
import sh.sar.basedbank.util.PaymvQrParser
|
|
||||||
import kotlin.math.abs
|
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
|
||||||
@@ -36,36 +31,6 @@ class DashboardFragment : Fragment() {
|
|||||||
private var _binding: FragmentDashboardBinding? = null
|
private var _binding: FragmentDashboardBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
private var pendingQrCardNumber: String? = null
|
|
||||||
|
|
||||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
|
||||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
|
||||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
|
||||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
val qr = PaymvQrParser.parse(raw)
|
|
||||||
if (qr?.accountNumber != null) {
|
|
||||||
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
|
|
||||||
val defaultFrom = CredentialStore(requireContext()).getDefaultAccountNumber()
|
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
|
||||||
R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
|
||||||
accountNumber = qr.accountNumber,
|
|
||||||
displayName = qr.merchantName ?: qr.accountNumber,
|
|
||||||
amount = qr.amount,
|
|
||||||
remarks = qr.purpose,
|
|
||||||
fromAccountNumber = defaultFrom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
|
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
|
||||||
@@ -73,24 +38,14 @@ class DashboardFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) {
|
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
|
||||||
updateBalances(it)
|
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances() }
|
||||||
updateAttentionRow()
|
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { updatePendingFinances() }
|
||||||
}
|
|
||||||
viewModel.financing.observe(viewLifecycleOwner) {
|
|
||||||
updatePendingFinances()
|
|
||||||
updateAttentionRow()
|
|
||||||
}
|
|
||||||
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) {
|
|
||||||
updatePendingFinances()
|
|
||||||
updateAttentionRow()
|
|
||||||
}
|
|
||||||
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()
|
updatePendingFinances()
|
||||||
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
|
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
|
||||||
updateAttentionRow()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.swipeRefresh.setOnRefreshListener {
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
@@ -102,39 +57,26 @@ class DashboardFragment : Fragment() {
|
|||||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.cardOverdue.setOnClickListener {
|
|
||||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
|
||||||
}
|
|
||||||
|
|
||||||
val cardAdapter = DashboardCardAdapter()
|
val cardAdapter = DashboardCardAdapter()
|
||||||
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||||
binding.rvCards.adapter = cardAdapter
|
binding.rvCards.adapter = cardAdapter
|
||||||
LinearSnapHelper().attachToRecyclerView(binding.rvCards)
|
LinearSnapHelper().attachToRecyclerView(binding.rvCards)
|
||||||
|
|
||||||
val updateCardList = {
|
val updateCardList = {
|
||||||
val credStore = CredentialStore(requireContext())
|
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
|
||||||
val hidden = credStore.getHiddenDashboardCardNumbers()
|
|
||||||
val mibItems = (viewModel.mibCards.value ?: emptyList())
|
|
||||||
.filter { !hidden.contains(it.maskedCardNumber) }
|
|
||||||
.map { CardItem.Mib(it) }
|
|
||||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) }
|
||||||
.map { CardItem.Bml(it) }
|
.map { CardItem.Bml(it) }
|
||||||
val all = mibItems + bmlItems
|
val all = mibItems + bmlItems
|
||||||
val defaultNum = credStore.getDefaultCardAccountNumber()
|
cardAdapter.update(all)
|
||||||
val ordered = if (defaultNum != null) {
|
binding.sectionCards.visibility = if (all.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
|
||||||
if (def != null) listOf(def) + all.filter { it !== def } else all
|
|
||||||
} else all
|
|
||||||
cardAdapter.update(ordered)
|
|
||||||
binding.sectionCards.visibility = if (ordered.isNotEmpty()) View.VISIBLE else View.GONE
|
|
||||||
}
|
}
|
||||||
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
||||||
viewModel.accounts.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 = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
@@ -145,37 +87,12 @@ class DashboardFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
val isBottom = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
|
requireActivity().title = getString(R.string.nav_dashboard)
|
||||||
if (isBottom) {
|
|
||||||
requireActivity().title = getString(R.string.app_name)
|
|
||||||
val size = (28 * resources.displayMetrics.density).toInt()
|
|
||||||
val gap = (8 * resources.displayMetrics.density).toInt()
|
|
||||||
val icon = requireContext().packageManager.getApplicationIcon(requireContext().packageName)
|
|
||||||
val bmp = android.graphics.Bitmap.createBitmap(size + gap, size, android.graphics.Bitmap.Config.ARGB_8888)
|
|
||||||
val canvas = android.graphics.Canvas(bmp)
|
|
||||||
icon.setBounds(0, 0, size, size)
|
|
||||||
icon.draw(canvas)
|
|
||||||
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo =
|
|
||||||
android.graphics.drawable.BitmapDrawable(resources, bmp)
|
|
||||||
} else {
|
|
||||||
requireActivity().title = getString(R.string.nav_dashboard)
|
|
||||||
}
|
|
||||||
refreshQuickActions()
|
refreshQuickActions()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
super.onPause()
|
|
||||||
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshQuickActions() {
|
private fun refreshQuickActions() {
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
val isBottom = NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
|
|
||||||
if (isBottom) {
|
|
||||||
binding.buttonBar.visibility = View.GONE
|
|
||||||
return
|
|
||||||
}
|
|
||||||
binding.buttonBar.visibility = View.VISIBLE
|
|
||||||
val ids = NavCustomization.getQuickActions(prefs)
|
val ids = NavCustomization.getQuickActions(prefs)
|
||||||
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
|
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
|
||||||
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
|
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
|
||||||
@@ -308,52 +225,6 @@ class DashboardFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateAttentionRow() {
|
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
|
||||||
val accounts = viewModel.accounts.value ?: emptyList()
|
|
||||||
|
|
||||||
// Blocked: sum across CASA-style accounts (exclude cards and loans) per currency.
|
|
||||||
val blockedByCurrency = accounts
|
|
||||||
.filter { it.profileType != "BML_CREDIT" && it.profileType != "BML_PREPAID" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" }
|
|
||||||
.mapNotNull { acc ->
|
|
||||||
val v = acc.blockedAmount.replace(",", "").toDoubleOrNull() ?: 0.0
|
|
||||||
if (v > 0.0) acc.currencyName.uppercase() to v else null
|
|
||||||
}
|
|
||||||
.groupBy({ it.first }, { it.second })
|
|
||||||
.mapValues { (_, vs) -> vs.sum() }
|
|
||||||
|
|
||||||
val blockedMvr = blockedByCurrency["MVR"] ?: 0.0
|
|
||||||
val blockedUsd = blockedByCurrency["USD"] ?: 0.0
|
|
||||||
val blockedTotal = blockedByCurrency.values.sum()
|
|
||||||
|
|
||||||
if (blockedMvr > 0.0) {
|
|
||||||
binding.tvBlockedMvr.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(blockedMvr)
|
|
||||||
binding.cardBlockedMvr.visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
binding.cardBlockedMvr.visibility = View.GONE
|
|
||||||
}
|
|
||||||
if (blockedUsd > 0.0) {
|
|
||||||
binding.tvBlockedUsd.text = if (hide) "USD ••••••" else "USD %,.2f".format(blockedUsd)
|
|
||||||
binding.cardBlockedUsd.visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
binding.cardBlockedUsd.visibility = View.GONE
|
|
||||||
}
|
|
||||||
binding.rowBlocked.visibility = if (blockedTotal > 0.0) View.VISIBLE else View.GONE
|
|
||||||
|
|
||||||
// Overdue: MIB finance deals + BML loan details (assumed MVR — matches existing Pending Finances).
|
|
||||||
val mibOverdue = (viewModel.financing.value ?: emptyList()).sumOf { it.overdueAmount }
|
|
||||||
val bmlOverdue = (viewModel.bmlLoanDetails.value ?: emptyMap()).values.sumOf { it.overdueAmount }
|
|
||||||
val overdueTotal = mibOverdue + bmlOverdue
|
|
||||||
if (overdueTotal > 0.0) {
|
|
||||||
binding.tvOverdueTotal.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(overdueTotal)
|
|
||||||
binding.cardOverdue.visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
binding.cardOverdue.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.rowAttention.visibility = if (overdueTotal > 0.0) View.VISIBLE else View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updatePendingFinances() {
|
private fun updatePendingFinances() {
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
|
val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
|
||||||
@@ -397,41 +268,31 @@ class DashboardFragment : Fragment() {
|
|||||||
when (item) {
|
when (item) {
|
||||||
is CardItem.Mib -> {
|
is CardItem.Mib -> {
|
||||||
tvCardOwner.text = item.card.cardHolderName
|
tvCardOwner.text = item.card.cardHolderName
|
||||||
tvCardNumber.text = CardsFragment.formatMasked(item.card.maskedCardNumber)
|
tvCardNumber.text = PayWithCardFragment.formatMasked(item.card.maskedCardNumber)
|
||||||
val assetPath = CardsFragment.cardImageAsset(item.card)
|
val assetPath = PayWithCardFragment.cardImageAsset(item.card)
|
||||||
if (assetPath != null) CardsFragment.loadCardImage(ivCardImage, assetPath)
|
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
|
||||||
else ivCardImage.setImageDrawable(null)
|
else ivCardImage.setImageDrawable(null)
|
||||||
CardsFragment.bindCardStatus(tvCardStatus, CardsFragment.mibCardStatusLabel(item.card.cardStatus))
|
PayWithCardFragment.bindCardStatus(tvCardStatus, PayWithCardFragment.mibCardStatusLabel(item.card.cardStatus))
|
||||||
}
|
}
|
||||||
is CardItem.Bml -> {
|
is CardItem.Bml -> {
|
||||||
tvCardOwner.text = item.account.accountBriefName
|
tvCardOwner.text = item.account.accountBriefName
|
||||||
tvCardNumber.text = CardsFragment.formatMasked(item.account.accountNumber)
|
tvCardNumber.text = PayWithCardFragment.formatMasked(item.account.accountNumber)
|
||||||
CardsFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
PayWithCardFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||||
CardsFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
|
PayWithCardFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val isMib = item is CardItem.Mib
|
|
||||||
btnPayQr.setOnClickListener {
|
btnPayQr.setOnClickListener {
|
||||||
if (isMib) {
|
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
|
||||||
} else {
|
|
||||||
pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
|
|
||||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(requireContext())
|
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(requireContext())
|
||||||
val nfcSupported = nfcAdapter != null
|
val nfcSupported = nfcAdapter != null
|
||||||
btnPayNfc.isEnabled = nfcSupported
|
btnPayNfc.isEnabled = nfcSupported
|
||||||
btnPayNfc.setOnClickListener {
|
if (nfcSupported) {
|
||||||
if (isMib) {
|
btnPayNfc.setOnClickListener {
|
||||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
|
||||||
val accountNumber = (item as CardItem.Bml).account.accountNumber
|
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
|
||||||
R.id.nav_pay_with_card,
|
|
||||||
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
btnPayNfc.setOnClickListener(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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 latestMibDeals: List<MibFinanceDeal> = emptyList()
|
||||||
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
|
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
|
||||||
@@ -45,8 +46,8 @@ class FinancingFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.swipeRefresh.setOnRefreshListener {
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
(activity as? HomeActivity)?.triggerRefresh()
|
financingRefreshing = true
|
||||||
binding.swipeRefresh.isRefreshing = false
|
(activity as? HomeActivity)?.triggerRefreshFinancing()
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
|
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
|
||||||
@@ -73,6 +74,10 @@ class FinancingFragment : Fragment() {
|
|||||||
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||||
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
|
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
|
||||||
binding.loadingView.visibility = View.GONE
|
binding.loadingView.visibility = View.GONE
|
||||||
|
if (financingRefreshing) {
|
||||||
|
financingRefreshing = false
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ import sh.sar.basedbank.util.CredentialStore
|
|||||||
import sh.sar.basedbank.util.CardsCache
|
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
|
||||||
import sh.sar.basedbank.util.ThemeHelper
|
|
||||||
|
|
||||||
class HomeActivity : AppCompatActivity() {
|
class HomeActivity : AppCompatActivity() {
|
||||||
|
|
||||||
@@ -76,7 +75,6 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
private val viewModel: HomeViewModel by viewModels()
|
private val viewModel: HomeViewModel by viewModels()
|
||||||
private lateinit var toggle: ActionBarDrawerToggle
|
private lateinit var toggle: ActionBarDrawerToggle
|
||||||
private var suppressBottomNavCallback = false
|
private var suppressBottomNavCallback = false
|
||||||
private var cachedTransferFragment: TransferFragment? = null
|
|
||||||
|
|
||||||
private var backPressedOnce = false
|
private var backPressedOnce = false
|
||||||
private val backPressHandler = Handler(Looper.getMainLooper())
|
private val backPressHandler = Handler(Looper.getMainLooper())
|
||||||
@@ -99,8 +97,6 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
if (securitySet) lock()
|
if (securitySet) lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun lockApp() = lock()
|
|
||||||
|
|
||||||
private fun lock() {
|
private fun lock() {
|
||||||
isLocked = true
|
isLocked = true
|
||||||
startActivity(
|
startActivity(
|
||||||
@@ -110,7 +106,6 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
ThemeHelper.applyAccent(this)
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityHomeBinding.inflate(layoutInflater)
|
binding = ActivityHomeBinding.inflate(layoutInflater)
|
||||||
@@ -123,21 +118,6 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
isAppearanceLightStatusBars = isLight
|
isAppearanceLightStatusBars = isLight
|
||||||
isAppearanceLightNavigationBars = isLight
|
isAppearanceLightNavigationBars = isLight
|
||||||
}
|
}
|
||||||
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
|
||||||
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
|
||||||
ta.recycle()
|
|
||||||
// Auth guard: HomeActivity must only be reachable after LockActivity or fresh login.
|
|
||||||
// Using loadSecurityHash() (EncryptedSharedPreferences) instead of plain prefs so
|
|
||||||
// a rooted device cannot bypass this by editing security_method in plain prefs.
|
|
||||||
val app = application as BasedBankApp
|
|
||||||
if (CredentialStore(this).loadSecurityHash() != null && !app.isUnlocked) {
|
|
||||||
startActivity(
|
|
||||||
android.content.Intent(this, sh.sar.basedbank.LockActivity::class.java)
|
|
||||||
)
|
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSupportActionBar(binding.toolbar)
|
setSupportActionBar(binding.toolbar)
|
||||||
|
|
||||||
toggle = ActionBarDrawerToggle(
|
toggle = ActionBarDrawerToggle(
|
||||||
@@ -159,7 +139,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
R.id.nav_dashboard -> DashboardFragment()
|
R.id.nav_dashboard -> DashboardFragment()
|
||||||
R.id.nav_accounts -> AccountsFragment()
|
R.id.nav_accounts -> AccountsFragment()
|
||||||
R.id.nav_contacts -> ContactsFragment()
|
R.id.nav_contacts -> ContactsFragment()
|
||||||
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
R.id.nav_transfer -> TransferFragment()
|
||||||
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||||
R.id.nav_more -> MoreFragment()
|
R.id.nav_more -> MoreFragment()
|
||||||
R.id.nav_activities -> ActivitiesFragment()
|
R.id.nav_activities -> ActivitiesFragment()
|
||||||
@@ -167,7 +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 -> CardsFragment()
|
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)
|
||||||
@@ -184,6 +165,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load data
|
// Load data
|
||||||
|
val app = application as BasedBankApp
|
||||||
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
|
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
|
||||||
// Came from fresh manual login — accounts ready, rest fetched in background
|
// Came from fresh manual login — accounts ready, rest fetched in background
|
||||||
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
|
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
|
||||||
@@ -236,36 +218,10 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
viewModel.hideAmounts.value = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_amounts", false)
|
viewModel.hideAmounts.value = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_amounts", false)
|
||||||
|
|
||||||
// Show dashboard on first create, or navigate to shortcut destination
|
// Show dashboard on first create
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
val navDest = intent.getIntExtra("nav_destination", -1)
|
show(DashboardFragment())
|
||||||
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
||||||
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
|
||||||
val shareQrText = intent.getStringExtra("share_qr_text")
|
|
||||||
when {
|
|
||||||
shareQrText != null -> {
|
|
||||||
show(DashboardFragment())
|
|
||||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
|
||||||
routeSharedQrText(shareQrText)
|
|
||||||
}
|
|
||||||
navDest != -1 -> {
|
|
||||||
val fragment = when {
|
|
||||||
autoScan && navDest == R.id.nav_transfer -> TransferFragment.newInstanceWithAutoScan()
|
|
||||||
autoTapMode && navDest == R.id.nav_pay_with_card -> CardsFragment.newInstanceWithAutoTapMode()
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
navigateTo(navDest, fragment)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val initPrefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
|
||||||
if (NavCustomization.getNavMode(initPrefs) == NavCustomization.NAV_MODE_CIRCULAR) {
|
|
||||||
show(CircularNavFragment())
|
|
||||||
} else {
|
|
||||||
show(DashboardFragment())
|
|
||||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
@@ -275,41 +231,15 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
binding.drawerLayout.closeDrawers()
|
binding.drawerLayout.closeDrawers()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Let CardsFragment handle back if in manage mode
|
|
||||||
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
|
||||||
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return
|
|
||||||
|
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
|
||||||
val navMode = NavCustomization.getNavMode(prefs)
|
|
||||||
|
|
||||||
// Circular nav mode: back always returns to the wheel
|
|
||||||
if (navMode == NavCustomization.NAV_MODE_CIRCULAR) {
|
|
||||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
|
||||||
supportFragmentManager.popBackStack()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (currentFrag is CircularNavFragment) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
show(CircularNavFragment())
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
|
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
|
||||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||||
supportFragmentManager.popBackStack()
|
supportFragmentManager.popBackStack()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// In bottom nav mode, pressing back navigates up the hierarchy
|
// In bottom nav mode, pressing back navigates up the hierarchy
|
||||||
if (navMode == NavCustomization.NAV_MODE_BOTTOM && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
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
|
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
|
||||||
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
|
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
|
||||||
show(MoreFragment())
|
show(MoreFragment())
|
||||||
@@ -367,28 +297,19 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
fun applyNavMode() {
|
fun applyNavMode() {
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
when (NavCustomization.getNavMode(prefs)) {
|
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||||
NavCustomization.NAV_MODE_BOTTOM -> {
|
if (isBottom) {
|
||||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||||
toggle.isDrawerIndicatorEnabled = false
|
toggle.isDrawerIndicatorEnabled = false
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||||
binding.bottomNavigation.visibility = View.VISIBLE
|
binding.bottomNavigation.visibility = View.VISIBLE
|
||||||
rebuildBottomNav(prefs)
|
rebuildBottomNav(prefs)
|
||||||
applyNavLabelVisibility()
|
applyNavLabelVisibility()
|
||||||
}
|
} else {
|
||||||
NavCustomization.NAV_MODE_CIRCULAR -> {
|
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
|
||||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
toggle.isDrawerIndicatorEnabled = true
|
||||||
toggle.isDrawerIndicatorEnabled = false
|
toggle.syncState()
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
binding.bottomNavigation.visibility = View.GONE
|
||||||
binding.bottomNavigation.visibility = View.GONE
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
supportActionBar?.show()
|
|
||||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
|
|
||||||
toggle.isDrawerIndicatorEnabled = true
|
|
||||||
toggle.syncState()
|
|
||||||
binding.bottomNavigation.visibility = View.GONE
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,23 +348,19 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun navigateTo(itemId: Int, fragment: Fragment? = null) {
|
fun navigateTo(itemId: Int, fragment: Fragment? = null) {
|
||||||
// Restore action bar when leaving the circular wheel screen
|
|
||||||
if (NavCustomization.getNavMode(getSharedPreferences("prefs", MODE_PRIVATE)) == NavCustomization.NAV_MODE_CIRCULAR) {
|
|
||||||
supportActionBar?.show()
|
|
||||||
}
|
|
||||||
val dest = fragment ?: when (itemId) {
|
val dest = fragment ?: when (itemId) {
|
||||||
R.id.nav_dashboard -> DashboardFragment()
|
R.id.nav_dashboard -> DashboardFragment()
|
||||||
R.id.nav_accounts -> AccountsFragment()
|
R.id.nav_accounts -> AccountsFragment()
|
||||||
R.id.nav_contacts -> ContactsFragment()
|
R.id.nav_contacts -> ContactsFragment()
|
||||||
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
R.id.nav_transfer -> TransferFragment()
|
||||||
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||||
R.id.nav_activities -> ActivitiesFragment()
|
R.id.nav_activities -> ActivitiesFragment()
|
||||||
R.id.nav_transfer_history -> TransferHistoryFragment()
|
R.id.nav_transfer_history -> TransferHistoryFragment()
|
||||||
R.id.nav_finances -> FinancingFragment()
|
R.id.nav_finances -> FinancingFragment()
|
||||||
R.id.nav_otp -> OtpFragment()
|
R.id.nav_otp -> OtpFragment()
|
||||||
R.id.nav_settings -> SettingsFragment()
|
R.id.nav_settings -> SettingsFragment()
|
||||||
R.id.nav_pay_with_card -> CardsFragment()
|
R.id.nav_pay_with_card -> PayWithCardFragment()
|
||||||
R.id.nav_more -> MoreFragment()
|
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)
|
||||||
@@ -461,8 +378,8 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setBottomNavVisible(visible: Boolean) {
|
fun setBottomNavVisible(visible: Boolean) {
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
if (NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM) {
|
if (isBottom) {
|
||||||
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
|
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,27 +408,6 @@ fun applyNavLabelVisibility() {
|
|||||||
.commit()
|
.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun routeSharedQrText(text: String) {
|
|
||||||
val store = CredentialStore(this)
|
|
||||||
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
|
|
||||||
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
|
||||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
|
|
||||||
if (qr?.accountNumber != null) {
|
|
||||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
|
||||||
accountNumber = qr.accountNumber,
|
|
||||||
displayName = qr.merchantName ?: qr.accountNumber,
|
|
||||||
amount = qr.amount,
|
|
||||||
remarks = qr.purpose,
|
|
||||||
fromAccountNumber = store.getDefaultAccountNumber()
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Toast.makeText(this, R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// Returning from LockActivity — refresh sessions since they may have expired.
|
// Returning from LockActivity — refresh sessions since they may have expired.
|
||||||
@@ -598,20 +494,14 @@ fun applyNavLabelVisibility() {
|
|||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
menuInflater.inflate(R.menu.toolbar_menu, menu)
|
menuInflater.inflate(R.menu.toolbar_menu, menu)
|
||||||
|
val eyeEnabled = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_sensitive_info", false)
|
||||||
val eyeItem = menu.findItem(R.id.action_hide_amounts)
|
val eyeItem = menu.findItem(R.id.action_hide_amounts)
|
||||||
eyeItem?.isVisible = true
|
eyeItem?.isVisible = eyeEnabled
|
||||||
val hidden = viewModel.hideAmounts.value ?: false
|
val hidden = viewModel.hideAmounts.value ?: false
|
||||||
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
|
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
|
||||||
val onWheel = supportFragmentManager.findFragmentById(R.id.contentFrame) is CircularNavFragment
|
|
||||||
menu.findItem(R.id.action_hide_amounts)?.isVisible = !onWheel
|
|
||||||
menu.findItem(R.id.action_lock)?.isVisible = !onWheel
|
|
||||||
return super.onPrepareOptionsMenu(menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
||||||
val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
|
val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
|
||||||
@@ -659,12 +549,12 @@ fun applyNavLabelVisibility() {
|
|||||||
autoRefresh(store)
|
autoRefresh(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showConnectivityBanner(message: String) {
|
private fun showConnectivityBanner(message: String) {
|
||||||
binding.connectivityBanner.text = message
|
binding.connectivityBanner.text = message
|
||||||
binding.connectivityBanner.visibility = View.VISIBLE
|
binding.connectivityBanner.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hideConnectivityBanner() {
|
private fun hideConnectivityBanner() {
|
||||||
binding.connectivityBanner.visibility = View.GONE
|
binding.connectivityBanner.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,23 +7,8 @@ import sh.sar.basedbank.R
|
|||||||
|
|
||||||
object NavCustomization {
|
object NavCustomization {
|
||||||
|
|
||||||
const val NAV_MODE_DRAWER = "drawer"
|
|
||||||
const val NAV_MODE_BOTTOM = "bottom"
|
|
||||||
const val NAV_MODE_CIRCULAR = "circular"
|
|
||||||
|
|
||||||
fun getNavMode(prefs: SharedPreferences): String {
|
|
||||||
val explicit = prefs.getString("nav_mode", null)
|
|
||||||
if (explicit != null) return explicit
|
|
||||||
return if (prefs.getBoolean("bottom_nav", false)) NAV_MODE_BOTTOM else NAV_MODE_DRAWER
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveNavMode(prefs: SharedPreferences, mode: String) {
|
|
||||||
prefs.edit().putString("nav_mode", mode).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class NavItemDef(
|
data class NavItemDef(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val key: String,
|
|
||||||
@DrawableRes val iconRes: Int,
|
@DrawableRes val iconRes: Int,
|
||||||
@StringRes val titleRes: Int,
|
@StringRes val titleRes: Int,
|
||||||
@StringRes val descriptionRes: Int
|
@StringRes val descriptionRes: Int
|
||||||
@@ -31,76 +16,48 @@ object NavCustomization {
|
|||||||
|
|
||||||
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
||||||
val ALL_SWAPPABLE = listOf(
|
val ALL_SWAPPABLE = listOf(
|
||||||
NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
|
NavItemDef(R.id.nav_accounts, R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
|
||||||
NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
|
NavItemDef(R.id.nav_contacts, R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
|
||||||
NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
|
NavItemDef(R.id.nav_transfer, R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
|
||||||
NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
|
NavItemDef(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
|
||||||
NavItemDef(R.id.nav_activities, "nav_activities", R.drawable.ic_nav_activities, R.string.nav_activities, R.string.nav_desc_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, "nav_transfer_history", R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history, R.string.nav_desc_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, "nav_finances", R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances),
|
NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances),
|
||||||
NavItemDef(R.id.nav_pay_with_card, "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_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, "nav_otp", R.drawable.ic_nav_otp, R.string.nav_otp, R.string.nav_desc_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, "nav_settings", R.drawable.ic_nav_settings, R.string.nav_settings, R.string.nav_desc_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),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun keyToId(key: String?, default: Int) =
|
|
||||||
ALL_SWAPPABLE.find { it.key == key }?.id ?: default
|
|
||||||
|
|
||||||
private fun idToKey(id: Int) =
|
|
||||||
ALL_SWAPPABLE.find { it.id == id }?.key
|
|
||||||
|
|
||||||
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||||
keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts),
|
prefs.getInt("bottom_nav_slot_1", R.id.nav_accounts),
|
||||||
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_contacts),
|
prefs.getInt("bottom_nav_slot_2", R.id.nav_contacts),
|
||||||
keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
|
prefs.getInt("bottom_nav_slot_3", R.id.nav_transfer),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
|
.putInt("bottom_nav_slot_1", slots[0])
|
||||||
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_contacts")
|
.putInt("bottom_nav_slot_2", slots[1])
|
||||||
.putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
|
.putInt("bottom_nav_slot_3", slots[2])
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf(
|
fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf(
|
||||||
keyToId(prefs.getString("quick_action_1_key", null), R.id.nav_transfer),
|
prefs.getInt("quick_action_1", R.id.nav_transfer),
|
||||||
keyToId(prefs.getString("quick_action_2_key", null), R.id.nav_pay_mv_qr),
|
prefs.getInt("quick_action_2", R.id.nav_pay_mv_qr),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) {
|
fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("quick_action_1_key", idToKey(ids[0]) ?: "nav_transfer")
|
.putInt("quick_action_1", ids[0])
|
||||||
.putString("quick_action_2_key", idToKey(ids[1]) ?: "nav_pay_mv_qr")
|
.putInt("quick_action_2", ids[1])
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
|
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
|
||||||
fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf(
|
|
||||||
keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer),
|
|
||||||
keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card),
|
|
||||||
keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_contacts),
|
|
||||||
keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun saveCircularSlots(prefs: SharedPreferences, slots: List<Int>) {
|
|
||||||
prefs.edit()
|
|
||||||
.putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer")
|
|
||||||
.putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card")
|
|
||||||
.putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_contacts")
|
|
||||||
.putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts")
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
|
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
|
||||||
if (getNavMode(prefs) == NAV_MODE_CIRCULAR) return getCircularMoreItems(prefs)
|
|
||||||
val slots = getSlots(prefs).toSet()
|
val slots = getSlots(prefs).toSet()
|
||||||
return ALL_SWAPPABLE.filter { it.id !in slots }
|
return ALL_SWAPPABLE.filter { it.id !in slots }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Items shown in More when circular nav is active — everything not in the saved wheel slots. */
|
|
||||||
private fun getCircularMoreItems(prefs: SharedPreferences): List<NavItemDef> {
|
|
||||||
val slotIds = getCircularSlots(prefs).toSet()
|
|
||||||
return ALL_SWAPPABLE.filter { it.id !in slotIds }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@@ -47,16 +42,6 @@ class OtpFragment : Fragment() {
|
|||||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||||
holder.b.tvOtpLabel.text = entries[position].label
|
holder.b.tvOtpLabel.text = entries[position].label
|
||||||
update(holder.b, entries[position].seed)
|
update(holder.b, entries[position].seed)
|
||||||
holder.b.root.setOnClickListener {
|
|
||||||
val code = holder.b.tvOtpCode.text.toString().replace(" ", "")
|
|
||||||
if (code.isNotEmpty()) {
|
|
||||||
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tick() {
|
fun tick() {
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
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.*
|
import android.widget.*
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.content.res.AppCompatResources
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
@@ -29,15 +32,11 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import sh.sar.basedbank.BasedBankApp
|
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
|
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
|
||||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
import sh.sar.basedbank.util.AccountListParser
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
@@ -50,7 +49,23 @@ class PayMvQrFragment : Fragment() {
|
|||||||
private var selectedAccount: BankAccount? = null
|
private var selectedAccount: BankAccount? = null
|
||||||
private var generatedBitmap: Bitmap? = null
|
private var generatedBitmap: Bitmap? = null
|
||||||
private var generateJob: Job? = null
|
private var generateJob: Job? = null
|
||||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
|
||||||
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
val qr = PaymvQrParser.parse(raw)
|
||||||
|
if (qr == null || qr.accountNumber == null) {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
activity.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
||||||
|
accountNumber = qr.accountNumber,
|
||||||
|
displayName = qr.merchantName ?: qr.accountNumber,
|
||||||
|
amount = qr.amount,
|
||||||
|
remarks = qr.purpose
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||||
@@ -70,23 +85,19 @@ class PayMvQrFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
setupDropdown()
|
setupDropdown()
|
||||||
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
||||||
binding.etReference.addTextChangedListener { scheduleGenerate() }
|
|
||||||
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
|
|
||||||
binding.btnShare.isEnabled = false
|
binding.btnShare.isEnabled = false
|
||||||
binding.btnSave.isEnabled = false
|
binding.btnSave.isEnabled = false
|
||||||
binding.btnShare.setOnClickListener { shareQr() }
|
binding.btnShare.setOnClickListener { shareQr() }
|
||||||
binding.btnSave.setOnClickListener { saveQr() }
|
binding.btnSave.setOnClickListener { saveQr() }
|
||||||
binding.btnScanQr.setOnClickListener {
|
binding.btnScanQr.setOnClickListener {
|
||||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceWithAutoScan())
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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_DEBIT" && it.profileType != "BML_LOAN" &&
|
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN"
|
||||||
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
|
|
||||||
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
|
|
||||||
}
|
}
|
||||||
val adapter = QrAccountAdapter(requireContext(), eligible)
|
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||||
binding.actvAccount.setAdapter(adapter)
|
binding.actvAccount.setAdapter(adapter)
|
||||||
@@ -95,20 +106,6 @@ class PayMvQrFragment : Fragment() {
|
|||||||
selectedAccount = picked
|
selectedAccount = picked
|
||||||
scheduleGenerate()
|
scheduleGenerate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-select default account if none is selected yet
|
|
||||||
if (selectedAccount == null) {
|
|
||||||
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
|
||||||
if (defaultNum != null) {
|
|
||||||
val defaultAcc = eligible.firstOrNull { it.accountNumber == defaultNum }
|
|
||||||
if (defaultAcc != null) {
|
|
||||||
selectedAccount = defaultAcc
|
|
||||||
val prefix = if (defaultAcc.bank == "BML" && defaultAcc.profileName.isNotBlank()) "${defaultAcc.profileName} · " else ""
|
|
||||||
binding.actvAccount.setText("$prefix${defaultAcc.accountBriefName}", false)
|
|
||||||
scheduleGenerate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,28 +132,8 @@ class PayMvQrFragment : Fragment() {
|
|||||||
?.let { "%.2f".format(it) }
|
?.let { "%.2f".format(it) }
|
||||||
|
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val includePhone = binding.switchIncludePhone.isChecked
|
|
||||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
|
||||||
val store = CredentialStore(ctx)
|
|
||||||
val mobile = if (includePhone) {
|
|
||||||
when (account.bank) {
|
|
||||||
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
|
|
||||||
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
|
|
||||||
else -> null
|
|
||||||
}?.let { m ->
|
|
||||||
when {
|
|
||||||
m.startsWith("+") -> m
|
|
||||||
m.length == 7 -> "+960$m"
|
|
||||||
else -> m
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else null
|
|
||||||
|
|
||||||
val purpose = binding.etReference.text?.toString()?.trim()
|
|
||||||
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
|
|
||||||
|
|
||||||
val bmp = withContext(Dispatchers.Default) {
|
val bmp = withContext(Dispatchers.Default) {
|
||||||
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose)
|
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted)
|
||||||
renderQrCard(ctx, account, payload, amountFormatted)
|
renderQrCard(ctx, account, payload, amountFormatted)
|
||||||
}
|
}
|
||||||
if (_binding == null) return
|
if (_binding == null) return
|
||||||
@@ -174,9 +151,7 @@ class PayMvQrFragment : Fragment() {
|
|||||||
accountNumber: String,
|
accountNumber: String,
|
||||||
accountName: String,
|
accountName: String,
|
||||||
acquirer: String,
|
acquirer: String,
|
||||||
amountStr: String?,
|
amountStr: String?
|
||||||
mobile: String?,
|
|
||||||
purpose: String
|
|
||||||
): String {
|
): String {
|
||||||
fun tlv(tag: String, value: String): String {
|
fun tlv(tag: String, value: String): String {
|
||||||
val len = value.length
|
val len = value.length
|
||||||
@@ -186,30 +161,17 @@ class PayMvQrFragment : Fragment() {
|
|||||||
val poi = tlv("01", "11")
|
val poi = tlv("01", "11")
|
||||||
val sub00 = tlv("00", "mv.favara.mpqr")
|
val sub00 = tlv("00", "mv.favara.mpqr")
|
||||||
val sub01 = tlv("01", acquirer)
|
val sub01 = tlv("01", acquirer)
|
||||||
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
|
|
||||||
val sub03 = tlv("03", accountNumber)
|
val sub03 = tlv("03", accountNumber)
|
||||||
val sub05 = if (!mobile.isNullOrBlank()) tlv("05", mobile) else ""
|
|
||||||
val sub10 = tlv("10", "IPAY")
|
val sub10 = tlv("10", "IPAY")
|
||||||
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
|
val merchantAcct = tlv("26", sub00 + sub01 + sub03 + sub10)
|
||||||
val mcc = tlv("52", "0000")
|
|
||||||
val currency = tlv("53", "462")
|
val currency = tlv("53", "462")
|
||||||
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
|
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
|
||||||
val country = tlv("58", "MV")
|
val country = tlv("58", "MV")
|
||||||
val name = tlv("59", accountName.take(25))
|
val name = tlv("59", accountName.take(25))
|
||||||
val ref = generateReference()
|
val prefix = format + poi + merchantAcct + currency + amountTLV + country + name + "6304"
|
||||||
val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose))
|
|
||||||
val timestamp = java.time.LocalDateTime.now()
|
|
||||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
|
|
||||||
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
|
|
||||||
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304"
|
|
||||||
return prefix + crc16(prefix)
|
return prefix + crc16(prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateReference(): String {
|
|
||||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
||||||
return (1..9).map { chars.random() }.joinToString("")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun crc16(data: String): String {
|
private fun crc16(data: String): String {
|
||||||
var crc = 0xFFFF
|
var crc = 0xFFFF
|
||||||
for (c in data) {
|
for (c in data) {
|
||||||
@@ -440,105 +402,9 @@ class PayMvQrFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||||
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||||
|
|
||||||
val displayData = AccountListParser.from(acc)
|
|
||||||
val typeLabel = displayData?.typeLabel
|
|
||||||
?: if (acc.bank == "BML") BmlDashboardParser.productLabel(acc.accountTypeName)
|
|
||||||
else acc.accountTypeName.trim()
|
|
||||||
b.tvDropdownAccountNumber.text = acc.accountNumber
|
b.tvDropdownAccountNumber.text = acc.accountNumber
|
||||||
if (typeLabel.isNotBlank()) {
|
b.tvDropdownBalance.text = ""
|
||||||
b.tvDropdownAccountType.text = typeLabel
|
|
||||||
b.tvDropdownAccountType.visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
b.tvDropdownAccountType.visibility = View.GONE
|
|
||||||
}
|
|
||||||
b.tvDropdownBalance.visibility = View.GONE
|
|
||||||
b.root.alpha = 1f
|
b.root.alpha = 1f
|
||||||
|
|
||||||
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
|
|
||||||
when {
|
|
||||||
networkIcon != null -> {
|
|
||||||
b.ivDropdownCardLogo.setImageResource(networkIcon)
|
|
||||||
b.ivDropdownCardLogo.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
acc.bank == "BML" -> {
|
|
||||||
val localKey = sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId)
|
|
||||||
val cachedLocal = dropdownProfileImageCache[localKey]
|
|
||||||
val imageView = b.ivDropdownCardLogo
|
|
||||||
imageView.tag = localKey
|
|
||||||
if (cachedLocal != null) {
|
|
||||||
imageView.setImageBitmap(cachedLocal)
|
|
||||||
} else {
|
|
||||||
imageView.setImageResource(R.drawable.bml_logo_vector)
|
|
||||||
if (acc.profileId.isNotBlank()) {
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
val bitmap = withContext(Dispatchers.IO) {
|
|
||||||
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
|
||||||
}
|
|
||||||
if (bitmap != null) {
|
|
||||||
dropdownProfileImageCache[localKey] = bitmap
|
|
||||||
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
imageView.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
acc.bank == "FAHIPAY" -> {
|
|
||||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
|
|
||||||
val localKey = sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId)
|
|
||||||
val cachedLocal = dropdownProfileImageCache[localKey]
|
|
||||||
val imageView = b.ivDropdownCardLogo
|
|
||||||
imageView.tag = localKey
|
|
||||||
if (cachedLocal != null) {
|
|
||||||
imageView.setImageBitmap(cachedLocal)
|
|
||||||
} else {
|
|
||||||
imageView.setImageResource(R.drawable.fahipay_logo)
|
|
||||||
if (loginId.isNotBlank()) {
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
val bitmap = withContext(Dispatchers.IO) {
|
|
||||||
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
|
||||||
}
|
|
||||||
if (bitmap != null) {
|
|
||||||
dropdownProfileImageCache[localKey] = bitmap
|
|
||||||
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
imageView.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
acc.bank == "MIB" -> {
|
|
||||||
val hash = acc.profileImageHash
|
|
||||||
val cached = hash?.let { dropdownProfileImageCache[it] }
|
|
||||||
val imageView = b.ivDropdownCardLogo
|
|
||||||
imageView.tag = hash
|
|
||||||
if (cached != null) {
|
|
||||||
imageView.setImageBitmap(cached)
|
|
||||||
} else {
|
|
||||||
imageView.setImageResource(R.drawable.mib_logo)
|
|
||||||
if (hash != null) {
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
val bitmap = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val sess = app.anyMibSession() ?: return@withContext null
|
|
||||||
val b64 = app.anyMibFlow()?.fetchProfileImage(sess, hash) ?: return@withContext null
|
|
||||||
val bytes = android.util.Base64.decode(b64, android.util.Base64.DEFAULT)
|
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
if (bitmap != null) {
|
|
||||||
dropdownProfileImageCache[hash] = bitmap
|
|
||||||
if (imageView.tag == hash) imageView.setImageBitmap(bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
imageView.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
else -> b.ivDropdownCardLogo.visibility = View.GONE
|
|
||||||
}
|
|
||||||
return b.root
|
return b.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ import android.provider.Settings
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import android.content.res.ColorStateList
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.drawable.Animatable
|
|
||||||
import android.view.ScaleGestureDetector
|
|
||||||
import androidx.appcompat.content.res.AppCompatResources
|
|
||||||
import androidx.camera.core.Camera
|
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
@@ -37,10 +31,8 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import sh.sar.basedbank.BasedBankApp
|
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.ActivityQrScannerBinding
|
import sh.sar.basedbank.databinding.ActivityQrScannerBinding
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
class QrScannerActivity : AppCompatActivity() {
|
class QrScannerActivity : AppCompatActivity() {
|
||||||
@@ -60,8 +52,6 @@ class QrScannerActivity : AppCompatActivity() {
|
|||||||
textMode = ZxingCpp.TextMode.PLAIN
|
textMode = ZxingCpp.TextMode.PLAIN
|
||||||
)
|
)
|
||||||
|
|
||||||
private var camera: Camera? = null
|
|
||||||
private var torchEnabled = false
|
|
||||||
private var cameraStarted = false
|
private var cameraStarted = false
|
||||||
|
|
||||||
private val permissionLauncher = registerForActivityResult(
|
private val permissionLauncher = registerForActivityResult(
|
||||||
@@ -97,14 +87,6 @@ class QrScannerActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
if (CredentialStore(this).loadSecurityHash() != null &&
|
|
||||||
!(application as BasedBankApp).isUnlocked) {
|
|
||||||
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java))
|
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
@@ -122,36 +104,8 @@ class QrScannerActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
|
binding.btnCancel.setOnClickListener { finish() }
|
||||||
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
|
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
|
||||||
binding.zoomSlider.addOnChangeListener { _, value, fromUser ->
|
|
||||||
if (fromUser) camera?.cameraControl?.setLinearZoom(value)
|
|
||||||
}
|
|
||||||
val scaleDetector = ScaleGestureDetector(this,
|
|
||||||
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
|
||||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
|
||||||
val state = camera?.cameraInfo?.zoomState?.value ?: return true
|
|
||||||
camera?.cameraControl?.setZoomRatio(
|
|
||||||
(state.zoomRatio * detector.scaleFactor)
|
|
||||||
.coerceIn(state.minZoomRatio, state.maxZoomRatio)
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
binding.previewView.setOnTouchListener { _, event ->
|
|
||||||
scaleDetector.onTouchEvent(event)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
binding.btnFlashlight.setOnClickListener {
|
|
||||||
torchEnabled = !torchEnabled
|
|
||||||
camera?.cameraControl?.enableTorch(torchEnabled)
|
|
||||||
val drawableRes = if (torchEnabled) R.drawable.ic_flashlight_to_on else R.drawable.ic_flashlight_to_off
|
|
||||||
val drawable = AppCompatResources.getDrawable(this, drawableRes)
|
|
||||||
binding.btnFlashlight.icon = drawable
|
|
||||||
(drawable as? Animatable)?.start()
|
|
||||||
binding.btnFlashlight.iconTint = ColorStateList.valueOf(
|
|
||||||
if (torchEnabled) Color.parseColor("#FFEB3B") else Color.WHITE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||||
== PackageManager.PERMISSION_GRANTED
|
== PackageManager.PERMISSION_GRANTED
|
||||||
@@ -225,12 +179,9 @@ class QrScannerActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
camera = provider.bindToLifecycle(
|
provider.bindToLifecycle(
|
||||||
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
|
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
|
||||||
)
|
)
|
||||||
camera?.cameraInfo?.zoomState?.observe(this@QrScannerActivity) { state ->
|
|
||||||
binding.zoomSlider.value = state.linearZoom
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ package sh.sar.basedbank.ui.home
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.graphics.Color
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.InputType
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -12,7 +10,6 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.ScrollView
|
import android.widget.ScrollView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
|
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
@@ -21,11 +18,8 @@ import androidx.recyclerview.widget.ItemTouchHelper
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.google.android.material.textfield.TextInputEditText
|
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
|
||||||
import sh.sar.basedbank.util.ThemeHelper
|
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
|
|
||||||
class SettingsAppearanceFragment : Fragment() {
|
class SettingsAppearanceFragment : Fragment() {
|
||||||
@@ -36,10 +30,8 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
private lateinit var prefs: SharedPreferences
|
private lateinit var prefs: SharedPreferences
|
||||||
private val slots = mutableListOf<Int>()
|
private val slots = mutableListOf<Int>()
|
||||||
private val quickActions = mutableListOf<Int>()
|
private val quickActions = mutableListOf<Int>()
|
||||||
private val circularSlots = mutableListOf<Int>()
|
|
||||||
private lateinit var slotAdapter: NavItemAdapter
|
private lateinit var slotAdapter: NavItemAdapter
|
||||||
private lateinit var quickActionAdapter: NavItemAdapter
|
private lateinit var quickActionAdapter: NavItemAdapter
|
||||||
private lateinit var circularSlotAdapter: NavItemAdapter
|
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
|
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
|
||||||
@@ -50,20 +42,11 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
// Navigation mode
|
// Navigation mode
|
||||||
val currentMode = NavCustomization.getNavMode(prefs)
|
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||||
binding.navModeToggle.check(when (currentMode) {
|
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
||||||
NavCustomization.NAV_MODE_BOTTOM -> R.id.btnNavBottom
|
|
||||||
NavCustomization.NAV_MODE_CIRCULAR -> R.id.btnNavCircular
|
|
||||||
else -> R.id.btnNavDrawer
|
|
||||||
})
|
|
||||||
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
val mode = when (checkedId) {
|
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
||||||
R.id.btnNavBottom -> NavCustomization.NAV_MODE_BOTTOM
|
|
||||||
R.id.btnNavCircular -> NavCustomization.NAV_MODE_CIRCULAR
|
|
||||||
else -> NavCustomization.NAV_MODE_DRAWER
|
|
||||||
}
|
|
||||||
NavCustomization.saveNavMode(prefs, mode)
|
|
||||||
(activity as? HomeActivity)?.applyNavMode()
|
(activity as? HomeActivity)?.applyNavMode()
|
||||||
updateShortcutsVisibility()
|
updateShortcutsVisibility()
|
||||||
}
|
}
|
||||||
@@ -71,41 +54,19 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
// Quick actions
|
// Quick actions
|
||||||
quickActions.clear()
|
quickActions.clear()
|
||||||
quickActions.addAll(NavCustomization.getQuickActions(prefs))
|
quickActions.addAll(NavCustomization.getQuickActions(prefs))
|
||||||
quickActionAdapter = NavItemAdapter(
|
quickActionAdapter = NavItemAdapter(quickActions) {
|
||||||
items = quickActions,
|
NavCustomization.saveQuickActions(prefs, quickActions)
|
||||||
onSave = { NavCustomization.saveQuickActions(prefs, quickActions) },
|
|
||||||
isEnabled = { NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM }
|
|
||||||
)
|
|
||||||
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) {
|
|
||||||
NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM
|
|
||||||
}
|
|
||||||
|
|
||||||
// Circular nav shortcuts
|
|
||||||
circularSlots.clear()
|
|
||||||
circularSlots.addAll(NavCustomization.getCircularSlots(prefs))
|
|
||||||
circularSlotAdapter = NavItemAdapter(
|
|
||||||
items = circularSlots,
|
|
||||||
onSave = { NavCustomization.saveCircularSlots(prefs, circularSlots) },
|
|
||||||
isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_CIRCULAR }
|
|
||||||
)
|
|
||||||
setupNavItemRecyclerView(binding.rvCircularSlots, circularSlotAdapter, circularSlots) {
|
|
||||||
NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_CIRCULAR
|
|
||||||
}
|
}
|
||||||
|
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions)
|
||||||
|
|
||||||
// Bottom bar shortcuts
|
// Bottom bar shortcuts
|
||||||
slots.clear()
|
slots.clear()
|
||||||
slots.addAll(NavCustomization.getSlots(prefs))
|
slots.addAll(NavCustomization.getSlots(prefs))
|
||||||
slotAdapter = NavItemAdapter(
|
slotAdapter = NavItemAdapter(slots) {
|
||||||
items = slots,
|
NavCustomization.saveSlots(prefs, slots)
|
||||||
onSave = {
|
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
||||||
NavCustomization.saveSlots(prefs, slots)
|
|
||||||
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
|
||||||
},
|
|
||||||
isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM }
|
|
||||||
)
|
|
||||||
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
|
|
||||||
NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
|
|
||||||
}
|
}
|
||||||
|
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots)
|
||||||
// Show labels toggle
|
// Show labels toggle
|
||||||
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
||||||
binding.switchShowLabels.isChecked = showLabels
|
binding.switchShowLabels.isChecked = showLabels
|
||||||
@@ -132,45 +93,8 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
prefs.edit().putString("theme", key).apply()
|
prefs.edit().putString("theme", key).apply()
|
||||||
AppCompatDelegate.setDefaultNightMode(mode)
|
AppCompatDelegate.setDefaultNightMode(mode)
|
||||||
updateAccentState(key == "system")
|
|
||||||
updatePitchBlackState(key == "dark")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pitch black
|
|
||||||
binding.switchPitchBlack.isChecked = prefs.getBoolean("pitch_black", false)
|
|
||||||
binding.switchPitchBlack.setOnCheckedChangeListener { _, checked ->
|
|
||||||
prefs.edit().putBoolean("pitch_black", checked).apply()
|
|
||||||
requireActivity().recreate()
|
|
||||||
}
|
|
||||||
val isDark = prefs.getString("theme", "system") == "dark"
|
|
||||||
updatePitchBlackState(isDark)
|
|
||||||
|
|
||||||
// Accent color
|
|
||||||
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
|
||||||
binding.accentToggle.check(when (savedPreset) {
|
|
||||||
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
|
|
||||||
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
|
|
||||||
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
|
|
||||||
else -> R.id.btnAccentBlue
|
|
||||||
})
|
|
||||||
binding.accentToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
|
||||||
val preset = when (checkedId) {
|
|
||||||
R.id.btnAccentOrange -> ThemeHelper.PRESET_RED
|
|
||||||
R.id.btnAccentGreen -> ThemeHelper.PRESET_GREEN
|
|
||||||
R.id.btnAccentCustom -> ThemeHelper.PRESET_CUSTOM
|
|
||||||
else -> ThemeHelper.PRESET_BLUE
|
|
||||||
}
|
|
||||||
if (preset == ThemeHelper.PRESET_CUSTOM) {
|
|
||||||
showCustomColorPicker()
|
|
||||||
} else {
|
|
||||||
prefs.edit().putString("accent_preset", preset).apply()
|
|
||||||
requireActivity().recreate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val isSystem = prefs.getString("theme", "system") == "system"
|
|
||||||
updateAccentState(isSystem)
|
|
||||||
|
|
||||||
// Language
|
// Language
|
||||||
val currentLocales = AppCompatDelegate.getApplicationLocales()
|
val currentLocales = AppCompatDelegate.getApplicationLocales()
|
||||||
val currentLang = if (currentLocales.isEmpty) "en" else currentLocales[0]?.language ?: "en"
|
val currentLang = if (currentLocales.isEmpty) "en" else currentLocales[0]?.language ?: "en"
|
||||||
@@ -185,18 +109,13 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
private fun setupNavItemRecyclerView(
|
private fun setupNavItemRecyclerView(
|
||||||
rv: RecyclerView,
|
rv: RecyclerView,
|
||||||
adapter: NavItemAdapter,
|
adapter: NavItemAdapter,
|
||||||
items: MutableList<Int>,
|
items: MutableList<Int>
|
||||||
isEnabled: () -> Boolean
|
|
||||||
) {
|
) {
|
||||||
rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||||
rv.adapter = adapter
|
rv.adapter = adapter
|
||||||
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||||
ItemTouchHelper.START or ItemTouchHelper.END, 0
|
ItemTouchHelper.START or ItemTouchHelper.END, 0
|
||||||
) {
|
) {
|
||||||
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
|
|
||||||
if (!isEnabled()) return 0
|
|
||||||
return super.getMovementFlags(recyclerView, viewHolder)
|
|
||||||
}
|
|
||||||
override fun onMove(
|
override fun onMove(
|
||||||
rv: RecyclerView,
|
rv: RecyclerView,
|
||||||
from: RecyclerView.ViewHolder,
|
from: RecyclerView.ViewHolder,
|
||||||
@@ -214,85 +133,12 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateShortcutsVisibility() {
|
private fun updateShortcutsVisibility() {
|
||||||
val mode = NavCustomization.getNavMode(prefs)
|
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||||
val isBottom = mode == NavCustomization.NAV_MODE_BOTTOM
|
|
||||||
val isCircular = mode == NavCustomization.NAV_MODE_CIRCULAR
|
|
||||||
binding.sectionQuickActions.alpha = if (!isBottom) 1f else 0.38f
|
|
||||||
binding.sectionCircularSlots.alpha = if (isCircular) 1f else 0.38f
|
|
||||||
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
|
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
|
||||||
binding.switchShowLabels.isClickable = isBottom
|
|
||||||
quickActionAdapter.notifyDataSetChanged()
|
|
||||||
circularSlotAdapter.notifyDataSetChanged()
|
|
||||||
slotAdapter.notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updatePitchBlackState(isDark: Boolean) {
|
|
||||||
binding.rowPitchBlack.alpha = if (isDark) 1f else 0.38f
|
|
||||||
binding.switchPitchBlack.isEnabled = isDark
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateAccentState(isSystem: Boolean) {
|
|
||||||
binding.sectionAccentColor.alpha = if (isSystem) 0.38f else 1f
|
|
||||||
for (i in 0 until binding.accentToggle.childCount) {
|
|
||||||
binding.accentToggle.getChildAt(i)?.isEnabled = !isSystem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showCustomColorPicker() {
|
|
||||||
val ctx = requireContext()
|
|
||||||
val currentHex = prefs.getString("accent_custom_color", "") ?: ""
|
|
||||||
val inputLayout = TextInputLayout(ctx).apply {
|
|
||||||
hint = getString(R.string.accent_custom_hint)
|
|
||||||
val pad = (16 * resources.displayMetrics.density).toInt()
|
|
||||||
setPadding(pad, pad / 2, pad, 0)
|
|
||||||
}
|
|
||||||
val input = TextInputEditText(ctx).apply {
|
|
||||||
setText(currentHex)
|
|
||||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
|
||||||
setSingleLine(true)
|
|
||||||
}
|
|
||||||
inputLayout.addView(input)
|
|
||||||
|
|
||||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
|
||||||
.setTitle(R.string.accent_custom_pick)
|
|
||||||
.setView(inputLayout)
|
|
||||||
.setPositiveButton(android.R.string.ok, null)
|
|
||||||
.setNegativeButton(R.string.cancel) { _, _ -> revertAccentToggle() }
|
|
||||||
.setOnCancelListener { revertAccentToggle() }
|
|
||||||
.show()
|
|
||||||
|
|
||||||
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
|
||||||
val raw = input.text.toString().trim()
|
|
||||||
val hex = if (raw.startsWith("#")) raw else "#$raw"
|
|
||||||
try {
|
|
||||||
Color.parseColor(hex)
|
|
||||||
prefs.edit()
|
|
||||||
.putString("accent_preset", ThemeHelper.PRESET_CUSTOM)
|
|
||||||
.putString("accent_custom_color", hex)
|
|
||||||
.apply()
|
|
||||||
dialog.dismiss()
|
|
||||||
requireActivity().recreate()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
Toast.makeText(ctx, R.string.accent_invalid_color, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun revertAccentToggle() {
|
|
||||||
val saved = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
|
||||||
binding.accentToggle.check(when (saved) {
|
|
||||||
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
|
|
||||||
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
|
|
||||||
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
|
|
||||||
else -> R.id.btnAccentBlue
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
||||||
val mode = NavCustomization.getNavMode(prefs)
|
if (items === slots && !prefs.getBoolean("bottom_nav", false)) return
|
||||||
if (items === slots && mode != NavCustomization.NAV_MODE_BOTTOM) return
|
|
||||||
if (items === quickActions && mode == NavCustomization.NAV_MODE_BOTTOM) return
|
|
||||||
if (items === circularSlots && mode != NavCustomization.NAV_MODE_CIRCULAR) return
|
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
|
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
|
||||||
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
|
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
|
||||||
@@ -301,7 +147,6 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
LayoutInflater.from(ctx).inflate(R.layout.item_more_nav, listLayout, false).also { row ->
|
LayoutInflater.from(ctx).inflate(R.layout.item_more_nav, listLayout, false).also { row ->
|
||||||
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).visibility = View.GONE
|
|
||||||
listLayout.addView(row)
|
listLayout.addView(row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,8 +169,7 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
|
|
||||||
private inner class NavItemAdapter(
|
private inner class NavItemAdapter(
|
||||||
val items: MutableList<Int>,
|
val items: MutableList<Int>,
|
||||||
val onSave: () -> Unit,
|
val onSave: () -> Unit
|
||||||
val isEnabled: () -> Boolean = { true }
|
|
||||||
) : RecyclerView.Adapter<NavItemAdapter.VH>() {
|
) : RecyclerView.Adapter<NavItemAdapter.VH>() {
|
||||||
|
|
||||||
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
@@ -347,12 +191,7 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == items[position] } ?: return
|
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == items[position] } ?: return
|
||||||
holder.ivNavIcon.setImageResource(def.iconRes)
|
holder.ivNavIcon.setImageResource(def.iconRes)
|
||||||
holder.tvNavLabel.setText(def.titleRes)
|
holder.tvNavLabel.setText(def.titleRes)
|
||||||
val enabled = isEnabled()
|
holder.itemView.setOnClickListener { showItemPicker(items, holder.adapterPosition, this) }
|
||||||
holder.itemView.setOnClickListener(
|
|
||||||
if (enabled) View.OnClickListener { showItemPicker(items, holder.adapterPosition, this) }
|
|
||||||
else null
|
|
||||||
)
|
|
||||||
holder.itemView.isClickable = enabled
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.provider.Settings as AndroidSettings
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -17,9 +10,6 @@ import android.view.ViewGroup
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
@@ -32,13 +22,11 @@ import sh.sar.basedbank.api.mib.TransactionCache
|
|||||||
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
||||||
import sh.sar.basedbank.ui.login.LoginActivity
|
import sh.sar.basedbank.ui.login.LoginActivity
|
||||||
import sh.sar.basedbank.util.AccountCache
|
import sh.sar.basedbank.util.AccountCache
|
||||||
import sh.sar.basedbank.util.CardsCache
|
|
||||||
import sh.sar.basedbank.util.ContactImageCache
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
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.FinancingCache
|
import sh.sar.basedbank.util.FinancingCache
|
||||||
import sh.sar.basedbank.util.ForeignLimitsCache
|
import sh.sar.basedbank.util.ForeignLimitsCache
|
||||||
import sh.sar.basedbank.util.ProfileImageStore
|
|
||||||
import sh.sar.basedbank.util.RecentsCache
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
@@ -49,8 +37,6 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.api.bml.BmlActivationResult
|
import sh.sar.basedbank.api.bml.BmlActivationResult
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class SettingsLoginsFragment : Fragment() {
|
class SettingsLoginsFragment : Fragment() {
|
||||||
|
|
||||||
@@ -58,275 +44,6 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
// ── Profile image picker state ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
private sealed class PendingImageTarget {
|
|
||||||
data class Mib(val loginId: String, val profile: MibProfile) : PendingImageTarget()
|
|
||||||
data class Bml(val profileId: String, val refreshImage: (Bitmap?) -> Unit) : PendingImageTarget()
|
|
||||||
data class Fahipay(val loginId: String, val refreshImage: (Bitmap?) -> Unit) : PendingImageTarget()
|
|
||||||
}
|
|
||||||
private var pendingImageTarget: PendingImageTarget? = null
|
|
||||||
private var cameraPhotoUri: Uri? = null
|
|
||||||
|
|
||||||
private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
|
||||||
if (uri == null) return@registerForActivityResult
|
|
||||||
val bitmap = loadAndScaleBitmap(uri) ?: return@registerForActivityResult
|
|
||||||
handlePickedImage(bitmap)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val cameraLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
|
||||||
if (!success) return@registerForActivityResult
|
|
||||||
val uri = cameraPhotoUri ?: return@registerForActivityResult
|
|
||||||
val bitmap = loadAndScaleBitmap(uri) ?: return@registerForActivityResult
|
|
||||||
handlePickedImage(bitmap)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val cameraPermissionLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) { granted ->
|
|
||||||
if (granted) cameraLauncher.launch(cameraPhotoUri ?: return@registerForActivityResult)
|
|
||||||
else showCameraPermissionRationale()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showCameraPermissionRationale() {
|
|
||||||
val ctx = requireContext()
|
|
||||||
MaterialAlertDialogBuilder(ctx)
|
|
||||||
.setTitle(R.string.qr_camera_permission_title)
|
|
||||||
.setMessage(R.string.camera_permission_profile_message)
|
|
||||||
.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() }
|
|
||||||
.setPositiveButton(R.string.go_to_settings) { _, _ ->
|
|
||||||
startActivity(
|
|
||||||
Intent(AndroidSettings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
|
||||||
data = Uri.fromParts("package", ctx.packageName, null)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadAndScaleBitmap(uri: Uri): Bitmap? {
|
|
||||||
return try {
|
|
||||||
val ctx = requireContext()
|
|
||||||
val inputStream = ctx.contentResolver.openInputStream(uri) ?: return null
|
|
||||||
val original = BitmapFactory.decodeStream(inputStream)
|
|
||||||
inputStream.close()
|
|
||||||
if (original == null) return null
|
|
||||||
val maxDim = 512
|
|
||||||
val scale = minOf(maxDim.toFloat() / original.width, maxDim.toFloat() / original.height, 1f)
|
|
||||||
if (scale < 1f) {
|
|
||||||
Bitmap.createScaledBitmap(original, (original.width * scale).toInt(), (original.height * scale).toInt(), true)
|
|
||||||
} else original
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handlePickedImage(bitmap: Bitmap) {
|
|
||||||
val target = pendingImageTarget ?: return
|
|
||||||
when (target) {
|
|
||||||
is PendingImageTarget.Mib -> uploadMibProfileImage(target.loginId, target.profile, bitmap)
|
|
||||||
is PendingImageTarget.Bml -> {
|
|
||||||
ProfileImageStore.save(requireContext(), ProfileImageStore.bmlKey(target.profileId), bitmap)
|
|
||||||
target.refreshImage(bitmap)
|
|
||||||
}
|
|
||||||
is PendingImageTarget.Fahipay -> {
|
|
||||||
ProfileImageStore.save(requireContext(), ProfileImageStore.fahipayKey(target.loginId), bitmap)
|
|
||||||
target.refreshImage(bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun uploadMibProfileImage(loginId: String, profile: MibProfile, bitmap: Bitmap) {
|
|
||||||
val ctx = requireContext()
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
|
||||||
val progress = MaterialAlertDialogBuilder(ctx)
|
|
||||||
.setMessage(getString(R.string.profile_image_uploading))
|
|
||||||
.setCancelable(false)
|
|
||||||
.show()
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
val result = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val session = app.mibSessions[loginId] ?: app.anyMibSession() ?: return@withContext null
|
|
||||||
val flow = app.mibLoginFlows[loginId] ?: return@withContext null
|
|
||||||
flow.switchProfile(session, profile)
|
|
||||||
// MIB server enforces a small payload limit (~4KB); scale to 100px max
|
|
||||||
val mibMax = 100
|
|
||||||
val mibScale = minOf(mibMax.toFloat() / bitmap.width, mibMax.toFloat() / bitmap.height, 1f)
|
|
||||||
val mibBitmap = if (mibScale < 1f)
|
|
||||||
Bitmap.createScaledBitmap(bitmap, (bitmap.width * mibScale).toInt(), (bitmap.height * mibScale).toInt(), true)
|
|
||||||
else bitmap
|
|
||||||
val baos = ByteArrayOutputStream()
|
|
||||||
mibBitmap.compress(Bitmap.CompressFormat.JPEG, 50, baos)
|
|
||||||
val base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
|
||||||
flow.uploadProfileImage(session, profile, base64)
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
progress.dismiss()
|
|
||||||
if (result == null) {
|
|
||||||
MaterialAlertDialogBuilder(ctx)
|
|
||||||
.setMessage(getString(R.string.profile_image_upload_failed))
|
|
||||||
.setPositiveButton(R.string.close, null)
|
|
||||||
.show()
|
|
||||||
} else {
|
|
||||||
clearAllCaches(ctx)
|
|
||||||
(activity as? HomeActivity)?.relogin()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showImagePickerMenu(anchor: View, target: PendingImageTarget, currentBitmap: Bitmap?) {
|
|
||||||
val ctx = requireContext()
|
|
||||||
val dp = ctx.resources.displayMetrics.density
|
|
||||||
|
|
||||||
val items = mutableListOf<Triple<Int, String, () -> Unit>>()
|
|
||||||
items += Triple(R.drawable.ic_image, getString(R.string.profile_image_select)) {
|
|
||||||
pendingImageTarget = target
|
|
||||||
galleryLauncher.launch("image/*")
|
|
||||||
}
|
|
||||||
items += Triple(R.drawable.ic_camera, getString(R.string.profile_image_camera)) {
|
|
||||||
pendingImageTarget = target
|
|
||||||
val photoFile = File(ctx.cacheDir, "profile_photo_tmp.jpg")
|
|
||||||
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", photoFile)
|
|
||||||
cameraPhotoUri = uri
|
|
||||||
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
cameraLauncher.launch(uri)
|
|
||||||
} else {
|
|
||||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (target is PendingImageTarget.Mib || currentBitmap != null || hasSavedImage(ctx, target)) {
|
|
||||||
items += Triple(R.drawable.ic_delete, getString(R.string.profile_image_remove)) {
|
|
||||||
removeProfileImage(ctx, target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val list = LinearLayout(ctx).apply {
|
|
||||||
orientation = LinearLayout.VERTICAL
|
|
||||||
val vp = (8 * dp).toInt()
|
|
||||||
setPadding(0, vp, 0, vp)
|
|
||||||
}
|
|
||||||
for ((iconRes, label, action) in items) {
|
|
||||||
val iconSize = (24 * dp).toInt()
|
|
||||||
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 = (24 * dp).toInt(); val vp2 = (12 * dp).toInt()
|
|
||||||
setPadding(hp, vp2, hp, vp2)
|
|
||||||
}
|
|
||||||
val iconColor = com.google.android.material.color.MaterialColors.getColor(
|
|
||||||
requireView(), com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
|
||||||
val icon = ImageView(ctx).apply {
|
|
||||||
setImageResource(iconRes)
|
|
||||||
imageTintList = android.content.res.ColorStateList.valueOf(iconColor)
|
|
||||||
}
|
|
||||||
val tv = TextView(ctx).apply {
|
|
||||||
text = label
|
|
||||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
|
||||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
|
||||||
marginStart = (12 * dp).toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
row.addView(icon, LinearLayout.LayoutParams(iconSize, iconSize))
|
|
||||||
row.addView(tv)
|
|
||||||
list.addView(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
|
||||||
.setTitle(R.string.profile_image_title)
|
|
||||||
.setView(list)
|
|
||||||
.setNegativeButton(R.string.cancel, null)
|
|
||||||
.show()
|
|
||||||
|
|
||||||
val rows = (0 until list.childCount).map { list.getChildAt(it) }
|
|
||||||
rows.forEachIndexed { i, row ->
|
|
||||||
row.setOnClickListener {
|
|
||||||
dialog.dismiss()
|
|
||||||
items[i].third()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasSavedImage(ctx: Context, target: PendingImageTarget): Boolean = when (target) {
|
|
||||||
is PendingImageTarget.Mib -> target.profile.customerImage != null
|
|
||||||
is PendingImageTarget.Bml -> ProfileImageStore.exists(ctx, ProfileImageStore.bmlKey(target.profileId))
|
|
||||||
is PendingImageTarget.Fahipay -> ProfileImageStore.exists(ctx, ProfileImageStore.fahipayKey(target.loginId))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeProfileImage(ctx: Context, target: PendingImageTarget) {
|
|
||||||
when (target) {
|
|
||||||
is PendingImageTarget.Mib -> {
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
|
||||||
val progress = MaterialAlertDialogBuilder(ctx)
|
|
||||||
.setMessage(getString(R.string.profile_image_deleting))
|
|
||||||
.setCancelable(false)
|
|
||||||
.show()
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val session = app.mibSessions[target.loginId] ?: app.anyMibSession() ?: return@withContext
|
|
||||||
val flow = app.mibLoginFlows[target.loginId] ?: return@withContext
|
|
||||||
flow.switchProfile(session, target.profile)
|
|
||||||
flow.deleteProfileImage(session, target.profile)
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
progress.dismiss()
|
|
||||||
clearAllCaches(ctx)
|
|
||||||
(activity as? HomeActivity)?.relogin()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is PendingImageTarget.Bml -> {
|
|
||||||
ProfileImageStore.delete(ctx, ProfileImageStore.bmlKey(target.profileId))
|
|
||||||
target.refreshImage(null)
|
|
||||||
}
|
|
||||||
is PendingImageTarget.Fahipay -> {
|
|
||||||
ProfileImageStore.delete(ctx, ProfileImageStore.fahipayKey(target.loginId))
|
|
||||||
target.refreshImage(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makePencilButton(ctx: Context): ImageView {
|
|
||||||
val dp = ctx.resources.displayMetrics.density
|
|
||||||
val size = (32 * dp).toInt()
|
|
||||||
return ImageView(ctx).apply {
|
|
||||||
setImageResource(R.drawable.ic_edit)
|
|
||||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackgroundBorderless))
|
|
||||||
background = ta.getDrawable(0); ta.recycle()
|
|
||||||
isClickable = true; isFocusable = true
|
|
||||||
layoutParams = LinearLayout.LayoutParams(size, size).apply {
|
|
||||||
marginStart = (4 * dp).toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makeCircleAvatarView(ctx: Context, sizeDp: Int): ImageView {
|
|
||||||
val dp = ctx.resources.displayMetrics.density
|
|
||||||
val size = (sizeDp * dp).toInt()
|
|
||||||
return ImageView(ctx).apply {
|
|
||||||
layoutParams = LinearLayout.LayoutParams(size, size)
|
|
||||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
|
||||||
clipToOutline = true
|
|
||||||
outlineProvider = object : android.view.ViewOutlineProvider() {
|
|
||||||
override fun getOutline(view: View, outline: android.graphics.Outline) {
|
|
||||||
outline.setOval(0, 0, view.width, view.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setAvatarBitmap(iv: ImageView, bitmap: Bitmap?) {
|
|
||||||
if (bitmap != null) {
|
|
||||||
iv.setImageBitmap(bitmap)
|
|
||||||
iv.imageTintList = null
|
|
||||||
} else {
|
|
||||||
iv.setImageResource(R.drawable.ic_image)
|
|
||||||
val color = com.google.android.material.color.MaterialColors.getColor(
|
|
||||||
iv, com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
|
||||||
iv.imageTintList = android.content.res.ColorStateList.valueOf(color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
|
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -383,7 +100,18 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val profile = store.loadFahipayUserProfile(loginId)
|
val profile = store.loadFahipayUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
||||||
showFahipayLoginDetails(store, loginId, profile)
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
val masked = "••••••"
|
||||||
|
showLoginDetails(
|
||||||
|
title = getString(R.string.fahipay_name),
|
||||||
|
details = buildString {
|
||||||
|
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
|
||||||
|
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${if (hide) masked else profile!!.email}")
|
||||||
|
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${if (hide) masked else profile!!.mobile}")
|
||||||
|
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${if (hide) masked else profile!!.nid}")
|
||||||
|
}.trim(),
|
||||||
|
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,21 +218,8 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
alpha = 0.6f
|
alpha = 0.6f
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
val pencil = makePencilButton(ctx).apply {
|
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||||
alpha = 0.38f
|
|
||||||
isEnabled = false
|
|
||||||
}
|
|
||||||
val toggle = MaterialSwitch(ctx).apply {
|
|
||||||
isChecked = p.profileId !in hidden
|
|
||||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
|
||||||
marginStart = (4 * dp).toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pencil.setOnClickListener {
|
|
||||||
android.widget.Toast.makeText(ctx, "Work in progress", android.widget.Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
row.addView(textCol)
|
row.addView(textCol)
|
||||||
row.addView(pencil)
|
|
||||||
row.addView(toggle)
|
row.addView(toggle)
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
p to toggle
|
p to toggle
|
||||||
@@ -612,10 +327,6 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val toggleRows = bmlProfiles.map { p ->
|
val toggleRows = bmlProfiles.map { p ->
|
||||||
val avatarIv = makeCircleAvatarView(ctx, 36)
|
|
||||||
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
|
||||||
setAvatarBitmap(avatarIv, currentBitmap)
|
|
||||||
|
|
||||||
val row = LinearLayout(ctx).apply {
|
val row = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
gravity = Gravity.CENTER_VERTICAL
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
@@ -638,24 +349,8 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
alpha = 0.6f
|
alpha = 0.6f
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
val pencil = makePencilButton(ctx)
|
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||||
val toggle = MaterialSwitch(ctx).apply {
|
|
||||||
isChecked = p.profileId !in hidden
|
|
||||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
|
||||||
marginStart = (4 * dp).toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val target = PendingImageTarget.Bml(p.profileId) { newBitmap ->
|
|
||||||
setAvatarBitmap(avatarIv, newBitmap)
|
|
||||||
}
|
|
||||||
pencil.setOnClickListener {
|
|
||||||
val cur = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
|
||||||
showImagePickerMenu(pencil, target, cur)
|
|
||||||
}
|
|
||||||
val avatarSize = (36 * dp).toInt()
|
|
||||||
row.addView(avatarIv, LinearLayout.LayoutParams(avatarSize, avatarSize).apply { marginEnd = (8 * dp).toInt() })
|
|
||||||
row.addView(textCol)
|
row.addView(textCol)
|
||||||
row.addView(pencil)
|
|
||||||
row.addView(toggle)
|
row.addView(toggle)
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
p to toggle
|
p to toggle
|
||||||
@@ -919,75 +614,6 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showFahipayLoginDetails(
|
|
||||||
store: CredentialStore,
|
|
||||||
loginId: String,
|
|
||||||
profile: CredentialStore.FahipayUserProfile?
|
|
||||||
) {
|
|
||||||
val ctx = requireContext()
|
|
||||||
val dp = ctx.resources.displayMetrics.density
|
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
|
||||||
val masked = "••••••"
|
|
||||||
|
|
||||||
val scroll = android.widget.ScrollView(ctx)
|
|
||||||
val container = LinearLayout(ctx).apply {
|
|
||||||
orientation = LinearLayout.VERTICAL
|
|
||||||
val pad = (16 * dp).toInt()
|
|
||||||
setPadding(pad, (8 * dp).toInt(), pad, pad)
|
|
||||||
}
|
|
||||||
scroll.addView(container)
|
|
||||||
|
|
||||||
// Avatar row with pencil
|
|
||||||
val avatarRow = LinearLayout(ctx).apply {
|
|
||||||
orientation = LinearLayout.HORIZONTAL
|
|
||||||
gravity = Gravity.CENTER_VERTICAL
|
|
||||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
|
||||||
bottomMargin = (12 * dp).toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val avatarSize = (56 * dp).toInt()
|
|
||||||
val avatarIv = makeCircleAvatarView(ctx, 56)
|
|
||||||
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
|
||||||
setAvatarBitmap(avatarIv, currentBitmap)
|
|
||||||
|
|
||||||
val pencil = makePencilButton(ctx)
|
|
||||||
val target = PendingImageTarget.Fahipay(loginId) { newBitmap ->
|
|
||||||
setAvatarBitmap(avatarIv, newBitmap)
|
|
||||||
}
|
|
||||||
pencil.setOnClickListener {
|
|
||||||
val cur = ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
|
||||||
showImagePickerMenu(pencil, target, cur)
|
|
||||||
}
|
|
||||||
avatarRow.addView(avatarIv, LinearLayout.LayoutParams(avatarSize, avatarSize).apply { marginEnd = (8 * dp).toInt() })
|
|
||||||
avatarRow.addView(pencil)
|
|
||||||
container.addView(avatarRow)
|
|
||||||
|
|
||||||
// Account info lines
|
|
||||||
listOfNotNull(
|
|
||||||
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
|
|
||||||
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
|
|
||||||
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" },
|
|
||||||
profile?.nid?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: ${if (hide) masked else it}" }
|
|
||||||
).forEach { line ->
|
|
||||||
container.addView(TextView(ctx).apply {
|
|
||||||
text = line
|
|
||||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
|
||||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
|
||||||
bottomMargin = (4 * dp).toInt()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialAlertDialogBuilder(ctx)
|
|
||||||
.setTitle(getString(R.string.fahipay_name))
|
|
||||||
.setView(scroll)
|
|
||||||
.setPositiveButton(R.string.close, null)
|
|
||||||
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
|
||||||
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
@@ -1016,7 +642,6 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
app.mibLoginFlows.remove(loginId)
|
app.mibLoginFlows.remove(loginId)
|
||||||
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" }
|
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" }
|
||||||
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" }
|
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" }
|
||||||
viewModel.financing.value = emptyList()
|
|
||||||
clearAllCaches(ctx)
|
clearAllCaches(ctx)
|
||||||
(activity as HomeActivity).relogin()
|
(activity as HomeActivity).relogin()
|
||||||
buildLoginsSection()
|
buildLoginsSection()
|
||||||
@@ -1027,17 +652,12 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
// Remove all per-profile sessions for this login from the in-memory map
|
// Remove all per-profile sessions for this login from the in-memory map
|
||||||
val profiles = app.bmlProfilesMap[loginId] ?: emptyList()
|
val profiles = app.bmlProfilesMap[loginId] ?: emptyList()
|
||||||
profiles.forEach { p ->
|
profiles.forEach { app.bmlSessions.remove(it.profileId) }
|
||||||
app.bmlSessions.remove(p.profileId)
|
|
||||||
ProfileImageStore.delete(ctx, ProfileImageStore.bmlKey(p.profileId))
|
|
||||||
}
|
|
||||||
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
|
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
|
||||||
store.clearBmlCredentials(loginId)
|
store.clearBmlCredentials(loginId)
|
||||||
app.bmlProfilesMap.remove(loginId)
|
app.bmlProfilesMap.remove(loginId)
|
||||||
app.bmlLoginFlows.remove(loginId)
|
app.bmlLoginFlows.remove(loginId)
|
||||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
|
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
|
||||||
viewModel.bmlLimits.value = emptyList()
|
|
||||||
viewModel.bmlLoanDetails.value = emptyMap()
|
|
||||||
clearAllCaches(ctx)
|
clearAllCaches(ctx)
|
||||||
(activity as HomeActivity).relogin()
|
(activity as HomeActivity).relogin()
|
||||||
buildLoginsSection()
|
buildLoginsSection()
|
||||||
@@ -1045,7 +665,6 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
|
|
||||||
private fun logoutFahipay(store: CredentialStore, loginId: String) {
|
private fun logoutFahipay(store: CredentialStore, loginId: String) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
ProfileImageStore.delete(ctx, ProfileImageStore.fahipayKey(loginId))
|
|
||||||
store.clearFahipayCredentials(loginId)
|
store.clearFahipayCredentials(loginId)
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
app.fahipaySessions.remove(loginId)
|
app.fahipaySessions.remove(loginId)
|
||||||
@@ -1058,7 +677,6 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
private fun clearAllCaches(ctx: Context) {
|
private fun clearAllCaches(ctx: Context) {
|
||||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||||
CardsCache.clear(ctx); TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||||
// Note: ProfileImageStore is intentionally NOT cleared here — profile images are user-set data
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,17 @@ class SettingsSecurityFragment : Fragment() {
|
|||||||
(activity as? HomeActivity)?.resetAutolockTimer()
|
(activity as? HomeActivity)?.resetAutolockTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide sensitive information (enables/disables the eye icon in toolbar)
|
||||||
|
val viewModel = (requireActivity() as HomeActivity).let {
|
||||||
|
androidx.lifecycle.ViewModelProvider(it)[HomeViewModel::class.java]
|
||||||
|
}
|
||||||
|
binding.switchHideAmounts.isChecked = prefs.getBoolean("hide_sensitive_info", false)
|
||||||
|
binding.switchHideAmounts.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("hide_sensitive_info", isChecked).apply()
|
||||||
|
if (!isChecked) viewModel.hideAmounts.value = false
|
||||||
|
requireActivity().invalidateOptionsMenu()
|
||||||
|
}
|
||||||
|
|
||||||
// Block screenshots
|
// Block screenshots
|
||||||
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
|
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
|
||||||
binding.switchBlockScreenshots.isChecked = blockScreenshots
|
binding.switchBlockScreenshots.isChecked = blockScreenshots
|
||||||
|
|||||||
@@ -7,12 +7,10 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentSettingsStorageBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsStorageBinding
|
||||||
import sh.sar.basedbank.util.AccountCache
|
import sh.sar.basedbank.util.AccountCache
|
||||||
import sh.sar.basedbank.util.CardsCache
|
|
||||||
import sh.sar.basedbank.util.ContactImageCache
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
import sh.sar.basedbank.util.ContactsCache
|
import sh.sar.basedbank.util.ContactsCache
|
||||||
import sh.sar.basedbank.util.FinancingCache
|
import sh.sar.basedbank.util.FinancingCache
|
||||||
@@ -23,7 +21,6 @@ class SettingsStorageFragment : Fragment() {
|
|||||||
|
|
||||||
private var _binding: FragmentSettingsStorageBinding? = null
|
private var _binding: FragmentSettingsStorageBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentSettingsStorageBinding.inflate(inflater, container, false)
|
_binding = FragmentSettingsStorageBinding.inflate(inflater, container, false)
|
||||||
@@ -35,7 +32,6 @@ class SettingsStorageFragment : Fragment() {
|
|||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
clearAllCaches(ctx)
|
clearAllCaches(ctx)
|
||||||
Toast.makeText(ctx, R.string.settings_cache_cleared, Toast.LENGTH_SHORT).show()
|
Toast.makeText(ctx, R.string.settings_cache_cleared, Toast.LENGTH_SHORT).show()
|
||||||
(activity as? HomeActivity)?.triggerRefresh()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,13 +48,6 @@ class SettingsStorageFragment : Fragment() {
|
|||||||
private fun clearAllCaches(ctx: Context) {
|
private fun clearAllCaches(ctx: Context) {
|
||||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||||
CardsCache.clear(ctx); TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||||
viewModel.accounts.value = emptyList()
|
|
||||||
viewModel.mibCards.value = null
|
|
||||||
viewModel.financing.value = emptyList()
|
|
||||||
viewModel.bmlLoanDetails.value = emptyMap()
|
|
||||||
viewModel.bmlLimits.value = emptyList()
|
|
||||||
viewModel.contacts.value = emptyList()
|
|
||||||
viewModel.contactCategories.value = emptyList()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
|||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||||
import sh.sar.basedbank.api.models.BankServerException
|
|
||||||
import sh.sar.basedbank.api.models.BankTransaction
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
|
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
|
||||||
@@ -162,15 +161,8 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
val accounts = accountStates.map { it.account }
|
val accounts = accountStates.map { it.account }
|
||||||
accountStates.clear()
|
accountStates.clear()
|
||||||
accounts.forEach { accountStates.add(AccountState(it)) }
|
accounts.forEach { accountStates.add(AccountState(it)) }
|
||||||
// Restore cache immediately so data stays visible while refreshing
|
adapter.setTransactions(emptyList())
|
||||||
val cached = TransactionCache.load(requireContext(), "transfer")
|
binding.emptyView.visibility = View.GONE
|
||||||
if (cached.isNotEmpty()) {
|
|
||||||
allTransactions.addAll(cached)
|
|
||||||
filterAndDisplay()
|
|
||||||
} else {
|
|
||||||
adapter.setTransactions(emptyList())
|
|
||||||
binding.emptyView.visibility = View.GONE
|
|
||||||
}
|
|
||||||
loadNextPages()
|
loadNextPages()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,14 +178,6 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val bannerMsg = java.util.concurrent.atomic.AtomicReference<String?>(null)
|
|
||||||
fun trackError(e: Exception) {
|
|
||||||
when {
|
|
||||||
e is java.io.IOException -> bannerMsg.compareAndSet(null, "IO")
|
|
||||||
e is BankServerException -> bannerMsg.compareAndSet(null, "SERVER:${e.bankName}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val newTransactions = withContext(Dispatchers.IO) {
|
val newTransactions = withContext(Dispatchers.IO) {
|
||||||
val results = mutableListOf<BankTransaction>()
|
val results = mutableListOf<BankTransaction>()
|
||||||
|
|
||||||
@@ -231,7 +215,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
list
|
list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) { trackError(e); emptyList<BankTransaction>() }
|
} catch (_: Exception) { emptyList<BankTransaction>() }
|
||||||
}
|
}
|
||||||
}.awaitAll().flatten())
|
}.awaitAll().flatten())
|
||||||
|
|
||||||
@@ -249,7 +233,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
if (total > 0) state.fahipayTotal = total
|
if (total > 0) state.fahipayTotal = total
|
||||||
state.fahipayNextStart += list.size
|
state.fahipayNextStart += list.size
|
||||||
results.addAll(list)
|
results.addAll(list)
|
||||||
} catch (e: Exception) { trackError(e) }
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MIB accounts: serialized per profile, protected by mutex to prevent session race
|
// MIB accounts: serialized per profile, protected by mutex to prevent session race
|
||||||
@@ -258,25 +242,23 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
val session = app.mibSessions[loginId] ?: continue
|
val session = app.mibSessions[loginId] ?: continue
|
||||||
for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
|
for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
|
||||||
app.mibMutex.withLock {
|
app.mibMutex.withLock {
|
||||||
try {
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
val profile = profiles.firstOrNull { it.profileId == profileId }
|
||||||
val profile = profiles.firstOrNull { it.profileId == profileId }
|
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
|
||||||
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
|
for (state in states) {
|
||||||
for (state in states) {
|
try {
|
||||||
try {
|
val (list, total) = MibHistoryClient().fetchHistory(
|
||||||
val (list, total) = MibHistoryClient().fetchHistory(
|
session = session,
|
||||||
session = session,
|
accountNo = state.account.accountNumber,
|
||||||
accountNo = state.account.accountNumber,
|
accountDisplayName = state.account.accountBriefName,
|
||||||
accountDisplayName = state.account.accountBriefName,
|
start = state.mibNextStart,
|
||||||
start = state.mibNextStart,
|
pageSize = pageSize
|
||||||
pageSize = pageSize
|
)
|
||||||
)
|
if (total > 0) state.mibTotalCount = total
|
||||||
if (total > 0) state.mibTotalCount = total
|
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||||
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
results.addAll(list)
|
||||||
results.addAll(list)
|
} catch (_: Exception) {}
|
||||||
} catch (e: Exception) { trackError(e) }
|
}
|
||||||
}
|
|
||||||
} catch (e: Exception) { trackError(e) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,16 +267,6 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
if (_binding == null) return@launch
|
|
||||||
|
|
||||||
val raw = bannerMsg.get()
|
|
||||||
when {
|
|
||||||
raw == null -> (activity as? HomeActivity)?.hideConnectivityBanner()
|
|
||||||
raw == "IO" -> (activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
|
||||||
raw.startsWith("SERVER:") -> (activity as? HomeActivity)?.showConnectivityBanner(
|
|
||||||
getString(R.string.connectivity_server_error, raw.removePrefix("SERVER:"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!firstBatchDone) {
|
if (!firstBatchDone) {
|
||||||
firstBatchDone = true
|
firstBatchDone = true
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package sh.sar.basedbank.ui.login
|
package sh.sar.basedbank.ui.login
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
@@ -84,17 +81,6 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.btnLogin.isEnabled = false
|
binding.btnLogin.isEnabled = false
|
||||||
binding.btnLogin.setOnClickListener { attemptLogin() }
|
binding.btnLogin.setOnClickListener { attemptLogin() }
|
||||||
|
|
||||||
binding.cardOtp.setOnClickListener {
|
|
||||||
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
|
||||||
if (code.isNotEmpty()) {
|
|
||||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
|
||||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
Toast.makeText(requireContext(), "OTP copied", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val loginFieldWatcher = object : TextWatcher {
|
val loginFieldWatcher = object : TextWatcher {
|
||||||
override fun afterTextChanged(s: Editable?) { updateLoginButtonState() }
|
override fun afterTextChanged(s: Editable?) { updateLoginButtonState() }
|
||||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
@@ -229,7 +215,6 @@ class CredentialsFragment : Fragment() {
|
|||||||
app.mibSessions[loginId] = flow.lastSession!!
|
app.mibSessions[loginId] = flow.lastSession!!
|
||||||
app.mibProfilesMap[loginId] = flow.lastProfiles
|
app.mibProfilesMap[loginId] = flow.lastProfiles
|
||||||
app.mibLoginFlows[loginId] = flow
|
app.mibLoginFlows[loginId] = flow
|
||||||
app.isUnlocked = true
|
|
||||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
@@ -379,7 +364,6 @@ class CredentialsFragment : Fragment() {
|
|||||||
if (hasBusinessProfiles) {
|
if (hasBusinessProfiles) {
|
||||||
Toast.makeText(requireContext(), "Business profiles can be enabled in Settings → Logins", Toast.LENGTH_LONG).show()
|
Toast.makeText(requireContext(), "Business profiles can be enabled in Settings → Logins", Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
(requireActivity().application as BasedBankApp).isUnlocked = true
|
|
||||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
@@ -512,7 +496,6 @@ class CredentialsFragment : Fragment() {
|
|||||||
app.fahipaySessions[loginId] = session
|
app.fahipaySessions[loginId] = session
|
||||||
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account)
|
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account)
|
||||||
app.accounts = app.accounts.filter { it.loginTag != loginTag } + listOf(account)
|
app.accounts = app.accounts.filter { it.loginTag != loginTag } + listOf(account)
|
||||||
app.isUnlocked = true
|
|
||||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
|
|||||||
@@ -1,33 +1,17 @@
|
|||||||
package sh.sar.basedbank.ui.login
|
package sh.sar.basedbank.ui.login
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import sh.sar.basedbank.BasedBankApp
|
|
||||||
import sh.sar.basedbank.LockActivity
|
|
||||||
import sh.sar.basedbank.databinding.ActivityLoginBinding
|
import sh.sar.basedbank.databinding.ActivityLoginBinding
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
|
||||||
import sh.sar.basedbank.util.ThemeHelper
|
|
||||||
|
|
||||||
class LoginActivity : AppCompatActivity() {
|
class LoginActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private lateinit var binding: ActivityLoginBinding
|
private lateinit var binding: ActivityLoginBinding
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
ThemeHelper.applyAccent(this)
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// If security is configured and the user hasn't unlocked this session,
|
|
||||||
// they must authenticate first before being able to add more accounts.
|
|
||||||
val app = application as BasedBankApp
|
|
||||||
if (CredentialStore(this).loadSecurityHash() != null && !app.isUnlocked) {
|
|
||||||
startActivity(Intent(this, LockActivity::class.java))
|
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityLoginBinding.inflate(layoutInflater)
|
binding = ActivityLoginBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
@@ -36,8 +20,5 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
isAppearanceLightStatusBars = isLight
|
isAppearanceLightStatusBars = isLight
|
||||||
isAppearanceLightNavigationBars = isLight
|
isAppearanceLightNavigationBars = isLight
|
||||||
}
|
}
|
||||||
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
|
||||||
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
|
||||||
ta.recycle()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,9 @@ import androidx.core.view.WindowCompat
|
|||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import sh.sar.basedbank.BasedBankApp
|
|
||||||
import sh.sar.basedbank.LockActivity
|
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.ActivityOnboardingBinding
|
import sh.sar.basedbank.databinding.ActivityOnboardingBinding
|
||||||
import sh.sar.basedbank.ui.login.LoginActivity
|
import sh.sar.basedbank.ui.login.LoginActivity
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
|
||||||
import sh.sar.basedbank.util.ThemeHelper
|
|
||||||
|
|
||||||
class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
||||||
|
|
||||||
@@ -28,17 +24,7 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
private var countDownTimer: CountDownTimer? = null
|
private var countDownTimer: CountDownTimer? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
ThemeHelper.applyAccent(this)
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// If security is already configured, onboarding is complete. Redirect to lock screen
|
|
||||||
// to prevent overwriting an existing PIN/pattern via direct activity launch.
|
|
||||||
if (CredentialStore(this).loadSecurityHash() != null) {
|
|
||||||
startActivity(Intent(this, LockActivity::class.java))
|
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityOnboardingBinding.inflate(layoutInflater)
|
binding = ActivityOnboardingBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
@@ -47,9 +33,6 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
isAppearanceLightStatusBars = isLight
|
isAppearanceLightStatusBars = isLight
|
||||||
isAppearanceLightNavigationBars = isLight
|
isAppearanceLightNavigationBars = isLight
|
||||||
}
|
}
|
||||||
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
|
||||||
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
|
||||||
ta.recycle()
|
|
||||||
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
val originalBottomPadding = binding.bottomBar.paddingBottom
|
val originalBottomPadding = binding.bottomBar.paddingBottom
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
|
||||||
@@ -114,9 +97,6 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
|
|
||||||
binding.btnGetStarted.setOnClickListener {
|
binding.btnGetStarted.setOnClickListener {
|
||||||
prefs.edit().putBoolean("onboarding_done", true).apply()
|
prefs.edit().putBoolean("onboarding_done", true).apply()
|
||||||
// Mark as unlocked so LoginActivity doesn't redirect to LockActivity.
|
|
||||||
// The user just completed setup — they shouldn't have to re-authenticate immediately.
|
|
||||||
(application as BasedBankApp).isUnlocked = true
|
|
||||||
startActivity(Intent(this, LoginActivity::class.java))
|
startActivity(Intent(this, LoginActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ data class AccountListDisplay(
|
|||||||
val number: String,
|
val number: String,
|
||||||
val typeLabel: String,
|
val typeLabel: String,
|
||||||
val balance: String,
|
val balance: String,
|
||||||
val blockedBalance: String? = null, // null when zero or not applicable
|
|
||||||
val isCard: Boolean = false,
|
val isCard: Boolean = false,
|
||||||
val cardBrandIcon: Int = 0, // drawable res, only meaningful if isCard
|
val cardBrandIcon: Int = 0, // drawable res, only meaningful if isCard
|
||||||
val statusLabel: String? = null // null = active; shown as status pill if set
|
val statusLabel: String? = null // null = active; shown as status pill if set
|
||||||
|
|||||||
@@ -615,41 +615,6 @@ class CredentialStore(context: Context) {
|
|||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Default payment card ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** BML card account number the user has pinned as their default payment card, or null. */
|
|
||||||
fun getDefaultCardAccountNumber(): String? = prefs.getString("default_card_account_number", null)
|
|
||||||
|
|
||||||
fun setDefaultCardAccountNumber(accountNumber: String?) {
|
|
||||||
val editor = prefs.edit()
|
|
||||||
if (accountNumber == null) editor.remove("default_card_account_number")
|
|
||||||
else editor.putString("default_card_account_number", accountNumber)
|
|
||||||
editor.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Default transfer/QR account ───────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Account number the user has pinned as their default source for transfers and PayMV QR, or null. */
|
|
||||||
fun getDefaultAccountNumber(): String? = prefs.getString("default_account_number", null)
|
|
||||||
|
|
||||||
fun setDefaultAccountNumber(accountNumber: String?) {
|
|
||||||
val editor = prefs.edit()
|
|
||||||
if (accountNumber == null) editor.remove("default_account_number")
|
|
||||||
else editor.putString("default_account_number", accountNumber)
|
|
||||||
editor.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dashboard card visibility ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun getHiddenDashboardCardNumbers(): Set<String> =
|
|
||||||
prefs.getStringSet("hidden_dashboard_cards", emptySet()) ?: emptySet()
|
|
||||||
|
|
||||||
fun setCardHiddenFromDashboard(accountNumber: String, hidden: Boolean) {
|
|
||||||
val current = getHiddenDashboardCardNumbers().toMutableSet()
|
|
||||||
if (hidden) current.add(accountNumber) else current.remove(accountNumber)
|
|
||||||
prefs.edit().putStringSet("hidden_dashboard_cards", current).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── MIB profile visibility (per loginId) ─────────────────────────────────
|
// ── MIB profile visibility (per loginId) ─────────────────────────────────
|
||||||
|
|
||||||
/** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */
|
/** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */
|
||||||
|
|||||||
@@ -9,24 +9,6 @@ data class PaymvQrData(
|
|||||||
|
|
||||||
object PaymvQrParser {
|
object PaymvQrParser {
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the BML gateway URL if [raw] is or contains one, otherwise null.
|
|
||||||
* Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR).
|
|
||||||
* For combined EMV QRs the URL is parsed from TLV (root tag 35 → sub-tag 20 → sub-sub-tag 01)
|
|
||||||
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes.
|
|
||||||
*/
|
|
||||||
fun extractBmlGatewayUrl(raw: String): String? {
|
|
||||||
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw
|
|
||||||
return try {
|
|
||||||
val root = parseTlv(raw)
|
|
||||||
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null
|
|
||||||
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null
|
|
||||||
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") }
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parse(raw: String): PaymvQrData? {
|
fun parse(raw: String): PaymvQrData? {
|
||||||
return try {
|
return try {
|
||||||
val root = parseTlv(raw)
|
val root = parseTlv(raw)
|
||||||
@@ -41,7 +23,7 @@ object PaymvQrParser {
|
|||||||
|
|
||||||
PaymvQrData(
|
PaymvQrData(
|
||||||
accountNumber = merchantInfo?.get("03"),
|
accountNumber = merchantInfo?.get("03"),
|
||||||
amount = root["54"]?.takeIf { it != "***" },
|
amount = root["54"],
|
||||||
purpose = additionalData?.get("08"),
|
purpose = additionalData?.get("08"),
|
||||||
merchantName = root["59"]
|
merchantName = root["59"]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
package sh.sar.basedbank.util
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores and loads profile images locally for BML and Fahipay accounts.
|
|
||||||
*
|
|
||||||
* Key conventions:
|
|
||||||
* BML profile: "bml_${profileId}"
|
|
||||||
* Fahipay login: "fahipay_${loginId}"
|
|
||||||
*/
|
|
||||||
object ProfileImageStore {
|
|
||||||
|
|
||||||
private fun dir(context: Context): File =
|
|
||||||
File(context.filesDir, "profile_images").also { it.mkdirs() }
|
|
||||||
|
|
||||||
private fun file(context: Context, key: String): File =
|
|
||||||
File(dir(context), "${key.replace(Regex("[^A-Za-z0-9_\\-]"), "_")}.jpg")
|
|
||||||
|
|
||||||
fun save(context: Context, key: String, bitmap: Bitmap) {
|
|
||||||
try {
|
|
||||||
file(context, key).outputStream().use {
|
|
||||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun load(context: Context, key: String): Bitmap? = try {
|
|
||||||
val f = file(context, key)
|
|
||||||
if (f.exists()) BitmapFactory.decodeFile(f.absolutePath) else null
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
|
|
||||||
fun delete(context: Context, key: String) {
|
|
||||||
try { file(context, key).delete() } catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun exists(context: Context, key: String): Boolean =
|
|
||||||
try { file(context, key).exists() } catch (_: Exception) { false }
|
|
||||||
|
|
||||||
fun clearAll(context: Context) {
|
|
||||||
try { dir(context).listFiles()?.forEach { it.delete() } } catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Key for a BML profile given its profileId. */
|
|
||||||
fun bmlKey(profileId: String) = "bml_$profileId"
|
|
||||||
|
|
||||||
/** Key for a Fahipay login given its loginId. */
|
|
||||||
fun fahipayKey(loginId: String) = "fahipay_$loginId"
|
|
||||||
|
|
||||||
/** Derives the loginId from a loginTag (e.g. "fahipay_abc" → "abc"). */
|
|
||||||
fun loginIdFromTag(loginTag: String): String =
|
|
||||||
loginTag.substringAfter('_', loginTag)
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
package sh.sar.basedbank.util
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import com.google.android.material.color.DynamicColors
|
|
||||||
import com.google.android.material.color.DynamicColorsOptions
|
|
||||||
import sh.sar.basedbank.R
|
|
||||||
|
|
||||||
object ThemeHelper {
|
|
||||||
|
|
||||||
const val PRESET_BLUE = "blue"
|
|
||||||
const val PRESET_RED = "red"
|
|
||||||
const val PRESET_GREEN = "green"
|
|
||||||
const val PRESET_CUSTOM = "custom"
|
|
||||||
|
|
||||||
fun isSystemTheme(context: Context): Boolean =
|
|
||||||
context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
|
||||||
.getString("theme", "system") == "system"
|
|
||||||
|
|
||||||
fun getAccentPreset(context: Context): String =
|
|
||||||
context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
|
||||||
.getString("accent_preset", PRESET_BLUE) ?: PRESET_BLUE
|
|
||||||
|
|
||||||
fun getCustomColor(context: Context): Int? {
|
|
||||||
val hex = context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
|
||||||
.getString("accent_custom_color", null) ?: return null
|
|
||||||
return try { Color.parseColor(hex) } catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun presetSeedColor(context: Context, preset: String): Int = when (preset) {
|
|
||||||
PRESET_RED -> Color.parseColor("#D32F2F")
|
|
||||||
PRESET_GREEN -> Color.parseColor("#4CAF50")
|
|
||||||
PRESET_CUSTOM -> getCustomColor(context) ?: Color.parseColor("#3F65AD")
|
|
||||||
else -> Color.parseColor("#3F65AD")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isPitchBlackEnabled(context: Context): Boolean =
|
|
||||||
context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
|
||||||
.getBoolean("pitch_black", false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply the user-chosen accent theme to the given activity.
|
|
||||||
* Must be called BEFORE super.onCreate() so window-level attributes
|
|
||||||
* (status bar color, etc.) are resolved from the correct overlay.
|
|
||||||
* Has no effect when the theme is set to "system" (dynamic colors are
|
|
||||||
* handled by BasedBankApp via DynamicColors.applyToActivitiesIfAvailable).
|
|
||||||
*/
|
|
||||||
fun applyAccent(activity: AppCompatActivity) {
|
|
||||||
if (isSystemTheme(activity)) return
|
|
||||||
val preset = getAccentPreset(activity)
|
|
||||||
val seed = presetSeedColor(activity, preset)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
|
|
||||||
bitmap.setPixel(0, 0, seed)
|
|
||||||
DynamicColors.applyToActivityIfAvailable(
|
|
||||||
activity,
|
|
||||||
DynamicColorsOptions.Builder().setContentBasedSource(bitmap).build()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// API < 31: apply a simple style overlay (partial palette, but functional)
|
|
||||||
val styleRes = when (preset) {
|
|
||||||
PRESET_RED -> R.style.ThemeOverlay_Accent_Orange
|
|
||||||
PRESET_GREEN -> R.style.ThemeOverlay_Accent_Green
|
|
||||||
else -> R.style.ThemeOverlay_Accent_Blue
|
|
||||||
}
|
|
||||||
activity.theme.applyStyle(styleRes, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
val prefs = activity.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
|
||||||
val isDark = prefs.getString("theme", "system") == "dark"
|
|
||||||
if (isDark && prefs.getBoolean("pitch_black", false)) {
|
|
||||||
activity.theme.applyStyle(R.style.ThemeOverlay_PitchBlack, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,9 @@
|
|||||||
package sh.sar.basedbank.util.bmlapi
|
package sh.sar.basedbank.util.bmlapi
|
||||||
|
|
||||||
import sh.sar.basedbank.R
|
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
|
||||||
object BmlCardParser {
|
object BmlCardParser {
|
||||||
|
|
||||||
/** Returns the drawable res for the card network logo, or null for non-card/unknown. */
|
|
||||||
fun cardNetworkIcon(account: BankAccount): Int? = when {
|
|
||||||
account.productCode.startsWith("C1") -> R.drawable.visa
|
|
||||||
account.productCode.startsWith("C3") -> R.drawable.americanexpress
|
|
||||||
account.productCode == "C8905" || account.productCode == "C8995" -> R.drawable.visa
|
|
||||||
account.productCode.startsWith("C8") -> R.drawable.mastercard
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the asset path for the card image.
|
* Returns the asset path for the card image.
|
||||||
* The product code is stored in [BankAccount.productCode] for BML Card accounts.
|
* The product code is stored in [BankAccount.productCode] for BML Card accounts.
|
||||||
|
|||||||
@@ -26,13 +26,11 @@ object BmlDashboardParser {
|
|||||||
statusLabel = if (isActive) null else account.statusDesc
|
statusLabel = if (isActive) null else account.statusDesc
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
|
|
||||||
AccountListDisplay(
|
AccountListDisplay(
|
||||||
name = account.accountBriefName,
|
name = account.accountBriefName,
|
||||||
number = account.accountNumber,
|
number = account.accountNumber,
|
||||||
typeLabel = productLabel(account.accountTypeName),
|
typeLabel = productLabel(account.accountTypeName),
|
||||||
balance = listBalance(account),
|
balance = listBalance(account)
|
||||||
blockedBalance = if (blocked > 0.0) "${account.currencyName} ${account.blockedAmount}" else null
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,9 +52,9 @@ object BmlDashboardParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Balance shown in the accounts list — available balance, consistent with transfer/contact picker. */
|
/** Balance shown in the accounts list — ledger (working) balance for BML CASA. */
|
||||||
fun listBalance(account: BankAccount): String =
|
fun listBalance(account: BankAccount): String =
|
||||||
"${account.currencyName} ${account.availableBalance}"
|
"${account.currencyName} ${account.currentBalance}"
|
||||||
|
|
||||||
fun cardBrandIcon(productName: String): Int = when {
|
fun cardBrandIcon(productName: String): Int = when {
|
||||||
productName.contains("AMEX", ignoreCase = true) ||
|
productName.contains("AMEX", ignoreCase = true) ||
|
||||||
|
|||||||
@@ -5,16 +5,12 @@ import sh.sar.basedbank.util.AccountListDisplay
|
|||||||
|
|
||||||
object MibAccountParser {
|
object MibAccountParser {
|
||||||
|
|
||||||
fun displayData(account: BankAccount): AccountListDisplay {
|
fun displayData(account: BankAccount) = AccountListDisplay(
|
||||||
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
|
name = account.accountBriefName,
|
||||||
return AccountListDisplay(
|
number = account.accountNumber,
|
||||||
name = account.accountBriefName,
|
typeLabel = productLabel(account.accountTypeName),
|
||||||
number = account.accountNumber,
|
balance = "${account.currencyName} ${account.availableBalance}"
|
||||||
typeLabel = productLabel(account.accountTypeName),
|
)
|
||||||
balance = "${account.currencyName} ${account.availableBalance}",
|
|
||||||
blockedBalance = if (blocked > 0.0) "${account.currencyName} ${account.blockedAmount}" else null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a display-ready product label for a MIB (Faisanet) account type name.
|
* Returns a display-ready product label for a MIB (Faisanet) account type name.
|
||||||
|
|||||||
@@ -5,16 +5,13 @@ import sh.sar.basedbank.util.AccountHistoryDisplay
|
|||||||
|
|
||||||
object MibHistoryParser {
|
object MibHistoryParser {
|
||||||
|
|
||||||
fun displayData(account: BankAccount): AccountHistoryDisplay {
|
fun displayData(account: BankAccount) = AccountHistoryDisplay(
|
||||||
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
|
name = account.accountBriefName,
|
||||||
return AccountHistoryDisplay(
|
number = account.accountNumber,
|
||||||
name = account.accountBriefName,
|
bankPill = null, // MIB has no bank pill
|
||||||
number = account.accountNumber,
|
typeLabel = MibAccountParser.productLabel(account.accountTypeName),
|
||||||
bankPill = null, // MIB has no bank pill
|
availableBalance = "${account.currencyName} ${account.availableBalance}",
|
||||||
typeLabel = MibAccountParser.productLabel(account.accountTypeName),
|
workingBalance = "${account.currencyName} ${account.currentBalance}",
|
||||||
availableBalance = "${account.currencyName} ${account.availableBalance}",
|
blockedBalance = null
|
||||||
workingBalance = "${account.currencyName} ${account.currentBalance}",
|
)
|
||||||
blockedBalance = if (blocked > 0.0) "${account.currencyName} ${account.blockedAmount}" else null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:duration="250"
|
|
||||||
android:propertyName="trimPathEnd"
|
|
||||||
android:valueFrom="0"
|
|
||||||
android:valueTo="1"
|
|
||||||
android:valueType="floatType"
|
|
||||||
android:interpolator="@android:interpolator/accelerate_decelerate" />
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:duration="250"
|
|
||||||
android:propertyName="trimPathEnd"
|
|
||||||
android:valueFrom="1"
|
|
||||||
android:valueTo="0"
|
|
||||||
android:valueType="floatType"
|
|
||||||
android:interpolator="@android:interpolator/accelerate_decelerate" />
|
|
||||||
@@ -5,20 +5,19 @@
|
|||||||
android:viewportWidth="60"
|
android:viewportWidth="60"
|
||||||
android:viewportHeight="60">
|
android:viewportHeight="60">
|
||||||
|
|
||||||
|
<!-- White background -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M0 59.776h59.785V0H0z"/>
|
||||||
|
|
||||||
<!-- Red background -->
|
<!-- Red background -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#E21B23"
|
android:fillColor="#E21B23"
|
||||||
android:pathData="M0 60h60V0H0z"/>
|
android:pathData="M3.297 56.421h53.191V3.356H3.298z"/>
|
||||||
|
|
||||||
<!-- White logo mark -->
|
<!-- White logo mark -->
|
||||||
<group
|
<path
|
||||||
android:scaleX="0.85"
|
android:fillColor="#FFFFFF"
|
||||||
android:scaleY="0.85"
|
android:fillType="evenOdd"
|
||||||
android:pivotX="30"
|
android:pathData="M37.421 6.708v34.059h-3.7V20.853L22.763 40.767H18.65l18.77-34.06zM18.517 51.073l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.03c-.613.313-1.228.626-1.88.626-.65 0-1.265-.313-1.879-.626-.553-.283-1.108-.564-1.624-.564-.514 0-1.069.28-1.62.564-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.068.28-1.62.564c-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.069.28-1.622.564c-.614.313-1.228.626-1.879.626-.6 0-1.167-.265-1.73-.55v-1.446zm0-2.816l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.028c-.613.315-1.228.627-1.88.627-.65 0-1.265-.312-1.879-.627-.553-.281-1.108-.562-1.624-.562-.514 0-1.069.28-1.62.562-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.068.28-1.62.562c-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.069.28-1.622.562c-.614.315-1.228.627-1.879.627-.6 0-1.167-.264-1.73-.55v-1.445zm12.428-6.042h12.41v3.969h-12.41v-.001H18.531l-2.1-3.968h14.514z"/>
|
||||||
android:pivotY="30">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M37.421 6.708v34.059h-3.7V20.853L22.763 40.767H18.65l18.77-34.06zM18.517 51.073l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.03c-.613.313-1.228.626-1.88.626-.65 0-1.265-.313-1.879-.626-.553-.283-1.108-.564-1.624-.564-.514 0-1.069.28-1.62.564-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.068.28-1.62.564c-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.069.28-1.622.564c-.614.313-1.228.626-1.879.626-.6 0-1.167-.265-1.73-.55v-1.446zm0-2.816l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.028c-.613.315-1.228.627-1.88.627-.65 0-1.265-.312-1.879-.627-.553-.281-1.108-.562-1.624-.562-.514 0-1.069.28-1.62.562-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.068.28-1.62.562c-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.069.28-1.622.562c-.614.315-1.228.627-1.879.627-.6 0-1.167-.264-1.73-.55v-1.445zm12.428-6.042h12.41v3.969h-12.41v-.001H18.531l-2.1-3.968h14.514z"/>
|
|
||||||
</group>
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:tint="?attr/colorControlNormal">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M12,15.2c1.767,0 3.2,-1.433 3.2,-3.2s-1.433,-3.2 -3.2,-3.2 -3.2,1.433 -3.2,3.2 1.433,3.2 3.2,3.2zM9,2L7.17,4H4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2H9zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z"/>
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/black"
|
|
||||||
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z" />
|
|
||||||
</vector>
|
|
||||||