add transactions and account history
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s

This commit is contained in:
2026-05-15 09:21:09 +05:00
parent deedd16ba2
commit 6779b6f3b7
21 changed files with 1629 additions and 5 deletions

View File

@@ -13,10 +13,13 @@ import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.util.Totp
import java.security.MessageDigest
import java.security.SecureRandom
import java.text.SimpleDateFormat
import java.util.Base64
import java.util.Locale
import java.util.concurrent.TimeUnit
class AuthExpiredException : Exception("Session expired")
@@ -419,6 +422,156 @@ class BmlLoginFlow {
}
}
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
private fun parsePurchaseNarrative1(narrative1: String): String? {
return try {
val parts = narrative1.split(" ")
if (parts.size < 2) null
else {
val timePart = parts[1].take(4)
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
}
} catch (_: Exception) { null }
}
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
private fun parseTransferNarrative1(narrative1: String): String? {
return try {
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
} catch (_: Exception) { null }
}
/**
* Fetches paginated transaction history for a BML CASA account.
* @return Pair of (transactions, totalPages)
*/
fun fetchAccountHistory(
session: BmlSession,
accountId: String,
accountDisplayName: String,
accountNumber: String,
page: Int
): Pair<List<Transaction>, Int> {
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/account/$accountId/history/$page")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0)
val totalPages = payload.optInt("totalPages", 0)
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages)
val transactions = (0 until history.length()).map { i ->
val item = history.getJSONObject(i)
val desc = item.optString("description").trim()
val narrative1 = item.optString("narrative1")
val date = when (desc) {
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
else -> item.optString("bookingDate")
}
Transaction(
id = item.optString("id"),
date = date,
description = desc,
amount = item.optDouble("amount", 0.0),
currency = item.optString("currency"),
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
reference = item.optString("reference").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML"
)
}
Pair(transactions, totalPages)
} catch (_: Exception) { Pair(emptyList(), 0) }
}
/**
* Fetches card statement for a BML prepaid card for the given month ("YYYYMM").
* Returns combined outstanding authorizations + settled statement entries.
*/
fun fetchCardHistory(
session: BmlSession,
cardId: String,
accountDisplayName: String,
accountNumber: String,
month: String
): List<Transaction> {
val body = """{"card":"$cardId","month":"$month"}"""
.toRequestBody("application/json".toMediaType())
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/card/statement").post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return emptyList()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload = root.optJSONObject("payload") ?: return emptyList()
val result = mutableListOf<Transaction>()
// Outstanding authorizations
val authDetails = payload.optJSONObject("outstanding")
?.optJSONArray("CardOutStdAuthDetails")
if (authDetails != null) {
for (i in 0 until authDetails.length()) {
val item = authDetails.getJSONObject(i)
result.add(Transaction(
id = "auth_${item.optString("TranApprCode")}_$i",
date = item.optString("DateTime"),
description = item.optString("TranDesc").trim(),
amount = item.optDouble("BillingAmount", 0.0),
currency = item.optString("BillingCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
// Settled statement entries
val statement = payload.optJSONArray("cardstatement")
if (statement != null) {
for (i in 0 until statement.length()) {
val item = statement.getJSONObject(i)
result.add(Transaction(
id = "stmt_${item.optString("TranRef", i.toString())}",
date = item.optString("TransDate", item.optString("TranDate", "")),
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
amount = -item.optDouble("TranAmount", 0.0),
currency = item.optString("TranCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
result
} catch (_: Exception) { emptyList() }
}
private fun apiRequest(session: BmlSession, url: String) =
Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")

View File

@@ -0,0 +1,87 @@
package sh.sar.basedbank.api.mib
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class MibHistoryClient {
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
/**
* Fetches transaction history for a single MIB account.
* @param start 1-based index; use 1 for first page, 21 for second, etc. (pageSize=20)
* @return Pair of (transactions, totalCount)
*/
fun fetchHistory(
session: MibSession,
accountNo: String,
accountDisplayName: String,
start: Int = 1,
pageSize: Int = 20
): Pair<List<Transaction>, Int> {
val body = FormBody.Builder()
.add("accountNo", accountNo)
.add("trxNo", "")
.add("trxType", "0")
.add("sortTrx", "date")
.add("sortDir", "desc")
.add("fromDate", "")
.add("toDate", "")
.add("start", start.toString())
.add("end", (start + pageSize - 1).toString())
.add("includeCount", "1")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxAccounts/trxHistory")
.post(body)
.header("Cookie", cookieHeader(session))
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL//accountDetails?trxh=1&dashurl=1&accountNo=$accountNo")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
val total = json.optString("total_count", "0").toIntOrNull() ?: 0
val data = json.optJSONArray("data") ?: return Pair(emptyList(), total)
val transactions = (0 until data.length()).map { i ->
val item = data.getJSONObject(i)
Transaction(
id = item.optString("trxNumber"),
date = item.optString("trxDate"),
description = item.optString("descr1").trim(),
amount = item.optString("baseAmount", "0").toDoubleOrNull() ?: 0.0,
currency = item.optString("curCodeDesc"),
counterpartyName = item.optString("benefName").takeIf {
it.isNotBlank() && it != "null"
},
reference = item.optString("trxNumber2").takeIf { it.isNotBlank() },
accountNumber = accountNo,
accountDisplayName = accountDisplayName,
source = "MIB"
)
}
Pair(transactions, total)
}
}
}

View File

@@ -72,6 +72,19 @@ data class MibIpsAccountInfo(
val bankId: String
)
data class Transaction(
val id: String,
val date: String, // "YYYY-MM-DD HH:mm:ss" for MIB, ISO8601 for BML
val description: String,
val amount: Double, // negative = debit, positive = credit
val currency: String,
val counterpartyName: String?,
val reference: String?,
val accountNumber: String,
val accountDisplayName: String,
val source: String // "MIB", "BML", "BML_CARD"
)
data class MibFinanceDeal(
val dealNo: String,
val productDesc: String,

View File

@@ -0,0 +1,47 @@
package sh.sar.basedbank.api.mib
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
object TransactionCache {
fun save(context: Context, key: String, transactions: List<Transaction>) {
try {
val arr = JSONArray()
for (t in transactions) arr.put(JSONObject().apply {
put("id", t.id)
put("date", t.date)
put("description", t.description)
put("amount", t.amount)
put("currency", t.currency)
put("counterpartyName", t.counterpartyName ?: "")
put("reference", t.reference ?: "")
put("accountNumber", t.accountNumber)
put("accountDisplayName", t.accountDisplayName)
put("source", t.source)
})
File(context.cacheDir, "tx_$key.json").writeText(arr.toString())
} catch (_: Exception) {}
}
fun load(context: Context, key: String): List<Transaction> = try {
val arr = JSONArray(File(context.cacheDir, "tx_$key.json").readText())
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
Transaction(
id = o.optString("id"),
date = o.optString("date"),
description = o.optString("description"),
amount = o.optDouble("amount", 0.0),
currency = o.optString("currency"),
counterpartyName = o.optString("counterpartyName").takeIf { it.isNotBlank() },
reference = o.optString("reference").takeIf { it.isNotBlank() },
accountNumber = o.optString("accountNumber"),
accountDisplayName = o.optString("accountDisplayName"),
source = o.optString("source")
)
}
} catch (_: Exception) { emptyList() }
}

View File

@@ -0,0 +1,237 @@
package sh.sar.basedbank.ui.home
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
import sh.sar.basedbank.databinding.ItemTransactionBinding
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class AccountHistoryAdapter(
private val account: MibAccount
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
data class DateHeader(val label: String) : Item()
data class Trx(val transaction: Transaction) : Item()
}
private val displayItems = mutableListOf<Item>()
private var _showLoadingFooter = false
var showLoadingFooter: Boolean
get() = _showLoadingFooter
set(value) {
if (_showLoadingFooter == value) return
if (value) {
_showLoadingFooter = true
notifyItemInserted(itemCount - 1)
} else {
val pos = itemCount - 1
_showLoadingFooter = false
notifyItemRemoved(pos)
}
}
/**
* Display the given (already sorted + filtered) list with date group headers.
* Silently resets the loading footer so notifyDataSetChanged covers everything.
*/
fun setTransactions(transactions: List<Transaction>) {
_showLoadingFooter = false
displayItems.clear()
var lastDateKey = ""
for (trx in transactions) {
val dateKey = trx.date.take(10)
if (dateKey != lastDateKey) {
displayItems.add(Item.DateHeader(formatDateHeader(trx.date)))
lastDateKey = dateKey
}
displayItems.add(Item.Trx(trx))
}
notifyDataSetChanged()
}
// Position 0 = account header card
// Positions 1..displayItems.size = date headers + transactions
// Last position = loading footer when showLoadingFooter = true
override fun getItemCount() = 1 + displayItems.size + if (_showLoadingFooter) 1 else 0
override fun getItemViewType(position: Int) = when {
position == 0 -> TYPE_HEADER
_showLoadingFooter && position == itemCount - 1 -> TYPE_LOADING
else -> when (displayItems[position - 1]) {
is Item.DateHeader -> TYPE_DATE_HEADER
is Item.Trx -> TYPE_TRANSACTION
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_HEADER -> HeaderVH(ItemAccountHistoryHeaderBinding.inflate(inflater, parent, false))
TYPE_DATE_HEADER -> DateHeaderVH(ItemDateHeaderBinding.inflate(inflater, parent, false))
TYPE_LOADING -> LoadingVH(ItemLoadingFooterBinding.inflate(inflater, parent, false))
else -> TransactionVH(ItemTransactionBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderVH -> holder.bind(account)
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
else -> Unit
}
}
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
RecyclerView.ViewHolder(b.root) {
fun bind(acc: MibAccount) {
b.tvHeaderAccountName.text = acc.accountBriefName
b.tvHeaderAccountNumber.text = acc.accountNumber
b.tvHeaderPillBank.text = if (acc.profileType.startsWith("BML")) "BML" else "MIB"
b.tvHeaderPillType.text = friendlyType(acc.accountTypeName)
b.tvHeaderAvailable.text = "${acc.currencyName} ${acc.availableBalance}"
b.tvHeaderBalance.text = "${acc.currencyName} ${acc.currentBalance}"
val blocked = acc.blockedAmount.toDoubleOrNull() ?: 0.0
if (blocked > 0.0) {
b.tvHeaderBlocked.text = "${acc.currencyName} ${acc.blockedAmount}"
b.llHeaderBlocked.visibility = View.VISIBLE
} else {
b.llHeaderBlocked.visibility = View.GONE
}
}
private fun friendlyType(raw: String): String {
val u = raw.trim().uppercase()
return when {
u.contains("SAVING") -> "Savings"
u.contains("CURRENT") -> "Current"
u.contains("WADIAH") -> "Islamic"
u.contains("VISA") || u.contains("MASTERCARD") || u.contains("AMEX") -> "Card"
u.contains("PREPAID") -> "Prepaid"
else -> raw.trim().take(12)
}
}
}
inner class DateHeaderVH(private val b: ItemDateHeaderBinding) :
RecyclerView.ViewHolder(b.root) {
fun bind(label: String) { b.tvDateHeader.text = label }
}
inner class TransactionVH(private val b: ItemTransactionBinding) :
RecyclerView.ViewHolder(b.root) {
fun bind(trx: Transaction) {
val isCredit = trx.amount >= 0
val color = sourceColor(trx.source)
val initial = (trx.counterpartyName ?: trx.description)
.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val circle = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.parseColor(color))
}
b.fvAvatar.background = circle
b.tvInitial.text = initial
b.tvDescription.text = trx.description
val counterparty = trx.counterpartyName
if (!counterparty.isNullOrBlank()) {
b.tvCounterparty.text = counterparty
b.tvCounterparty.visibility = View.VISIBLE
} else {
b.tvCounterparty.visibility = View.GONE
}
b.tvDate.text = formatTime(trx.date)
val sign = if (isCredit) "+" else "-"
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
b.tvAmount.setTextColor(
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
)
b.root.setOnClickListener { showDetail(trx) }
}
private fun showDetail(trx: Transaction) {
val ctx = b.root.context
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
val details = buildString {
if (!trx.counterpartyName.isNullOrBlank()) append("Type\n${trx.description}\n\n")
val sign = if (trx.amount >= 0) "+" else "-"
append("Amount\n$sign ${trx.currency} ${"%.2f".format(kotlin.math.abs(trx.amount))}\n\n")
append("Date\n${formatFullDate(trx.date)}\n\n")
if (!trx.reference.isNullOrBlank()) append("Reference\n${trx.reference}\n\n")
append("Account\n${trx.accountDisplayName}")
}
MaterialAlertDialogBuilder(ctx)
.setTitle(title)
.setMessage(details)
.setPositiveButton("OK", null)
.show()
}
}
inner class LoadingVH(b: ItemLoadingFooterBinding) : RecyclerView.ViewHolder(b.root)
companion object {
const val TYPE_HEADER = 0
const val TYPE_TRANSACTION = 1
const val TYPE_LOADING = 2
const val TYPE_DATE_HEADER = 3
private val MIB_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val BML_FMT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
private val DATE_HEADER_FMT = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
private val TIME_FMT = SimpleDateFormat("h:mm a", Locale.getDefault())
private val FULL_DATE_FMT = SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.getDefault())
fun parseDateMillis(raw: String): Long {
if (raw.isBlank()) return 0L
return try {
if (raw.contains("T")) BML_FMT.parse(raw.take(25))?.time ?: 0L
else MIB_FMT.parse(raw)?.time ?: 0L
} catch (_: Exception) { 0L }
}
private fun parseDate(raw: String): Date? = try {
if (raw.contains("T")) BML_FMT.parse(raw.take(25))
else MIB_FMT.parse(raw)
} catch (_: Exception) { null }
fun formatDateHeader(raw: String): String {
val date = parseDate(raw) ?: return raw.take(10)
return DATE_HEADER_FMT.format(date)
}
fun formatTime(raw: String): String {
val date = parseDate(raw) ?: return ""
return TIME_FMT.format(date)
}
fun formatFullDate(raw: String): String {
val date = parseDate(raw) ?: return raw
return FULL_DATE_FMT.format(date)
}
fun sourceColor(source: String) = when (source) {
"MIB" -> "#FE860E"
"BML", "BML_CARD" -> "#0066A1"
else -> "#888888"
}
}
}

View File

@@ -0,0 +1,216 @@
package sh.sar.basedbank.ui.home
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
class AccountHistoryFragment : Fragment() {
private var _binding: FragmentAccountHistoryBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: AccountHistoryAdapter
private lateinit var account: MibAccount
private val allTransactions = mutableListOf<Transaction>()
private var searchQuery = ""
private var firstPageDone = false
// Pagination state
private var mibNextStart = 1
private var mibTotalCount = -1 // -1 = unknown; loaded on first fetch
private var bmlNextPage = 1
private var bmlTotalPages = -1
private var cardMonthOffset = 0 // 0 = current month, 1 = prev, etc.
private var isLoading = false
private val pageSize = 10
companion object {
private const val ARG_ACCOUNT_NUMBER = "account_number"
fun newInstance(account: MibAccount) = AccountHistoryFragment().apply {
arguments = Bundle().apply {
putString(ARG_ACCOUNT_NUMBER, account.accountNumber)
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentAccountHistoryBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
adapter = AccountHistoryAdapter(account)
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0 || isLoading) return
val lm = rv.layoutManager as LinearLayoutManager
if (lm.findLastVisibleItemPosition() >= adapter.itemCount - 3) {
loadNextPage()
}
}
})
binding.etSearch.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
searchQuery = s?.toString()?.trim() ?: ""
filterAndDisplay()
if (searchQuery.isNotBlank() && hasMore() && !isLoading) loadNextPage()
}
})
// Load cache immediately, then fetch fresh data in background
val cached = TransactionCache.load(requireContext(), account.accountNumber)
if (cached.isNotEmpty()) {
allTransactions.addAll(cached)
filterAndDisplay()
}
(activity as? HomeActivity)?.setRefreshing(true)
loadNextPage()
}
override fun onResume() {
super.onResume()
if (::account.isInitialized) requireActivity().title = account.accountBriefName
}
private fun filterAndDisplay() {
val filtered = if (searchQuery.isBlank()) allTransactions
else allTransactions.filter {
it.counterpartyName?.contains(searchQuery, ignoreCase = true) == true ||
it.description.contains(searchQuery, ignoreCase = true)
}
adapter.setTransactions(filtered)
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
}
private fun isMib() = !account.profileType.startsWith("BML")
private fun isBmlCard() = account.profileType == "BML_PREPAID"
private fun hasMore(): Boolean = when {
isMib() -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
isBmlCard() -> cardMonthOffset < 3 // load up to 3 months
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
}
private fun loadNextPage() {
if (isLoading || !hasMore()) return
isLoading = true
if (firstPageDone && allTransactions.isNotEmpty()) {
adapter.showLoadingFooter = true
}
val app = requireActivity().application as BasedBankApp
lifecycleScope.launch {
val transactions: List<Transaction> = withContext(Dispatchers.IO) {
when {
isMib() -> {
val session = app.mibSession ?: return@withContext emptyList()
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
val client = MibHistoryClient()
val (list, total) = client.fetchHistory(
session = session,
accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName,
start = mibNextStart,
pageSize = pageSize
)
if (total > 0) mibTotalCount = total
mibNextStart += list.size.coerceAtLeast(pageSize)
list
}
isBmlCard() -> {
val session = app.bmlSession ?: return@withContext emptyList()
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
cardMonthOffset++
BmlLoginFlow().fetchCardHistory(
session = session,
cardId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
month = month
)
}
else -> {
val session = app.bmlSession ?: return@withContext emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session,
accountId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
page = bmlNextPage
)
if (totalPages > 0) bmlTotalPages = totalPages
bmlNextPage++
list
}
}
}
isLoading = false
if (!firstPageDone) {
firstPageDone = true
(activity as? HomeActivity)?.setRefreshing(false)
}
if (transactions.isNotEmpty()) {
val existingIds = allTransactions.map { it.id }.toHashSet()
val newOnes = transactions.filter { it.id !in existingIds }
if (newOnes.isNotEmpty()) {
allTransactions.addAll(newOnes)
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
filterAndDisplay()
} else {
adapter.showLoadingFooter = false
}
if (searchQuery.isNotBlank() && hasMore()) loadNextPage()
} else {
adapter.showLoadingFooter = false
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
}
}
}
override fun onDestroyView() {
(activity as? HomeActivity)?.setRefreshing(false)
super.onDestroyView()
_binding = null
}
}

View File

@@ -15,8 +15,10 @@ import sh.sar.basedbank.databinding.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemCardBinding
import sh.sar.basedbank.databinding.ItemProfileHeaderBinding
class AccountsAdapter(accounts: List<MibAccount>) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
class AccountsAdapter(
accounts: List<MibAccount>,
private val onAccountClick: (MibAccount) -> Unit = {}
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
data class SectionTitle(val label: String, val chip: String) : Item()
@@ -97,6 +99,7 @@ class AccountsAdapter(accounts: List<MibAccount>) :
else -> account.profileName
}
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
binding.root.setOnClickListener { onAccountClick(account) }
binding.root.setOnLongClickListener {
copyToClipboard(it.context, account.accountNumber)
true
@@ -128,6 +131,7 @@ class AccountsAdapter(accounts: List<MibAccount>) :
binding.tvCardStatus.visibility = View.VISIBLE
binding.root.alpha = 0.45f
}
binding.root.setOnClickListener { onAccountClick(account) }
}
}

View File

@@ -23,7 +23,9 @@ class AccountsFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = AccountsAdapter(emptyList())
adapter = AccountsAdapter(emptyList()) { account ->
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
}
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter

View File

@@ -84,6 +84,7 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_add_account -> startActivity(Intent(this, LoginActivity::class.java))
R.id.nav_accounts -> show(AccountsFragment())
R.id.nav_contacts -> show(ContactsFragment())
R.id.nav_transfer_history -> show(TransferHistoryFragment())
R.id.nav_finances -> show(FinancingFragment())
R.id.nav_settings -> show(SettingsFragment())
else -> Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
@@ -148,6 +149,10 @@ class HomeActivity : AppCompatActivity() {
.commit()
}
fun setRefreshing(visible: Boolean) {
binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE
}
fun showWithBackStack(fragment: Fragment) {
supportFragmentManager.beginTransaction()
.replace(R.id.contentFrame, fragment)

View File

@@ -0,0 +1,144 @@
package sh.sar.basedbank.ui.home
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
import sh.sar.basedbank.databinding.ItemTransactionBinding
/** Adapter for Transfer History — date-grouped, shows account name in secondary line. */
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
data class DateHeader(val label: String) : Item()
data class Trx(val transaction: Transaction) : Item()
}
private val displayItems = mutableListOf<Item>()
private var _showLoadingFooter = false
var showLoadingFooter: Boolean
get() = _showLoadingFooter
set(value) {
if (_showLoadingFooter == value) return
if (value) {
_showLoadingFooter = true
notifyItemInserted(itemCount - 1)
} else {
val pos = itemCount - 1
_showLoadingFooter = false
notifyItemRemoved(pos)
}
}
/** Replace the full sorted transaction list and rebuild date groups. */
fun setTransactions(transactions: List<Transaction>) {
_showLoadingFooter = false // reset silently; notifyDataSetChanged covers it
displayItems.clear()
var lastDateKey = ""
for (trx in transactions) {
val dateKey = trx.date.take(10)
if (dateKey != lastDateKey) {
displayItems.add(Item.DateHeader(AccountHistoryAdapter.formatDateHeader(trx.date)))
lastDateKey = dateKey
}
displayItems.add(Item.Trx(trx))
}
notifyDataSetChanged()
}
override fun getItemCount() = displayItems.size + if (showLoadingFooter) 1 else 0
override fun getItemViewType(position: Int) = when {
showLoadingFooter && position == displayItems.size -> TYPE_LOADING
displayItems[position] is Item.DateHeader -> TYPE_DATE_HEADER
else -> TYPE_TRANSACTION
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_DATE_HEADER -> DateHeaderVH(ItemDateHeaderBinding.inflate(inflater, parent, false))
TYPE_LOADING -> LoadingVH(ItemLoadingFooterBinding.inflate(inflater, parent, false))
else -> TransactionVH(ItemTransactionBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is DateHeaderVH -> holder.bind((displayItems[position] as Item.DateHeader).label)
is TransactionVH -> holder.bind((displayItems[position] as Item.Trx).transaction)
else -> Unit
}
}
inner class DateHeaderVH(private val b: ItemDateHeaderBinding) :
RecyclerView.ViewHolder(b.root) {
fun bind(label: String) { b.tvDateHeader.text = label }
}
inner class TransactionVH(private val b: ItemTransactionBinding) :
RecyclerView.ViewHolder(b.root) {
fun bind(trx: Transaction) {
val isCredit = trx.amount >= 0
val color = AccountHistoryAdapter.sourceColor(trx.source)
val initial = (trx.counterpartyName ?: trx.description)
.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val circle = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.parseColor(color))
}
b.fvAvatar.background = circle
b.tvInitial.text = initial
b.tvDescription.text = trx.description
// Show account name in secondary line for Transfer History
b.tvCounterparty.text = trx.accountDisplayName
b.tvCounterparty.visibility = View.VISIBLE
b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date)
val sign = if (isCredit) "+" else "-"
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
b.tvAmount.setTextColor(
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
)
b.root.setOnClickListener { showDetail(trx) }
}
private fun showDetail(trx: Transaction) {
val ctx = b.root.context
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
val details = buildString {
if (!trx.counterpartyName.isNullOrBlank()) append("Type\n${trx.description}\n\n")
val sign = if (trx.amount >= 0) "+" else "-"
append("Amount\n$sign ${trx.currency} ${"%.2f".format(kotlin.math.abs(trx.amount))}\n\n")
append("Date\n${AccountHistoryAdapter.formatFullDate(trx.date)}\n\n")
if (!trx.reference.isNullOrBlank()) append("Reference\n${trx.reference}\n\n")
append("Account\n${trx.accountDisplayName}")
}
MaterialAlertDialogBuilder(ctx)
.setTitle(title)
.setMessage(details)
.setPositiveButton("OK", null)
.show()
}
}
inner class LoadingVH(b: ItemLoadingFooterBinding) : RecyclerView.ViewHolder(b.root)
companion object {
const val TYPE_TRANSACTION = 0
const val TYPE_LOADING = 1
const val TYPE_DATE_HEADER = 2
}
}

