Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e978f11343
|
|||
|
d227d468b1
|
|||
|
d0fb88d15a
|
|||
|
b08d983077
|
|||
|
c7c89184c0
|
|||
|
0e5435f0fe
|
|||
|
3bb44f1c32
|
|||
|
5dc1a5dbc9
|
|||
|
982596f2a8
|
|||
|
140b0069bd
|
|||
|
74ec9c383c
|
|||
|
b4f66342af
|
|||
|
f575941141
|
|||
|
ceaad0e313
|
|||
|
528663a330
|
|||
|
a1abbc9843
|
Generated
+2
-2
@@ -4,10 +4,10 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<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">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=4254e2f" />
|
||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.245:5555;connection=d182cf37" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 14
|
||||
versionName = "1.0.15"
|
||||
versionCode = 15
|
||||
versionName = "1.0.16"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -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.ACCESS_NETWORK_STATE" />
|
||||
<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" />
|
||||
|
||||
|
||||
@@ -124,8 +124,17 @@ class LockActivity : AppCompatActivity() {
|
||||
else
|
||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||
val btn = MaterialButton(this, null, style).apply {
|
||||
text = key
|
||||
textSize = 24f
|
||||
if (key == "⌫" || key == "✓") {
|
||||
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
|
||||
minimumWidth = 0; minimumHeight = 0
|
||||
cornerRadius = btnSize / 2
|
||||
|
||||
@@ -153,6 +153,46 @@ class BmlHistoryClient {
|
||||
} 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
|
||||
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
||||
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 {
|
||||
val json = JSONObject(bodyStr)
|
||||
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 {
|
||||
val payload = json.optJSONObject("payload")
|
||||
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 {
|
||||
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 var lastInsertedDateKey = ""
|
||||
private val imageCache = mutableMapOf<String, Bitmap>()
|
||||
@@ -48,9 +49,11 @@ class AccountHistoryAdapter(
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
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) {
|
||||
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
|
||||
displayItems.forEachIndexed { i, item ->
|
||||
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
|
||||
displayItems.forEachIndexed { i, item ->
|
||||
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
|
||||
var showLoadingFooter: Boolean
|
||||
get() = _showLoadingFooter
|
||||
@@ -127,18 +139,24 @@ class AccountHistoryAdapter(
|
||||
displayItems.add(Item.Trx(trx))
|
||||
}
|
||||
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
|
||||
// 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
|
||||
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 {
|
||||
position == 0 -> TYPE_HEADER
|
||||
_showLoadingFooter && position == itemCount - 1 -> TYPE_LOADING
|
||||
else -> when (displayItems[position - 1]) {
|
||||
else -> when (itemAt(position)) {
|
||||
is Item.DateHeader -> TYPE_DATE_HEADER
|
||||
is Item.Trx -> TYPE_TRANSACTION
|
||||
}
|
||||
@@ -157,8 +175,11 @@ class AccountHistoryAdapter(
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is HeaderVH -> holder.bind(display)
|
||||
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
|
||||
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
|
||||
is DateHeaderVH -> holder.bind((itemAt(position) as Item.DateHeader).label)
|
||||
is TransactionVH -> {
|
||||
val item = itemAt(position) as Item.Trx
|
||||
holder.bind(item.transaction, item.showDate)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -203,7 +224,7 @@ class AccountHistoryAdapter(
|
||||
|
||||
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(trx: BankTransaction) {
|
||||
fun bind(trx: BankTransaction, showDate: Boolean = false) {
|
||||
val isCredit = trx.amount >= 0
|
||||
val color = sourceColor(trx.source)
|
||||
val name = trx.counterpartyName ?: trx.description
|
||||
@@ -239,7 +260,7 @@ class AccountHistoryAdapter(
|
||||
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) {
|
||||
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 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_ONLY_FMT = SimpleDateFormat("MMM d, yyyy", 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())
|
||||
|
||||
@@ -307,6 +329,11 @@ class AccountHistoryAdapter(
|
||||
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 {
|
||||
val date = parseDate(raw) ?: return ""
|
||||
return TIME_FMT.format(date)
|
||||
|
||||
@@ -24,6 +24,7 @@ import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
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.models.BankTransaction
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
@@ -138,6 +139,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
loadNextPage()
|
||||
loadPendingTransactions()
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
if (isLoading) {
|
||||
@@ -184,6 +186,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
binding.emptyView.visibility = View.GONE
|
||||
}
|
||||
loadNextPage()
|
||||
loadPendingTransactions()
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!pendingImageNames.add(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)
|
||||
.setView(container)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
.setCancelable(false)
|
||||
|
||||
@@ -4,10 +4,13 @@ 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
|
||||
@@ -16,6 +19,8 @@ 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
|
||||
@@ -68,8 +73,9 @@ class CircularNavFragment : Fragment() {
|
||||
accentColor = colorPrimary
|
||||
surfaceColor = colorSurface
|
||||
labelColor = colorOnSurface
|
||||
onItemClick = { navId -> (activity as? HomeActivity)?.navigateTo(navId) }
|
||||
onCenterClick = { (activity as? HomeActivity)?.lockApp() }
|
||||
onItemClick = { navId -> (activity as? HomeActivity)?.navigateTo(navId) }
|
||||
onCenterClick = { /* unused: tap on unlocked center locks the wheel */ }
|
||||
onWheelCenterLockedTap = { (activity as? HomeActivity)?.notifyWheelLockTap() }
|
||||
}
|
||||
wheelContainer.addView(wheelView)
|
||||
|
||||
@@ -86,6 +92,14 @@ class CircularNavFragment : Fragment() {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -123,6 +137,10 @@ class CircularNavFragment : Fragment() {
|
||||
toolbar.findViewWithTag<android.view.View>("wheel_title")?.let { toolbar.removeView(it) }
|
||||
requireActivity().invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
fun unlockWheelLock() {
|
||||
wheelView?.unlockWheel()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -159,8 +177,12 @@ class CircularWheelView @JvmOverloads constructor(
|
||||
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 ---------------------------------------------------------
|
||||
|
||||
@@ -185,6 +207,10 @@ class CircularWheelView @JvmOverloads constructor(
|
||||
|
||||
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 ----------------------------------------------------
|
||||
|
||||
@@ -234,7 +260,8 @@ class CircularWheelView @JvmOverloads constructor(
|
||||
iconBitmaps[i] = tintedBitmap(item.iconRes, accentColor, iconPx)
|
||||
}
|
||||
val centerPx = (innerRadius * 0.64f).toInt().coerceAtLeast(1)
|
||||
centerBitmap = tintedBitmap(R.drawable.ic_lock, accentColor, centerPx)
|
||||
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? {
|
||||
@@ -294,8 +321,13 @@ class CircularWheelView @JvmOverloads constructor(
|
||||
canvas.drawCircle(cx, cy, innerRadius + dp(3f), centerRingPaint)
|
||||
centerFillPaint.color = surfaceColor
|
||||
canvas.drawCircle(cx, cy, innerRadius, centerFillPaint)
|
||||
centerBitmap?.let {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,24 +338,41 @@ class CircularWheelView @JvmOverloads constructor(
|
||||
|
||||
val iconX = cx + cosA * (outerRadius * 0.63f)
|
||||
val iconY = cy + sinA * (outerRadius * 0.63f)
|
||||
val textX = cx + cosA * (outerRadius * 0.84f)
|
||||
val textY = cy + sinA * (outerRadius * 0.84f)
|
||||
|
||||
// Icon — radially oriented; top items are naturally upside-down
|
||||
iconBitmaps.getOrNull(index)?.let { bmp ->
|
||||
canvas.save()
|
||||
canvas.translate(iconX, iconY)
|
||||
canvas.rotate(midDeg - 90f)
|
||||
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()
|
||||
}
|
||||
|
||||
// Label — same radial rotation
|
||||
textPaint.color = labelColor
|
||||
// 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.drawText(items[index].label, 0f, textPaint.textSize * 0.36f, textPaint)
|
||||
canvas.drawTextOnPath(label, arcPath, 0f, textPaint.textSize * 0.36f, textPaint)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
@@ -358,18 +407,38 @@ class CircularWheelView @JvmOverloads constructor(
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (!isDragging) {
|
||||
val dist = hypot(event.x - cx, event.y - cy)
|
||||
when {
|
||||
dist <= innerRadius -> onCenterClick?.invoke()
|
||||
dist <= innerRadius -> {
|
||||
if (isWheelLocked) {
|
||||
onWheelCenterLockedTap?.invoke()
|
||||
} else {
|
||||
isWheelLocked = true
|
||||
}
|
||||
}
|
||||
dist <= outerRadius -> {
|
||||
val idx = segmentAt(event.x, event.y)
|
||||
if (idx in items.indices) animateToSixOClock(idx) { onItemClick?.invoke(items[idx].navId) }
|
||||
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() // degrees per millisecond
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -464,4 +533,29 @@ class CircularWheelView @JvmOverloads constructor(
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,11 +99,11 @@ class DashboardFragment : Fragment() {
|
||||
}
|
||||
|
||||
binding.cardPendingFinances.setOnClickListener {
|
||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
||||
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
|
||||
}
|
||||
|
||||
binding.cardOverdue.setOnClickListener {
|
||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
||||
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
|
||||
}
|
||||
|
||||
val cardAdapter = DashboardCardAdapter()
|
||||
|
||||
@@ -77,6 +77,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
private lateinit var toggle: ActionBarDrawerToggle
|
||||
private var suppressBottomNavCallback = false
|
||||
private var cachedTransferFragment: TransferFragment? = null
|
||||
private val navBackStack = ArrayDeque<Int>()
|
||||
|
||||
private var backPressedOnce = false
|
||||
private val backPressHandler = Handler(Looper.getMainLooper())
|
||||
@@ -90,6 +91,10 @@ class HomeActivity : AppCompatActivity() {
|
||||
private val warningRunnable = Runnable { showAutolockWarning() }
|
||||
|
||||
private var isLocked = false
|
||||
private var pendingWheelUnlock = false
|
||||
|
||||
private var hasUnreadNotifications = false
|
||||
private var notifMenuItem: MenuItem? = null
|
||||
|
||||
private val autolockRunnable = Runnable {
|
||||
countdownTimer?.cancel(); countdownTimer = null
|
||||
@@ -101,6 +106,19 @@ class HomeActivity : AppCompatActivity() {
|
||||
|
||||
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() {
|
||||
isLocked = true
|
||||
startActivity(
|
||||
@@ -286,6 +304,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
if (navMode == NavCustomization.NAV_MODE_CIRCULAR) {
|
||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||
supportFragmentManager.popBackStack()
|
||||
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
|
||||
return
|
||||
}
|
||||
if (currentFrag is CircularNavFragment) {
|
||||
@@ -306,6 +325,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
|
||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||
supportFragmentManager.popBackStack()
|
||||
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
|
||||
return
|
||||
}
|
||||
// In bottom nav mode, pressing back navigates up the hierarchy
|
||||
@@ -365,6 +385,20 @@ class HomeActivity : AppCompatActivity() {
|
||||
.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() {
|
||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
when (NavCustomization.getNavMode(prefs)) {
|
||||
@@ -447,17 +481,7 @@ fun applyNavLabelVisibility() {
|
||||
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
||||
}
|
||||
show(dest)
|
||||
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
|
||||
}
|
||||
}
|
||||
updateNavSelection(itemId)
|
||||
}
|
||||
|
||||
fun setBottomNavVisible(visible: Boolean) {
|
||||
@@ -491,6 +515,12 @@ fun applyNavLabelVisibility() {
|
||||
.commit()
|
||||
}
|
||||
|
||||
fun showWithBackStackAndNav(fragment: Fragment, itemId: Int) {
|
||||
navBackStack.addLast(binding.bottomNavigation.selectedItemId)
|
||||
showWithBackStack(fragment)
|
||||
updateNavSelection(itemId)
|
||||
}
|
||||
|
||||
private fun routeSharedQrText(text: String) {
|
||||
val store = CredentialStore(this)
|
||||
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
|
||||
@@ -520,6 +550,11 @@ fun applyNavLabelVisibility() {
|
||||
pauseTime = 0L
|
||||
resetAutolockTimer()
|
||||
autoRefresh(CredentialStore(this))
|
||||
if (pendingWheelUnlock) {
|
||||
pendingWheelUnlock = false
|
||||
(supportFragmentManager.findFragmentById(R.id.contentFrame) as? CircularNavFragment)
|
||||
?.unlockWheelLock()
|
||||
}
|
||||
return
|
||||
}
|
||||
// If we were away long enough to have hit the autolock timeout (e.g. while
|
||||
@@ -602,6 +637,8 @@ fun applyNavLabelVisibility() {
|
||||
eyeItem?.isVisible = true
|
||||
val hidden = viewModel.hideAmounts.value ?: false
|
||||
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
|
||||
}
|
||||
|
||||
@@ -609,6 +646,7 @@ fun applyNavLabelVisibility() {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -624,6 +662,10 @@ fun applyNavLabelVisibility() {
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (item.itemId == R.id.action_notifications) {
|
||||
openNotificationsSheet()
|
||||
return true
|
||||
}
|
||||
if (item.itemId == R.id.action_hide_amounts) {
|
||||
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
||||
viewModel.hideAmounts.value = newHidden
|
||||
@@ -637,6 +679,16 @@ fun applyNavLabelVisibility() {
|
||||
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() {
|
||||
val store = CredentialStore(this)
|
||||
|
||||
@@ -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.btnShare.setOnClickListener { shareQr() }
|
||||
binding.btnSave.setOnClickListener { saveQr() }
|
||||
binding.btnScanQr.setOnClickListener {
|
||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceWithAutoScan())
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupDropdown() {
|
||||
|
||||
@@ -510,15 +510,37 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
tapAnimView = animView
|
||||
|
||||
val dp = resources.displayMetrics.density
|
||||
val cancelBtn = MaterialButton(requireContext(), null,
|
||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||
).apply { setText(R.string.cancel); setOnClickListener { setTapMode(false) } }
|
||||
val cancelBtn = (layoutInflater.inflate(R.layout.view_cancel_button, null, false) as MaterialButton).apply {
|
||||
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 {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
setPadding(0, 0, 0, (24 * dp).toInt())
|
||||
addView(cancelBtn)
|
||||
setPadding((16 * dp).toInt(), (8 * dp).toInt(), (16 * dp).toInt(), baseCancelPaddingBottom)
|
||||
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 {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
@@ -529,6 +551,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
addView(animView.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 3f)
|
||||
})
|
||||
addView(tapDivider)
|
||||
addView(cancelWrapper.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||
})
|
||||
@@ -660,7 +683,10 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
view?.post {
|
||||
if (!isTapMode) return@post
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ class QrScannerActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
// Black camera background — always use light (white) system bar icons
|
||||
|
||||
@@ -2,6 +2,7 @@ package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
@@ -16,6 +17,8 @@ import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -48,6 +51,14 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
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
|
||||
val currentMode = NavCustomization.getNavMode(prefs)
|
||||
@@ -125,6 +136,7 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
})
|
||||
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
if (!isChecked) return@addOnButtonCheckedListener
|
||||
val previousKey = prefs.getString("theme", "system")
|
||||
val (key, mode) = when (checkedId) {
|
||||
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
|
||||
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
|
||||
@@ -134,6 +146,16 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
AppCompatDelegate.setDefaultNightMode(mode)
|
||||
updateAccentState(key == "system")
|
||||
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
|
||||
@@ -148,7 +170,7 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
// Accent color
|
||||
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
||||
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_CUSTOM -> R.id.btnAccentCustom
|
||||
else -> R.id.btnAccentBlue
|
||||
|
||||
@@ -9,6 +9,8 @@ import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import sh.sar.basedbank.R
|
||||
|
||||
@@ -32,6 +34,14 @@ class SettingsFragment : Fragment() {
|
||||
inflater.inflate(R.layout.fragment_settings, container, false)
|
||||
|
||||
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 inflater = LayoutInflater.from(requireContext())
|
||||
for (item in items) {
|
||||
|
||||
@@ -20,6 +20,8 @@ import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
@@ -333,6 +335,14 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
|
||||
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 {
|
||||
startActivity(Intent(requireContext(), LoginActivity::class.java))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentSettingsSecurityBinding
|
||||
@@ -22,6 +24,14 @@ class SettingsSecurityFragment : Fragment() {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Change lock
|
||||
|
||||
@@ -6,6 +6,8 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import sh.sar.basedbank.R
|
||||
@@ -31,6 +33,14 @@ class SettingsStorageFragment : Fragment() {
|
||||
}
|
||||
|
||||
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 {
|
||||
val ctx = requireContext()
|
||||
clearAllCaches(ctx)
|
||||
|
||||
@@ -26,6 +26,8 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.button.MaterialButton
|
||||
@@ -111,6 +113,32 @@ class TransferReceiptFragment : Fragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
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 {
|
||||
parentFragmentManager.popBackStack()
|
||||
}
|
||||
|
||||
@@ -31,9 +31,14 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
||||
ThemeHelper.applyAccent(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// If security is already configured, onboarding is complete. Redirect to lock screen
|
||||
// to prevent overwriting an existing PIN/pattern via direct activity launch.
|
||||
if (CredentialStore(this).loadSecurityHash() != null) {
|
||||
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
|
||||
// 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))
|
||||
finish()
|
||||
return
|
||||
@@ -50,7 +55,6 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
||||
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
||||
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
||||
ta.recycle()
|
||||
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
val originalBottomPadding = binding.bottomBar.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.biometric.BiometricManager
|
||||
import androidx.fragment.app.Fragment
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
|
||||
import sh.sar.basedbank.ui.home.NavCustomization
|
||||
|
||||
class OnboardingConfigureFragment : Fragment() {
|
||||
|
||||
@@ -24,12 +25,20 @@ class OnboardingConfigureFragment : Fragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
|
||||
// Navigation — default Drawer
|
||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
||||
// Navigation
|
||||
binding.navModeToggle.check(when (NavCustomization.getNavMode(prefs)) {
|
||||
NavCustomization.NAV_MODE_BOTTOM -> R.id.btnNavBottom
|
||||
NavCustomization.NAV_MODE_CIRCULAR -> R.id.btnNavCircular
|
||||
else -> R.id.btnNavDrawer
|
||||
})
|
||||
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
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
|
||||
|
||||
@@ -59,6 +59,7 @@ class OnboardingFragment : Fragment() {
|
||||
|
||||
private fun notifyScrolledToBottom() {
|
||||
if (scrolledToBottom) return
|
||||
if (!isAdded) return
|
||||
scrolledToBottom = true
|
||||
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
|
||||
@@ -102,8 +103,17 @@ class SecuritySetupFragment : Fragment() {
|
||||
else
|
||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||
val btn = MaterialButton(requireContext(), null, style).apply {
|
||||
text = key
|
||||
textSize = 24f
|
||||
if (key == "⌫" || key == "✓") {
|
||||
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
|
||||
minimumWidth = 0; minimumHeight = 0
|
||||
cornerRadius = btnSize / 2
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -40,10 +40,10 @@ object BmlCardParser {
|
||||
"C8040", "C8044" -> "cards/bml/master_world.png"
|
||||
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
|
||||
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
|
||||
"C1030", "C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
||||
"C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
||||
"C8905", "C8995" -> "cards/bml/visa_credit.png"
|
||||
"C1001", "C1011", "C1082", "C1081", "C1101", "C1111", "C1181", "C1182" -> "cards/bml/visa_debit_generic.png"
|
||||
"C1005", "C1006", "C1089" -> "cards/bml/visa_debit_islamic.png"
|
||||
"C1005", "C1006", "C1030", "C1089" -> "cards/bml/visa_debit_islamic.png"
|
||||
"C1017" -> "cards/bml/visa_infinite.png"
|
||||
"C1009", "C1019", "C1085", "C1086", "C1109", "C1119", "C1185", "C1186" -> "cards/bml/visa_platinum.png"
|
||||
"C1050", "C1051", "C1087", "C1088", "C1150", "C1151", "C1187", "C1188", "C1040", "C1041", "C1047", "C1048", "C1140", "C1141", "C1147", "C1148" -> "cards/bml/visa_student_black.png"
|
||||
|
||||
@@ -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,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">
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#E8B547" />
|
||||
<solid android:color="@color/ic_logo_background" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:drawable="@drawable/ic_launcher_foreground" />
|
||||
|
||||
@@ -44,6 +44,14 @@
|
||||
android:layout_weight="1"
|
||||
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
|
||||
android:id="@+id/btnNavBottom"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
|
||||
@@ -139,21 +139,11 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:enabled="false"
|
||||
android:text="@string/paymvqr_save_image"
|
||||
app:icon="@drawable/ic_save" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnScanQr"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="@string/transfer_scan_qr"
|
||||
app:icon="@drawable/ic_qr_scan" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
android:orientation="vertical"
|
||||
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 -->
|
||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||
@@ -207,10 +215,13 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||
<!-- Action buttons — outside renderable area -->
|
||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/btnRow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
android:orientation="vertical"
|
||||
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) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/receiptCard"
|
||||
@@ -236,8 +244,11 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
<!-- Action buttons — outside renderable area -->
|
||||
<LinearLayout
|
||||
android:id="@+id/btnRow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
|
||||
@@ -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"
|
||||
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
|
||||
android:id="@+id/action_hide_amounts"
|
||||
android:icon="@drawable/ic_visibility"
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
<color name="seed_primary">#3F65AD</color>
|
||||
<color name="seed_secondary">#9AD141</color>
|
||||
<color name="color_unpaid">#E85D04</color>
|
||||
<color name="ic_logo_background">#E8B547</color>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user