diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt index f6fdde2..5b1c5ff 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt @@ -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, 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 { + 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() + + // 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}") diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibHistoryClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibHistoryClient.kt new file mode 100644 index 0000000..ec1786d --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibHistoryClient.kt @@ -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, 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) + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt index ea3b195..bc97f5a 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt @@ -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, diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/TransactionCache.kt b/app/src/main/java/sh/sar/basedbank/api/mib/TransactionCache.kt new file mode 100644 index 0000000..81dc947 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/mib/TransactionCache.kt @@ -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) { + 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 = 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() } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryAdapter.kt new file mode 100644 index 0000000..adcbf50 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryAdapter.kt @@ -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() { + + private sealed class Item { + data class DateHeader(val label: String) : Item() + data class Trx(val transaction: Transaction) : Item() + } + + private val displayItems = mutableListOf() + + 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) { + _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" + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt new file mode 100644 index 0000000..a99f1b9 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt @@ -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() + 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 = 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 + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt index 07b0393..5125266 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt @@ -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) : - RecyclerView.Adapter() { +class AccountsAdapter( + accounts: List, + private val onAccountClick: (MibAccount) -> Unit = {} +) : RecyclerView.Adapter() { private sealed class Item { data class SectionTitle(val label: String, val chip: String) : Item() @@ -97,6 +99,7 @@ class AccountsAdapter(accounts: List) : 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) : binding.tvCardStatus.visibility = View.VISIBLE binding.root.alpha = 0.45f } + binding.root.setOnClickListener { onAccountClick(account) } } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AccountsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AccountsFragment.kt index 77a7461..d32fdd7 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/AccountsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AccountsFragment.kt @@ -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 diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index 9ca43e7..c759c8a 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -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) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransactionAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransactionAdapter.kt new file mode 100644 index 0000000..e7ecf16 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransactionAdapter.kt @@ -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() { + + private sealed class Item { + data class DateHeader(val label: String) : Item() + data class Trx(val transaction: Transaction) : Item() + } + + private val displayItems = mutableListOf() + + 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) { + _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 + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt new file mode 100644 index 0000000..f2dc932 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt @@ -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() + 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() + 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() + + // 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() } + } + }.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 + } +} diff --git a/app/src/main/res/drawable/bg_skeleton.xml b/app/src/main/res/drawable/bg_skeleton.xml new file mode 100644 index 0000000..1bf5a79 --- /dev/null +++ b/app/src/main/res/drawable/bg_skeleton.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_account_history.xml b/app/src/main/res/layout/fragment_account_history.xml new file mode 100644 index 0000000..c486246 --- /dev/null +++ b/app/src/main/res/layout/fragment_account_history.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_transfer_history.xml b/app/src/main/res/layout/fragment_transfer_history.xml new file mode 100644 index 0000000..0b790d4 --- /dev/null +++ b/app/src/main/res/layout/fragment_transfer_history.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_account.xml b/app/src/main/res/layout/item_account.xml index cf4cd0e..12acb7a 100644 --- a/app/src/main/res/layout/item_account.xml +++ b/app/src/main/res/layout/item_account.xml @@ -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"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_card.xml b/app/src/main/res/layout/item_card.xml index 754c7af..a8e4830 100644 --- a/app/src/main/res/layout/item_card.xml +++ b/app/src/main/res/layout/item_card.xml @@ -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"> + diff --git a/app/src/main/res/layout/item_loading_footer.xml b/app/src/main/res/layout/item_loading_footer.xml new file mode 100644 index 0000000..866cf71 --- /dev/null +++ b/app/src/main/res/layout/item_loading_footer.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/layout/item_skeleton_transaction.xml b/app/src/main/res/layout/item_skeleton_transaction.xml new file mode 100644 index 0000000..ce12912 --- /dev/null +++ b/app/src/main/res/layout/item_skeleton_transaction.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_transaction.xml b/app/src/main/res/layout/item_transaction.xml new file mode 100644 index 0000000..83c64e7 --- /dev/null +++ b/app/src/main/res/layout/item_transaction.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + +