add notifcations #24
Auto Tag on Version Change / check-version (push) Successful in 3s

This commit is contained in:
2026-06-04 01:38:38 +05:00
parent d0fb88d15a
commit d227d468b1
11 changed files with 1112 additions and 11 deletions
@@ -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) }
}
@@ -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)
}
}
@@ -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()
)
@@ -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())
@@ -92,6 +93,9 @@ class HomeActivity : AppCompatActivity() {
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
warningDialog?.dismiss(); warningDialog = null
@@ -300,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) {
@@ -320,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
@@ -379,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)) {
@@ -461,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) {
@@ -505,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)
@@ -621,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
}
@@ -628,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)
}
@@ -643,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
@@ -656,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"
}
}
}
}
@@ -0,0 +1,137 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.ui.home.AppNotification
object NotificationsCache {
private const val PREFS = "notifications_cache"
private const val KEY_MIB_READ_IDS = "mib_read_ids"
private fun bmlKey(loginId: String) = "bml_notifs_$loginId"
private fun mibKey(loginId: String) = "mib_activities_$loginId"
// ── BML ─────────────────────────────────────────────────────────────────────
fun saveBml(ctx: Context, loginId: String, items: List<AppNotification>) {
val arr = JSONArray()
items.forEach { n ->
arr.put(JSONObject().apply {
put("id", n.id)
put("group", n.group)
put("title", n.title)
put("message", n.message)
put("timestampMs", n.timestampMs)
put("isRead", n.isRead)
val fields = JSONArray()
n.detailFields.forEach { (k, v) -> fields.put(JSONObject().put("k", k).put("v", v)) }
put("detailFields", fields)
})
}
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadBml(ctx: Context, loginId: String): List<AppNotification> {
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(bmlKey(loginId), null) ?: return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
val fields = obj.optJSONArray("detailFields")
val detailFields = if (fields != null) {
(0 until fields.length()).map { j ->
val f = fields.getJSONObject(j)
f.getString("k") to f.getString("v")
}
} else emptyList()
AppNotification(
id = obj.getString("id"),
bank = "BML",
loginId = loginId,
group = obj.getString("group"),
title = obj.getString("title"),
message = obj.getString("message"),
timestampMs = obj.getLong("timestampMs"),
isRead = obj.getBoolean("isRead"),
detailFields = detailFields
)
}
} catch (_: Exception) { emptyList() }
}
// ── MIB ─────────────────────────────────────────────────────────────────────
fun saveMib(ctx: Context, loginId: String, items: List<AppNotification>) {
val arr = JSONArray()
items.forEach { n ->
arr.put(JSONObject().apply {
put("id", n.id)
put("title", n.title)
put("message", n.message)
put("timestampMs", n.timestampMs)
val fields = JSONArray()
n.detailFields.forEach { (k, v) -> fields.put(JSONObject().put("k", k).put("v", v)) }
put("detailFields", fields)
})
}
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(mibKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadMib(ctx: Context, loginId: String, readIds: Set<String>): List<AppNotification> {
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(mibKey(loginId), null) ?: return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
val id = obj.getString("id")
val fields = obj.optJSONArray("detailFields")
val detailFields = if (fields != null) {
(0 until fields.length()).map { j ->
val f = fields.getJSONObject(j)
f.getString("k") to f.getString("v")
}
} else emptyList()
AppNotification(
id = id,
bank = "MIB",
loginId = loginId,
group = "ALERTS",
title = obj.getString("title"),
message = obj.getString("message"),
timestampMs = obj.getLong("timestampMs"),
isRead = id in readIds,
detailFields = detailFields
)
}
} catch (_: Exception) { emptyList() }
}
// ── MIB read IDs (in-app only) ───────────────────────────────────────────────
fun getMibReadIds(ctx: Context): Set<String> {
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_MIB_READ_IDS, null) ?: return emptySet()
return try {
val arr = JSONArray(raw)
(0 until arr.length()).map { arr.getString(it) }.toSet()
} catch (_: Exception) { emptySet() }
}
fun addMibReadIds(ctx: Context, ids: Collection<String>) {
val current = getMibReadIds(ctx).toMutableSet()
current.addAll(ids)
val arr = JSONArray().apply { current.forEach { put(it) } }
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_MIB_READ_IDS, arr.toString()).apply()
}
fun clearAll(ctx: Context) {
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
}
@@ -0,0 +1,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>
+18
View File
@@ -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,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>
+6
View File
@@ -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"