add transactions and account history
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
144
app/src/main/java/sh/sar/basedbank/ui/home/TransactionAdapter.kt
Normal file
144
app/src/main/java/sh/sar/basedbank/ui/home/TransactionAdapter.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
5
app/src/main/res/drawable/bg_skeleton.xml
Normal file
5
app/src/main/res/drawable/bg_skeleton.xml
Normal 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>
|
||||
58
app/src/main/res/layout/fragment_account_history.xml
Normal file
58
app/src/main/res/layout/fragment_account_history.xml
Normal 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>
|
||||
59
app/src/main/res/layout/fragment_transfer_history.xml
Normal file
59
app/src/main/res/layout/fragment_transfer_history.xml
Normal 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>
|
||||
@@ -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
|
||||
|
||||
172
app/src/main/res/layout/item_account_history_header.xml
Normal file
172
app/src/main/res/layout/item_account_history_header.xml
Normal 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>
|
||||
@@ -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
|
||||
|
||||
11
app/src/main/res/layout/item_date_header.xml
Normal file
11
app/src/main/res/layout/item_date_header.xml
Normal 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" />
|
||||
13
app/src/main/res/layout/item_loading_footer.xml
Normal file
13
app/src/main/res/layout/item_loading_footer.xml
Normal 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>
|
||||
63
app/src/main/res/layout/item_skeleton_transaction.xml
Normal file
63
app/src/main/res/layout/item_skeleton_transaction.xml
Normal 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>
|
||||
93
app/src/main/res/layout/item_transaction.xml
Normal file
93
app/src/main/res/layout/item_transaction.xml
Normal 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>
|
||||
Reference in New Issue
Block a user