This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user