diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesAdapter.kt new file mode 100644 index 0000000..bdfb39e --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesAdapter.kt @@ -0,0 +1,108 @@ +package sh.sar.basedbank.ui.home + +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import sh.sar.basedbank.databinding.ItemDateHeaderBinding +import sh.sar.basedbank.databinding.ItemTransactionBinding +import sh.sar.basedbank.util.ReceiptStore +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class ActivitiesAdapter( + private val onItemClick: (ReceiptStore.Entry) -> Unit +) : RecyclerView.Adapter() { + + private sealed class Item { + data class DateHeader(val label: String) : Item() + data class ReceiptItem(val entry: ReceiptStore.Entry) : Item() + } + + private val displayItems = mutableListOf() + + fun setEntries(entries: List) { + displayItems.clear() + var lastDateKey = "" + for (entry in entries) { + val dateKey = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(entry.savedAt)) + if (dateKey != lastDateKey) { + displayItems.add(Item.DateHeader(formatDateHeader(entry.savedAt))) + lastDateKey = dateKey + } + displayItems.add(Item.ReceiptItem(entry)) + } + notifyDataSetChanged() + } + + override fun getItemCount() = displayItems.size + + override fun getItemViewType(position: Int) = + if (displayItems[position] is Item.DateHeader) TYPE_DATE_HEADER else TYPE_RECEIPT + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return if (viewType == TYPE_DATE_HEADER) + DateHeaderVH(ItemDateHeaderBinding.inflate(inflater, parent, false)) + else + ReceiptVH(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 ReceiptVH -> holder.bind((displayItems[position] as Item.ReceiptItem).entry) + } + } + + inner class DateHeaderVH(private val b: ItemDateHeaderBinding) : + RecyclerView.ViewHolder(b.root) { + fun bind(label: String) { b.tvDateHeader.text = label } + } + + inner class ReceiptVH(private val b: ItemTransactionBinding) : + RecyclerView.ViewHolder(b.root) { + fun bind(entry: ReceiptStore.Entry) { + val d = entry.data + val colorHex = d.fromColorHex.takeIf { it.isNotBlank() } ?: "#607D8B" + val initial = d.toLabel.firstOrNull()?.uppercaseChar()?.toString() ?: "?" + + b.fvAvatar.background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }) + } + b.tvInitial.visibility = android.view.View.VISIBLE + b.tvInitial.text = initial + + b.tvCounterparty.text = d.toLabel + b.tvCounterparty.visibility = android.view.View.VISIBLE + b.tvDescription.text = buildString { + append(d.fromLabel) + if (d.toBank.isNotBlank()) append(" ยท ${d.toBank}") + } + b.tvDate.text = formatTime(entry.savedAt) + + b.tvAmount.text = "- ${d.currency} ${d.amount}" + b.tvAmount.setTextColor(Color.parseColor("#FF7043")) + + b.root.setOnClickListener { onItemClick(entry) } + } + } + + private fun formatDateHeader(millis: Long): String { + val sdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US) + return sdf.format(Date(millis)) + } + + private fun formatTime(millis: Long): String { + val sdf = SimpleDateFormat("HH:mm", Locale.US) + return sdf.format(Date(millis)) + } + + companion object { + private const val TYPE_DATE_HEADER = 0 + private const val TYPE_RECEIPT = 1 + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesFragment.kt new file mode 100644 index 0000000..a127dd4 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesFragment.kt @@ -0,0 +1,95 @@ +package sh.sar.basedbank.ui.home + +import android.content.Context +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.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import sh.sar.basedbank.R +import sh.sar.basedbank.databinding.FragmentActivitiesBinding +import sh.sar.basedbank.util.ReceiptStore + +class ActivitiesFragment : Fragment() { + + private var _binding: FragmentActivitiesBinding? = null + private val binding get() = _binding!! + + private lateinit var adapter: ActivitiesAdapter + private val allEntries = mutableListOf() + private var searchQuery = "" + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentActivitiesBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + adapter = ActivitiesAdapter { entry -> + (activity as? HomeActivity)?.showWithBackStack( + TransferReceiptFragment.newInstance(entry.data, null) + ) + } + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.adapter = adapter + + val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt() + ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets -> + val isBottomNav = requireContext() + .getSharedPreferences("prefs", Context.MODE_PRIVATE) + .getBoolean("bottom_nav", false) + val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val extraBottom = if (isBottomNav) 0 else navBar.bottom + v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom) + insets + } + + 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() + } + }) + + loadEntries() + } + + override fun onResume() { + super.onResume() + requireActivity().title = getString(R.string.nav_activities) + // Reload in case a new receipt was added while we were away + loadEntries() + } + + private fun loadEntries() { + allEntries.clear() + allEntries.addAll(ReceiptStore.loadAll(requireContext())) + filterAndDisplay() + } + + private fun filterAndDisplay() { + val filtered = if (searchQuery.isBlank()) allEntries + else allEntries.filter { entry -> + entry.data.toLabel.contains(searchQuery, ignoreCase = true) || + entry.data.fromLabel.contains(searchQuery, ignoreCase = true) || + entry.data.toAccount.contains(searchQuery, ignoreCase = true) || + entry.data.toBank.contains(searchQuery, ignoreCase = true) || + entry.data.mibReferenceNo.contains(searchQuery, ignoreCase = true) || + entry.data.bmlReference.contains(searchQuery, ignoreCase = true) + } + adapter.setEntries(filtered) + binding.emptyView.visibility = if (filtered.isEmpty()) View.VISIBLE else View.GONE + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} 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 4d50009..1cb3e7c 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 @@ -116,6 +116,7 @@ class HomeActivity : AppCompatActivity() { R.id.nav_contacts -> ContactsFragment() R.id.nav_transfer -> TransferFragment() R.id.nav_more -> MoreFragment() + R.id.nav_activities -> ActivitiesFragment() R.id.nav_transfer_history -> TransferHistoryFragment() R.id.nav_finances -> FinancingFragment() R.id.nav_otp -> OtpFragment() @@ -276,6 +277,7 @@ class HomeActivity : AppCompatActivity() { R.id.nav_accounts -> AccountsFragment() R.id.nav_contacts -> ContactsFragment() R.id.nav_transfer -> TransferFragment() + R.id.nav_activities -> ActivitiesFragment() R.id.nav_transfer_history -> TransferHistoryFragment() R.id.nav_finances -> FinancingFragment() R.id.nav_otp -> OtpFragment() diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index 09a2047..709ae56 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -56,6 +56,7 @@ import sh.sar.basedbank.util.AccountInputParser import sh.sar.basedbank.util.PaymvQrParser import sh.sar.basedbank.util.RecentPick import sh.sar.basedbank.util.RecentsCache +import sh.sar.basedbank.util.ReceiptStore import sh.sar.basedbank.util.Totp class TransferFragment : Fragment() { @@ -628,6 +629,7 @@ class TransferFragment : Fragment() { binding.btnTransfer.isEnabled = true (activity as? HomeActivity)?.setRefreshing(false) if (ok && receipt != null) { + ReceiptStore.save(requireContext(), receipt) clearForm() val activity = requireActivity() as HomeActivity activity.refreshBalances(src) @@ -755,7 +757,7 @@ class TransferFragment : Fragment() { ) if (result.success) { val receipt = TransferReceiptData( - isMib = true, + bank = "MIB", amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0), currency = currency, fromLabel = src.accountBriefName, @@ -839,7 +841,7 @@ class TransferFragment : Fragment() { val result = bmlFlow.confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank) if (result.success) { val receipt = TransferReceiptData( - isMib = false, + bank = "BML", amount = "%.2f".format(amount), currency = currency, fromLabel = src.accountBriefName, diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptData.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptData.kt index 815739d..829c28b 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptData.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptData.kt @@ -1,7 +1,7 @@ package sh.sar.basedbank.ui.home data class TransferReceiptData( - val isMib: Boolean, + val bank: String, // "MIB", "BML", etc. val amount: String, val currency: String, val fromLabel: String, diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt index 3616651..8d6e08f 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt @@ -46,7 +46,7 @@ class TransferReceiptFragment : Fragment() { private val receiptCard get() = _receiptCard!! companion object { - private const val ARG_IS_MIB = "is_mib" + private const val ARG_BANK = "bank" private const val ARG_AMOUNT = "amount" private const val ARG_CURRENCY = "currency" private const val ARG_FROM_LABEL = "from_label" @@ -69,7 +69,7 @@ class TransferReceiptFragment : Fragment() { fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply { pendingToAvatarBitmap = toAvatarBitmap arguments = Bundle().apply { - putBoolean(ARG_IS_MIB, data.isMib) + putString(ARG_BANK, data.bank) putString(ARG_AMOUNT, data.amount) putString(ARG_CURRENCY, data.currency) putString(ARG_FROM_LABEL, data.fromLabel) @@ -90,8 +90,8 @@ class TransferReceiptFragment : Fragment() { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val isMib = arguments?.getBoolean(ARG_IS_MIB, true) ?: true - return if (isMib) { + val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB" + return if (bank == "MIB") { val binding = FragmentReceiptMibBinding.inflate(inflater, container, false) bindMib(binding) _receiptCard = binding.receiptCard diff --git a/app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt b/app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt new file mode 100644 index 0000000..8b1580c --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt @@ -0,0 +1,83 @@ +package sh.sar.basedbank.util + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import sh.sar.basedbank.ui.home.TransferReceiptData +import java.io.File + +/** Persistent (non-cache) store for completed transfer receipts shown in Activities. */ +object ReceiptStore { + + private const val FILE_NAME = "activities.json" + + data class Entry(val data: TransferReceiptData, val savedAt: Long) + + fun save(context: Context, receipt: TransferReceiptData) { + val existing = loadAll(context).toMutableList() + existing.add(0, Entry(receipt, System.currentTimeMillis())) + writeAll(context, existing) + } + + fun loadAll(context: Context): List { + val file = File(context.filesDir, FILE_NAME) + if (!file.exists()) return emptyList() + return try { + val arr = JSONArray(CacheEncryption.decrypt(file.readText())) + (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + Entry( + data = TransferReceiptData( + bank = o.optString("bank", "MIB"), + amount = o.optString("amount"), + currency = o.optString("currency"), + fromLabel = o.optString("fromLabel"), + fromColorHex = o.optString("fromColorHex"), + fromProfileImageHash = o.optString("fromProfileImageHash").takeIf { it.isNotBlank() }, + toLabel = o.optString("toLabel"), + toAccount = o.optString("toAccount"), + toBank = o.optString("toBank"), + remarks = o.optString("remarks"), + mibReferenceNo = o.optString("mibReferenceNo"), + mibTransactionDate = o.optString("mibTransactionDate"), + bmlFromName = o.optString("bmlFromName"), + bmlReference = o.optString("bmlReference"), + bmlTimestamp = o.optString("bmlTimestamp"), + bmlMessage = o.optString("bmlMessage") + ), + savedAt = o.optLong("savedAt", 0L) + ) + } + } catch (_: Exception) { emptyList() } + } + + fun clearAll(context: Context) { + File(context.filesDir, FILE_NAME).delete() + } + + private fun writeAll(context: Context, items: List) { + try { + val arr = JSONArray() + for ((d, ts) in items) arr.put(JSONObject().apply { + put("bank", d.bank) + put("amount", d.amount) + put("currency", d.currency) + put("fromLabel", d.fromLabel) + put("fromColorHex", d.fromColorHex) + put("fromProfileImageHash", d.fromProfileImageHash ?: "") + put("toLabel", d.toLabel) + put("toAccount", d.toAccount) + put("toBank", d.toBank) + put("remarks", d.remarks) + put("mibReferenceNo", d.mibReferenceNo) + put("mibTransactionDate", d.mibTransactionDate) + put("bmlFromName", d.bmlFromName) + put("bmlReference", d.bmlReference) + put("bmlTimestamp", d.bmlTimestamp) + put("bmlMessage", d.bmlMessage) + put("savedAt", ts) + }) + File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(arr.toString())) + } catch (_: Exception) {} + } +} diff --git a/app/src/main/res/layout/fragment_activities.xml b/app/src/main/res/layout/fragment_activities.xml new file mode 100644 index 0000000..5328bde --- /dev/null +++ b/app/src/main/res/layout/fragment_activities.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + +