forked from shihaam/thijooree
add notifcations #24
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 lateinit var toggle: ActionBarDrawerToggle
|
||||||
private var suppressBottomNavCallback = false
|
private var suppressBottomNavCallback = false
|
||||||
private var cachedTransferFragment: TransferFragment? = null
|
private var cachedTransferFragment: TransferFragment? = null
|
||||||
|
private val navBackStack = ArrayDeque<Int>()
|
||||||
|
|
||||||
private var backPressedOnce = false
|
private var backPressedOnce = false
|
||||||
private val backPressHandler = Handler(Looper.getMainLooper())
|
private val backPressHandler = Handler(Looper.getMainLooper())
|
||||||
@@ -92,6 +93,9 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
private var isLocked = false
|
private var isLocked = false
|
||||||
private var pendingWheelUnlock = false
|
private var pendingWheelUnlock = false
|
||||||
|
|
||||||
|
private var hasUnreadNotifications = false
|
||||||
|
private var notifMenuItem: MenuItem? = null
|
||||||
|
|
||||||
private val autolockRunnable = Runnable {
|
private val autolockRunnable = Runnable {
|
||||||
countdownTimer?.cancel(); countdownTimer = null
|
countdownTimer?.cancel(); countdownTimer = null
|
||||||
warningDialog?.dismiss(); warningDialog = null
|
warningDialog?.dismiss(); warningDialog = null
|
||||||
@@ -300,6 +304,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
if (navMode == NavCustomization.NAV_MODE_CIRCULAR) {
|
if (navMode == NavCustomization.NAV_MODE_CIRCULAR) {
|
||||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||||
supportFragmentManager.popBackStack()
|
supportFragmentManager.popBackStack()
|
||||||
|
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (currentFrag is CircularNavFragment) {
|
if (currentFrag is CircularNavFragment) {
|
||||||
@@ -320,6 +325,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
|
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
|
||||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||||
supportFragmentManager.popBackStack()
|
supportFragmentManager.popBackStack()
|
||||||
|
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// In bottom nav mode, pressing back navigates up the hierarchy
|
// In bottom nav mode, pressing back navigates up the hierarchy
|
||||||
@@ -379,6 +385,20 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
.commit()
|
.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateNavSelection(itemId: Int) {
|
||||||
|
binding.navigationView.setCheckedItem(itemId)
|
||||||
|
if (binding.bottomNavigation.visibility == View.VISIBLE) {
|
||||||
|
val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
|
||||||
|
.map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
|
||||||
|
val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
|
||||||
|
if (selectId != null) {
|
||||||
|
suppressBottomNavCallback = true
|
||||||
|
binding.bottomNavigation.selectedItemId = selectId
|
||||||
|
suppressBottomNavCallback = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun applyNavMode() {
|
fun applyNavMode() {
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
when (NavCustomization.getNavMode(prefs)) {
|
when (NavCustomization.getNavMode(prefs)) {
|
||||||
@@ -461,17 +481,7 @@ fun applyNavLabelVisibility() {
|
|||||||
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
||||||
}
|
}
|
||||||
show(dest)
|
show(dest)
|
||||||
binding.navigationView.setCheckedItem(itemId)
|
updateNavSelection(itemId)
|
||||||
if (binding.bottomNavigation.visibility == View.VISIBLE) {
|
|
||||||
val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
|
|
||||||
.map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
|
|
||||||
val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
|
|
||||||
if (selectId != null) {
|
|
||||||
suppressBottomNavCallback = true
|
|
||||||
binding.bottomNavigation.selectedItemId = selectId
|
|
||||||
suppressBottomNavCallback = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setBottomNavVisible(visible: Boolean) {
|
fun setBottomNavVisible(visible: Boolean) {
|
||||||
@@ -505,6 +515,12 @@ fun applyNavLabelVisibility() {
|
|||||||
.commit()
|
.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showWithBackStackAndNav(fragment: Fragment, itemId: Int) {
|
||||||
|
navBackStack.addLast(binding.bottomNavigation.selectedItemId)
|
||||||
|
showWithBackStack(fragment)
|
||||||
|
updateNavSelection(itemId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun routeSharedQrText(text: String) {
|
private fun routeSharedQrText(text: String) {
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
|
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
|
||||||
@@ -621,6 +637,8 @@ fun applyNavLabelVisibility() {
|
|||||||
eyeItem?.isVisible = true
|
eyeItem?.isVisible = true
|
||||||
val hidden = viewModel.hideAmounts.value ?: false
|
val hidden = viewModel.hideAmounts.value ?: false
|
||||||
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
|
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
|
||||||
|
notifMenuItem = menu.findItem(R.id.action_notifications)
|
||||||
|
notifMenuItem?.setIcon(if (hasUnreadNotifications) R.drawable.ic_bell else R.drawable.ic_bell_read)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,6 +646,7 @@ fun applyNavLabelVisibility() {
|
|||||||
val onWheel = supportFragmentManager.findFragmentById(R.id.contentFrame) is CircularNavFragment
|
val onWheel = supportFragmentManager.findFragmentById(R.id.contentFrame) is CircularNavFragment
|
||||||
menu.findItem(R.id.action_hide_amounts)?.isVisible = !onWheel
|
menu.findItem(R.id.action_hide_amounts)?.isVisible = !onWheel
|
||||||
menu.findItem(R.id.action_lock)?.isVisible = !onWheel
|
menu.findItem(R.id.action_lock)?.isVisible = !onWheel
|
||||||
|
menu.findItem(R.id.action_notifications)?.isVisible = !onWheel
|
||||||
return super.onPrepareOptionsMenu(menu)
|
return super.onPrepareOptionsMenu(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,6 +662,10 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if (item.itemId == R.id.action_notifications) {
|
||||||
|
openNotificationsSheet()
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (item.itemId == R.id.action_hide_amounts) {
|
if (item.itemId == R.id.action_hide_amounts) {
|
||||||
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
||||||
viewModel.hideAmounts.value = newHidden
|
viewModel.hideAmounts.value = newHidden
|
||||||
@@ -656,6 +679,16 @@ fun applyNavLabelVisibility() {
|
|||||||
return super.onOptionsItemSelected(item)
|
return super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setNotificationUnread(hasUnread: Boolean) {
|
||||||
|
hasUnreadNotifications = hasUnread
|
||||||
|
notifMenuItem?.setIcon(if (hasUnread) R.drawable.ic_bell else R.drawable.ic_bell_read)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openNotificationsSheet() {
|
||||||
|
val sheet = NotificationsSheetFragment()
|
||||||
|
sheet.onUnreadCountChanged = { hasUnread -> setNotificationUnread(hasUnread) }
|
||||||
|
sheet.show(supportFragmentManager, "notifications")
|
||||||
|
}
|
||||||
|
|
||||||
fun relogin() {
|
fun relogin() {
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
|
|||||||
@@ -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"
|
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_notifications"
|
||||||
|
android:icon="@drawable/ic_bell_read"
|
||||||
|
android:title="Notifications"
|
||||||
|
app:showAsAction="always" />
|
||||||
|
|
||||||
<item
|
<item
|
||||||
android:id="@+id/action_hide_amounts"
|
android:id="@+id/action_hide_amounts"
|
||||||
android:icon="@drawable/ic_visibility"
|
android:icon="@drawable/ic_visibility"
|
||||||
|
|||||||
Reference in New Issue
Block a user