View File

@@ -0,0 +1,240 @@
package sh.sar.basedbank.ui.home
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
class TransferHistoryFragment : Fragment() {
private var _binding: FragmentTransferHistoryBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: TransactionAdapter
// All merged transactions sorted by date desc
private val allTransactions = mutableListOf<Transaction>()
private var searchQuery = ""
// Per-account pagination state
private data class AccountState(
val account: MibAccount,
var mibNextStart: Int = 1,
var mibTotalCount: Int = -1,
var bmlNextPage: Int = 1,
var bmlTotalPages: Int = -1,
var cardMonthOffset: Int = 0
) {
fun hasMore(): Boolean = when {
account.profileType == "BML_PREPAID" -> cardMonthOffset < 2
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
}
}
private val accountStates = mutableListOf<AccountState>()
private var isLoading = false
private var firstBatchDone = false
private val pageSize = 20
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentTransferHistoryBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = TransactionAdapter()
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0 || isLoading) return
val lm = rv.layoutManager as LinearLayoutManager
if (lm.findLastVisibleItemPosition() >= adapter.itemCount - 5) {
loadNextPages()
}
}
})
binding.etSearch.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
searchQuery = s?.toString()?.trim() ?: ""
filterAndDisplay()
if (searchQuery.isNotBlank() && accountStates.any { it.hasMore() } && !isLoading) {
loadNextPages()
}
}
})
val accounts = viewModel.accounts.value ?: emptyList()
if (accounts.isEmpty()) {
binding.emptyView.visibility = View.VISIBLE
return
}
accountStates.clear()
accounts.forEach { accountStates.add(AccountState(it)) }
// Load cache immediately, then fetch fresh data in background
val cached = TransactionCache.load(requireContext(), "transfer")
if (cached.isNotEmpty()) {
allTransactions.addAll(cached)
filterAndDisplay()
}
(activity as? HomeActivity)?.setRefreshing(true)
loadNextPages()
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_transfer_history)
}
private fun loadNextPages() {
val activeStates = accountStates.filter { it.hasMore() }
if (isLoading || activeStates.isEmpty()) return
isLoading = true
if (firstBatchDone && allTransactions.isNotEmpty()) {
adapter.showLoadingFooter = true
}
val app = requireActivity().application as BasedBankApp
val mibSession = app.mibSession
val bmlSession = app.bmlSession
lifecycleScope.launch {
val newTransactions = withContext(Dispatchers.IO) {
val results = mutableListOf<Transaction>()
// BML accounts: fetch in parallel
val bmlStates = activeStates.filter { it.account.profileType.startsWith("BML") }
results.addAll(bmlStates.map { state ->
async {
try {
when {
state.account.profileType == "BML_PREPAID" -> {
val session = bmlSession ?: return@async emptyList()
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -state.cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
state.cardMonthOffset++
BmlLoginFlow().fetchCardHistory(
session = session,
cardId = state.account.internalId,
accountDisplayName = state.account.accountBriefName,
accountNumber = state.account.accountNumber,
month = month
)
}
else -> {
val session = bmlSession ?: return@async emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session,
accountId = state.account.internalId,
accountDisplayName = state.account.accountBriefName,
accountNumber = state.account.accountNumber,
page = state.bmlNextPage
)
if (totalPages > 0) state.bmlTotalPages = totalPages
state.bmlNextPage++
list
}
}
} catch (_: Exception) { emptyList<Transaction>() }
}
}.awaitAll().flatten())
// MIB accounts: serialized per profile to avoid session race
val mibStates = activeStates.filter { !it.account.profileType.startsWith("BML") }
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) {
val session = mibSession ?: break
val profile = app.mibProfiles.firstOrNull { it.profileId == profileId }
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
for (state in states) {
try {
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName,
start = state.mibNextStart,
pageSize = pageSize
)
if (total > 0) state.mibTotalCount = total
state.mibNextStart += list.size.coerceAtLeast(pageSize)
results.addAll(list)
} catch (_: Exception) {}
}
}
results
}
isLoading = false
if (!firstBatchDone) {
firstBatchDone = true
(activity as? HomeActivity)?.setRefreshing(false)
}
if (newTransactions.isNotEmpty()) {
val existingIds = allTransactions.map { it.id }.toHashSet()
val newOnes = newTransactions.filter { it.id !in existingIds }
if (newOnes.isNotEmpty()) {
allTransactions.addAll(newOnes)
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
TransactionCache.save(requireContext(), "transfer", allTransactions)
filterAndDisplay()
} else {
adapter.showLoadingFooter = false
}
if (searchQuery.isNotBlank() && accountStates.any { it.hasMore() }) loadNextPages()
} else {
adapter.showLoadingFooter = false
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
}
}
}
private fun filterAndDisplay() {
val filtered = if (searchQuery.isBlank()) allTransactions
else allTransactions.filter {
it.counterpartyName?.contains(searchQuery, ignoreCase = true) == true ||
it.description.contains(searchQuery, ignoreCase = true)
}
adapter.setTransactions(filtered)
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
}
override fun onDestroyView() {
(activity as? HomeActivity)?.setRefreshing(false)
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?attr/colorSurfaceVariant" />
<corners android:radius="4dp" />
</shape>

View File

@@ -0,0 +1,58 @@
<?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:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?attr/colorSurface">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
app:startIconDrawable="@android:drawable/ic_menu_search"
app:boxCornerRadiusTopStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusBottomEnd="24dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Search"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionSearch" />
</com.google.android.material.textfield.TextInputLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="16dp"
android:clipToPadding="false" />
<TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="No transactions found"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,59 @@
<?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:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?attr/colorSurface">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
app:startIconDrawable="@android:drawable/ic_menu_search"
app:boxCornerRadiusTopStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusBottomEnd="24dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Search"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionSearch" />
</com.google.android.material.textfield.TextInputLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="4dp"
android:paddingBottom="16dp"
android:clipToPadding="false" />
<TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="No transactions found"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>

View File

@@ -15,7 +15,8 @@
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="20dp"
android:gravity="center_vertical">
android:gravity="center_vertical"
android:foreground="?attr/selectableItemBackground">
<!-- Left: name / number -->
<LinearLayout

View File

@@ -0,0 +1,172 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="12dp"
android:layout_marginBottom="8dp"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Name + pill row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvHeaderAccountName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvHeaderAccountNumber"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<!-- Bank + type pill -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginStart="12dp"
android:background="@drawable/pill_segment_bg">
<TextView
android:id="@+id/tvHeaderPillBank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingEnd="10dp"
android:paddingVertical="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface" />
<View
android:layout_width="1dp"
android:layout_height="16dp"
android:background="?attr/colorOutline" />
<TextView
android:id="@+id/tvHeaderPillType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="10dp"
android:paddingEnd="12dp"
android:paddingVertical="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
</LinearLayout>
<!-- Balance row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Available"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvHeaderAvailable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="2dp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Balance"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvHeaderBalance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="2dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/llHeaderBlocked"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Blocked"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvHeaderBlocked"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorError"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -15,7 +15,8 @@
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="20dp"
android:gravity="center_vertical">
android:gravity="center_vertical"
android:foreground="?attr/selectableItemBackground">
<!-- Brand chip -->
<TextView

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/tvDateHeader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingTop="20dp"
android:paddingBottom="6dp"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorPrimary" />

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="16dp">
<ProgressBar
android:layout_width="28dp"
android:layout_height="28dp" />
</LinearLayout>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp">
<View
android:id="@+id/skAvatar"
android:layout_width="42dp"
android:layout_height="42dp"
android:background="@drawable/bg_skeleton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<View
android:id="@+id/skName"
android:layout_width="0dp"
android:layout_height="13dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_skeleton"
app:layout_constraintStart_toEndOf="@id/skAvatar"
app:layout_constraintEnd_toStartOf="@id/skAmount"
app:layout_constraintTop_toTopOf="@id/skAvatar"
app:layout_constraintBottom_toTopOf="@id/skDesc"
app:layout_constraintVertical_chainStyle="packed" />
<View
android:id="@+id/skDesc"
android:layout_width="110dp"
android:layout_height="10dp"
android:layout_marginStart="12dp"
android:layout_marginTop="5dp"
android:background="@drawable/bg_skeleton"
app:layout_constraintStart_toEndOf="@id/skAvatar"
app:layout_constraintTop_toBottomOf="@id/skName"
app:layout_constraintBottom_toTopOf="@id/skTime" />
<View
android:id="@+id/skTime"
android:layout_width="60dp"
android:layout_height="8dp"
android:layout_marginStart="12dp"
android:layout_marginTop="5dp"
android:background="@drawable/bg_skeleton"
app:layout_constraintStart_toEndOf="@id/skAvatar"
app:layout_constraintTop_toBottomOf="@id/skDesc"
app:layout_constraintBottom_toBottomOf="@id/skAvatar" />
<View
android:id="@+id/skAmount"
android:layout_width="72dp"
android:layout_height="13dp"
android:background="@drawable/bg_skeleton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/skAvatar"
app:layout_constraintBottom_toBottomOf="@id/skAvatar" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp"
android:background="?attr/selectableItemBackground">
<!-- Colored circle avatar -->
<FrameLayout
android:id="@+id/fvAvatar"
android:layout_width="42dp"
android:layout_height="42dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<TextView
android:id="@+id/tvInitial"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="#FFFFFF" />
</FrameLayout>
<!-- Counterparty name — top of vertical packed chain -->
<TextView
android:id="@+id/tvCounterparty"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="8dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:maxLines="1"
android:ellipsize="end"
android:visibility="gone"
app:layout_constraintStart_toEndOf="@id/fvAvatar"
app:layout_constraintEnd_toStartOf="@id/tvAmount"
app:layout_constraintTop_toTopOf="@id/fvAvatar"
app:layout_constraintBottom_toTopOf="@id/tvDescription"
app:layout_constraintVertical_chainStyle="packed" />
<!-- Description (e.g. "Purchase") -->
<TextView
android:id="@+id/tvDescription"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="8dp"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/fvAvatar"
app:layout_constraintEnd_toStartOf="@id/tvAmount"
app:layout_constraintTop_toBottomOf="@id/tvCounterparty"
app:layout_constraintBottom_toTopOf="@id/tvDate"
app:layout_goneMarginTop="0dp" />
<!-- Time -->
<TextView
android:id="@+id/tvDate"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="8dp"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant"
app:layout_constraintStart_toEndOf="@id/fvAvatar"
app:layout_constraintEnd_toStartOf="@id/tvAmount"
app:layout_constraintTop_toBottomOf="@id/tvDescription"
app:layout_constraintBottom_toBottomOf="@id/fvAvatar" />
<!-- Amount (right-aligned) -->
<TextView
android:id="@+id/tvAmount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/fvAvatar"
app:layout_constraintBottom_toBottomOf="@id/fvAvatar" />
</androidx.constraintlayout.widget.ConstraintLayout>