diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlNotificationsClient.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlNotificationsClient.kt new file mode 100644 index 0000000..8e59102 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlNotificationsClient.kt @@ -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, + 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>() + 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) } +} diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibActivityHistoryClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibActivityHistoryClient.kt new file mode 100644 index 0000000..51b999a --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibActivityHistoryClient.kt @@ -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, // 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() + 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>().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() + 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) + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AppNotification.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AppNotification.kt new file mode 100644 index 0000000..7a5f759 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AppNotification.kt @@ -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> = emptyList() +) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index e7db253..f21729d 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -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() 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) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/NotificationsSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/NotificationsSheetFragment.kt new file mode 100644 index 0000000..bfd7498 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/NotificationsSheetFragment.kt @@ -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): List { + val result = mutableListOf() + 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() + + private val bmlNextPage = mutableMapOf() + private val bmlDone = mutableMapOf() + private val mibNextStart = mutableMapOf() + private val mibDone = mutableMapOf() + + private var isLoadingMore = false + private var mediator: TabLayoutMediator? = null + + private val tabAdapters = arrayOfNulls(3) + private val tabLabels = listOf("All", "Alerts", "Information") + private val tabGroupFilters = listOf(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(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(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() + 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, incoming: List) { + 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() { + 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() { + + private val displayItems = mutableListOf() + + fun update(filtered: List) { + 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 { + 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" + } + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/util/NotificationsCache.kt b/app/src/main/java/sh/sar/basedbank/util/NotificationsCache.kt new file mode 100644 index 0000000..2f0d1ad --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/NotificationsCache.kt @@ -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) { + 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 { + 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) { + 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): List { + 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 { + 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) { + 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() + } +} diff --git a/app/src/main/res/drawable/drag_handle_bg.xml b/app/src/main/res/drawable/drag_handle_bg.xml new file mode 100644 index 0000000..f4747e1 --- /dev/null +++ b/app/src/main/res/drawable/drag_handle_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_bell.xml b/app/src/main/res/drawable/ic_bell.xml new file mode 100644 index 0000000..1e896fd --- /dev/null +++ b/app/src/main/res/drawable/ic_bell.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_bell_read.xml b/app/src/main/res/drawable/ic_bell_read.xml new file mode 100644 index 0000000..525bfe5 --- /dev/null +++ b/app/src/main/res/drawable/ic_bell_read.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/src/main/res/layout/sheet_notifications.xml b/app/src/main/res/layout/sheet_notifications.xml new file mode 100644 index 0000000..dd33ff2 --- /dev/null +++ b/app/src/main/res/layout/sheet_notifications.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/toolbar_menu.xml b/app/src/main/res/menu/toolbar_menu.xml index 58b205e..b680d26 100644 --- a/app/src/main/res/menu/toolbar_menu.xml +++ b/app/src/main/res/menu/toolbar_menu.xml @@ -2,6 +2,12 @@ + +