Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
26dcb20f7f
|
|||
|
33eb33e18c
|
|||
|
6a910facaf
|
|||
|
e3c6b3a695
|
|||
|
e978f11343
|
|||
|
d227d468b1
|
|||
|
d0fb88d15a
|
|||
|
b08d983077
|
|||
|
c7c89184c0
|
|||
|
0e5435f0fe
|
|||
|
3bb44f1c32
|
|||
|
5dc1a5dbc9
|
|||
|
982596f2a8
|
|||
|
140b0069bd
|
|||
|
74ec9c383c
|
|||
|
b4f66342af
|
|||
|
f575941141
|
|||
|
ceaad0e313
|
|||
|
528663a330
|
|||
|
a1abbc9843
|
|||
|
ffee918258
|
|||
|
fc7fa420b2
|
|||
|
5f6ec236bf
|
|||
|
890cf15fd0
|
|||
|
98a003727b
|
|||
|
9ca13d3518
|
|||
|
395e2308a0
|
|||
|
ad7c5a4e5b
|
|||
|
0ba2396c2c
|
@@ -17,6 +17,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n' | base64 -d > app/key.jks
|
echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n' | base64 -d > app/key.jks
|
||||||
echo "${{ secrets.DOTENV_BASE64 }}" | tr -d '\n' | base64 -d > .build/release/.env
|
echo "${{ secrets.DOTENV_BASE64 }}" | tr -d '\n' | base64 -d > .build/release/.env
|
||||||
|
echo "ACCOUNT_MVR=${{ vars.ACCOUNT_MVR }}" >> .build/release/.env
|
||||||
|
echo "ACCOUNT_USD=${{ vars.ACCOUNT_USD }}" >> .build/release/.env
|
||||||
|
|
||||||
- name: Build APK
|
- name: Build APK
|
||||||
working-directory: .build/release
|
working-directory: .build/release
|
||||||
|
|||||||
Generated
+2
-2
@@ -4,10 +4,10 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-05-28T18:41:19.777722821Z">
|
<DropdownSelection timestamp="2026-06-03T08:28:30.389803148Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=4254e2f" />
|
<DeviceId pluginId="Default" identifier="serial=10.0.1.245:5555;connection=d182cf37" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
|
|||||||
+16
-2
@@ -1,8 +1,18 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val localProps = Properties().also { props ->
|
||||||
|
val f = rootProject.file("local.properties")
|
||||||
|
if (f.exists()) props.load(f.inputStream())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun localOrEnv(key: String, envKey: String) =
|
||||||
|
localProps.getProperty(key) ?: System.getenv(envKey) ?: ""
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "sh.sar.basedbank"
|
namespace = "sh.sar.basedbank"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
@@ -11,10 +21,13 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 12
|
versionCode = 17
|
||||||
versionName = "1.0.13"
|
versionName = "1.0.18"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
buildConfigField("String", "ACCOUNT_MVR", "\"${localOrEnv("account.mvr", "ACCOUNT_MVR")}\"")
|
||||||
|
buildConfigField("String", "ACCOUNT_USD", "\"${localOrEnv("account.usd", "ACCOUNT_USD")}\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -49,6 +62,7 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_logo_background">#CC0000</color>
|
||||||
|
</resources>
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<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-permission android:name="android.permission.NFC" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
||||||
|
|
||||||
|
|||||||
@@ -124,8 +124,17 @@ class LockActivity : AppCompatActivity() {
|
|||||||
else
|
else
|
||||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||||
val btn = MaterialButton(this, null, style).apply {
|
val btn = MaterialButton(this, null, style).apply {
|
||||||
text = key
|
if (key == "⌫" || key == "✓") {
|
||||||
textSize = 24f
|
text = ""
|
||||||
|
icon = ContextCompat.getDrawable(this@LockActivity,
|
||||||
|
if (key == "⌫") R.drawable.ic_backspace else R.drawable.ic_check)
|
||||||
|
iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
|
||||||
|
iconPadding = 0
|
||||||
|
iconSize = (28 * dp).toInt()
|
||||||
|
} else {
|
||||||
|
text = key
|
||||||
|
textSize = 24f
|
||||||
|
}
|
||||||
insetTop = 0; insetBottom = 0
|
insetTop = 0; insetBottom = 0
|
||||||
minimumWidth = 0; minimumHeight = 0
|
minimumWidth = 0; minimumHeight = 0
|
||||||
cornerRadius = btnSize / 2
|
cornerRadius = btnSize / 2
|
||||||
|
|||||||
@@ -153,6 +153,46 @@ class BmlHistoryClient {
|
|||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun fetchPendingHistory(
|
||||||
|
session: BmlSession,
|
||||||
|
accountId: String,
|
||||||
|
accountDisplayName: String,
|
||||||
|
accountNumber: String
|
||||||
|
): List<BankTransaction> {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BML_BASE_URL/api/mobile/history/pending/$accountId")
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val code = resp.code
|
||||||
|
val json = resp.body?.string() ?: return emptyList()
|
||||||
|
resp.close()
|
||||||
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
|
if (code in 500..599) throw BankServerException("BML")
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
|
val payload = root.optJSONArray("payload") ?: return emptyList()
|
||||||
|
(0 until payload.length()).map { i ->
|
||||||
|
val item = payload.getJSONObject(i)
|
||||||
|
BankTransaction(
|
||||||
|
id = item.optString("LockedID"),
|
||||||
|
date = item.optString("FromDate"),
|
||||||
|
description = "Pending",
|
||||||
|
amount = -item.optDouble("LockedAmount", 0.0),
|
||||||
|
currency = "MVR",
|
||||||
|
counterpartyName = item.optString("Description").trim().takeIf { it.isNotBlank() },
|
||||||
|
reference = null,
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountDisplayName = accountDisplayName,
|
||||||
|
source = "BML"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
||||||
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.ui.home.AppNotification
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
private const val BML_NOTIF_BASE = "https://app.bankofmaldives.com.mv"
|
||||||
|
|
||||||
|
class BmlNotificationsClient {
|
||||||
|
|
||||||
|
private val client = newBmlApiClient()
|
||||||
|
private val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
|
||||||
|
|
||||||
|
data class FetchResult(
|
||||||
|
val items: List<AppNotification>,
|
||||||
|
val total: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
fun fetchNotifications(
|
||||||
|
session: BmlSession,
|
||||||
|
loginId: String,
|
||||||
|
group: String = "ALL",
|
||||||
|
page: Int = 1
|
||||||
|
): FetchResult {
|
||||||
|
val url = "$BML_NOTIF_BASE/api/v2/notifications?group=$group&page=$page"
|
||||||
|
return try {
|
||||||
|
val resp = client.newCall(bmlApiRequest(session, url)).execute()
|
||||||
|
if (!resp.isSuccessful) { resp.close(); return FetchResult(emptyList(), 0) }
|
||||||
|
val body = resp.body?.string().also { resp.close() } ?: return FetchResult(emptyList(), 0)
|
||||||
|
parseResponse(body, loginId)
|
||||||
|
} catch (_: Exception) { FetchResult(emptyList(), 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markAllRead(session: BmlSession): Boolean {
|
||||||
|
val url = "$BML_NOTIF_BASE/api/v2/notifications/read"
|
||||||
|
val reqBody = """{"all":true}""".toRequestBody("application/json".toMediaType())
|
||||||
|
val req = Request.Builder().url(url)
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.put(reqBody)
|
||||||
|
.build()
|
||||||
|
return try {
|
||||||
|
val resp = client.newCall(req).execute()
|
||||||
|
val ok = resp.isSuccessful
|
||||||
|
resp.close()
|
||||||
|
ok
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseResponse(body: String, loginId: String): FetchResult {
|
||||||
|
val json = JSONObject(body)
|
||||||
|
if (!json.optBoolean("success")) return FetchResult(emptyList(), 0)
|
||||||
|
val total = json.optInt("total", 0)
|
||||||
|
val payload = json.optJSONArray("payload") ?: return FetchResult(emptyList(), total)
|
||||||
|
|
||||||
|
val items = (0 until payload.length()).map { i ->
|
||||||
|
val obj = payload.getJSONObject(i)
|
||||||
|
val dataObj = obj.optJSONObject("data")
|
||||||
|
val detailFields = mutableListOf<Pair<String, String>>()
|
||||||
|
detailFields.add("Bank" to "BML")
|
||||||
|
detailFields.add("Group" to obj.optString("group"))
|
||||||
|
detailFields.add("Type" to obj.optString("type"))
|
||||||
|
if (dataObj != null) {
|
||||||
|
dataObj.keys().forEach { key ->
|
||||||
|
val v = dataObj.opt(key)?.toString()?.takeIf { it.isNotBlank() } ?: return@forEach
|
||||||
|
detailFields.add(formatKey(key) to v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val createdAt = obj.optString("created_at")
|
||||||
|
val tsMs = try { sdf.parse(createdAt)?.time ?: System.currentTimeMillis() }
|
||||||
|
catch (_: Exception) { System.currentTimeMillis() }
|
||||||
|
AppNotification(
|
||||||
|
id = obj.optString("id"),
|
||||||
|
bank = "BML",
|
||||||
|
loginId = loginId,
|
||||||
|
group = obj.optString("group", "ALERTS"),
|
||||||
|
title = obj.optString("title"),
|
||||||
|
message = obj.optString("message"),
|
||||||
|
timestampMs = tsMs,
|
||||||
|
isRead = obj.optBoolean("is_read", true),
|
||||||
|
detailFields = detailFields
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return FetchResult(items, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatKey(key: String): String =
|
||||||
|
key.replace('_', ' ').split(' ').joinToString(" ") { it.replaceFirstChar(Char::uppercase) }
|
||||||
|
}
|
||||||
@@ -84,7 +84,8 @@ class BmlTransferClient {
|
|||||||
try {
|
try {
|
||||||
val json = JSONObject(bodyStr)
|
val json = JSONObject(bodyStr)
|
||||||
if (!json.optBoolean("success")) {
|
if (!json.optBoolean("success")) {
|
||||||
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
|
val payloadStr = json.optString("payload").takeIf { it.isNotBlank() && it != "null" }
|
||||||
|
BmlTransferResult(false, errorMessage = payloadStr ?: json.optString("message").ifBlank { "Transfer failed" })
|
||||||
} else {
|
} else {
|
||||||
val payload = json.optJSONObject("payload")
|
val payload = json.optJSONObject("payload")
|
||||||
BmlTransferResult(
|
BmlTransferResult(
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.ui.home.AppNotification
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
private val SKIP_TYPES = setOf("Switch Profile")
|
||||||
|
private const val MIB_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||||
|
|
||||||
|
class MibActivityHistoryClient {
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val sdf = SimpleDateFormat("dd MMM yyyy HH:mm", Locale.US)
|
||||||
|
|
||||||
|
data class FetchResult(
|
||||||
|
val items: List<AppNotification>, // already filtered (no Switch Profile)
|
||||||
|
val rawCount: Int, // raw items returned by API before filtering
|
||||||
|
val totalCount: Int,
|
||||||
|
val nextStart: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
fun fetchActivity(
|
||||||
|
session: MibSession,
|
||||||
|
loginId: String,
|
||||||
|
start: Int,
|
||||||
|
end: Int
|
||||||
|
): FetchResult {
|
||||||
|
val cookieHeader = "mbmodel=IOS-1.0; " +
|
||||||
|
"xxid=${session.xxid}; " +
|
||||||
|
"IBSID=${session.xxid}; " +
|
||||||
|
"mbnonce=${session.nonceGenerator}; " +
|
||||||
|
"time-tracker=597"
|
||||||
|
|
||||||
|
val formBody = FormBody.Builder()
|
||||||
|
.add("start", start.toString())
|
||||||
|
.add("end", end.toString())
|
||||||
|
.add("includeCount", "1")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val req = Request.Builder()
|
||||||
|
.url("$MIB_WV_URL/aProfile/getPagedActivityHistory")
|
||||||
|
.header("Cookie", cookieHeader)
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
|
||||||
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.post(formBody)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val body = try {
|
||||||
|
val resp = client.newCall(req).execute()
|
||||||
|
if (!resp.isSuccessful) { resp.close(); return FetchResult(emptyList(), 0, 0, end + 1) }
|
||||||
|
resp.body?.string().also { resp.close() } ?: return FetchResult(emptyList(), 0, 0, end + 1)
|
||||||
|
} catch (_: Exception) { return FetchResult(emptyList(), 0, 0, end + 1) }
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val json = JSONObject(body)
|
||||||
|
if (!json.optBoolean("success")) return FetchResult(emptyList(), 0, 0, end + 1)
|
||||||
|
val totalCount = json.optString("total_count", "0").toIntOrNull() ?: 0
|
||||||
|
val dataArr = json.optJSONArray("data") ?: return FetchResult(emptyList(), 0, totalCount, end + 1)
|
||||||
|
|
||||||
|
val items = mutableListOf<AppNotification>()
|
||||||
|
val rawCount = dataArr.length()
|
||||||
|
for (i in 0 until rawCount) {
|
||||||
|
val obj = dataArr.getJSONObject(i)
|
||||||
|
val activityType = obj.optString("activityType")
|
||||||
|
if (activityType in SKIP_TYPES) continue
|
||||||
|
|
||||||
|
val pa = obj.optString("pa")
|
||||||
|
val activity = obj.optString("activity")
|
||||||
|
val pb = obj.optString("pb")
|
||||||
|
val dateStr = obj.optString("date")
|
||||||
|
|
||||||
|
val message = buildString {
|
||||||
|
append(pa)
|
||||||
|
if (activity.isNotBlank()) { append(" "); append(activity) }
|
||||||
|
if (pb.isNotBlank()) { append(" "); append(pb) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val tsMs = try { sdf.parse(dateStr)?.time ?: System.currentTimeMillis() }
|
||||||
|
catch (_: Exception) { System.currentTimeMillis() }
|
||||||
|
|
||||||
|
val detailFields = mutableListOf<Pair<String, String>>().apply {
|
||||||
|
add("Bank" to "MIB")
|
||||||
|
add("Type" to activityType)
|
||||||
|
if (pa.isNotBlank()) add("By" to pa)
|
||||||
|
if (activity.isNotBlank() && pb.isNotBlank()) add("Action" to "$activity $pb")
|
||||||
|
if (dateStr.isNotBlank()) add("Date" to dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
items.add(AppNotification(
|
||||||
|
id = obj.optString("aid"),
|
||||||
|
bank = "MIB",
|
||||||
|
loginId = loginId,
|
||||||
|
group = "ALERTS",
|
||||||
|
title = activityType,
|
||||||
|
message = message,
|
||||||
|
timestampMs = tsMs,
|
||||||
|
isRead = false, // resolved from cache in the sheet
|
||||||
|
detailFields = detailFields
|
||||||
|
))
|
||||||
|
}
|
||||||
|
FetchResult(items, rawCount, totalCount, end + 1)
|
||||||
|
} catch (_: Exception) { FetchResult(emptyList(), 0, 0, end + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keeps fetching pages until at least `minCount` non-Switch-Profile items found or all pages exhausted.
|
||||||
|
fun fetchUntilEnough(
|
||||||
|
session: MibSession,
|
||||||
|
loginId: String,
|
||||||
|
minCount: Int = 5,
|
||||||
|
pageSize: Int = 30
|
||||||
|
): FetchResult {
|
||||||
|
val accumulated = mutableListOf<AppNotification>()
|
||||||
|
var start = 1
|
||||||
|
var totalCount = 0
|
||||||
|
|
||||||
|
while (accumulated.size < minCount) {
|
||||||
|
val result = fetchActivity(session, loginId, start, start + pageSize - 1)
|
||||||
|
totalCount = result.totalCount
|
||||||
|
accumulated.addAll(result.items)
|
||||||
|
if (result.rawCount == 0 || start + pageSize - 1 >= totalCount) break
|
||||||
|
start = result.nextStart
|
||||||
|
}
|
||||||
|
return FetchResult(accumulated, accumulated.size, totalCount, start)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,9 +27,10 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
private sealed class Item {
|
private sealed class Item {
|
||||||
data class DateHeader(val label: String) : Item()
|
data class DateHeader(val label: String) : Item()
|
||||||
data class Trx(val transaction: BankTransaction) : Item()
|
data class Trx(val transaction: BankTransaction, val showDate: Boolean = false) : Item()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val pendingItems = mutableListOf<Item>()
|
||||||
private val displayItems = mutableListOf<Item>()
|
private val displayItems = mutableListOf<Item>()
|
||||||
private var lastInsertedDateKey = ""
|
private var lastInsertedDateKey = ""
|
||||||
private val imageCache = mutableMapOf<String, Bitmap>()
|
private val imageCache = mutableMapOf<String, Bitmap>()
|
||||||
@@ -48,9 +49,11 @@ class AccountHistoryAdapter(
|
|||||||
if (hideAmounts == hide) return
|
if (hideAmounts == hide) return
|
||||||
hideAmounts = hide
|
hideAmounts = hide
|
||||||
notifyItemChanged(0) // refresh header card
|
notifyItemChanged(0) // refresh header card
|
||||||
// refresh all transaction rows
|
for (i in pendingItems.indices) {
|
||||||
|
if (pendingItems[i] is Item.Trx) notifyItemChanged(i + 1)
|
||||||
|
}
|
||||||
for (i in displayItems.indices) {
|
for (i in displayItems.indices) {
|
||||||
if (displayItems[i] is Item.Trx) notifyItemChanged(i + 1)
|
if (displayItems[i] is Item.Trx) notifyItemChanged(1 + pendingItems.size + i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +61,7 @@ class AccountHistoryAdapter(
|
|||||||
imageCache[counterpartyName] = bitmap
|
imageCache[counterpartyName] = bitmap
|
||||||
displayItems.forEachIndexed { i, item ->
|
displayItems.forEachIndexed { i, item ->
|
||||||
if (item is Item.Trx && item.transaction.counterpartyName == counterpartyName)
|
if (item is Item.Trx && item.transaction.counterpartyName == counterpartyName)
|
||||||
notifyItemChanged(i + 1) // +1 for account header at position 0
|
notifyItemChanged(1 + pendingItems.size + i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +69,19 @@ class AccountHistoryAdapter(
|
|||||||
iconUrlCache[url] = bitmap
|
iconUrlCache[url] = bitmap
|
||||||
displayItems.forEachIndexed { i, item ->
|
displayItems.forEachIndexed { i, item ->
|
||||||
if (item is Item.Trx && item.transaction.iconUrl == url)
|
if (item is Item.Trx && item.transaction.iconUrl == url)
|
||||||
notifyItemChanged(i + 1) // +1 for account header at position 0
|
notifyItemChanged(1 + pendingItems.size + i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setPendingTransactions(transactions: List<BankTransaction>) {
|
||||||
|
pendingItems.clear()
|
||||||
|
if (transactions.isNotEmpty()) {
|
||||||
|
pendingItems.add(Item.DateHeader("Pending"))
|
||||||
|
for (trx in transactions) pendingItems.add(Item.Trx(trx, showDate = true))
|
||||||
|
}
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
private var _showLoadingFooter = false
|
private var _showLoadingFooter = false
|
||||||
var showLoadingFooter: Boolean
|
var showLoadingFooter: Boolean
|
||||||
get() = _showLoadingFooter
|
get() = _showLoadingFooter
|
||||||
@@ -127,18 +139,24 @@ class AccountHistoryAdapter(
|
|||||||
displayItems.add(Item.Trx(trx))
|
displayItems.add(Item.Trx(trx))
|
||||||
}
|
}
|
||||||
val added = displayItems.size - oldCount
|
val added = displayItems.size - oldCount
|
||||||
if (added > 0) notifyItemRangeInserted(1 + oldCount, added) // +1 for account header
|
if (added > 0) notifyItemRangeInserted(1 + pendingItems.size + oldCount, added)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position 0 = account header card
|
// Position 0 = account header card
|
||||||
// Positions 1..displayItems.size = date headers + transactions
|
// Positions 1..pendingItems.size = pending header + pending transactions
|
||||||
|
// Positions 1+pendingItems.size..1+pendingItems.size+displayItems.size = date headers + transactions
|
||||||
// Last position = loading footer when showLoadingFooter = true
|
// Last position = loading footer when showLoadingFooter = true
|
||||||
override fun getItemCount() = 1 + displayItems.size + if (_showLoadingFooter) 1 else 0
|
override fun getItemCount() = 1 + pendingItems.size + displayItems.size + if (_showLoadingFooter) 1 else 0
|
||||||
|
|
||||||
|
private fun itemAt(position: Int): Item {
|
||||||
|
val idx = position - 1
|
||||||
|
return if (idx < pendingItems.size) pendingItems[idx] else displayItems[idx - pendingItems.size]
|
||||||
|
}
|
||||||
|
|
||||||
override fun getItemViewType(position: Int) = when {
|
override fun getItemViewType(position: Int) = when {
|
||||||
position == 0 -> TYPE_HEADER
|
position == 0 -> TYPE_HEADER
|
||||||
_showLoadingFooter && position == itemCount - 1 -> TYPE_LOADING
|
_showLoadingFooter && position == itemCount - 1 -> TYPE_LOADING
|
||||||
else -> when (displayItems[position - 1]) {
|
else -> when (itemAt(position)) {
|
||||||
is Item.DateHeader -> TYPE_DATE_HEADER
|
is Item.DateHeader -> TYPE_DATE_HEADER
|
||||||
is Item.Trx -> TYPE_TRANSACTION
|
is Item.Trx -> TYPE_TRANSACTION
|
||||||
}
|
}
|
||||||
@@ -157,8 +175,11 @@ class AccountHistoryAdapter(
|
|||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
when (holder) {
|
when (holder) {
|
||||||
is HeaderVH -> holder.bind(display)
|
is HeaderVH -> holder.bind(display)
|
||||||
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
|
is DateHeaderVH -> holder.bind((itemAt(position) as Item.DateHeader).label)
|
||||||
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
|
is TransactionVH -> {
|
||||||
|
val item = itemAt(position) as Item.Trx
|
||||||
|
holder.bind(item.transaction, item.showDate)
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,7 +224,7 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
||||||
RecyclerView.ViewHolder(b.root) {
|
RecyclerView.ViewHolder(b.root) {
|
||||||
fun bind(trx: BankTransaction) {
|
fun bind(trx: BankTransaction, showDate: Boolean = false) {
|
||||||
val isCredit = trx.amount >= 0
|
val isCredit = trx.amount >= 0
|
||||||
val color = sourceColor(trx.source)
|
val color = sourceColor(trx.source)
|
||||||
val name = trx.counterpartyName ?: trx.description
|
val name = trx.counterpartyName ?: trx.description
|
||||||
@@ -239,7 +260,7 @@ class AccountHistoryAdapter(
|
|||||||
b.tvCounterparty.visibility = View.GONE
|
b.tvCounterparty.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
b.tvDate.text = formatTime(trx.date)
|
b.tvDate.text = if (showDate) formatDateOnly(trx.date) else formatTime(trx.date)
|
||||||
|
|
||||||
if (hideAmounts) {
|
if (hideAmounts) {
|
||||||
b.tvAmount.text = "${trx.currency} ••••••"
|
b.tvAmount.text = "${trx.currency} ••••••"
|
||||||
@@ -286,6 +307,7 @@ class AccountHistoryAdapter(
|
|||||||
private val MIB_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
private val MIB_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||||
private val BML_FMT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
|
private val BML_FMT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
|
||||||
private val DATE_HEADER_FMT = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
|
private val DATE_HEADER_FMT = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
|
||||||
|
private val DATE_ONLY_FMT = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
|
||||||
private val TIME_FMT = SimpleDateFormat("h:mm a", Locale.getDefault())
|
private val TIME_FMT = SimpleDateFormat("h:mm a", Locale.getDefault())
|
||||||
private val FULL_DATE_FMT = SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.getDefault())
|
private val FULL_DATE_FMT = SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.getDefault())
|
||||||
|
|
||||||
@@ -307,6 +329,11 @@ class AccountHistoryAdapter(
|
|||||||
return DATE_HEADER_FMT.format(date)
|
return DATE_HEADER_FMT.format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun formatDateOnly(raw: String): String {
|
||||||
|
val date = parseDate(raw) ?: return raw.take(10)
|
||||||
|
return DATE_ONLY_FMT.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
fun formatTime(raw: String): String {
|
fun formatTime(raw: String): String {
|
||||||
val date = parseDate(raw) ?: return ""
|
val date = parseDate(raw) ?: return ""
|
||||||
return TIME_FMT.format(date)
|
return TIME_FMT.format(date)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ 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.models.BankServerException
|
||||||
|
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||||
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
|
||||||
@@ -138,6 +139,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
(activity as? HomeActivity)?.setRefreshing(true)
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
loadNextPage()
|
loadNextPage()
|
||||||
|
loadPendingTransactions()
|
||||||
|
|
||||||
binding.swipeRefresh.setOnRefreshListener {
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -184,6 +186,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
binding.emptyView.visibility = View.GONE
|
binding.emptyView.visibility = View.GONE
|
||||||
}
|
}
|
||||||
loadNextPage()
|
loadNextPage()
|
||||||
|
loadPendingTransactions()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadNextPage() {
|
private fun loadNextPage() {
|
||||||
@@ -250,6 +253,26 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadPendingTransactions() {
|
||||||
|
if (account.bank != "BML" || account.profileType != "BML") return
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val session = app.bmlSessionFor(account) ?: return
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val pending = withContext(Dispatchers.IO) {
|
||||||
|
BmlHistoryClient().fetchPendingHistory(
|
||||||
|
session = session,
|
||||||
|
accountId = account.internalId,
|
||||||
|
accountDisplayName = account.accountBriefName,
|
||||||
|
accountNumber = account.accountNumber
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
adapter.setPendingTransactions(pending)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadContactImage(name: String) {
|
private fun loadContactImage(name: String) {
|
||||||
if (!pendingImageNames.add(name)) return
|
if (!pendingImageNames.add(name)) return
|
||||||
val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return
|
val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
data class AppNotification(
|
||||||
|
val id: String,
|
||||||
|
val bank: String, // "BML" or "MIB"
|
||||||
|
val loginId: String, // key in bmlSessions / mibSessions
|
||||||
|
val group: String, // "ALERTS" or "INFORMATION"
|
||||||
|
val title: String,
|
||||||
|
val message: String,
|
||||||
|
val timestampMs: Long,
|
||||||
|
val isRead: Boolean,
|
||||||
|
val detailFields: List<Pair<String, String>> = emptyList()
|
||||||
|
)
|
||||||
@@ -330,6 +330,7 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
.setTitle(R.string.bml_qr_payment_success)
|
.setTitle(R.string.bml_qr_payment_success)
|
||||||
.setView(container)
|
.setView(container)
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||||
}
|
}
|
||||||
.setCancelable(false)
|
.setCancelable(false)
|
||||||
|
|||||||
@@ -0,0 +1,561 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.animation.ValueAnimator
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.*
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.*
|
||||||
|
import android.view.animation.DecelerateInterpolator
|
||||||
|
import android.view.animation.LinearInterpolator
|
||||||
|
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.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
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 = { /* unused: tap on unlocked center locks the wheel */ }
|
||||||
|
onWheelCenterLockedTap = { (activity as? HomeActivity)?.notifyWheelLockTap() }
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
|
||||||
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
(footerIcon.layoutParams as android.widget.LinearLayout.LayoutParams).bottomMargin = dp(16f).toInt() + bars.bottom
|
||||||
|
footerIcon.requestLayout()
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unlockWheelLock() {
|
||||||
|
wheelView?.unlockWheel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 isWheelLocked = false
|
||||||
|
set(value) { field = value; invalidate() }
|
||||||
|
|
||||||
|
var onItemClick: ((Int) -> Unit)? = null
|
||||||
|
var onCenterClick: (() -> Unit)? = null
|
||||||
|
var onWheelCenterLockedTap: (() -> 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
|
||||||
|
private var centerUnlockedBitmap: Bitmap? = null
|
||||||
|
private val grayFilter = ColorMatrixColorFilter(ColorMatrix().apply { setSaturation(0f) })
|
||||||
|
private var lockShakeAngle = 0f
|
||||||
|
private var shakeAnimator: ValueAnimator? = 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)
|
||||||
|
centerUnlockedBitmap = tintedBitmap(R.drawable.ic_lock_open, 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)
|
||||||
|
val activeCenterBitmap = if (isWheelLocked) centerBitmap else centerUnlockedBitmap
|
||||||
|
activeCenterBitmap?.let {
|
||||||
|
canvas.save()
|
||||||
|
// Shake pivots around the bottom-centre of the icon
|
||||||
|
if (lockShakeAngle != 0f) canvas.rotate(lockShakeAngle, cx, cy + it.height / 2f)
|
||||||
|
canvas.drawBitmap(it, cx - it.width / 2f, cy - it.height / 2f, bitmapPaint)
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Icon — radially oriented; top items are naturally upside-down
|
||||||
|
iconBitmaps.getOrNull(index)?.let { bmp ->
|
||||||
|
canvas.save()
|
||||||
|
canvas.translate(iconX, iconY)
|
||||||
|
canvas.rotate(midDeg - 90f)
|
||||||
|
if (isWheelLocked) {
|
||||||
|
bitmapPaint.colorFilter = grayFilter
|
||||||
|
bitmapPaint.alpha = 100
|
||||||
|
}
|
||||||
|
canvas.drawBitmap(bmp, -bmp.width / 2f, -bmp.height / 2f, bitmapPaint)
|
||||||
|
if (isWheelLocked) {
|
||||||
|
bitmapPaint.colorFilter = null
|
||||||
|
bitmapPaint.alpha = 255
|
||||||
|
}
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Curved label — same radial orientation as icons.
|
||||||
|
// In the local rotated frame the wheel arc is a circle of radius `labelRadius`
|
||||||
|
// with its centre directly "above" at (0, -labelRadius). A CCW arc through (0,0)
|
||||||
|
// flows rightward at that point, matching the natural reading direction at 6 o'clock.
|
||||||
|
val labelRadius = outerRadius * 0.84f
|
||||||
|
val textX = cx + cosA * labelRadius
|
||||||
|
val textY = cy + sinA * labelRadius
|
||||||
|
val label = items[index].label
|
||||||
|
textPaint.color = if (isWheelLocked) (labelColor and 0x00FFFFFF) or (80 shl 24) else labelColor
|
||||||
|
textPaint.textAlign = Paint.Align.LEFT
|
||||||
|
val halfAngleDeg = Math.toDegrees((textPaint.measureText(label) / 2.0) / labelRadius).toFloat()
|
||||||
|
val localArcRect = RectF(-labelRadius, -2f * labelRadius, labelRadius, 0f)
|
||||||
|
val arcPath = Path().apply { addArc(localArcRect, 90f + halfAngleDeg, -(halfAngleDeg * 2f)) }
|
||||||
|
canvas.save()
|
||||||
|
canvas.translate(textX, textY)
|
||||||
|
canvas.rotate(midDeg - 90f)
|
||||||
|
canvas.drawTextOnPath(label, arcPath, 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 -> {
|
||||||
|
if (!isDragging) {
|
||||||
|
val dist = hypot(event.x - cx, event.y - cy)
|
||||||
|
when {
|
||||||
|
dist <= innerRadius -> {
|
||||||
|
if (isWheelLocked) {
|
||||||
|
onWheelCenterLockedTap?.invoke()
|
||||||
|
} else {
|
||||||
|
isWheelLocked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dist <= outerRadius -> {
|
||||||
|
if (isWheelLocked) {
|
||||||
|
val idx = segmentAt(event.x, event.y)
|
||||||
|
if (idx in items.indices) animateToSixOClock(idx) {
|
||||||
|
vibrateDevice()
|
||||||
|
shakeLock()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val idx = segmentAt(event.x, event.y)
|
||||||
|
if (idx in items.indices) animateToSixOClock(idx) { onItemClick?.invoke(items[idx].navId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val vel = computeVelocity()
|
||||||
|
if (abs(vel) > 0.05f) fling(vel) else snapToNearest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_CANCEL -> {
|
||||||
|
if (isDragging) {
|
||||||
|
val vel = computeVelocity()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun vibrateDevice() {
|
||||||
|
val v = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||||
|
v.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shakeLock() {
|
||||||
|
shakeAnimator?.cancel()
|
||||||
|
shakeAnimator = ValueAnimator.ofFloat(0f, -18f, 18f, -12f, 12f, -6f, 6f, 0f).apply {
|
||||||
|
duration = 500
|
||||||
|
interpolator = LinearInterpolator()
|
||||||
|
addUpdateListener { lockShakeAngle = it.animatedValue as Float; invalidate() }
|
||||||
|
addListener(object : AnimatorListenerAdapter() {
|
||||||
|
override fun onAnimationEnd(a: Animator) { lockShakeAngle = 0f; invalidate() }
|
||||||
|
})
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unlockWheel() {
|
||||||
|
isWheelLocked = false
|
||||||
|
lockShakeAngle = 0f
|
||||||
|
shakeAnimator?.cancel()
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ 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.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.NfcPaymentUtil
|
||||||
import sh.sar.basedbank.util.PaymvQrParser
|
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
|
||||||
@@ -99,11 +100,11 @@ class DashboardFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.cardPendingFinances.setOnClickListener {
|
binding.cardPendingFinances.setOnClickListener {
|
||||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.cardOverdue.setOnClickListener {
|
binding.cardOverdue.setOnClickListener {
|
||||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
|
||||||
}
|
}
|
||||||
|
|
||||||
val cardAdapter = DashboardCardAdapter()
|
val cardAdapter = DashboardCardAdapter()
|
||||||
@@ -134,7 +135,7 @@ class DashboardFragment : Fragment() {
|
|||||||
|
|
||||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
|
||||||
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
val isBottomNav = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
|
||||||
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,8 +146,7 @@ class DashboardFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
val isBottom = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
val isBottom = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
|
||||||
.getBoolean("bottom_nav", false)
|
|
||||||
if (isBottom) {
|
if (isBottom) {
|
||||||
requireActivity().title = getString(R.string.app_name)
|
requireActivity().title = getString(R.string.app_name)
|
||||||
val size = (28 * resources.displayMetrics.density).toInt()
|
val size = (28 * resources.displayMetrics.density).toInt()
|
||||||
@@ -171,7 +171,7 @@ class DashboardFragment : Fragment() {
|
|||||||
|
|
||||||
private fun refreshQuickActions() {
|
private fun refreshQuickActions() {
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val isBottom = NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
|
||||||
if (isBottom) {
|
if (isBottom) {
|
||||||
binding.buttonBar.visibility = View.GONE
|
binding.buttonBar.visibility = View.GONE
|
||||||
return
|
return
|
||||||
@@ -427,11 +427,13 @@ class DashboardFragment : Fragment() {
|
|||||||
if (isMib) {
|
if (isMib) {
|
||||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
val accountNumber = (item as CardItem.Bml).account.accountNumber
|
NfcPaymentUtil.checkAndProceed(requireContext()) {
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
val accountNumber = (item as CardItem.Bml).account.accountNumber
|
||||||
R.id.nav_pay_with_card,
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
|
R.id.nav_pay_with_card,
|
||||||
)
|
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ 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 val navBackStack = ArrayDeque<Int>()
|
||||||
|
|
||||||
private var backPressedOnce = false
|
private var backPressedOnce = false
|
||||||
private val backPressHandler = Handler(Looper.getMainLooper())
|
private val backPressHandler = Handler(Looper.getMainLooper())
|
||||||
@@ -89,6 +91,10 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
private val warningRunnable = Runnable { showAutolockWarning() }
|
private val warningRunnable = Runnable { showAutolockWarning() }
|
||||||
|
|
||||||
private var isLocked = false
|
private var isLocked = false
|
||||||
|
private var pendingWheelUnlock = false
|
||||||
|
|
||||||
|
private var hasUnreadNotifications = false
|
||||||
|
private var notifMenuItem: MenuItem? = null
|
||||||
|
|
||||||
private val autolockRunnable = Runnable {
|
private val autolockRunnable = Runnable {
|
||||||
countdownTimer?.cancel(); countdownTimer = null
|
countdownTimer?.cancel(); countdownTimer = null
|
||||||
@@ -98,6 +104,21 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
if (securitySet) lock()
|
if (securitySet) lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun lockApp() = lock()
|
||||||
|
|
||||||
|
fun notifyWheelLockTap() {
|
||||||
|
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
|
.getString("security_method", null) != null
|
||||||
|
if (securitySet) {
|
||||||
|
pendingWheelUnlock = true
|
||||||
|
lock()
|
||||||
|
} else {
|
||||||
|
// No security configured — unlock the wheel immediately
|
||||||
|
(supportFragmentManager.findFragmentById(R.id.contentFrame) as? CircularNavFragment)
|
||||||
|
?.unlockWheelLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun lock() {
|
private fun lock() {
|
||||||
isLocked = true
|
isLocked = true
|
||||||
startActivity(
|
startActivity(
|
||||||
@@ -156,7 +177,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 -> TransferFragment()
|
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
||||||
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()
|
||||||
@@ -254,8 +275,13 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
navigateTo(navDest, fragment)
|
navigateTo(navDest, fragment)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
show(DashboardFragment())
|
val initPrefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
if (NavCustomization.getNavMode(initPrefs) == NavCustomization.NAV_MODE_CIRCULAR) {
|
||||||
|
show(CircularNavFragment())
|
||||||
|
} else {
|
||||||
|
show(DashboardFragment())
|
||||||
|
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,14 +296,40 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
// Let CardsFragment handle back if in manage mode
|
// Let CardsFragment handle back if in manage mode
|
||||||
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
||||||
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return
|
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()
|
||||||
|
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
|
||||||
|
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()
|
||||||
|
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// In bottom nav mode, pressing back navigates up the hierarchy
|
// In bottom nav mode, pressing back navigates up the hierarchy
|
||||||
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
if (navMode == NavCustomization.NAV_MODE_BOTTOM && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
||||||
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
|
||||||
// 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())
|
||||||
@@ -333,21 +385,44 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
.commit()
|
.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateNavSelection(itemId: Int) {
|
||||||
|
binding.navigationView.setCheckedItem(itemId)
|
||||||
|
if (binding.bottomNavigation.visibility == View.VISIBLE) {
|
||||||
|
val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
|
||||||
|
.map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
|
||||||
|
val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
|
||||||
|
if (selectId != null) {
|
||||||
|
suppressBottomNavCallback = true
|
||||||
|
binding.bottomNavigation.selectedItemId = selectId
|
||||||
|
suppressBottomNavCallback = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun applyNavMode() {
|
fun applyNavMode() {
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
when (NavCustomization.getNavMode(prefs)) {
|
||||||
if (isBottom) {
|
NavCustomization.NAV_MODE_BOTTOM -> {
|
||||||
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 {
|
}
|
||||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
|
NavCustomization.NAV_MODE_CIRCULAR -> {
|
||||||
toggle.isDrawerIndicatorEnabled = true
|
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||||
toggle.syncState()
|
toggle.isDrawerIndicatorEnabled = false
|
||||||
binding.bottomNavigation.visibility = View.GONE
|
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,11 +461,15 @@ 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 -> TransferFragment()
|
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
||||||
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()
|
||||||
@@ -398,25 +477,16 @@ fun applyNavLabelVisibility() {
|
|||||||
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 -> CardsFragment()
|
||||||
|
R.id.nav_more -> MoreFragment()
|
||||||
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
||||||
}
|
}
|
||||||
show(dest)
|
show(dest)
|
||||||
binding.navigationView.setCheckedItem(itemId)
|
updateNavSelection(itemId)
|
||||||
if (binding.bottomNavigation.visibility == View.VISIBLE) {
|
|
||||||
val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
|
|
||||||
.map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
|
|
||||||
val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
|
|
||||||
if (selectId != null) {
|
|
||||||
suppressBottomNavCallback = true
|
|
||||||
binding.bottomNavigation.selectedItemId = selectId
|
|
||||||
suppressBottomNavCallback = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setBottomNavVisible(visible: Boolean) {
|
fun setBottomNavVisible(visible: Boolean) {
|
||||||
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
if (isBottom) {
|
if (NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM) {
|
||||||
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
|
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,6 +515,12 @@ fun applyNavLabelVisibility() {
|
|||||||
.commit()
|
.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showWithBackStackAndNav(fragment: Fragment, itemId: Int) {
|
||||||
|
navBackStack.addLast(binding.bottomNavigation.selectedItemId)
|
||||||
|
showWithBackStack(fragment)
|
||||||
|
updateNavSelection(itemId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun routeSharedQrText(text: String) {
|
private fun routeSharedQrText(text: String) {
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
|
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
|
||||||
@@ -474,6 +550,11 @@ fun applyNavLabelVisibility() {
|
|||||||
pauseTime = 0L
|
pauseTime = 0L
|
||||||
resetAutolockTimer()
|
resetAutolockTimer()
|
||||||
autoRefresh(CredentialStore(this))
|
autoRefresh(CredentialStore(this))
|
||||||
|
if (pendingWheelUnlock) {
|
||||||
|
pendingWheelUnlock = false
|
||||||
|
(supportFragmentManager.findFragmentById(R.id.contentFrame) as? CircularNavFragment)
|
||||||
|
?.unlockWheelLock()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// If we were away long enough to have hit the autolock timeout (e.g. while
|
// If we were away long enough to have hit the autolock timeout (e.g. while
|
||||||
@@ -556,9 +637,19 @@ fun applyNavLabelVisibility() {
|
|||||||
eyeItem?.isVisible = true
|
eyeItem?.isVisible = true
|
||||||
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)
|
||||||
|
notifMenuItem = menu.findItem(R.id.action_notifications)
|
||||||
|
notifMenuItem?.setIcon(if (hasUnreadNotifications) R.drawable.ic_bell else R.drawable.ic_bell_read)
|
||||||
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
|
||||||
|
menu.findItem(R.id.action_notifications)?.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
|
||||||
@@ -571,6 +662,10 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if (item.itemId == R.id.action_notifications) {
|
||||||
|
openNotificationsSheet()
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (item.itemId == R.id.action_hide_amounts) {
|
if (item.itemId == R.id.action_hide_amounts) {
|
||||||
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
||||||
viewModel.hideAmounts.value = newHidden
|
viewModel.hideAmounts.value = newHidden
|
||||||
@@ -584,6 +679,16 @@ fun applyNavLabelVisibility() {
|
|||||||
return super.onOptionsItemSelected(item)
|
return super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setNotificationUnread(hasUnread: Boolean) {
|
||||||
|
hasUnreadNotifications = hasUnread
|
||||||
|
notifMenuItem?.setIcon(if (hasUnread) R.drawable.ic_bell else R.drawable.ic_bell_read)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openNotificationsSheet() {
|
||||||
|
val sheet = NotificationsSheetFragment()
|
||||||
|
sheet.onUnreadCountChanged = { hasUnread -> setNotificationUnread(hasUnread) }
|
||||||
|
sheet.show(supportFragmentManager, "notifications")
|
||||||
|
}
|
||||||
|
|
||||||
fun relogin() {
|
fun relogin() {
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ 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,
|
val key: String,
|
||||||
@@ -62,8 +76,31 @@ object NavCustomization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,568 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.tabs.TabLayout
|
||||||
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
|
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.BmlNotificationsClient
|
||||||
|
import sh.sar.basedbank.api.mib.MibActivityHistoryClient
|
||||||
|
import sh.sar.basedbank.util.NotificationsCache
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
// ── Sealed list item for date-grouped lists ───────────────────────────────────
|
||||||
|
private sealed class NotifListItem {
|
||||||
|
data class Header(val label: String) : NotifListItem()
|
||||||
|
data class Entry(val n: AppNotification) : NotifListItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val headerSdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US)
|
||||||
|
private val dateKeySdf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
|
|
||||||
|
private fun toGroupedList(notifications: List<AppNotification>): List<NotifListItem> {
|
||||||
|
val result = mutableListOf<NotifListItem>()
|
||||||
|
var lastKey = ""
|
||||||
|
for (n in notifications) {
|
||||||
|
val key = dateKeySdf.format(Date(n.timestampMs))
|
||||||
|
if (key != lastKey) {
|
||||||
|
result.add(NotifListItem.Header(headerSdf.format(Date(n.timestampMs))))
|
||||||
|
lastKey = key
|
||||||
|
}
|
||||||
|
result.add(NotifListItem.Entry(n))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationsSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
|
var onUnreadCountChanged: ((hasUnread: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
private val allNotifications = mutableListOf<AppNotification>()
|
||||||
|
|
||||||
|
private val bmlNextPage = mutableMapOf<String, Int>()
|
||||||
|
private val bmlDone = mutableMapOf<String, Boolean>()
|
||||||
|
private val mibNextStart = mutableMapOf<String, Int>()
|
||||||
|
private val mibDone = mutableMapOf<String, Boolean>()
|
||||||
|
|
||||||
|
private var isLoadingMore = false
|
||||||
|
private var mediator: TabLayoutMediator? = null
|
||||||
|
|
||||||
|
private val tabAdapters = arrayOfNulls<NotifPageAdapter>(3)
|
||||||
|
private val tabLabels = listOf("All", "Alerts", "Information")
|
||||||
|
private val tabGroupFilters = listOf<String?>(null, "ALERTS", "INFORMATION")
|
||||||
|
|
||||||
|
private lateinit var viewPager: ViewPager2
|
||||||
|
private lateinit var btnMarkAllRead: TextView
|
||||||
|
|
||||||
|
private val app get() = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
|
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
val d = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
|
||||||
|
d.setOnShowListener {
|
||||||
|
val sheet = d.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)!!
|
||||||
|
BottomSheetBehavior.from(sheet).apply {
|
||||||
|
state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
skipCollapsed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||||
|
inflater.inflate(R.layout.sheet_notifications, container, false)
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val tabLayout = view.findViewById<TabLayout>(R.id.notifTabs)
|
||||||
|
viewPager = view.findViewById(R.id.notifPager)
|
||||||
|
btnMarkAllRead = view.findViewById(R.id.btnMarkAllRead)
|
||||||
|
|
||||||
|
tabAdapters[0] = NotifPageAdapter(null)
|
||||||
|
tabAdapters[1] = NotifPageAdapter("ALERTS")
|
||||||
|
tabAdapters[2] = NotifPageAdapter("INFORMATION")
|
||||||
|
|
||||||
|
viewPager.adapter = PageAdapter()
|
||||||
|
viewPager.offscreenPageLimit = 2
|
||||||
|
|
||||||
|
mediator = TabLayoutMediator(tabLayout, viewPager) { tab, pos ->
|
||||||
|
tab.text = tabLabels[pos]
|
||||||
|
}.also { it.attach() }
|
||||||
|
|
||||||
|
btnMarkAllRead.setOnClickListener { markAllRead() }
|
||||||
|
|
||||||
|
loadFromCache()
|
||||||
|
refreshFromNetwork()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
mediator?.detach()
|
||||||
|
mediator = null
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data loading ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun loadFromCache() {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val readIds = NotificationsCache.getMibReadIds(ctx)
|
||||||
|
val cached = mutableListOf<AppNotification>()
|
||||||
|
app.bmlSessions.forEach { (loginId, _) ->
|
||||||
|
cached.addAll(NotificationsCache.loadBml(ctx, loginId))
|
||||||
|
}
|
||||||
|
app.mibSessions.forEach { (loginId, _) ->
|
||||||
|
cached.addAll(NotificationsCache.loadMib(ctx, loginId, readIds))
|
||||||
|
}
|
||||||
|
if (cached.isNotEmpty()) {
|
||||||
|
mergeInto(allNotifications, cached)
|
||||||
|
refreshAdapters()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshFromNetwork() {
|
||||||
|
val bmlSessions = app.bmlSessions.toMap()
|
||||||
|
val mibSessions = app.mibSessions.toMap()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val bmlClient = BmlNotificationsClient()
|
||||||
|
bmlSessions.forEach { (loginId, session) ->
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
bmlClient.fetchNotifications(session, loginId, page = 1)
|
||||||
|
}
|
||||||
|
if (result.items.isNotEmpty() && isAdded) {
|
||||||
|
allNotifications.removeAll { it.bank == "BML" && it.loginId == loginId }
|
||||||
|
allNotifications.addAll(result.items)
|
||||||
|
allNotifications.sortByDescending { it.timestampMs }
|
||||||
|
bmlNextPage[loginId] = 2
|
||||||
|
bmlDone[loginId] = result.items.size >= result.total
|
||||||
|
NotificationsCache.saveBml(requireContext(), loginId, result.items)
|
||||||
|
refreshAdapters()
|
||||||
|
broadcastUnread()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mibClient = MibActivityHistoryClient()
|
||||||
|
mibSessions.forEach { (loginId, session) ->
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
mibClient.fetchUntilEnough(session, loginId)
|
||||||
|
}
|
||||||
|
if (result.items.isNotEmpty() && isAdded) {
|
||||||
|
val readIds = NotificationsCache.getMibReadIds(requireContext())
|
||||||
|
val resolved = result.items.map { it.copy(isRead = it.id in readIds) }
|
||||||
|
allNotifications.removeAll { it.bank == "MIB" && it.loginId == loginId }
|
||||||
|
allNotifications.addAll(resolved)
|
||||||
|
allNotifications.sortByDescending { it.timestampMs }
|
||||||
|
mibNextStart[loginId] = result.nextStart
|
||||||
|
mibDone[loginId] = result.nextStart > result.totalCount
|
||||||
|
NotificationsCache.saveMib(requireContext(), loginId, result.items)
|
||||||
|
refreshAdapters()
|
||||||
|
broadcastUnread()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMore() {
|
||||||
|
if (isLoadingMore) return
|
||||||
|
val bmlSessions = app.bmlSessions.toMap()
|
||||||
|
val mibSessions = app.mibSessions.toMap()
|
||||||
|
val anyLeft = bmlSessions.keys.any { bmlDone[it] != true } ||
|
||||||
|
mibSessions.keys.any { mibDone[it] != true }
|
||||||
|
if (!anyLeft) return
|
||||||
|
|
||||||
|
isLoadingMore = true
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val bmlClient = BmlNotificationsClient()
|
||||||
|
bmlSessions.forEach { (loginId, session) ->
|
||||||
|
if (bmlDone[loginId] == true) return@forEach
|
||||||
|
val page = bmlNextPage[loginId] ?: 2
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
bmlClient.fetchNotifications(session, loginId, page = page)
|
||||||
|
}
|
||||||
|
if (result.items.isNotEmpty() && isAdded) {
|
||||||
|
allNotifications.addAll(result.items.filter { n -> allNotifications.none { it.id == n.id } })
|
||||||
|
allNotifications.sortByDescending { it.timestampMs }
|
||||||
|
bmlNextPage[loginId] = page + 1
|
||||||
|
bmlDone[loginId] = allNotifications.count { it.bank == "BML" && it.loginId == loginId } >= result.total
|
||||||
|
val allForLogin = allNotifications.filter { it.bank == "BML" && it.loginId == loginId }
|
||||||
|
NotificationsCache.saveBml(requireContext(), loginId, allForLogin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mibClient = MibActivityHistoryClient()
|
||||||
|
mibSessions.forEach { (loginId, session) ->
|
||||||
|
if (mibDone[loginId] == true) return@forEach
|
||||||
|
val start = mibNextStart[loginId] ?: 1
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
mibClient.fetchActivity(session, loginId, start, start + 29)
|
||||||
|
}
|
||||||
|
if (result.items.isNotEmpty() && isAdded) {
|
||||||
|
val readIds = NotificationsCache.getMibReadIds(requireContext())
|
||||||
|
val resolved = result.items.map { it.copy(isRead = it.id in readIds) }
|
||||||
|
allNotifications.addAll(resolved.filter { n -> allNotifications.none { it.id == n.id } })
|
||||||
|
allNotifications.sortByDescending { it.timestampMs }
|
||||||
|
mibNextStart[loginId] = result.nextStart
|
||||||
|
mibDone[loginId] = result.nextStart > result.totalCount
|
||||||
|
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
|
||||||
|
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingMore = false
|
||||||
|
if (isAdded) refreshAdapters()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mark all read ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun markAllRead() {
|
||||||
|
val bmlSessions = app.bmlSessions.toMap()
|
||||||
|
val mibIds = allNotifications.filter { it.bank == "MIB" && !it.isRead }.map { it.id }
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
var bmlOk = true
|
||||||
|
bmlSessions.forEach { (_, session) ->
|
||||||
|
val ok = withContext(Dispatchers.IO) { BmlNotificationsClient().markAllRead(session) }
|
||||||
|
if (!ok) bmlOk = false
|
||||||
|
}
|
||||||
|
if (mibIds.isNotEmpty()) NotificationsCache.addMibReadIds(requireContext(), mibIds)
|
||||||
|
|
||||||
|
val updated = allNotifications.map { it.copy(isRead = true) }
|
||||||
|
allNotifications.clear()
|
||||||
|
allNotifications.addAll(updated)
|
||||||
|
refreshAdapters()
|
||||||
|
broadcastUnread()
|
||||||
|
|
||||||
|
if (isAdded) {
|
||||||
|
val msg = if (bmlOk) "All notifications marked as read"
|
||||||
|
else "Marked read locally — some accounts had a network error"
|
||||||
|
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun mergeInto(target: MutableList<AppNotification>, incoming: List<AppNotification>) {
|
||||||
|
val existingIds = target.map { it.id }.toSet()
|
||||||
|
target.addAll(incoming.filter { it.id !in existingIds })
|
||||||
|
target.sortByDescending { it.timestampMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshAdapters() {
|
||||||
|
tabGroupFilters.forEachIndexed { i, filter ->
|
||||||
|
val filtered = if (filter == null) allNotifications
|
||||||
|
else allNotifications.filter { it.group == filter }
|
||||||
|
tabAdapters[i]?.update(filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcastUnread() {
|
||||||
|
onUnreadCountChanged?.invoke(allNotifications.any { !it.isRead })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onNotificationTapped(item: AppNotification) {
|
||||||
|
val idx = allNotifications.indexOfFirst { it.id == item.id }
|
||||||
|
if (idx >= 0 && !allNotifications[idx].isRead) {
|
||||||
|
allNotifications[idx] = allNotifications[idx].copy(isRead = true)
|
||||||
|
if (item.bank == "MIB") NotificationsCache.addMibReadIds(requireContext(), listOf(item.id))
|
||||||
|
refreshAdapters()
|
||||||
|
broadcastUnread()
|
||||||
|
}
|
||||||
|
val detail = item.detailFields.joinToString("\n\n") { (k, v) -> "$k\n$v" }
|
||||||
|
MaterialAlertDialogBuilder(requireActivity())
|
||||||
|
.setTitle(item.title)
|
||||||
|
.setMessage(detail.ifBlank { item.message })
|
||||||
|
.setPositiveButton("OK", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ViewPager2 page adapter ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private inner class PageAdapter : RecyclerView.Adapter<PageAdapter.VH>() {
|
||||||
|
inner class VH(val rv: RecyclerView) : RecyclerView.ViewHolder(rv)
|
||||||
|
|
||||||
|
override fun getItemCount() = 3
|
||||||
|
override fun getItemViewType(position: Int) = position
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||||
|
val rv = RecyclerView(parent.context).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
layoutManager = LinearLayoutManager(context)
|
||||||
|
adapter = tabAdapters[viewType]
|
||||||
|
addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
|
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
||||||
|
val lm = rv.layoutManager as LinearLayoutManager
|
||||||
|
if (lm.findLastVisibleItemPosition() >= lm.itemCount - 4) loadMore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return VH(rv)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: VH, position: Int) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-tab list adapter ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private inner class NotifPageAdapter(private val groupFilter: String?) :
|
||||||
|
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
private val displayItems = mutableListOf<NotifListItem>()
|
||||||
|
|
||||||
|
fun update(filtered: List<AppNotification>) {
|
||||||
|
displayItems.clear()
|
||||||
|
displayItems.addAll(toGroupedList(filtered))
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = if (displayItems.isEmpty()) 1 else displayItems.size
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int): Int {
|
||||||
|
if (displayItems.isEmpty()) return 2 // empty
|
||||||
|
return when (displayItems[position]) {
|
||||||
|
is NotifListItem.Header -> 0
|
||||||
|
is NotifListItem.Entry -> 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
|
||||||
|
when (viewType) {
|
||||||
|
0 -> HeaderVH(buildHeaderView(parent.context))
|
||||||
|
1 -> ItemVH(buildRowView(parent.context))
|
||||||
|
else -> EmptyVH(buildEmptyView(parent.context))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
when (holder) {
|
||||||
|
is HeaderVH -> holder.bind((displayItems[position] as NotifListItem.Header).label)
|
||||||
|
is ItemVH -> holder.bind((displayItems[position] as NotifListItem.Entry).n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Date header ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
inner class HeaderVH(private val tv: TextView) : RecyclerView.ViewHolder(tv) {
|
||||||
|
fun bind(label: String) { tv.text = label }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildHeaderView(ctx: android.content.Context): TextView {
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
return TextView(ctx).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||||
|
setTextColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, Color.CYAN))
|
||||||
|
setPadding((16 * dp).toInt(), (20 * dp).toInt(), (16 * dp).toInt(), (6 * dp).toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty state ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
inner class EmptyVH(v: View) : RecyclerView.ViewHolder(v)
|
||||||
|
|
||||||
|
private fun buildEmptyView(ctx: android.content.Context): View {
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
return LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
(300 * dp).toInt()
|
||||||
|
)
|
||||||
|
addView(ImageView(ctx).apply {
|
||||||
|
setImageResource(R.drawable.ic_bell_read)
|
||||||
|
val s = (48 * dp).toInt()
|
||||||
|
layoutParams = LinearLayout.LayoutParams(s, s).apply {
|
||||||
|
gravity = Gravity.CENTER_HORIZONTAL
|
||||||
|
bottomMargin = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
alpha = 0.35f
|
||||||
|
})
|
||||||
|
addView(TextView(ctx).apply {
|
||||||
|
text = "No notifications"
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||||
|
alpha = 0.5f
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification row ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
inner class ItemVH(v: View) : RecyclerView.ViewHolder(v) {
|
||||||
|
val iconBg: View = v.findViewWithTag("iconBg")
|
||||||
|
val iconIv: ImageView = v.findViewWithTag("icon")
|
||||||
|
val unreadBadge: View = v.findViewWithTag("badge")
|
||||||
|
val titleTv: TextView = v.findViewWithTag("title")
|
||||||
|
val messageTv: TextView = v.findViewWithTag("message")
|
||||||
|
val bankBadge: TextView = v.findViewWithTag("bank")
|
||||||
|
|
||||||
|
fun bind(item: AppNotification) {
|
||||||
|
titleTv.text = item.title
|
||||||
|
messageTv.text = item.message
|
||||||
|
bankBadge.text = item.bank
|
||||||
|
unreadBadge.isVisible = !item.isRead
|
||||||
|
|
||||||
|
val (iconRes, colorHex) = iconAndColor(item)
|
||||||
|
iconIv.setImageResource(iconRes)
|
||||||
|
iconIv.imageTintList = ColorStateList.valueOf(Color.parseColor(colorHex))
|
||||||
|
(iconBg.background as? GradientDrawable)
|
||||||
|
?.setColor(Color.parseColor(colorHex.replace("#", "#22")))
|
||||||
|
|
||||||
|
itemView.alpha = if (item.isRead) 0.65f else 1f
|
||||||
|
itemView.setOnClickListener { onNotificationTapped(item) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildRowView(ctx: android.content.Context): View {
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val surfaceColor = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, Color.BLACK)
|
||||||
|
|
||||||
|
return LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||||
|
background = ta.getDrawable(0); ta.recycle()
|
||||||
|
isClickable = true; isFocusable = true
|
||||||
|
setPadding((16 * dp).toInt(), (12 * dp).toInt(), (16 * dp).toInt(), (12 * dp).toInt())
|
||||||
|
|
||||||
|
// Icon circle + badge overlay
|
||||||
|
val frameSize = (44 * dp).toInt()
|
||||||
|
val iconFrame = FrameLayout(ctx).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(frameSize, frameSize).apply {
|
||||||
|
marginEnd = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Circle background (fills the frame)
|
||||||
|
val circleSize = (40 * dp).toInt()
|
||||||
|
iconFrame.addView(View(ctx).apply {
|
||||||
|
tag = "iconBg"
|
||||||
|
layoutParams = FrameLayout.LayoutParams(circleSize, circleSize, Gravity.CENTER)
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(Color.parseColor("#33FFFFFF"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Icon
|
||||||
|
val iconSize = (22 * dp).toInt()
|
||||||
|
iconFrame.addView(ImageView(ctx).apply {
|
||||||
|
tag = "icon"
|
||||||
|
layoutParams = FrameLayout.LayoutParams(iconSize, iconSize, Gravity.CENTER)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unread badge — bottom-right corner
|
||||||
|
val badgeSize = (12 * dp).toInt()
|
||||||
|
iconFrame.addView(View(ctx).apply {
|
||||||
|
tag = "badge"
|
||||||
|
layoutParams = FrameLayout.LayoutParams(badgeSize, badgeSize, Gravity.BOTTOM or Gravity.END)
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(Color.parseColor("#EF5350"))
|
||||||
|
setStroke((2 * dp).toInt(), surfaceColor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
addView(iconFrame)
|
||||||
|
|
||||||
|
// Text column
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title + bank badge row
|
||||||
|
val titleRow = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
titleRow.addView(TextView(ctx).apply {
|
||||||
|
tag = "title"
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||||
|
setTypeface(null, Typeface.BOLD)
|
||||||
|
maxLines = 1
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
})
|
||||||
|
titleRow.addView(TextView(ctx).apply {
|
||||||
|
tag = "bank"
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelSmall)
|
||||||
|
alpha = 0.55f
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply { marginStart = (6 * dp).toInt() }
|
||||||
|
})
|
||||||
|
|
||||||
|
textCol.addView(titleRow)
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
tag = "message"
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.7f
|
||||||
|
maxLines = 2
|
||||||
|
})
|
||||||
|
addView(textCol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun iconAndColor(item: AppNotification): Pair<Int, String> {
|
||||||
|
if (item.bank == "MIB") return when {
|
||||||
|
item.title.contains("Transfer", ignoreCase = true) ||
|
||||||
|
item.title.contains("Payment", ignoreCase = true) -> R.drawable.ic_send to "#4CAF50"
|
||||||
|
item.title.contains("Log in", ignoreCase = true) -> R.drawable.ic_lock_open to "#2196F3"
|
||||||
|
else -> R.drawable.ic_receipt_check to "#9C27B0"
|
||||||
|
}
|
||||||
|
return when {
|
||||||
|
item.group == "INFORMATION" -> R.drawable.ic_receipt_check to "#2196F3"
|
||||||
|
item.title.contains("Received", ignoreCase = true) ||
|
||||||
|
item.title.contains("Sent", ignoreCase = true) ||
|
||||||
|
item.title.contains("Transfer", ignoreCase = true) ||
|
||||||
|
item.title.contains("Payment", ignoreCase = true) ||
|
||||||
|
item.title.contains("Paid", ignoreCase = true) ||
|
||||||
|
item.title.contains("Funds", ignoreCase = true) -> R.drawable.ic_send to "#4CAF50"
|
||||||
|
else -> R.drawable.ic_lock to "#EF5350"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,9 +76,6 @@ class PayMvQrFragment : Fragment() {
|
|||||||
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 {
|
|
||||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceWithAutoScan())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupDropdown() {
|
private fun setupDropdown() {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import sh.sar.basedbank.util.CardsCache
|
|||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
|
import sh.sar.basedbank.util.NfcPaymentUtil
|
||||||
import sh.sar.basedbank.util.PaymvQrParser
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
@@ -232,11 +233,13 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
return@setOnClickListener
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
val bmlItem = item as CardItem.Bml
|
val bmlItem = item as CardItem.Bml
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
NfcPaymentUtil.checkAndProceed(requireContext()) {
|
||||||
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
showBiometricPromptForTap(bmlItem)
|
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
||||||
} else {
|
showBiometricPromptForTap(bmlItem)
|
||||||
setTapMode(true, bmlItem)
|
} else {
|
||||||
|
setTapMode(true, bmlItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,15 +513,37 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
tapAnimView = animView
|
tapAnimView = animView
|
||||||
|
|
||||||
val dp = resources.displayMetrics.density
|
val dp = resources.displayMetrics.density
|
||||||
val cancelBtn = MaterialButton(requireContext(), null,
|
val cancelBtn = (layoutInflater.inflate(R.layout.view_cancel_button, null, false) as MaterialButton).apply {
|
||||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
setOnClickListener { setTapMode(false) }
|
||||||
).apply { setText(R.string.cancel); setOnClickListener { setTapMode(false) } }
|
}
|
||||||
|
|
||||||
|
val colorOutlineVariant = MaterialColors.getColor(
|
||||||
|
requireContext(), com.google.android.material.R.attr.colorOutlineVariant, android.graphics.Color.LTGRAY
|
||||||
|
)
|
||||||
|
val tapDivider = View(requireContext()).apply {
|
||||||
|
setBackgroundColor(colorOutlineVariant)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT, dp.toInt().coerceAtLeast(1)
|
||||||
|
).also {
|
||||||
|
it.marginStart = (24 * dp).toInt()
|
||||||
|
it.marginEnd = (24 * dp).toInt()
|
||||||
|
it.bottomMargin = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val baseCancelPaddingBottom = (24 * dp).toInt()
|
||||||
val cancelWrapper = LinearLayout(requireContext()).apply {
|
val cancelWrapper = LinearLayout(requireContext()).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
gravity = Gravity.CENTER_HORIZONTAL
|
gravity = Gravity.CENTER_HORIZONTAL
|
||||||
setPadding(0, 0, 0, (24 * dp).toInt())
|
setPadding((16 * dp).toInt(), (8 * dp).toInt(), (16 * dp).toInt(), baseCancelPaddingBottom)
|
||||||
addView(cancelBtn)
|
addView(cancelBtn, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
|
||||||
|
}
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(cancelWrapper) { v, insets ->
|
||||||
|
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
|
||||||
|
val navBarBottom = if (bottomNav?.visibility == View.VISIBLE) 0
|
||||||
|
else insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
|
||||||
|
v.setPadding((16 * dp).toInt(), (8 * dp).toInt(), (16 * dp).toInt(), baseCancelPaddingBottom + navBarBottom)
|
||||||
|
insets
|
||||||
}
|
}
|
||||||
val container = LinearLayout(requireContext()).apply {
|
val container = LinearLayout(requireContext()).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
@@ -529,6 +554,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
addView(animView.apply {
|
addView(animView.apply {
|
||||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 3f)
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 3f)
|
||||||
})
|
})
|
||||||
|
addView(tapDivider)
|
||||||
addView(cancelWrapper.apply {
|
addView(cancelWrapper.apply {
|
||||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||||
})
|
})
|
||||||
@@ -660,7 +686,10 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
view?.post {
|
view?.post {
|
||||||
if (!isTapMode) return@post
|
if (!isTapMode) return@post
|
||||||
setTapMode(false)
|
setTapMode(false)
|
||||||
if (success) Toast.makeText(requireContext(), "Payment complete", Toast.LENGTH_SHORT).show()
|
if (success) {
|
||||||
|
Toast.makeText(requireContext(), "Payment complete", Toast.LENGTH_SHORT).show()
|
||||||
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -716,11 +745,13 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
currentCardPosition = pos
|
currentCardPosition = pos
|
||||||
binding.rvCards.scrollToPosition(pos)
|
binding.rvCards.scrollToPosition(pos)
|
||||||
}
|
}
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
NfcPaymentUtil.checkAndProceed(requireContext()) {
|
||||||
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
showBiometricPromptForTap(targetCard)
|
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
||||||
} else {
|
showBiometricPromptForTap(targetCard)
|
||||||
setTapMode(true, targetCard)
|
} else {
|
||||||
|
setTapMode(true, targetCard)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ class QrScannerActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
|
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||||
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
// Black camera background — always use light (white) system bar icons
|
// Black camera background — always use light (white) system bar icons
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import sh.sar.basedbank.BuildConfig
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentSettingsAboutBinding
|
||||||
|
|
||||||
|
class SettingsAboutFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentSettingsAboutBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentSettingsAboutBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||||
|
val basePaddingBottom = binding.root.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
binding.tvAppName.text = getString(R.string.app_name)
|
||||||
|
binding.tvVersion.text = getString(R.string.about_version, BuildConfig.VERSION_NAME)
|
||||||
|
|
||||||
|
binding.rowMibTerms.setOnClickListener { openUrl("https://faisanet.mib.com.mv/terms") }
|
||||||
|
binding.rowBmlTerms.setOnClickListener { openUrl("https://www.bankofmaldives.com.mv/storage/file/121/10289/terms-conditions-online-banking-en.pdf") }
|
||||||
|
binding.rowFahipayTerms.setOnClickListener { openUrl("https://fahipay.mv/tos/") }
|
||||||
|
|
||||||
|
val hasMvr = BuildConfig.ACCOUNT_MVR.isNotEmpty()
|
||||||
|
val hasUsd = BuildConfig.ACCOUNT_USD.isNotEmpty()
|
||||||
|
|
||||||
|
if (!hasMvr && !hasUsd) {
|
||||||
|
binding.sectionDonate.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
if (!hasMvr) binding.btnDonateMvr.visibility = View.GONE
|
||||||
|
else binding.btnDonateMvr.setOnClickListener { openDonate(BuildConfig.ACCOUNT_MVR) }
|
||||||
|
if (!hasUsd) binding.btnDonateUsd.visibility = View.GONE
|
||||||
|
else binding.btnDonateUsd.setOnClickListener { openDonate(BuildConfig.ACCOUNT_USD) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openUrl(url: String) {
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openDonate(accountNumber: String) {
|
||||||
|
val fragment = TransferFragment.newInstance(
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
displayName = getString(R.string.app_name),
|
||||||
|
subtitle = accountNumber,
|
||||||
|
colorHex = "#607D8B",
|
||||||
|
imageHash = null
|
||||||
|
)
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.settings_about)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +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.content.res.Configuration
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
@@ -16,6 +17,8 @@ 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
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@@ -36,8 +39,10 @@ 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)
|
||||||
@@ -46,13 +51,30 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||||
|
val basePaddingBottom = binding.root.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||||
|
val isBottomNav = prefs.getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
// Navigation mode
|
// Navigation mode
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val currentMode = NavCustomization.getNavMode(prefs)
|
||||||
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
binding.navModeToggle.check(when (currentMode) {
|
||||||
|
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
|
||||||
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
val mode = when (checkedId) {
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
@@ -63,10 +85,22 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
quickActionAdapter = NavItemAdapter(
|
quickActionAdapter = NavItemAdapter(
|
||||||
items = quickActions,
|
items = quickActions,
|
||||||
onSave = { NavCustomization.saveQuickActions(prefs, quickActions) },
|
onSave = { NavCustomization.saveQuickActions(prefs, quickActions) },
|
||||||
isEnabled = { !prefs.getBoolean("bottom_nav", false) }
|
isEnabled = { NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM }
|
||||||
)
|
)
|
||||||
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) {
|
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) {
|
||||||
!prefs.getBoolean("bottom_nav", false)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom bar shortcuts
|
// Bottom bar shortcuts
|
||||||
@@ -78,10 +112,10 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
NavCustomization.saveSlots(prefs, slots)
|
NavCustomization.saveSlots(prefs, slots)
|
||||||
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
||||||
},
|
},
|
||||||
isEnabled = { prefs.getBoolean("bottom_nav", false) }
|
isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM }
|
||||||
)
|
)
|
||||||
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
|
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
|
||||||
prefs.getBoolean("bottom_nav", false)
|
NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
|
||||||
}
|
}
|
||||||
// Show labels toggle
|
// Show labels toggle
|
||||||
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
||||||
@@ -102,6 +136,7 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
|
val previousKey = prefs.getString("theme", "system")
|
||||||
val (key, mode) = when (checkedId) {
|
val (key, mode) = when (checkedId) {
|
||||||
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
|
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
|
||||||
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
|
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
|
||||||
@@ -111,6 +146,16 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
AppCompatDelegate.setDefaultNightMode(mode)
|
AppCompatDelegate.setDefaultNightMode(mode)
|
||||||
updateAccentState(key == "system")
|
updateAccentState(key == "system")
|
||||||
updatePitchBlackState(key == "dark")
|
updatePitchBlackState(key == "dark")
|
||||||
|
if (key == "system") {
|
||||||
|
requireActivity().recreate()
|
||||||
|
} else if (previousKey == "system") {
|
||||||
|
// setDefaultNightMode only recreates if the effective mode changes.
|
||||||
|
// If system was already dark and we switch to dark (or light→light),
|
||||||
|
// no recreation is triggered and the custom accent never gets applied.
|
||||||
|
val currentIsNight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
val newIsNight = mode == AppCompatDelegate.MODE_NIGHT_YES
|
||||||
|
if (currentIsNight == newIsNight) requireActivity().recreate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pitch black
|
// Pitch black
|
||||||
@@ -125,7 +170,7 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
// Accent color
|
// Accent color
|
||||||
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
||||||
binding.accentToggle.check(when (savedPreset) {
|
binding.accentToggle.check(when (savedPreset) {
|
||||||
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
|
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
|
||||||
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
|
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
|
||||||
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
|
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
|
||||||
else -> R.id.btnAccentBlue
|
else -> R.id.btnAccentBlue
|
||||||
@@ -191,11 +236,15 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateShortcutsVisibility() {
|
private fun updateShortcutsVisibility() {
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val mode = NavCustomization.getNavMode(prefs)
|
||||||
binding.sectionQuickActions.alpha = if (isBottom) 0.38f else 1f
|
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
|
binding.switchShowLabels.isClickable = isBottom
|
||||||
quickActionAdapter.notifyDataSetChanged()
|
quickActionAdapter.notifyDataSetChanged()
|
||||||
|
circularSlotAdapter.notifyDataSetChanged()
|
||||||
slotAdapter.notifyDataSetChanged()
|
slotAdapter.notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,9 +311,10 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val mode = NavCustomization.getNavMode(prefs)
|
||||||
if (items === slots && !isBottom) return
|
if (items === slots && mode != NavCustomization.NAV_MODE_BOTTOM) return
|
||||||
if (items === quickActions && isBottom) 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 }
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import android.widget.LinearLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
|
|
||||||
@@ -26,12 +28,21 @@ class SettingsFragment : Fragment() {
|
|||||||
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() },
|
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() },
|
||||||
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
|
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
|
||||||
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
|
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
|
||||||
|
SettingsItem(R.drawable.ic_info, R.string.settings_about, R.string.settings_desc_about) { SettingsAboutFragment() },
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||||
inflater.inflate(R.layout.fragment_settings, container, false)
|
inflater.inflate(R.layout.fragment_settings, container, false)
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
(view as? android.widget.ScrollView)?.clipToPadding = false
|
||||||
|
val basePaddingBottom = view.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
val list = view.findViewById<LinearLayout>(R.id.settingsList)
|
val list = view.findViewById<LinearLayout>(R.id.settingsList)
|
||||||
val inflater = LayoutInflater.from(requireContext())
|
val inflater = LayoutInflater.from(requireContext())
|
||||||
for (item in items) {
|
for (item in items) {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import android.widget.TextView
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
@@ -333,6 +335,14 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||||
|
val basePaddingBottom = binding.root.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
binding.btnAddAccount.setOnClickListener {
|
binding.btnAddAccount.setOnClickListener {
|
||||||
startActivity(Intent(requireContext(), LoginActivity::class.java))
|
startActivity(Intent(requireContext(), LoginActivity::class.java))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentSettingsSecurityBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsSecurityBinding
|
||||||
@@ -22,6 +24,14 @@ class SettingsSecurityFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||||
|
val basePaddingBottom = binding.root.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
// Change lock
|
// Change lock
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ 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.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
@@ -31,6 +33,14 @@ class SettingsStorageFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||||
|
val basePaddingBottom = binding.root.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
binding.btnClearCache.setOnClickListener {
|
binding.btnClearCache.setOnClickListener {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
clearAllCaches(ctx)
|
clearAllCaches(ctx)
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ class TransferFragment : Fragment() {
|
|||||||
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
||||||
private var selectedFahipayService: String? = null
|
private var selectedFahipayService: String? = null
|
||||||
|
|
||||||
|
// Form state preserved across view destroy/create when the fragment instance is cached
|
||||||
|
private var savedAmount = ""
|
||||||
|
private var savedRemarks = ""
|
||||||
|
private var savedToText = ""
|
||||||
|
private var savedToSubtitle = ""
|
||||||
|
private var savedToColorHex = "#607D8B"
|
||||||
|
private var savedToImageHash: String? = null
|
||||||
|
|
||||||
// BML QR merchant payment mode (set when navigated from a card QR scan)
|
// BML QR merchant payment mode (set when navigated from a card QR scan)
|
||||||
private var bmlQrInfo: BmlQrPayInfo? = null
|
private var bmlQrInfo: BmlQrPayInfo? = null
|
||||||
private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step)
|
private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step)
|
||||||
@@ -255,6 +263,18 @@ class TransferFragment : Fragment() {
|
|||||||
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
|
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
|
||||||
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
|
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
|
||||||
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
|
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
|
||||||
|
if (selectedAccount == null) {
|
||||||
|
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||||
|
if (defaultNum != null) {
|
||||||
|
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum }
|
||||||
|
if (defaultAcc != null) {
|
||||||
|
selectedAccount = defaultAcc
|
||||||
|
updateAmountPrefix(defaultAcc)
|
||||||
|
showFromCard(defaultAcc)
|
||||||
|
updateTransferButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.btnPickContact.setOnClickListener {
|
binding.btnPickContact.setOnClickListener {
|
||||||
@@ -294,6 +314,33 @@ class TransferFragment : Fragment() {
|
|||||||
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
||||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore form state when view is recreated on the cached no-args instance
|
||||||
|
if (arguments == null) {
|
||||||
|
if (resolvedAccountNumber.isNotEmpty()) {
|
||||||
|
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
||||||
|
if (ownAccount != null) {
|
||||||
|
showToCard(ownAccount)
|
||||||
|
} else {
|
||||||
|
binding.tvToAccountName.text = resolvedRecipientName
|
||||||
|
binding.tvToBankBic.text = savedToSubtitle
|
||||||
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
|
binding.tvToBalance.visibility = View.GONE
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(resolvedRecipientName, savedToColorHex))
|
||||||
|
}
|
||||||
|
binding.tilTo.visibility = View.GONE
|
||||||
|
binding.btnPickContact.visibility = View.GONE
|
||||||
|
binding.btnScanQr.visibility = View.GONE
|
||||||
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null)
|
||||||
|
} else if (savedToText.isNotEmpty()) {
|
||||||
|
binding.etTo.setText(savedToText)
|
||||||
|
}
|
||||||
|
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
|
||||||
|
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
|
||||||
|
updateTransferButton()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupBmlQrMerchant(qrUrl: String) {
|
private fun lookupBmlQrMerchant(qrUrl: String) {
|
||||||
@@ -439,6 +486,20 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-select default account when arriving from contacts page (TO account already pre-filled)
|
||||||
|
if (selectedAccount == null && arguments?.getString(ARG_ACCOUNT) != null) {
|
||||||
|
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||||
|
if (defaultNum != null) {
|
||||||
|
val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum }
|
||||||
|
if (defaultAcc != null) {
|
||||||
|
selectedAccount = defaultAcc
|
||||||
|
updateAmountPrefix(defaultAcc)
|
||||||
|
showFromCard(defaultAcc)
|
||||||
|
updateTransferButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// On a cold start (e.g. share intent), anyBmlSession() may be null when
|
// On a cold start (e.g. share intent), anyBmlSession() may be null when
|
||||||
// onViewCreated runs. Retry the lookup once sessions are available.
|
// onViewCreated runs. Retry the lookup once sessions are available.
|
||||||
val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL)
|
val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL)
|
||||||
@@ -446,6 +507,13 @@ class TransferFragment : Fragment() {
|
|||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
if (app.anyBmlSession() != null) lookupBmlQrMerchant(pendingBmlQrUrl)
|
if (app.anyBmlSession() != null) lookupBmlQrMerchant(pendingBmlQrUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-render the from card when the view is recreated on a cached instance
|
||||||
|
if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) {
|
||||||
|
updateAmountPrefix(selectedAccount!!)
|
||||||
|
showFromCard(selectedAccount!!)
|
||||||
|
updateTransferButton()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,6 +799,13 @@ class TransferFragment : Fragment() {
|
|||||||
resolvedAccountNumber = info.accountNumber
|
resolvedAccountNumber = info.accountNumber
|
||||||
resolvedRecipientName = info.accountName
|
resolvedRecipientName = info.accountName
|
||||||
resolvedBankName = info.bankId
|
resolvedBankName = info.bankId
|
||||||
|
savedToSubtitle = "${info.accountNumber} · ${info.bankId}"
|
||||||
|
savedToColorHex = colorHex
|
||||||
|
savedToImageHash = when {
|
||||||
|
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
|
||||||
|
matchedCont?.customerImgHash != null -> matchedCont.customerImgHash
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
if (matchedAcc != null) {
|
if (matchedAcc != null) {
|
||||||
showToCard(matchedAcc)
|
showToCard(matchedAcc)
|
||||||
@@ -863,6 +938,9 @@ class TransferFragment : Fragment() {
|
|||||||
) {
|
) {
|
||||||
resolvedAccountNumber = accountNumber
|
resolvedAccountNumber = accountNumber
|
||||||
resolvedRecipientName = displayName
|
resolvedRecipientName = displayName
|
||||||
|
savedToSubtitle = subtitle
|
||||||
|
savedToColorHex = colorHex
|
||||||
|
savedToImageHash = imageHash
|
||||||
val contacts = viewModel.contacts.value ?: emptyList()
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
|
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
|
||||||
|
|
||||||
@@ -2053,6 +2131,14 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
// Persist form state so it can be restored when the view is recreated
|
||||||
|
savedAmount = binding.etAmount.text?.toString() ?: ""
|
||||||
|
savedRemarks = binding.etRemarks.text?.toString() ?: ""
|
||||||
|
savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
|
||||||
|
// Reset in-progress OTP flow — it cannot sensibly resume after the view is gone
|
||||||
|
bmlOtpState = BmlOtpState.NONE
|
||||||
|
pendingBmlTransfer = null
|
||||||
|
bmlOtpChannel = null
|
||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
@@ -111,6 +113,32 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
receiptCard.setOnClickListener { showFullScreenReceipt() }
|
receiptCard.setOnClickListener { showFullScreenReceipt() }
|
||||||
|
|
||||||
|
val btnRow = view.findViewById<View>(R.id.btnRow)
|
||||||
|
val basePaddingBottom = btnRow.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(btnRow) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
val receiptContainer = view.findViewById<android.widget.ScrollView>(R.id.receiptContainer)
|
||||||
|
receiptContainer.setOnTouchListener { _, _ -> true }
|
||||||
|
receiptContainer.viewTreeObserver.addOnGlobalLayoutListener(object : android.view.ViewTreeObserver.OnGlobalLayoutListener {
|
||||||
|
override fun onGlobalLayout() {
|
||||||
|
receiptContainer.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||||
|
val available = receiptContainer.height
|
||||||
|
val natural = receiptCard.height
|
||||||
|
if (natural > available && available > 0) {
|
||||||
|
val scale = available.toFloat() / natural
|
||||||
|
receiptCard.scaleX = scale
|
||||||
|
receiptCard.scaleY = scale
|
||||||
|
receiptCard.pivotX = receiptCard.width / 2f
|
||||||
|
receiptCard.pivotY = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
||||||
parentFragmentManager.popBackStack()
|
parentFragmentManager.popBackStack()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.ui.login
|
package sh.sar.basedbank.ui.login
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -13,11 +14,13 @@ import android.os.Looper
|
|||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.util.OtpauthParser
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
@@ -34,6 +37,7 @@ import sh.sar.basedbank.util.AccountCache
|
|||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
||||||
import sh.sar.basedbank.ui.home.HomeActivity
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
|
import sh.sar.basedbank.ui.home.QrScannerActivity
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
|
||||||
class CredentialsFragment : Fragment() {
|
class CredentialsFragment : Fragment() {
|
||||||
@@ -60,6 +64,25 @@ class CredentialsFragment : Fragment() {
|
|||||||
private var bmlLoginId: String = ""
|
private var bmlLoginId: String = ""
|
||||||
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
|
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
|
||||||
|
|
||||||
|
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 entries = OtpauthParser.parse(raw)
|
||||||
|
when {
|
||||||
|
entries.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show()
|
||||||
|
entries.size == 1 -> binding.etOtpSeed.setText(entries[0].secret)
|
||||||
|
else -> {
|
||||||
|
val labels = entries.map { e ->
|
||||||
|
if (e.issuer.isNotBlank()) "${e.issuer} (${e.name})" else e.name.ifBlank { e.secret.take(8) + "…" }
|
||||||
|
}.toTypedArray()
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle("Choose account")
|
||||||
|
.setItems(labels) { _, i -> binding.etOtpSeed.setText(entries[i].secret) }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -75,7 +98,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.ivBankLogo.setImageResource(R.drawable.fahipay_logo_long)
|
binding.ivBankLogo.setImageResource(R.drawable.fahipay_logo_long)
|
||||||
binding.tvSignInDesc.setText(R.string.fahipay_sign_in_desc)
|
binding.tvSignInDesc.setText(R.string.fahipay_sign_in_desc)
|
||||||
binding.tilUsername.hint = getString(R.string.fahipay_id_card)
|
binding.tilUsername.hint = getString(R.string.fahipay_id_card)
|
||||||
binding.tilOtpSeed.visibility = android.view.View.GONE
|
binding.rowOtpSeed.visibility = android.view.View.GONE
|
||||||
binding.etOtpSeed.isEnabled = false
|
binding.etOtpSeed.isEnabled = false
|
||||||
binding.etOtpSeed.isFocusable = false
|
binding.etOtpSeed.isFocusable = false
|
||||||
}
|
}
|
||||||
@@ -83,6 +106,9 @@ class CredentialsFragment : Fragment() {
|
|||||||
|
|
||||||
binding.btnLogin.isEnabled = false
|
binding.btnLogin.isEnabled = false
|
||||||
binding.btnLogin.setOnClickListener { attemptLogin() }
|
binding.btnLogin.setOnClickListener { attemptLogin() }
|
||||||
|
binding.btnScanOtpSeed.setOnClickListener {
|
||||||
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
binding.cardOtp.setOnClickListener {
|
binding.cardOtp.setOnClickListener {
|
||||||
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
ThemeHelper.applyAccent(this)
|
ThemeHelper.applyAccent(this)
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// If security is already configured, onboarding is complete. Redirect to lock screen
|
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
// to prevent overwriting an existing PIN/pattern via direct activity launch.
|
|
||||||
if (CredentialStore(this).loadSecurityHash() != null) {
|
// Only redirect to the lock screen if onboarding is fully complete. Checking the
|
||||||
|
// security hash alone is not sufficient — the hash is written during the PIN/pattern
|
||||||
|
// setup step (page 1) which happens *before* the user clicks "Get Started", so a
|
||||||
|
// theme change or process restart mid-onboarding would otherwise trigger this guard
|
||||||
|
// and strand the user in the lock flow without finishing onboarding.
|
||||||
|
if (prefs.getBoolean("onboarding_done", false) && CredentialStore(this).loadSecurityHash() != null) {
|
||||||
startActivity(Intent(this, LockActivity::class.java))
|
startActivity(Intent(this, LockActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
@@ -50,7 +55,6 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
||||||
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
||||||
ta.recycle()
|
ta.recycle()
|
||||||
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 ->
|
||||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.biometric.BiometricManager
|
|||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
|
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
|
||||||
|
import sh.sar.basedbank.ui.home.NavCustomization
|
||||||
|
|
||||||
class OnboardingConfigureFragment : Fragment() {
|
class OnboardingConfigureFragment : Fragment() {
|
||||||
|
|
||||||
@@ -24,12 +25,20 @@ class OnboardingConfigureFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
// Navigation — default Drawer
|
// Navigation
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
binding.navModeToggle.check(when (NavCustomization.getNavMode(prefs)) {
|
||||||
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
|
||||||
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
val mode = when (checkedId) {
|
||||||
|
R.id.btnNavBottom -> NavCustomization.NAV_MODE_BOTTOM
|
||||||
|
R.id.btnNavCircular -> NavCustomization.NAV_MODE_CIRCULAR
|
||||||
|
else -> NavCustomization.NAV_MODE_DRAWER
|
||||||
|
}
|
||||||
|
NavCustomization.saveNavMode(prefs, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme — default System
|
// Theme — default System
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class OnboardingFragment : Fragment() {
|
|||||||
|
|
||||||
private fun notifyScrolledToBottom() {
|
private fun notifyScrolledToBottom() {
|
||||||
if (scrolledToBottom) return
|
if (scrolledToBottom) return
|
||||||
|
if (!isAdded) return
|
||||||
scrolledToBottom = true
|
scrolledToBottom = true
|
||||||
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
|
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
|
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
|
||||||
@@ -102,8 +103,17 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
else
|
else
|
||||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||||
val btn = MaterialButton(requireContext(), null, style).apply {
|
val btn = MaterialButton(requireContext(), null, style).apply {
|
||||||
text = key
|
if (key == "⌫" || key == "✓") {
|
||||||
textSize = 24f
|
text = ""
|
||||||
|
icon = ContextCompat.getDrawable(requireContext(),
|
||||||
|
if (key == "⌫") R.drawable.ic_backspace else R.drawable.ic_check)
|
||||||
|
iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
|
||||||
|
iconPadding = 0
|
||||||
|
iconSize = (28 * dp).toInt()
|
||||||
|
} else {
|
||||||
|
text = key
|
||||||
|
textSize = 24f
|
||||||
|
}
|
||||||
insetTop = 0; insetBottom = 0
|
insetTop = 0; insetBottom = 0
|
||||||
minimumWidth = 0; minimumHeight = 0
|
minimumWidth = 0; minimumHeight = 0
|
||||||
cornerRadius = btnSize / 2
|
cornerRadius = btnSize / 2
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.nfc.NfcAdapter
|
||||||
|
import android.nfc.cardemulation.CardEmulation
|
||||||
|
import android.provider.Settings
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
|
||||||
|
|
||||||
|
object NfcPaymentUtil {
|
||||||
|
fun checkAndProceed(context: Context, onReady: () -> Unit) {
|
||||||
|
val nfcAdapter = NfcAdapter.getDefaultAdapter(context) ?: run {
|
||||||
|
MaterialAlertDialogBuilder(context)
|
||||||
|
.setTitle(R.string.nfc_unsupported_title)
|
||||||
|
.setMessage(R.string.nfc_unsupported_message)
|
||||||
|
.setPositiveButton(android.R.string.ok, null)
|
||||||
|
.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nfcAdapter.isEnabled) {
|
||||||
|
MaterialAlertDialogBuilder(context)
|
||||||
|
.setTitle(R.string.nfc_disabled_title)
|
||||||
|
.setMessage(R.string.nfc_disabled_message)
|
||||||
|
.setPositiveButton(R.string.nfc_open_settings) { _, _ ->
|
||||||
|
context.startActivity(Intent(Settings.ACTION_NFC_SETTINGS))
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val cardEmulation = CardEmulation.getInstance(nfcAdapter)
|
||||||
|
val componentName = ComponentName(context, BmlHostCardEmulatorService::class.java)
|
||||||
|
if (!cardEmulation.isDefaultServiceForCategory(componentName, CardEmulation.CATEGORY_PAYMENT)) {
|
||||||
|
MaterialAlertDialogBuilder(context)
|
||||||
|
.setTitle(R.string.nfc_not_default_title)
|
||||||
|
.setMessage(context.getString(R.string.nfc_not_default_message,
|
||||||
|
context.applicationInfo.loadLabel(context.packageManager)))
|
||||||
|
.setPositiveButton(R.string.nfc_payment_open_settings) { _, _ ->
|
||||||
|
context.startActivity(Intent(Settings.ACTION_NFC_PAYMENT_SETTINGS))
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.ui.home.AppNotification
|
||||||
|
|
||||||
|
object NotificationsCache {
|
||||||
|
|
||||||
|
private const val PREFS = "notifications_cache"
|
||||||
|
private const val KEY_MIB_READ_IDS = "mib_read_ids"
|
||||||
|
|
||||||
|
private fun bmlKey(loginId: String) = "bml_notifs_$loginId"
|
||||||
|
private fun mibKey(loginId: String) = "mib_activities_$loginId"
|
||||||
|
|
||||||
|
// ── BML ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun saveBml(ctx: Context, loginId: String, items: List<AppNotification>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
items.forEach { n ->
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("id", n.id)
|
||||||
|
put("group", n.group)
|
||||||
|
put("title", n.title)
|
||||||
|
put("message", n.message)
|
||||||
|
put("timestampMs", n.timestampMs)
|
||||||
|
put("isRead", n.isRead)
|
||||||
|
val fields = JSONArray()
|
||||||
|
n.detailFields.forEach { (k, v) -> fields.put(JSONObject().put("k", k).put("v", v)) }
|
||||||
|
put("detailFields", fields)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBml(ctx: Context, loginId: String): List<AppNotification> {
|
||||||
|
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getString(bmlKey(loginId), null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val obj = arr.getJSONObject(i)
|
||||||
|
val fields = obj.optJSONArray("detailFields")
|
||||||
|
val detailFields = if (fields != null) {
|
||||||
|
(0 until fields.length()).map { j ->
|
||||||
|
val f = fields.getJSONObject(j)
|
||||||
|
f.getString("k") to f.getString("v")
|
||||||
|
}
|
||||||
|
} else emptyList()
|
||||||
|
AppNotification(
|
||||||
|
id = obj.getString("id"),
|
||||||
|
bank = "BML",
|
||||||
|
loginId = loginId,
|
||||||
|
group = obj.getString("group"),
|
||||||
|
title = obj.getString("title"),
|
||||||
|
message = obj.getString("message"),
|
||||||
|
timestampMs = obj.getLong("timestampMs"),
|
||||||
|
isRead = obj.getBoolean("isRead"),
|
||||||
|
detailFields = detailFields
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MIB ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun saveMib(ctx: Context, loginId: String, items: List<AppNotification>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
items.forEach { n ->
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("id", n.id)
|
||||||
|
put("title", n.title)
|
||||||
|
put("message", n.message)
|
||||||
|
put("timestampMs", n.timestampMs)
|
||||||
|
val fields = JSONArray()
|
||||||
|
n.detailFields.forEach { (k, v) -> fields.put(JSONObject().put("k", k).put("v", v)) }
|
||||||
|
put("detailFields", fields)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit().putString(mibKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMib(ctx: Context, loginId: String, readIds: Set<String>): List<AppNotification> {
|
||||||
|
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getString(mibKey(loginId), null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val obj = arr.getJSONObject(i)
|
||||||
|
val id = obj.getString("id")
|
||||||
|
val fields = obj.optJSONArray("detailFields")
|
||||||
|
val detailFields = if (fields != null) {
|
||||||
|
(0 until fields.length()).map { j ->
|
||||||
|
val f = fields.getJSONObject(j)
|
||||||
|
f.getString("k") to f.getString("v")
|
||||||
|
}
|
||||||
|
} else emptyList()
|
||||||
|
AppNotification(
|
||||||
|
id = id,
|
||||||
|
bank = "MIB",
|
||||||
|
loginId = loginId,
|
||||||
|
group = "ALERTS",
|
||||||
|
title = obj.getString("title"),
|
||||||
|
message = obj.getString("message"),
|
||||||
|
timestampMs = obj.getLong("timestampMs"),
|
||||||
|
isRead = id in readIds,
|
||||||
|
detailFields = detailFields
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MIB read IDs (in-app only) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun getMibReadIds(ctx: Context): Set<String> {
|
||||||
|
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getString(KEY_MIB_READ_IDS, null) ?: return emptySet()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(raw)
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }.toSet()
|
||||||
|
} catch (_: Exception) { emptySet() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addMibReadIds(ctx: Context, ids: Collection<String>) {
|
||||||
|
val current = getMibReadIds(ctx).toMutableSet()
|
||||||
|
current.addAll(ids)
|
||||||
|
val arr = JSONArray().apply { current.forEach { put(it) } }
|
||||||
|
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit().putString(KEY_MIB_READ_IDS, arr.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll(ctx: Context) {
|
||||||
|
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Base64
|
||||||
|
|
||||||
|
data class OtpEntry(val name: String, val issuer: String, val secret: String)
|
||||||
|
|
||||||
|
object OtpauthParser {
|
||||||
|
|
||||||
|
fun parse(raw: String): List<OtpEntry> = when {
|
||||||
|
raw.startsWith("otpauth-migration://") -> parseMigration(raw)
|
||||||
|
raw.startsWith("otpauth://") -> parseStandard(raw)?.let { listOf(it) } ?: emptyList()
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseStandard(raw: String): OtpEntry? {
|
||||||
|
val uri = Uri.parse(raw)
|
||||||
|
val secret = uri.getQueryParameter("secret") ?: return null
|
||||||
|
val issuer = uri.getQueryParameter("issuer") ?: ""
|
||||||
|
val label = uri.path?.trimStart('/') ?: ""
|
||||||
|
val name = if (':' in label) label.substringAfter(':').trim() else label
|
||||||
|
return OtpEntry(name, issuer, secret.uppercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseMigration(raw: String): List<OtpEntry> {
|
||||||
|
val data = Uri.parse(raw).getQueryParameter("data") ?: return emptyList()
|
||||||
|
val bytes = try { Base64.decode(data, Base64.DEFAULT) } catch (_: Exception) { return emptyList() }
|
||||||
|
val reader = ProtobufReader(bytes)
|
||||||
|
val entries = mutableListOf<OtpEntry>()
|
||||||
|
while (reader.hasMore()) {
|
||||||
|
val tag = reader.readVarint().toInt()
|
||||||
|
val fieldNum = tag ushr 3
|
||||||
|
val wireType = tag and 0x7
|
||||||
|
if (fieldNum == 1 && wireType == 2) {
|
||||||
|
parseOtpParameters(reader.readBytes())?.let { entries.add(it) }
|
||||||
|
} else {
|
||||||
|
reader.skip(wireType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseOtpParameters(bytes: ByteArray): OtpEntry? {
|
||||||
|
val reader = ProtobufReader(bytes)
|
||||||
|
var secret: ByteArray? = null
|
||||||
|
var name = ""
|
||||||
|
var issuer = ""
|
||||||
|
var type = 2 // default to TOTP
|
||||||
|
while (reader.hasMore()) {
|
||||||
|
val tag = reader.readVarint().toInt()
|
||||||
|
val fieldNum = tag ushr 3
|
||||||
|
val wireType = tag and 0x7
|
||||||
|
when (fieldNum) {
|
||||||
|
1 -> secret = reader.readBytes()
|
||||||
|
2 -> name = String(reader.readBytes(), Charsets.UTF_8)
|
||||||
|
3 -> issuer = String(reader.readBytes(), Charsets.UTF_8)
|
||||||
|
6 -> type = reader.readVarint().toInt()
|
||||||
|
else -> reader.skip(wireType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type == 1) return null // skip HOTP
|
||||||
|
val secretBase32 = base32Encode(secret ?: return null)
|
||||||
|
return OtpEntry(name, issuer, secretBase32)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun base32Encode(bytes: ByteArray): String {
|
||||||
|
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||||
|
val sb = StringBuilder()
|
||||||
|
var buffer = 0
|
||||||
|
var bitsLeft = 0
|
||||||
|
for (b in bytes) {
|
||||||
|
buffer = (buffer shl 8) or (b.toInt() and 0xFF)
|
||||||
|
bitsLeft += 8
|
||||||
|
while (bitsLeft >= 5) {
|
||||||
|
bitsLeft -= 5
|
||||||
|
sb.append(alphabet[(buffer ushr bitsLeft) and 0x1F])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bitsLeft > 0) sb.append(alphabet[(buffer shl (5 - bitsLeft)) and 0x1F])
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ProtobufReader(private val bytes: ByteArray) {
|
||||||
|
private var pos = 0
|
||||||
|
|
||||||
|
fun hasMore() = pos < bytes.size
|
||||||
|
|
||||||
|
fun readVarint(): Long {
|
||||||
|
var result = 0L
|
||||||
|
var shift = 0
|
||||||
|
while (pos < bytes.size) {
|
||||||
|
val b = bytes[pos++].toInt() and 0xFF
|
||||||
|
result = result or ((b and 0x7F).toLong() shl shift)
|
||||||
|
if (b and 0x80 == 0) break
|
||||||
|
shift += 7
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readBytes(): ByteArray {
|
||||||
|
val len = readVarint().toInt()
|
||||||
|
val data = bytes.copyOfRange(pos, pos + len)
|
||||||
|
pos += len
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skip(wireType: Int) {
|
||||||
|
when (wireType) {
|
||||||
|
0 -> readVarint()
|
||||||
|
1 -> pos += 8
|
||||||
|
2 -> readBytes()
|
||||||
|
5 -> pos += 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,10 +40,10 @@ object BmlCardParser {
|
|||||||
"C8040", "C8044" -> "cards/bml/master_world.png"
|
"C8040", "C8044" -> "cards/bml/master_world.png"
|
||||||
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
|
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
|
||||||
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
|
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
|
||||||
"C1030", "C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
"C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
||||||
"C8905", "C8995" -> "cards/bml/visa_credit.png"
|
"C8905", "C8995" -> "cards/bml/visa_credit.png"
|
||||||
"C1001", "C1011", "C1082", "C1081", "C1101", "C1111", "C1181", "C1182" -> "cards/bml/visa_debit_generic.png"
|
"C1001", "C1011", "C1082", "C1081", "C1101", "C1111", "C1181", "C1182" -> "cards/bml/visa_debit_generic.png"
|
||||||
"C1005", "C1006", "C1089" -> "cards/bml/visa_debit_islamic.png"
|
"C1005", "C1006", "C1030", "C1089" -> "cards/bml/visa_debit_islamic.png"
|
||||||
"C1017" -> "cards/bml/visa_infinite.png"
|
"C1017" -> "cards/bml/visa_infinite.png"
|
||||||
"C1009", "C1019", "C1085", "C1086", "C1109", "C1119", "C1185", "C1186" -> "cards/bml/visa_platinum.png"
|
"C1009", "C1019", "C1085", "C1086", "C1109", "C1119", "C1185", "C1186" -> "cards/bml/visa_platinum.png"
|
||||||
"C1050", "C1051", "C1087", "C1088", "C1150", "C1151", "C1187", "C1188", "C1040", "C1041", "C1047", "C1048", "C1140", "C1141", "C1147", "C1148" -> "cards/bml/visa_student_black.png"
|
"C1050", "C1051", "C1087", "C1088", "C1150", "C1151", "C1187", "C1188", "C1040", "C1041", "C1047", "C1048", "C1140", "C1141", "C1147", "C1148" -> "cards/bml/visa_student_black.png"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#44808080" />
|
||||||
|
<corners android:radius="2dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M22,3H7C6.31,3 5.77,3.35 5.41,3.88L0,12L5.41,20.12C5.77,20.65 6.31,21 7,21H22C23.1,21 24,20.1 24,19V5C24,3.9 23.1,3 22,3ZM19,15.59L17.59,17L14,13.41L10.41,17L9,15.59L12.59,12L9,8.41L10.41,7L14,10.59L17.59,7L19,8.41L15.41,12L19,15.59Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<!-- Bell body (white) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
|
||||||
|
|
||||||
|
<!-- Unread notification dot (red) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#EF5350"
|
||||||
|
android:pathData="M18.5,2A3.5,3.5,0,1,0,18.5,9A3.5,3.5,0,0,0,18.5,2Z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
|
||||||
|
<!-- Bell outline (no fill) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeWidth="1.5"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M9,16.17L4.83,12L3.41,13.41L9,19L21,7L19.59,5.59Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<!-- Shackle (open - right leg lifted free) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:pathData="M8.9,10V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeWidth="2.2"/>
|
||||||
|
|
||||||
|
<!-- Body + keyhole cutout -->
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M6,8H18c1.1,0 2,0.9 2,2V20c0,1.1-0.9,2-2,2H6c-1.1,0-2,-0.9-2,-2V10c0,-1.1 0.9,-2 2,-2zM12,15c-1.1,0-2,0.9-2,2s0.9,2 2,2 2,-0.9 2,-2-0.9,-2-2,-2z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item>
|
<item>
|
||||||
<shape android:shape="oval">
|
<shape android:shape="oval">
|
||||||
<solid android:color="#E8B547" />
|
<solid android:color="@color/ic_logo_background" />
|
||||||
</shape>
|
</shape>
|
||||||
</item>
|
</item>
|
||||||
<item android:drawable="@drawable/ic_launcher_foreground" />
|
<item android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
|||||||
@@ -73,22 +73,42 @@
|
|||||||
android:singleLine="true" />
|
android:singleLine="true" />
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<LinearLayout
|
||||||
android:id="@+id/tilOtpSeed"
|
android:id="@+id/rowOtpSeed"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:hint="@string/otp_seed"
|
android:orientation="horizontal"
|
||||||
android:layout_marginBottom="8dp"
|
android:gravity="center_vertical"
|
||||||
app:endIconMode="password_toggle"
|
android:layout_marginBottom="8dp">
|
||||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
android:id="@+id/etOtpSeed"
|
android:id="@+id/tilOtpSeed"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="textPassword"
|
android:layout_weight="1"
|
||||||
android:imeOptions="actionDone"
|
android:hint="@string/otp_seed"
|
||||||
android:singleLine="true" />
|
app:endIconMode="password_toggle"
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etOtpSeed"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textPassword"
|
||||||
|
android:imeOptions="actionDone"
|
||||||
|
android:singleLine="true" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnScanOtpSeed"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
app:icon="@drawable/ic_qr_scan"
|
||||||
|
android:contentDescription="@string/scan_otp_qr"
|
||||||
|
android:tooltipText="@string/scan_otp_qr" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
android:id="@+id/tilTotpCode"
|
android:id="@+id/tilTotpCode"
|
||||||
|
|||||||
@@ -44,6 +44,14 @@
|
|||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="@string/settings_nav_drawer" />
|
android:text="@string/settings_nav_drawer" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnNavCircular"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/settings_nav_circular" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnNavBottom"
|
android:id="@+id/btnNavBottom"
|
||||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
|||||||
@@ -139,21 +139,11 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="4dp"
|
android:layout_marginStart="4dp"
|
||||||
android:enabled="false"
|
android:enabled="false"
|
||||||
android:text="@string/paymvqr_save_image"
|
android:text="@string/paymvqr_save_image"
|
||||||
app:icon="@drawable/ic_save" />
|
app:icon="@drawable/ic_save" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
|
||||||
android:id="@+id/btnScanQr"
|
|
||||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="4dp"
|
|
||||||
android:text="@string/transfer_scan_qr"
|
|
||||||
app:icon="@drawable/ic_qr_scan" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:background="?attr/colorSurface">
|
android:background="?attr/colorSurface">
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/receiptContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:overScrollMode="never"
|
||||||
|
android:scrollbars="none">
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||||
<!-- Renderable receipt card -->
|
<!-- Renderable receipt card -->
|
||||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||||
@@ -207,10 +215,13 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||||
<!-- Action buttons — outside renderable area -->
|
<!-- Action buttons — outside renderable area -->
|
||||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/btnRow"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:background="?attr/colorSurface">
|
android:background="?attr/colorSurface">
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/receiptContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:overScrollMode="never"
|
||||||
|
android:scrollbars="none">
|
||||||
|
|
||||||
<!-- Renderable receipt card (header grows to fill remaining space) -->
|
<!-- Renderable receipt card (header grows to fill remaining space) -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/receiptCard"
|
android:id="@+id/receiptCard"
|
||||||
@@ -236,8 +244,11 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
<!-- Action buttons — outside renderable area -->
|
<!-- Action buttons — outside renderable area -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/btnRow"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="?attr/colorSurface">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="24dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivLogo"
|
||||||
|
android:layout_width="72dp"
|
||||||
|
android:layout_height="72dp"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:src="@drawable/ic_logo"
|
||||||
|
android:contentDescription="@string/app_name" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAppName"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||||
|
android:layout_marginBottom="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvVersion"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:alpha="0.6"
|
||||||
|
android:layout_marginBottom="20dp" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="?attr/colorOutlineVariant"
|
||||||
|
android:layout_marginBottom="24dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/about_legal"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:layout_marginBottom="24dp" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="?attr/colorOutlineVariant"
|
||||||
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/about_terms"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:layout_marginBottom="4dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowMibTerms"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingVertical="12dp"
|
||||||
|
android:background="?attr/selectableItemBackground">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:src="@drawable/mib_logo"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:contentDescription="MIB" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Maldives Islamic Bank"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:src="@drawable/ic_arrow_right"
|
||||||
|
android:alpha="0.4"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowBmlTerms"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingVertical="12dp"
|
||||||
|
android:background="?attr/selectableItemBackground">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:src="@drawable/bml_icon"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:contentDescription="BML" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Bank of Maldives"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:src="@drawable/ic_arrow_right"
|
||||||
|
android:alpha="0.4"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowFahipayTerms"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingVertical="12dp"
|
||||||
|
android:background="?attr/selectableItemBackground">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:src="@drawable/fahipay_logo"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:contentDescription="Fahipay" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Fahipay"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:src="@drawable/ic_arrow_right"
|
||||||
|
android:alpha="0.4"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/sectionDonate"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="?attr/colorOutlineVariant"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/about_donate_title"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:layout_marginBottom="6dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/about_donate_desc"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:alpha="0.7"
|
||||||
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnDonateMvr"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:text="@string/about_donate_mvr" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnDonateUsd"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/about_donate_usd" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
@@ -35,6 +35,14 @@
|
|||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="@string/settings_nav_drawer" />
|
android:text="@string/settings_nav_drawer" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnNavCircular"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/settings_nav_circular" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnNavBottom"
|
android:id="@+id/btnNavBottom"
|
||||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
@@ -70,6 +78,31 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Circular nav shortcuts — shown only when circular nav is active -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/sectionCircularSlots"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_marginBottom="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/settings_circular_shortcuts"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvCircularSlots"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:nestedScrollingEnabled="false"
|
||||||
|
android:overScrollMode="never" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Bottom bar shortcuts — shown only when bottom nav is active -->
|
<!-- Bottom bar shortcuts — shown only when bottom nav is active -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/sectionBottomBarShortcuts"
|
android:id="@+id/sectionBottomBarShortcuts"
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<!-- Drag handle -->
|
||||||
|
<View
|
||||||
|
android:layout_width="36dp"
|
||||||
|
android:layout_height="4dp"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
|
android:background="@drawable/drag_handle_bg" />
|
||||||
|
|
||||||
|
<!-- Header row -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/notifHeader"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingHorizontal="20dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNotifTitle"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Notifications"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleLarge"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/btnMarkAllRead"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Mark all read"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:focusable="true"
|
||||||
|
android:clickable="true"
|
||||||
|
android:textColor="?attr/colorPrimary" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:alpha="0.12"
|
||||||
|
android:background="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<com.google.android.material.tabs.TabLayout
|
||||||
|
android:id="@+id/notifTabs"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:tabMode="fixed"
|
||||||
|
app:tabGravity="fill" />
|
||||||
|
|
||||||
|
<!-- Pager fills remaining height -->
|
||||||
|
<androidx.viewpager2.widget.ViewPager2
|
||||||
|
android:id="@+id/notifPager"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minWidth="0dp"
|
||||||
|
android:minHeight="0dp"
|
||||||
|
android:paddingTop="14dp"
|
||||||
|
android:paddingBottom="14dp"
|
||||||
|
android:text="@string/cancel"
|
||||||
|
android:textSize="13sp"
|
||||||
|
app:icon="@drawable/ic_block"
|
||||||
|
app:iconSize="22dp"
|
||||||
|
app:iconGravity="top"
|
||||||
|
app:iconPadding="6dp" />
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_notifications"
|
||||||
|
android:icon="@drawable/ic_bell_read"
|
||||||
|
android:title="Notifications"
|
||||||
|
app:showAsAction="always" />
|
||||||
|
|
||||||
<item
|
<item
|
||||||
android:id="@+id/action_hide_amounts"
|
android:id="@+id/action_hide_amounts"
|
||||||
android:icon="@drawable/ic_visibility"
|
android:icon="@drawable/ic_visibility"
|
||||||
|
|||||||
@@ -4,4 +4,5 @@
|
|||||||
<color name="seed_primary">#3F65AD</color>
|
<color name="seed_primary">#3F65AD</color>
|
||||||
<color name="seed_secondary">#9AD141</color>
|
<color name="seed_secondary">#9AD141</color>
|
||||||
<color name="color_unpaid">#E85D04</color>
|
<color name="color_unpaid">#E85D04</color>
|
||||||
|
<color name="ic_logo_background">#E8B547</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
<string name="password">Password</string>
|
<string name="password">Password</string>
|
||||||
<string name="otp_seed">OTP Seed (TOTP Secret)</string>
|
<string name="otp_seed">OTP Seed (TOTP Secret)</string>
|
||||||
<string name="otp_seed_hint">The Base32 secret from your authenticator setup</string>
|
<string name="otp_seed_hint">The Base32 secret from your authenticator setup</string>
|
||||||
|
<string name="scan_otp_qr">Scan OTP QR</string>
|
||||||
<string name="login">Login</string>
|
<string name="login">Login</string>
|
||||||
|
|
||||||
<!-- Lock screen -->
|
<!-- Lock screen -->
|
||||||
@@ -175,7 +176,9 @@
|
|||||||
<string name="settings_navigation">Navigation</string>
|
<string name="settings_navigation">Navigation</string>
|
||||||
<string name="settings_nav_drawer">Drawer</string>
|
<string name="settings_nav_drawer">Drawer</string>
|
||||||
<string name="settings_nav_bottom">Bottom Bar</string>
|
<string name="settings_nav_bottom">Bottom Bar</string>
|
||||||
|
<string name="settings_nav_circular">Circular</string>
|
||||||
<string name="settings_appearance">Appearance</string>
|
<string name="settings_appearance">Appearance</string>
|
||||||
|
<string name="settings_circular_shortcuts">Circular Nav Shortcuts</string>
|
||||||
<string name="settings_bottom_bar_shortcuts">Bottom Bar Shortcuts</string>
|
<string name="settings_bottom_bar_shortcuts">Bottom Bar Shortcuts</string>
|
||||||
<string name="settings_bottom_bar_show_labels">Always show bottom bar labels</string>
|
<string name="settings_bottom_bar_show_labels">Always show bottom bar labels</string>
|
||||||
<string name="settings_bottom_bar_select">Choose button</string>
|
<string name="settings_bottom_bar_select">Choose button</string>
|
||||||
@@ -189,6 +192,16 @@
|
|||||||
<string name="settings_desc_appearance">Theme, language, and display options</string>
|
<string name="settings_desc_appearance">Theme, language, and display options</string>
|
||||||
<string name="settings_desc_privacy_security">App lock, PIN, and security preferences</string>
|
<string name="settings_desc_privacy_security">App lock, PIN, and security preferences</string>
|
||||||
<string name="settings_desc_storage">Manage cached data and storage usage</string>
|
<string name="settings_desc_storage">Manage cached data and storage usage</string>
|
||||||
|
<string name="settings_about">About</string>
|
||||||
|
<string name="settings_desc_about">App info, version, and legal</string>
|
||||||
|
<string name="about_version">Version %s</string>
|
||||||
|
<string name="about_short_desc">Thijooree is a native Android client for Maldivian banking services.</string>
|
||||||
|
<string name="about_terms">Terms of Service</string>
|
||||||
|
<string name="about_donate_title">Support Development</string>
|
||||||
|
<string name="about_donate_desc">If you find this app useful, a small donation goes a long way in keeping it alive and improving.</string>
|
||||||
|
<string name="about_donate_mvr">Donate in MVR</string>
|
||||||
|
<string name="about_donate_usd">Donate in USD</string>
|
||||||
|
<string name="about_legal">Thijooree is an independent, third-party app. It is not affiliated with, endorsed by, or officially supported by any bank or financial institution.\n\nThis app works by logging into your internet banking services directly using your credentials and communicating with bank APIs. It is built using techniques derived from reverse-engineered official bank apps. Behaviour may change or break without notice if banks update their systems.\n\nDhiraagu and Ooredoo APIs are used to look up details about phone numbers.\n\nThis app does not collect any analytics, telemetry, or personal data, and does not transmit any information about you or your usage to the developer. Everything stays entirely on your device.</string>
|
||||||
<string name="settings_logout">Log out</string>
|
<string name="settings_logout">Log out</string>
|
||||||
<string name="settings_logout_confirm_title">Log out of %s?</string>
|
<string name="settings_logout_confirm_title">Log out of %s?</string>
|
||||||
<string name="settings_logout_confirm_message">All cached data will be cleared and remaining accounts will be refreshed.</string>
|
<string name="settings_logout_confirm_message">All cached data will be cleared and remaining accounts will be refreshed.</string>
|
||||||
@@ -332,6 +345,14 @@
|
|||||||
<string name="card_pay_qr">Scan to Pay</string>
|
<string name="card_pay_qr">Scan to Pay</string>
|
||||||
<string name="card_pay_nfc">Tap to Pay</string>
|
<string name="card_pay_nfc">Tap to Pay</string>
|
||||||
<string name="mib_qr_nfc_not_supported">Skill issue on MIB side, Not supported</string>
|
<string name="mib_qr_nfc_not_supported">Skill issue on MIB side, Not supported</string>
|
||||||
|
<string name="nfc_unsupported_title">Not Supported</string>
|
||||||
|
<string name="nfc_unsupported_message">Tap to Pay is not supported on this device.</string>
|
||||||
|
<string name="nfc_disabled_title">NFC is Off</string>
|
||||||
|
<string name="nfc_disabled_message">Turn on NFC to use Tap to Pay.</string>
|
||||||
|
<string name="nfc_open_settings">NFC Settings</string>
|
||||||
|
<string name="nfc_not_default_title">Set Default Payment App</string>
|
||||||
|
<string name="nfc_not_default_message">Set %1$s as the default contactless payment app to use Tap to Pay.</string>
|
||||||
|
<string name="nfc_payment_open_settings">Payment Settings</string>
|
||||||
<string name="card_manage">Manage Card</string>
|
<string name="card_manage">Manage Card</string>
|
||||||
<string name="card_set_as_default">Set as Default Card</string>
|
<string name="card_set_as_default">Set as Default Card</string>
|
||||||
<string name="card_hide_from_dashboard">Hide from Dashboard</string>
|
<string name="card_hide_from_dashboard">Hide from Dashboard</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user