diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ChatFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ChatFragment.kt new file mode 100644 index 0000000..2564e7d --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ChatFragment.kt @@ -0,0 +1,398 @@ +package sh.sar.basedbank.ui.home + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.color.MaterialColors +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.sar.basedbank.BasedBankApp +import sh.sar.basedbank.R +import sh.sar.basedbank.api.models.BankAccount +import sh.sar.basedbank.databinding.FragmentChatBinding +import sh.sar.basedbank.databinding.ItemAccountDropdownBinding +import sh.sar.basedbank.databinding.ViewChatHeaderBinding +import sh.sar.basedbank.util.ContactDisplay +import sh.sar.basedbank.util.ContactImageCache +import sh.sar.basedbank.util.ContactListParser +import sh.sar.basedbank.util.ReceiptStore +import sh.sar.basedbank.util.AccountListParser +import sh.sar.basedbank.util.ChatAccount +import sh.sar.basedbank.util.ChatStore +import sh.sar.basedbank.util.ChatThread + +/** + * One person's transfers as a chat. A person can have several accounts (e.g. MVR and USD); the + * "To" bar picks which one to send to. Sending opens [TransferFragment] in a sheet, prefilled with + * the recipient, source account and amount, so the usual confirm/OTP flow still applies. + */ +class ChatFragment : Fragment(), ContactSheetHost { + + private var _binding: FragmentChatBinding? = null + private val binding get() = _binding!! + private val viewModel: HomeViewModel by activityViewModels() + private val app get() = requireActivity().application as BasedBankApp + + private var peerKey = "" + private var header: ViewChatHeaderBinding? = null + private val adapter = ChatMessagesAdapter { openReceipt(it.receiptKey) } + private var thread: ChatThread? = null + private var fromAccounts: List = emptyList() + private var selectedFrom: BankAccount? = null + private var selectedTo: ChatAccount? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentChatBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + peerKey = savedInstanceState?.getString(ARG_PEER_KEY) ?: requireArguments().getString(ARG_PEER_KEY).orEmpty() + binding.rvMessages.layoutManager = LinearLayoutManager(requireContext()).apply { stackFromEnd = true } + binding.rvMessages.adapter = adapter + + adapter.setHideAmounts(viewModel.hideAmounts.value ?: false) + viewModel.hideAmounts.observe(viewLifecycleOwner) { + adapter.setHideAmounts(it) + selectedFrom?.let { acc -> bindAccountRow(binding.fromAccountRow, acc) } + } + + viewModel.accounts.observe(viewLifecycleOwner) { accounts -> + fromAccounts = accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN } + bindFromAccounts() + } + + binding.cardFromAccount.setOnClickListener { showAccountPicker() } + binding.btnSaveContact.setOnClickListener { openContact() } + binding.toAccountBar.setOnClickListener { showRecipientPicker() } + binding.btnSend.setOnClickListener { send() } + binding.etAmount.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_SEND) { send(); true } else false + } + + // Transfers started from this chat come straight back here instead of opening the receipt. + parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ -> + onTransferDone() + } + + // A newly saved contact adds an account (and maybe a nickname) to this chat; reload. + viewModel.contacts.observe(viewLifecycleOwner) { loadThread() } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString(ARG_PEER_KEY, peerKey) + } + + private fun loadThread() { + val ctx = requireContext().applicationContext + val contacts = viewModel.contacts.value ?: emptyList() + viewLifecycleOwner.lifecycleScope.launch { + val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) } + val knownIds = thread?.messages?.mapTo(HashSet()) { it.id }.orEmpty() + val t = threads.firstOrNull { it.peerKey == peerKey } + ?: threads.firstOrNull { th -> th.messages.any { it.id in knownIds } } + if (_binding == null) return@launch + thread = t + if (t == null) return@launch + peerKey = t.peerKey + adapter.showDestination = t.accounts.size > 1 + adapter.setMessages(t.messages, getString(R.string.chat_today), getString(R.string.chat_yesterday)) + binding.rvMessages.scrollToPosition(adapter.itemCount - 1) + updateToolbar() + + bindFromAccounts() + val previous = selectedTo + val to = t.accounts.firstOrNull { it.account == previous?.account } + ?: t.accounts.firstOrNull { it.account == t.peerAccount } + selectTo(to, matchFromCurrency = previous == null) + } + } + + /** Picks which of the person's accounts to send to and updates everything that depends on it. */ + private fun selectTo(to: ChatAccount?, matchFromCurrency: Boolean) { + val b = _binding ?: return + val t = thread ?: return + selectedTo = to + + b.toAccountBar.visibility = if (to == null) View.GONE else View.VISIBLE + if (to != null) { + b.tvToAccount.text = to.account + b.tvToCurrency.text = to.currency + b.tvToCurrency.visibility = if (to.currency.isBlank()) View.GONE else View.VISIBLE + val nickname = to.contact?.benefNickName?.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) } + b.tvToNickname.text = nickname + b.tvToNickname.visibility = if (nickname == null) View.GONE else View.VISIBLE + b.ivToSwitch.visibility = if (t.accounts.size > 1) View.VISIBLE else View.GONE + } + + // Offer to save the selected account (or the person, when no account is known yet). + b.saveContactBar.visibility = if (to?.contact == null) View.VISIBLE else View.GONE + + val canSend = to != null + b.composer.visibility = if (canSend) View.VISIBLE else View.GONE + b.tvCannotSend.visibility = if (canSend) View.GONE else View.VISIBLE + + // Sending to a USD account: default to a USD source account, and likewise for MVR. + if (matchFromCurrency && to != null && to.currency.isNotBlank() && + !selectedFrom?.currencyName.equals(to.currency, ignoreCase = true)) { + fromAccounts.firstOrNull { it.currencyName.equals(to.currency, ignoreCase = true) }?.let(::selectFrom) + } + } + + private fun showRecipientPicker() { + val t = thread ?: return + if (t.accounts.size < 2) return + val ctx = requireContext() + val dialog = BottomSheetDialog(ctx) + val list = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + val pad = (8 * resources.displayMetrics.density).toInt() + setPadding(0, pad, 0, pad * 3) + } + list.addView(sheetTitle(getString(R.string.chat_pick_recipient_account))) + val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer) + for (account in t.accounts) { + val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false) + row.tvDropdownAccountName.text = account.contact?.benefNickName?.takeIf { it.isNotBlank() } + ?: getString(R.string.chat_not_in_contacts) + row.tvDropdownAccountNumber.text = account.account + row.tvDropdownBalance.text = account.currency + row.tvDropdownAccountType.visibility = View.GONE + row.ivDropdownCardLogo.visibility = View.GONE + if (account.account == selectedTo?.account) row.root.setBackgroundColor(selectedBg) + else row.root.setBackgroundResource(android.R.drawable.list_selector_background) + row.root.setOnClickListener { + selectTo(account, matchFromCurrency = true) + dialog.dismiss() + } + list.addView(row.root) + } + dialog.setContentView(list) + dialog.show() + } + + private fun sheetTitle(text: String) = TextView(requireContext()).apply { + this.text = text + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleMedium) + val pad = (16 * resources.displayMetrics.density).toInt() + setPadding(pad, pad / 2, pad, pad / 2) + } + + private fun bindFromAccounts() { + val b = _binding ?: return + if (fromAccounts.isEmpty()) return + // Default to the account last used with this person. + val lastUsed = thread?.messages?.lastOrNull { it.isSent }?.accountNumber + val current = selectedFrom?.takeIf { s -> fromAccounts.any { it.accountNumber == s.accountNumber } } + selectedFrom = current ?: fromAccounts.firstOrNull { it.accountNumber == lastUsed } ?: fromAccounts.first() + + selectFrom(selectedFrom!!) + } + + private fun onTransferDone() { + binding.etAmount.text = null + // The receipt shows the new bubble right away; history catches up a little later and + // replaces it with the bank's own entry. + sync() + viewLifecycleOwner.lifecycleScope.launch { + delay(HISTORY_CATCH_UP_MS) + sync() + } + Snackbar.make(binding.root, R.string.chat_transfer_sent, Snackbar.LENGTH_LONG) + .setAnchorView(binding.composer) + .setAction(R.string.chat_view_receipt) { + val latest = ReceiptStore.loadAll(requireContext()).firstOrNull() ?: return@setAction + openReceipt(latest.savedAt.toString()) + } + .show() + } + + private fun openReceipt(receiptKey: String) { + val entry = ReceiptStore.loadAll(requireContext()).firstOrNull { it.savedAt.toString() == receiptKey } + if (entry == null) { + Snackbar.make(binding.root, R.string.chat_receipt_missing, Snackbar.LENGTH_SHORT).show() + return + } + (requireActivity() as HomeActivity).showWithBackStack(TransferReceiptFragment.newInstance(entry.data, null)) + } + + private fun sync() { + val ctx = requireContext().applicationContext + val accounts = viewModel.accounts.value ?: emptyList() + val contacts = viewModel.contacts.value ?: emptyList() + viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) } + if (_binding != null) loadThread() + } + } + + private fun selectFrom(account: BankAccount) { + val b = _binding ?: return + selectedFrom = account + bindAccountRow(b.fromAccountRow, account) + b.tilAmount.prefixText = account.currencyName + } + + /** Fills an account row: name, full account number, balance (masked when amounts are hidden). */ + private fun bindAccountRow(row: ItemAccountDropdownBinding, account: BankAccount) { + val display = AccountListParser.from(account) + row.tvDropdownAccountName.text = account.accountBriefName + row.tvDropdownAccountNumber.text = account.accountNumber + val balance = display?.balance.orEmpty() + val hide = viewModel.hideAmounts.value ?: false + row.tvDropdownBalance.text = if (hide && balance.isNotBlank()) AccountHistoryAdapter.maskAmount(balance) else balance + val type = display?.typeLabel.orEmpty() + row.tvDropdownAccountType.text = type + row.tvDropdownAccountType.visibility = if (type.isBlank()) View.GONE else View.VISIBLE + row.ivDropdownCardLogo.visibility = View.GONE + } + + private fun showAccountPicker() { + if (fromAccounts.isEmpty()) return + val ctx = requireContext() + val dialog = BottomSheetDialog(ctx) + val list = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + val pad = (8 * resources.displayMetrics.density).toInt() + setPadding(0, pad, 0, pad * 3) + } + list.addView(sheetTitle(getString(R.string.chat_pick_account))) + val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer) + for (account in fromAccounts) { + val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false) + bindAccountRow(row, account) + if (account.accountNumber == selectedFrom?.accountNumber) row.root.setBackgroundColor(selectedBg) + else row.root.setBackgroundResource(android.R.drawable.list_selector_background) + row.root.setOnClickListener { + selectFrom(account) + dialog.dismiss() + } + list.addView(row.root) + } + dialog.setContentView(list) + dialog.show() + } + + private fun send() { + val t = thread ?: return + val from = selectedFrom ?: return + val to = selectedTo ?: return + val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() + if (amount == null || amount <= 0.0) { + binding.tilAmount.error = getString(R.string.chat_amount_invalid) + return + } + binding.tilAmount.error = null + val transfer = TransferFragment.newInstanceFromQr( + accountNumber = to.account, + displayName = to.contact?.benefNickName?.takeIf { it.isNotBlank() } ?: t.peerName, + amount = "%.2f".format(amount), + remarks = null, + fromAccountNumber = from.accountNumber, + returnOnSuccess = true + ) + if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return + TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet") + } + + /** Shows the photo, contact name and real name in the toolbar; tapping it opens the contact. */ + private fun updateToolbar() { + val t = thread ?: return + val bar = (activity as? AppCompatActivity)?.supportActionBar ?: return + val h = header ?: ViewChatHeaderBinding.inflate(layoutInflater).also { h -> + header = h + h.root.setOnClickListener { openContact() } + } + h.tvHeaderName.text = t.peerName + // Show the real account-holder name when the chat is titled with a nickname. + val subtitle = t.realName.takeIf { !it.equals(t.peerName, ignoreCase = true) }.orEmpty() + h.tvHeaderAccount.text = subtitle + h.tvHeaderAccount.visibility = if (subtitle.isBlank()) View.GONE else View.VISIBLE + val sizePx = (40 * resources.displayMetrics.density).toInt() + val photo = t.contact?.customerImgHash?.let { ContactImageCache.load(requireContext(), it) } + h.ivHeaderAvatar.setImageBitmap(photo ?: contactInitialsBitmap(t.peerName, ChatsAdapter.avatarColor(t.peerKey), sizePx)) + + requireActivity().title = t.peerName + bar.setDisplayShowTitleEnabled(false) + bar.setDisplayShowCustomEnabled(true) + bar.customView = h.root + } + + private fun openContact() { + val t = thread ?: return + val display = (selectedTo?.contact ?: t.contact)?.let { ContactListParser.from(it) } + if (display != null) { + if (childFragmentManager.findFragmentByTag("contact_details") != null) return + ContactDetailsSheetFragment.newInstance(display).show(childFragmentManager, "contact_details") + } else { + // Not a saved contact yet: offer to save them, prefilled with what we know. + if (childFragmentManager.findFragmentByTag("add_contact") != null) return + AddContactSheetFragment.newInstance( + bmlProfileId = selectedFrom?.profileId?.takeIf { it.isNotBlank() }, + accountNumber = selectedTo?.account, + recipientName = t.realName.ifBlank { t.peerName }, + currency = selectedTo?.currency?.takeIf { it.isNotBlank() } ?: selectedFrom?.currencyName + ).show(childFragmentManager, "add_contact") + } + } + + override fun openTransfer(contact: ContactDisplay) { + val fragment = TransferFragment.newInstance( + accountNumber = contact.accountNumber, + displayName = contact.name, + subtitle = contact.transferSubtitle, + colorHex = contact.bankColor, + imageHash = contact.imageHash + ) + (requireActivity() as HomeActivity).showWithBackStack(fragment) + } + + /** Deleting lives on the Contacts page, which also keeps its caches in sync. */ + override fun confirmDelete(contact: ContactDisplay) { + (requireActivity() as HomeActivity).showWithBackStack(ContactsFragment()) + } + + override fun onResume() { + super.onResume() + updateToolbar() + } + + override fun onPause() { + super.onPause() + (activity as? AppCompatActivity)?.supportActionBar?.apply { + setDisplayShowCustomEnabled(false) + setDisplayShowTitleEnabled(true) + customView = null + } + } + + override fun onDestroyView() { + super.onDestroyView() + header = null + _binding = null + } + + companion object { + private const val ARG_PEER_KEY = "peer_key" + private const val HISTORY_CATCH_UP_MS = 10_000L + private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN") + + fun newInstance(peerKey: String) = ChatFragment().apply { + arguments = Bundle().apply { putString(ARG_PEER_KEY, peerKey) } + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ChatMessagesAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ChatMessagesAdapter.kt new file mode 100644 index 0000000..32412c8 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ChatMessagesAdapter.kt @@ -0,0 +1,162 @@ +package sh.sar.basedbank.ui.home + +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.text.format.DateUtils +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import sh.sar.basedbank.R +import sh.sar.basedbank.databinding.ItemChatBubbleBinding +import sh.sar.basedbank.databinding.ItemChatDateBinding +import sh.sar.basedbank.util.ChatMessage + +/** Transfers with one person as chat bubbles: sent on the right, received on the left. */ +class ChatMessagesAdapter( + private val onReceiptClick: (ChatMessage) -> Unit +) : RecyclerView.Adapter() { + + private sealed class Item { + data class DateChip(val label: String) : Item() + data class Bubble(val message: ChatMessage) : Item() + } + + private val items = mutableListOf() + private var hideAmounts = false + + /** When the person has several accounts, sent bubbles say which one the money went to. */ + var showDestination = false + + fun setHideAmounts(hide: Boolean) { + if (hideAmounts == hide) return + hideAmounts = hide + notifyDataSetChanged() + } + + /** [messages] must be oldest first. */ + fun setMessages(messages: List, todayLabel: String, yesterdayLabel: String) { + items.clear() + var lastDay = "" + for (m in messages) { + val day = m.date.take(10) + if (day != lastDay) { + val label = when { + DateUtils.isToday(m.timeMillis) -> todayLabel + DateUtils.isToday(m.timeMillis + DateUtils.DAY_IN_MILLIS) -> yesterdayLabel + else -> AccountHistoryAdapter.formatDateHeader(m.date) + } + items.add(Item.DateChip(label)) + lastDay = day + } + items.add(Item.Bubble(m)) + } + notifyDataSetChanged() + } + + override fun getItemCount() = items.size + + override fun getItemViewType(position: Int) = when (items[position]) { + is Item.DateChip -> TYPE_DATE + is Item.Bubble -> TYPE_BUBBLE + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return if (viewType == TYPE_DATE) DateVH(ItemChatDateBinding.inflate(inflater, parent, false)) + else BubbleVH(ItemChatBubbleBinding.inflate(inflater, parent, false)) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = items[position]) { + is Item.DateChip -> (holder as DateVH).b.tvDate.text = item.label + is Item.Bubble -> (holder as BubbleVH).bind(item.message) + } + } + + class DateVH(val b: ItemChatDateBinding) : RecyclerView.ViewHolder(b.root) + + inner class BubbleVH(private val b: ItemChatBubbleBinding) : RecyclerView.ViewHolder(b.root) { + fun bind(m: ChatMessage) { + val ctx = b.root.context + val sent = m.isSent + + b.bubbleRow.gravity = if (sent) Gravity.END else Gravity.START + (b.cardBubble.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START + (b.tvMeta.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START + + // Sent: tinted like the mockup. Received: neutral grey with an outline, so the two + // read apart even on a red-tinted theme. + val night = (ctx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + val bg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorErrorContainer) + else Color.parseColor(if (night) "#2A2C2E" else "#F1F2F4") + val fg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnErrorContainer) + else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurface) + b.cardBubble.setCardBackgroundColor(bg) + b.cardBubble.strokeWidth = if (sent) 0 else (1 * ctx.resources.displayMetrics.density).toInt() + b.cardBubble.strokeColor = MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOutlineVariant) + b.tvLabel.setTextColor(fg) + b.tvAmount.setTextColor(fg) + b.tvAccount.setTextColor(fg) + b.tvNote.setTextColor(fg) + b.btnReceipt.setColorFilter(fg) + b.btnReceipt.visibility = if (m.receiptKey.isNotBlank()) View.VISIBLE else View.GONE + b.btnReceipt.setOnClickListener { onReceiptClick(m) } + + b.tvLabel.text = ctx.getString(if (sent) R.string.chat_sent_label else R.string.chat_received_label) + // Own account on its own line so long names aren't cut off by the receipt icon and tag. + b.tvAccount.text = when { + !sent -> ctx.getString(R.string.chat_into_label, m.accountDisplayName) + showDestination && m.peerAccount.isNotBlank() -> + ctx.getString(R.string.chat_from_to_label, m.accountDisplayName, m.peerAccount) + else -> ctx.getString(R.string.chat_from_label, m.accountDisplayName) + } + b.tvAccount.visibility = if (m.accountDisplayName.isBlank()) View.GONE else View.VISIBLE + b.tvBadge.text = ctx.getString(if (sent) R.string.chat_debit else R.string.chat_credit) + // Debit uses the bubble's own text colour; Credit a green that stays readable in both themes. + val badgeFg = if (sent) fg else Color.parseColor(if (night) "#81C784" else "#2E7D32") + b.tvBadge.setTextColor(badgeFg) + b.tvBadge.background = GradientDrawable().apply { + cornerRadius = 10 * ctx.resources.displayMetrics.density + setColor(androidx.core.graphics.ColorUtils.setAlphaComponent(badgeFg, 0x2E)) + } + + b.tvAmount.text = if (hideAmounts) "${m.currency} ••••••" + else "${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}" + b.tvNote.text = m.note + b.tvNote.visibility = if (m.note.isBlank()) View.GONE else View.VISIBLE + + val time = AccountHistoryAdapter.formatTime(m.date) + b.tvMeta.text = if (sent) ctx.getString(R.string.chat_sent_meta, time) else time + + b.cardBubble.setOnClickListener { showDetail(m) } + } + + private fun showDetail(m: ChatMessage) { + val ctx = b.root.context + val details = buildString { + val sign = if (m.isSent) "-" else "+" + append("Amount\n$sign ${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}\n\n") + append("Date\n${AccountHistoryAdapter.formatFullDate(m.date)}\n\n") + if (m.note.isNotBlank()) append("Remarks\n${m.note}\n\n") + if (m.reference.isNotBlank()) append("Reference\n${m.reference}\n\n") + append("Account\n${m.accountDisplayName}") + } + MaterialAlertDialogBuilder(ctx) + .setTitle(m.peerName) + .setMessage(details) + .setPositiveButton("OK", null) + .show() + } + } + + companion object { + private const val TYPE_DATE = 0 + private const val TYPE_BUBBLE = 1 + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ChatsAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ChatsAdapter.kt new file mode 100644 index 0000000..526cdf9 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ChatsAdapter.kt @@ -0,0 +1,93 @@ +package sh.sar.basedbank.ui.home + +import android.text.format.DateUtils +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import sh.sar.basedbank.R +import sh.sar.basedbank.databinding.ItemChatBinding +import sh.sar.basedbank.util.ChatThread +import sh.sar.basedbank.util.ContactImageCache + +/** Telegram-style chat list: one row per person, latest transfer as the preview. */ +class ChatsAdapter( + private val onChatClick: (ChatThread) -> Unit +) : RecyclerView.Adapter() { + + private var allThreads: List = emptyList() + private var displayed: List = emptyList() + private var searchQuery = "" + private var hideAmounts = false + + fun updateThreads(threads: List) { + allThreads = threads + applyFilter() + } + + fun setSearch(query: String) { + searchQuery = query + applyFilter() + } + + fun setHideAmounts(hide: Boolean) { + if (hideAmounts == hide) return + hideAmounts = hide + notifyDataSetChanged() + } + + val isEmpty get() = displayed.isEmpty() + + private fun applyFilter() { + displayed = if (searchQuery.isBlank()) allThreads else allThreads.filter { + it.peerName.contains(searchQuery, ignoreCase = true) || + it.realName.contains(searchQuery, ignoreCase = true) || + it.peerAccount.contains(searchQuery) || + it.messages.any { m -> m.note.contains(searchQuery, ignoreCase = true) } + } + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val holder = ViewHolder(ItemChatBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + holder.binding.root.setOnClickListener { + val pos = holder.bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) onChatClick(displayed[pos]) + } + return holder + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(displayed[position]) + + override fun getItemCount() = displayed.size + + inner class ViewHolder(val binding: ItemChatBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(thread: ChatThread) { + val ctx = binding.root.context + val last = thread.last + binding.tvName.text = thread.peerName + + val amount = if (hideAmounts) "${last.currency} ••••••" + else "${last.currency} ${"%.2f".format(kotlin.math.abs(last.amount))}" + binding.tvPreview.text = if (last.isSent) { + val note = last.note.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty() + ctx.getString(R.string.chat_preview_sent, amount) + note + } else { + ctx.getString(R.string.chat_preview_received, amount) + } + + binding.tvTime.text = if (DateUtils.isToday(last.timeMillis)) AccountHistoryAdapter.formatTime(last.date) + else AccountHistoryAdapter.formatDateOnly(last.date) + + val sizePx = (52 * ctx.resources.displayMetrics.density).toInt() + val photo = thread.contact?.customerImgHash?.let { ContactImageCache.load(ctx, it) } + binding.ivAvatar.setImageBitmap(photo ?: contactInitialsBitmap(thread.peerName, avatarColor(thread.peerKey), sizePx)) + } + } + + companion object { + private val AVATAR_COLORS = listOf("#E8B04B", "#5C9CE6", "#E57373", "#66BB6A", "#9575CD", "#4DB6AC", "#F06292", "#FF8A65") + + /** Stable per-person avatar colour, so a chat keeps its colour between visits. */ + fun avatarColor(peerKey: String) = AVATAR_COLORS[(peerKey.hashCode() and 0x7fffffff) % AVATAR_COLORS.size] + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ChatsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ChatsFragment.kt new file mode 100644 index 0000000..c6052b9 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ChatsFragment.kt @@ -0,0 +1,138 @@ +package sh.sar.basedbank.ui.home + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.core.view.MenuProvider +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.sar.basedbank.BasedBankApp +import sh.sar.basedbank.R +import sh.sar.basedbank.databinding.FragmentChatsBinding +import sh.sar.basedbank.util.ChatStore +import java.text.DateFormat +import java.util.Date + +/** Transfers grouped by person, shown as a chat list. Contacts is reachable from the toolbar. */ +class ChatsFragment : Fragment() { + + private var _binding: FragmentChatsBinding? = null + private val binding get() = _binding!! + private val viewModel: HomeViewModel by activityViewModels() + private val app get() = requireActivity().application as BasedBankApp + + private lateinit var adapter: ChatsAdapter + private var syncedThisView = false + private var syncedWithContacts = false + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentChatsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + adapter = ChatsAdapter { thread -> + (requireActivity() as HomeActivity).showWithBackStack(ChatFragment.newInstance(thread.peerKey)) + } + binding.rvChats.layoutManager = LinearLayoutManager(requireContext()) + binding.rvChats.adapter = adapter + + binding.etSearch.addTextChangedListener { text -> + adapter.setSearch(text?.toString() ?: "") + updateEmptyView() + } + + binding.swipeRefresh.setOnRefreshListener { sync() } + + requireActivity().addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menu.add(Menu.NONE, R.id.action_open_contacts, 0, R.string.nav_contacts) + .setIcon(R.drawable.ic_contacts) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + if (menuItem.itemId != R.id.action_open_contacts) return false + (requireActivity() as HomeActivity).showWithBackStack(ContactsFragment()) + return true + } + }, viewLifecycleOwner, Lifecycle.State.RESUMED) + + adapter.setHideAmounts(viewModel.hideAmounts.value ?: false) + viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) } + + // Contacts give chats a saved name and an account number to send to. + (activity as? HomeActivity)?.loadAllContacts() + viewModel.contacts.observe(viewLifecycleOwner) { contacts -> + loadThreads() + // Contacts usually load after the first sync; sync again so their real names get looked up. + if (syncedThisView && !syncedWithContacts && contacts.isNotEmpty()) sync() + } + + // Accounts arrive after login; sync once they are available. + viewModel.accounts.observe(viewLifecycleOwner) { accounts -> + if (!syncedThisView && accounts.any { it.bank == "BML" }) sync() + } + + loadThreads() + } + + private fun sync() { + syncedThisView = true + binding.swipeRefresh.isRefreshing = true + val accounts = viewModel.accounts.value ?: emptyList() + val contacts = viewModel.contacts.value ?: emptyList() + if (contacts.isNotEmpty()) syncedWithContacts = true + val ctx = requireContext().applicationContext + viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) } + _binding?.swipeRefresh?.isRefreshing = false + loadThreads() + } + } + + private fun loadThreads() { + val ctx = requireContext().applicationContext + val contacts = viewModel.contacts.value ?: emptyList() + viewLifecycleOwner.lifecycleScope.launch { + val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) } + if (_binding == null) return@launch + adapter.updateThreads(threads) + updateEmptyView() + } + } + + private fun updateEmptyView() { + val b = _binding ?: return + b.emptyView.visibility = if (adapter.isEmpty) View.VISIBLE else View.GONE + if (!adapter.isEmpty) return + val since = ChatStore.sinceMillis(requireContext()) + b.emptyView.text = when { + b.etSearch.text?.isNotBlank() == true -> getString(R.string.chats_no_results) + since > 0L -> getString(R.string.chats_empty_since, + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(since))) + else -> getString(R.string.chats_empty) + } + } + + override fun onResume() { + super.onResume() + requireActivity().title = getString(R.string.nav_chats) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt index b0c756b..66050dd 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactDetailsSheetFragment.kt @@ -24,14 +24,14 @@ import sh.sar.basedbank.util.ContactListParser import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.TransferNetwork -/** Contact details drawer shown when a row in [ContactsFragment] is tapped. */ +/** Contact details drawer shown when a contact is tapped in [ContactsFragment] or a chat header. */ class ContactDetailsSheetFragment : BottomSheetDialogFragment() { private var _binding: SheetContactDetailsBinding? = null private val binding get() = _binding!! private val viewModel: HomeViewModel by activityViewModels() - private val contactsFragment get() = parentFragment as? ContactsFragment + private val contactsFragment get() = parentFragment as? ContactSheetHost override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = SheetContactDetailsBinding.inflate(inflater, container, false) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactSheetHost.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactSheetHost.kt new file mode 100644 index 0000000..c75df41 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactSheetHost.kt @@ -0,0 +1,9 @@ +package sh.sar.basedbank.ui.home + +import sh.sar.basedbank.util.ContactDisplay + +/** A screen that can show [ContactDetailsSheetFragment] and handle its Transfer/Delete buttons. */ +interface ContactSheetHost { + fun openTransfer(contact: ContactDisplay) + fun confirmDelete(contact: ContactDisplay) +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt index f1cb4a0..6a9f4d1 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt @@ -40,7 +40,7 @@ import sh.sar.basedbank.util.ContactManager import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.TransferNetwork -class ContactsFragment : Fragment() { +class ContactsFragment : Fragment(), ContactSheetHost { private var _binding: FragmentContactsBinding? = null private val binding get() = _binding!! @@ -182,7 +182,7 @@ class ContactsFragment : Fragment() { ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details") } - internal fun openTransfer(contact: ContactDisplay) { + override fun openTransfer(contact: ContactDisplay) { val fragment = TransferFragment.newInstance( accountNumber = contact.accountNumber, displayName = contact.name, @@ -193,7 +193,7 @@ class ContactsFragment : Fragment() { (requireActivity() as HomeActivity).showWithBackStack(fragment) } - internal fun confirmDelete(contact: ContactDisplay) { + override fun confirmDelete(contact: ContactDisplay) { val ctx = requireContext() val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError) val icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) } 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 947b654..1b433e7 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 @@ -176,6 +176,7 @@ class HomeActivity : AppCompatActivity() { val frag = when (item.itemId) { R.id.nav_dashboard -> DashboardFragment() R.id.nav_accounts -> AccountsFragment() + R.id.nav_chats -> ChatsFragment() R.id.nav_contacts -> ContactsFragment() R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it } R.id.nav_pay_mv_qr -> PayMvQrFragment() @@ -473,6 +474,7 @@ fun applyNavLabelVisibility() { val dest = fragment ?: when (itemId) { R.id.nav_dashboard -> DashboardFragment() R.id.nav_accounts -> AccountsFragment() + R.id.nav_chats -> ChatsFragment() R.id.nav_contacts -> ContactsFragment() R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it } R.id.nav_pay_mv_qr -> PayMvQrFragment() diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/NavCustomization.kt b/app/src/main/java/sh/sar/basedbank/ui/home/NavCustomization.kt index c5be4ea..efadbe2 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/NavCustomization.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/NavCustomization.kt @@ -32,6 +32,7 @@ object NavCustomization { /** All items that can occupy either a bottom nav slot or the "More" screen. */ val ALL_SWAPPABLE = listOf( NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts), + NavItemDef(R.id.nav_chats, "nav_chats", R.drawable.ic_chat, R.string.nav_chats, R.string.nav_desc_chats), NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts), NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer), NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr), @@ -51,14 +52,14 @@ object NavCustomization { fun getSlots(prefs: SharedPreferences): List = listOf( keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts), - keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_contacts), + keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_chats), keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer), ) fun saveSlots(prefs: SharedPreferences, slots: List) { prefs.edit() .putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts") - .putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_contacts") + .putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_chats") .putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer") .apply() } @@ -79,7 +80,7 @@ object NavCustomization { fun getCircularSlots(prefs: SharedPreferences): List = listOf( keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer), keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card), - keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_contacts), + keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_chats), keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts), ) @@ -87,7 +88,7 @@ object NavCustomization { prefs.edit() .putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer") .putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card") - .putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_contacts") + .putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_chats") .putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts") .apply() } 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 0a6c4a8..d36bf63 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 @@ -115,7 +115,7 @@ class TransferFragment : Fragment() { clearForm() val activity = requireActivity() as HomeActivity activity.triggerRefresh() - activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar)) + showReceipt(receipt, avatar) } ).also { bmlHandler = it } @@ -155,7 +155,7 @@ class TransferFragment : Fragment() { clearForm() val activity = requireActivity() as HomeActivity activity.triggerRefresh() - activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar)) + showReceipt(receipt, avatar) } ).also { mfaisaHandler = it } @@ -245,6 +245,10 @@ class TransferFragment : Fragment() { private const val ARG_REMARKS_PREFILL = "remarks_prefill" private const val ARG_BML_QR_URL = "bml_qr_url" private const val ARG_AUTO_SCAN = "auto_scan" + private const val ARG_RETURN_ON_SUCCESS = "return_on_success" + + /** Fragment result sent instead of opening the receipt when [ARG_RETURN_ON_SUCCESS] is set. */ + const val RESULT_TRANSFER_DONE = "transfer_done" fun newInstanceWithAutoScan() = TransferFragment().apply { arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) } @@ -282,7 +286,8 @@ class TransferFragment : Fragment() { displayName: String, amount: String?, remarks: String?, - fromAccountNumber: String? = null + fromAccountNumber: String? = null, + returnOnSuccess: Boolean = false ) = TransferFragment().apply { arguments = Bundle().apply { putString(ARG_ACCOUNT, accountNumber) @@ -292,10 +297,28 @@ class TransferFragment : Fragment() { if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber) if (amount != null) putString(ARG_AMOUNT_PREFILL, amount) if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks) + if (returnOnSuccess) putBoolean(ARG_RETURN_ON_SUCCESS, true) } } } + /** + * Opens the receipt, or — when opened with returnOnSuccess (e.g. from a chat) — goes back to + * the caller and tells it via [RESULT_TRANSFER_DONE] on the activity's fragment manager; + * the receipt is already in ReceiptStore. + */ + private fun showReceipt(receipt: TransferReceiptData, avatar: Bitmap?) { + val activity = requireActivity() as HomeActivity + if (arguments?.getBoolean(ARG_RETURN_ON_SUCCESS) == true) { + activity.supportFragmentManager.setFragmentResult(RESULT_TRANSFER_DONE, Bundle()) + // Hosted in a sheet (e.g. over a chat): close the sheet; otherwise leave this page. + val sheet = parentFragment as? androidx.fragment.app.DialogFragment + if (sheet != null) sheet.dismiss() else parentFragmentManager.popBackStack() + } else { + activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar)) + } + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentTransferBinding.inflate(inflater, container, false) return binding.root @@ -1205,7 +1228,7 @@ class TransferFragment : Fragment() { val activity = requireActivity() as HomeActivity activity.triggerRefresh() dialog.dismiss() - activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar)) + showReceipt(receipt, capturedToAvatar) } else if (!ok) { dialog.dismiss() if (msg == "CONNECTIVITY") { diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferSheetFragment.kt new file mode 100644 index 0000000..a298e39 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferSheetFragment.kt @@ -0,0 +1,60 @@ +package sh.sar.basedbank.ui.home + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import sh.sar.basedbank.databinding.SheetTransferBinding + +/** + * Shows [TransferFragment] in a bottom sheet so the screen behind (a chat) stays visible. + * The transfer page runs unchanged inside; with returnOnSuccess it dismisses this sheet. + */ +class TransferSheetFragment : BottomSheetDialogFragment() { + + private var _binding: SheetTransferBinding? = null + private val binding get() = _binding!! + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = SheetTransferBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + // Leave the top of the chat visible above the sheet. + binding.transferContainer.layoutParams.height = (resources.displayMetrics.heightPixels * SHEET_HEIGHT_FRACTION).toInt() + if (childFragmentManager.findFragmentById(binding.transferContainer.id) == null) { + val transferArgs = requireArguments() + childFragmentManager.beginTransaction() + .replace(binding.transferContainer.id, TransferFragment().apply { arguments = transferArgs }) + .commitNow() + } + } + + override fun onStart() { + super.onStart() + (dialog as? BottomSheetDialog)?.let { d -> + // Keep the amount/remarks fields above the keyboard. + @Suppress("DEPRECATION") + d.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + d.behavior.skipCollapsed = true + d.behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val SHEET_HEIGHT_FRACTION = 0.8f + + /** [transferArgs] are the arguments of a TransferFragment built by one of its factories. */ + fun newInstance(transferArgs: Bundle) = TransferSheetFragment().apply { arguments = transferArgs } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/util/AccountNameStore.kt b/app/src/main/java/sh/sar/basedbank/util/AccountNameStore.kt new file mode 100644 index 0000000..eec1880 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/AccountNameStore.kt @@ -0,0 +1,85 @@ +package sh.sar.basedbank.util + +import android.content.Context +import org.json.JSONObject +import sh.sar.basedbank.api.bml.BmlSession +import sh.sar.basedbank.api.bml.BmlValidateClient +import java.io.File + +/** + * Encrypted directory of account number → real account-holder name and currency, looked up through BML's + * account validation. Saved contacts often carry a nickname, while bank history only shows the + * real name; this lets a name-only transfer be matched to the right account. + */ +object AccountNameStore { + + private const val FILE_NAME = "account_names.json" + private val lock = Any() + + /** Names are re-checked after this long, in case an account changes hands. */ + private const val REFRESH_AFTER_MS = 30L * 24 * 60 * 60 * 1000 + /** Failed lookups (closed or foreign accounts) are retried after this long. */ + private const val RETRY_FAILED_AFTER_MS = 24L * 60 * 60 * 1000 + /** Upper bound on lookups per call, so a big contact list is resolved over several syncs. */ + private const val MAX_LOOKUPS_PER_RUN = 20 + + data class Info(val name: String, val currency: String) + + private data class Entry(val name: String, val currency: String, val checkedAt: Long) + + /** Account number → real name and currency, for every account resolved so far. */ + fun all(context: Context): Map = synchronized(lock) { + load(context).filterValues { it.name.isNotBlank() }.mapValues { Info(it.value.name, it.value.currency) } + } + + /** Looks up names for [accounts] that are unknown or stale. Network I/O — call on Dispatchers.IO. */ + fun resolve(context: Context, session: BmlSession, accounts: Collection) { + val now = System.currentTimeMillis() + val known = synchronized(lock) { load(context) } + val due = accounts.filter { it.isNotBlank() }.distinct().filter { account -> + val e = known[account] ?: return@filter true + val age = now - e.checkedAt + if (e.name.isBlank()) age > RETRY_FAILED_AFTER_MS else age > REFRESH_AFTER_MS + }.take(MAX_LOOKUPS_PER_RUN) + if (due.isEmpty()) return + + val client = BmlValidateClient() + // null = network error: leave the account due for next time. A failed lookup is stored blank. + val found = due.associateWith { account -> + try { + val v = client.validateAccount(session, account) + Entry(v?.name?.trim().orEmpty(), v?.currency.orEmpty(), now) + } catch (_: Exception) { null } + } + + synchronized(lock) { + val current = load(context).toMutableMap() + found.forEach { (account, entry) -> if (entry != null) current[account] = entry } + save(context, current) + } + } + + fun clearAll(context: Context) = synchronized(lock) { File(context.filesDir, FILE_NAME).delete() } + + private fun load(context: Context): Map { + val file = File(context.filesDir, FILE_NAME) + if (!file.exists()) return emptyMap() + return try { + val root = JSONObject(CacheEncryption.decrypt(file.readText())) + root.keys().asSequence().associateWith { key -> + val o = root.getJSONObject(key) + Entry(o.optString("name"), o.optString("currency"), o.optLong("checkedAt", 0L)) + } + } catch (_: Exception) { emptyMap() } + } + + private fun save(context: Context, entries: Map) { + try { + val root = JSONObject() + entries.forEach { (account, e) -> + root.put(account, JSONObject().put("name", e.name).put("currency", e.currency).put("checkedAt", e.checkedAt)) + } + File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString())) + } catch (_: Exception) {} + } +} diff --git a/app/src/main/java/sh/sar/basedbank/util/ChatStore.kt b/app/src/main/java/sh/sar/basedbank/util/ChatStore.kt new file mode 100644 index 0000000..3835591 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/ChatStore.kt @@ -0,0 +1,432 @@ +package sh.sar.basedbank.util + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import sh.sar.basedbank.BasedBankApp +import sh.sar.basedbank.api.models.BankAccount +import sh.sar.basedbank.api.bml.BmlNotificationsClient +import sh.sar.basedbank.api.models.BankContact +import sh.sar.basedbank.ui.home.AppNotification +import sh.sar.basedbank.ui.home.AccountHistoryAdapter +import java.io.File +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +/** + * One transfer shown as a chat bubble. BML history only carries the counterparty's name, so + * [peerKey] is the normalised name; [ChatStore.threads] groups by the account holder's real name. + */ +data class ChatMessage( + val id: String, + val peerKey: String, + val peerName: String, + val amount: Double, // negative = sent, positive = received + val currency: String, + val timeMillis: Long, + val accountNumber: String, // own account the money moved in/out of + val accountDisplayName: String, + val reference: String = "", + val note: String = "", // remarks — only known for transfers sent from this app + val peerAccount: String = "", // only known for transfers sent from this app + val receiptKey: String = "", // ReceiptStore entry (its savedAt) for transfers sent from this app + val dateOnly: Boolean = false // bank gave only a date (Favara); time of day unknown +) { + val isSent get() = amount < 0 + /** "yyyy-MM-dd HH:mm:ss", the format AccountHistoryAdapter's date helpers expect. */ + val date: String get() = DATE_FMT.format(Date(timeMillis)) + + companion object { + private val DATE_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + } +} + +/** One of a person's accounts that can be sent to from their chat. */ +data class ChatAccount( + val account: String, + val currency: String, // "MVR", "USD"; blank if not known yet + val contact: BankContact? // saved contact for this account, if any +) + +data class ChatThread( + val peerKey: String, // "name:" + val peerName: String, // contact nickname, else the real name + val realName: String, // account holder's name as the bank knows it; may be blank + val accounts: List, // empty = no known account, sending from the chat is disabled + val messages: List // oldest first +) { + val last get() = messages.last() + val contact: BankContact? get() = accounts.firstNotNullOfOrNull { it.contact } + /** The account last sent to, else the first known one; blank when none is known. */ + val peerAccount: String get() = + messages.lastOrNull { it.isSent && it.peerAccount.isNotBlank() }?.peerAccount + ?.takeIf { a -> accounts.any { it.account == a } } + ?: accounts.firstOrNull()?.account.orEmpty() +} + +/** + * Encrypted local record of BML transfers, shown as chats. + * + * Old history is never loaded: the first call to [sync] only stamps the start time, and every + * later sync keeps transfers newer than it. Each sync reads BML's transfer notifications (instant), + * pages back through the latest history of every BML current/savings account (booked later), and + * merges in receipts of transfers sent from this app, keeping one message per transfer. + */ +object ChatStore { + + private const val FILE_NAME = "chats.json" + private val lock = Any() + + private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN") + private val TRANSFER_TYPES = setOf("Transfer Debit", "Transfer Credit", "Favara Debit", "Favara Credit") + + /** "You have received MVR 5.00 from NAME to 7730*****1234" */ + private val NOTIF_RECEIVED = Regex("""You have received ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (.+) to (\S+)""") + /** "You have sent MVR 5.00 from 7730*****1234 to NAME" */ + private val NOTIF_SENT = Regex("""You have sent ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""") + /** Masked own account in notifications, e.g. "7730*****1234". */ + private val MASKED_ACCOUNT = Regex("""(\d{3,})\*+(\d{3,})""") + + /** How far apart two records of the same transfer can be timestamped. */ + private const val MATCH_WINDOW_MS = 15 * 60 * 1000L + + /** Upper bound on history pages fetched per account in one sync. */ + private const val MAX_PAGES_PER_SYNC = 5 + + private const val NAME_KEY_PREFIX = "name:" + + private data class State(val sinceMillis: Long, val messages: List) + + fun normalise(name: String) = name.trim().replace(Regex("\\s+"), " ").lowercase(Locale.US) + + fun sinceMillis(context: Context): Long = synchronized(lock) { load(context).sinceMillis } + + fun isStarted(context: Context) = sinceMillis(context) > 0L + + /** + * Groups stored messages into chats, most recent chat first. Call off the main thread. + * + * Chats are per person, keyed by the account holder's real name: bank history only names the + * sender, never their account, so money can't be told apart per account anyway. Every known + * account with that real name (saved contacts, accounts sent to) belongs to the chat, and the + * user picks which one to send to — typically their MVR or USD account. + * + * Real names come from [AccountNameStore], so a contact saved as "Ali" joins the + * "Mohamed Ali" chat. + */ + fun threads(context: Context, contacts: List): List { + val messages = synchronized(lock) { load(context).messages } + val lookedUp = AccountNameStore.all(context) + val contactsByAccount = contacts.filter { it.benefAccount.isNotBlank() }.associateBy { it.benefAccount } + + fun realNameOf(account: String): String? = + lookedUp[account]?.name?.takeIf { it.isNotBlank() } + ?: contactsByAccount[account]?.benefName?.takeIf { it.isNotBlank() } + + fun keyOf(m: ChatMessage): String { + val name = m.peerAccount.takeIf { it.isNotBlank() }?.let(::realNameOf) ?: m.peerName + return NAME_KEY_PREFIX + normalise(name) + } + + // Every known account, grouped under its holder's real name. + val accountsByKey = HashMap>() + fun addAccount(account: String, fallbackCurrency: String) { + val name = realNameOf(account) ?: return + val contact = contactsByAccount[account] + val currency = lookedUp[account]?.currency?.takeIf { it.isNotBlank() } + ?: contact?.transferCyDesc?.takeIf { it.isNotBlank() } ?: fallbackCurrency + accountsByKey.getOrPut(NAME_KEY_PREFIX + normalise(name)) { LinkedHashMap() } + .getOrPut(account) { ChatAccount(account, currency, contact) } + } + contactsByAccount.keys.forEach { addAccount(it, "") } + messages.filter { it.peerAccount.isNotBlank() }.forEach { addAccount(it.peerAccount, it.currency) } + + return messages.groupBy(::keyOf).map { (key, msgs) -> + val sorted = msgs.sortedBy { it.timeMillis } + val accounts = accountsByKey[key]?.values?.toList().orEmpty() + // Bank history and the account lookup carry the real name; a receipt only has our label. + val realName = accounts.firstNotNullOfOrNull { lookedUp[it.account]?.name?.takeIf { n -> n.isNotBlank() } } + ?: sorted.lastOrNull { !it.id.startsWith("rcpt_") }?.peerName + ?: accounts.firstNotNullOfOrNull { it.contact?.benefName?.takeIf { n -> n.isNotBlank() } } + ?: "" + val nickname = accounts.firstNotNullOfOrNull { it.contact?.benefNickName?.takeIf { n -> n.isNotBlank() } } + ChatThread( + peerKey = key, + peerName = nickname ?: realName.takeIf { it.isNotBlank() } ?: sorted.last().peerName, + realName = realName, + accounts = accounts, + messages = sorted + ) + }.sortedByDescending { it.last.timeMillis } + } + + /** Fetches new transfers and stores them. Does network and file I/O — call on Dispatchers.IO. */ + suspend fun sync(context: Context, app: BasedBankApp, accounts: List, contacts: List) { + val state = synchronized(lock) { load(context) } + if (state.sinceMillis == 0L) { + synchronized(lock) { save(context, State(System.currentTimeMillis(), emptyList())) } + return + } + val since = state.sinceMillis + + val history = fetchHistory(app, accounts, since, state.messages.mapTo(HashSet()) { it.id }) + val notifications = fetchNotifications(context, app, accounts, since) + val receipts = receiptMessages(context, accounts, since) + + synchronized(lock) { + val current = load(context) + save(context, current.copy(messages = merge(current.messages, history + notifications + receipts))) + } + + // Learn real names for contacts and people we've sent to, so name-only transfers match them. + val session = app.anyBmlSession() ?: return + val sentTo = synchronized(lock) { load(context).messages.map { it.peerAccount } } + // SWIFT (foreign) beneficiaries can't be looked up through BML. + val toResolve = contacts.filter { it.benefType != "S" }.map { it.benefAccount } + sentTo + AccountNameStore.resolve(context, session, toResolve) + } + + /** + * Transfers from account history. BML only adds a transaction here once it is booked (often + * the next day), so recent ones come from [fetchNotifications] first. + */ + private suspend fun fetchHistory( + app: BasedBankApp, + accounts: List, + since: Long, + knownIds: Set + ): List { + val fetched = mutableListOf() + for (account in accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }) { + val fetcher = HistoryFetcher(account) + var pages = 0 + // Page back until we reach transfers we already have or ones older than the start time. + while (pages < MAX_PAGES_PER_SYNC && fetcher.hasMore()) { + val page = try { fetcher.fetchNextPage(app) } catch (_: Exception) { break } + pages++ + if (page.isEmpty()) break + var reachedKnown = false + for (trx in page) { + val time = AccountHistoryAdapter.parseDateMillis(trx.date) + if (time in 1 until startOfDay(since) || "bml_${trx.id}" in knownIds) reachedKnown = true + trx.toChatMessage(time, since)?.let { fetched += it } + } + if (reachedKnown) break + } + } + return fetched + } + + private fun sh.sar.basedbank.api.models.BankTransaction.toChatMessage(time: Long, since: Long): ChatMessage? { + val name = counterpartyName?.trim().orEmpty() + if (description !in TRANSFER_TYPES || name.isBlank()) return null + // Favara (other-bank) entries carry only a date; keep those from the start day onwards. + val dateOnly = date.contains("T00:00:00") + if (if (dateOnly) time < startOfDay(since) else time < since) return null + return ChatMessage( + id = "bml_$id", + peerKey = normalise(name), + peerName = name, + amount = amount, + currency = currency, + timeMillis = time, + accountNumber = accountNumber, + accountDisplayName = accountDisplayName, + reference = reference.orEmpty(), + dateOnly = dateOnly + ) + } + + /** + * Transfers from BML's "Funds Received" / "Funds Transferred" notifications, which arrive + * instantly. Uses the notifications the app has cached plus a fresh first page; the fresh page + * is not written back to the cache, which the notification poller uses to spot new alerts. + */ + private fun fetchNotifications( + context: Context, + app: BasedBankApp, + accounts: List, + since: Long + ): List { + val client = BmlNotificationsClient() + val notifications = LinkedHashMap() + for ((loginId, session) in app.bmlSessions.toMap()) { + NotificationsCache.loadBml(context, loginId).forEach { notifications[it.id] = it } + try { client.fetchNotifications(session, loginId, page = 1).items.forEach { notifications[it.id] = it } } + catch (_: Exception) {} + } + return notifications.values + .filter { it.timestampMs >= since } + .mapNotNull { notificationToMessage(it, accounts) } + } + + private fun notificationToMessage(n: AppNotification, accounts: List): ChatMessage? { + val text = n.message.trim() + val received = NOTIF_RECEIVED.matchEntire(text) + val sent = if (received == null) NOTIF_SENT.matchEntire(text) else null + val (currency, amountText, name, ownMasked) = when { + received != null -> received.destructured.let { (c, a, who, own) -> listOf(c, a, who, own) } + sent != null -> sent.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) } + else -> return null + } + if (MASKED_ACCOUNT.matches(name.trim())) return null // between the user's own accounts + val amount = amountText.replace(",", "").toDoubleOrNull() ?: return null + val own = MASKED_ACCOUNT.matchEntire(ownMasked)?.let { m -> + val (head, tail) = m.destructured + accounts.firstOrNull { it.bank == "BML" && it.accountNumber.startsWith(head) && it.accountNumber.endsWith(tail) } + } + return ChatMessage( + id = "ntf_${n.id}", + peerKey = normalise(name), + peerName = name.trim(), + amount = if (received != null) amount else -amount, + currency = currency, + timeMillis = n.timestampMs, + accountNumber = own?.accountNumber.orEmpty(), + accountDisplayName = own?.accountBriefName ?: ownMasked + ) + } + + /** Transfers sent from this app, from their saved receipts: these carry the note and slip. */ + private fun receiptMessages(context: Context, accounts: List, since: Long): List = + ReceiptStore.loadAll(context).mapNotNull { entry -> + val r = entry.data + if (r.bank != "BML" || entry.savedAt < since) return@mapNotNull null + val amount = r.amount.toDoubleOrNull() ?: return@mapNotNull null + val time = AccountHistoryAdapter.parseDateMillis(r.bmlTimestamp).takeIf { it > 0L } ?: entry.savedAt + val from = accounts.firstOrNull { it.bank == "BML" && it.accountBriefName == r.fromLabel } + ChatMessage( + id = "rcpt_${r.bmlReference.ifBlank { entry.savedAt.toString() }}", + peerKey = normalise(r.toLabel), + peerName = r.toLabel, + amount = -amount, + currency = r.currency, + timeMillis = time, + accountNumber = from?.accountNumber.orEmpty(), + accountDisplayName = r.fromLabel, + reference = r.bmlReference, + note = r.remarks, + peerAccount = r.toAccount, + receiptKey = entry.savedAt.toString() + ) + } + + /** + * Merges stored and newly fetched messages so each transfer appears once. The same transfer can + * come from history, a notification and a receipt; the highest-ranked source is kept (history, + * then notification, then receipt) and picks up what the others add: the exact time, the note, + * the recipient account and the receipt. + */ + private fun merge(existing: List, incoming: List): List { + val byId = LinkedHashMap() + incoming.forEach { byId[it.id] = it } + existing.forEach { byId[it.id] = it } // stored copies already carry merged details + + val kept = mutableListOf() + val absorbed = HashMap>() // kept id -> source ranks merged into it + for (candidate in byId.values.sortedWith(compareBy({ rank(it) }, { it.timeMillis }))) { + val r = rank(candidate) + val index = kept.indexOfFirst { k -> + rank(k) < r && r !in absorbed[k.id].orEmpty() && sameTransfer(k, candidate) + } + if (index >= 0) { + val k = kept[index] + kept[index] = k.copy( + timeMillis = if (k.dateOnly && !candidate.dateOnly) candidate.timeMillis else k.timeMillis, + dateOnly = k.dateOnly && candidate.dateOnly, + accountNumber = k.accountNumber.ifBlank { candidate.accountNumber }, + reference = k.reference.ifBlank { candidate.reference }, + note = k.note.ifBlank { candidate.note }, + peerAccount = k.peerAccount.ifBlank { candidate.peerAccount }, + receiptKey = k.receiptKey.ifBlank { candidate.receiptKey } + ) + absorbed.getOrPut(k.id) { mutableSetOf() } += r + } else { + kept += candidate + } + } + return kept.sortedBy { it.timeMillis } + } + + private fun rank(m: ChatMessage) = when { + m.id.startsWith("bml_") -> 0 + m.id.startsWith("ntf_") -> 1 + else -> 2 + } + + private fun sameTransfer(a: ChatMessage, b: ChatMessage): Boolean { + if (a.reference.isNotBlank() && a.reference == b.reference) return true + if (a.isSent != b.isSent || kotlin.math.abs(a.amount - b.amount) >= 0.005) return false + if (a.currency.isNotBlank() && b.currency.isNotBlank() && !a.currency.equals(b.currency, ignoreCase = true)) return false + if (a.accountNumber.isNotBlank() && b.accountNumber.isNotBlank() && a.accountNumber != b.accountNumber) return false + if (a.dateOnly || b.dateOnly) { + // A date-only entry could be any transfer that day, so the names must agree too. + if (startOfDay(a.timeMillis) != startOfDay(b.timeMillis)) return false + val receipt = a.id.startsWith("rcpt_") || b.id.startsWith("rcpt_") + return receipt || a.peerKey == b.peerKey || a.peerKey.contains(b.peerKey) || b.peerKey.contains(a.peerKey) + } + return kotlin.math.abs(a.timeMillis - b.timeMillis) <= MATCH_WINDOW_MS + } + + private fun startOfDay(millis: Long): Long = Calendar.getInstance().apply { + timeInMillis = millis + set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) + }.timeInMillis + + fun clearAll(context: Context) = synchronized(lock) { File(context.filesDir, FILE_NAME).delete() } + + private fun load(context: Context): State { + val file = File(context.filesDir, FILE_NAME) + if (!file.exists()) return State(0L, emptyList()) + return try { + val root = JSONObject(CacheEncryption.decrypt(file.readText())) + val arr = root.optJSONArray("messages") ?: JSONArray() + State( + sinceMillis = root.optLong("since", 0L), + messages = (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + ChatMessage( + id = o.optString("id"), + peerKey = o.optString("peerKey"), + peerName = o.optString("peerName"), + amount = o.optDouble("amount", 0.0), + currency = o.optString("currency"), + timeMillis = o.optLong("time", 0L), + accountNumber = o.optString("account"), + accountDisplayName = o.optString("accountName"), + reference = o.optString("reference"), + note = o.optString("note"), + peerAccount = o.optString("peerAccount"), + receiptKey = o.optString("receiptKey"), + dateOnly = o.optBoolean("dateOnly", false) + ) + } + ) + } catch (_: Exception) { State(0L, emptyList()) } + } + + private fun save(context: Context, state: State) { + try { + val arr = JSONArray() + for (m in state.messages) arr.put(JSONObject().apply { + put("id", m.id) + put("peerKey", m.peerKey) + put("peerName", m.peerName) + put("amount", m.amount) + put("currency", m.currency) + put("time", m.timeMillis) + put("account", m.accountNumber) + put("accountName", m.accountDisplayName) + put("reference", m.reference) + put("note", m.note) + put("peerAccount", m.peerAccount) + put("receiptKey", m.receiptKey) + put("dateOnly", m.dateOnly) + }) + val root = JSONObject().put("since", state.sinceMillis).put("messages", arr) + File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString())) + } catch (_: Exception) {} + } +} diff --git a/app/src/main/res/drawable/bg_chat_date.xml b/app/src/main/res/drawable/bg_chat_date.xml new file mode 100644 index 0000000..76a90c1 --- /dev/null +++ b/app/src/main/res/drawable/bg_chat_date.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_chat.xml b/app/src/main/res/drawable/ic_chat.xml new file mode 100644 index 0000000..f7014bf --- /dev/null +++ b/app/src/main/res/drawable/ic_chat.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_receipt.xml b/app/src/main/res/drawable/ic_receipt.xml new file mode 100644 index 0000000..04fbb41 --- /dev/null +++ b/app/src/main/res/drawable/ic_receipt.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_chat.xml b/app/src/main/res/layout/fragment_chat.xml new file mode 100644 index 0000000..e45994d --- /dev/null +++ b/app/src/main/res/layout/fragment_chat.xml @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_chats.xml b/app/src/main/res/layout/fragment_chats.xml new file mode 100644 index 0000000..95b412d --- /dev/null +++ b/app/src/main/res/layout/fragment_chats.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_chat.xml b/app/src/main/res/layout/item_chat.xml new file mode 100644 index 0000000..e2cb96e --- /dev/null +++ b/app/src/main/res/layout/item_chat.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_chat_bubble.xml b/app/src/main/res/layout/item_chat_bubble.xml new file mode 100644 index 0000000..cf23947 --- /dev/null +++ b/app/src/main/res/layout/item_chat_bubble.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_chat_date.xml b/app/src/main/res/layout/item_chat_date.xml new file mode 100644 index 0000000..d055751 --- /dev/null +++ b/app/src/main/res/layout/item_chat_date.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/layout/sheet_transfer.xml b/app/src/main/res/layout/sheet_transfer.xml new file mode 100644 index 0000000..b6041df --- /dev/null +++ b/app/src/main/res/layout/sheet_transfer.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/app/src/main/res/layout/view_chat_header.xml b/app/src/main/res/layout/view_chat_header.xml new file mode 100644 index 0000000..5cb8953 --- /dev/null +++ b/app/src/main/res/layout/view_chat_header.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/bottom_nav_menu.xml b/app/src/main/res/menu/bottom_nav_menu.xml index f99f6fd..20bdbd4 100644 --- a/app/src/main/res/menu/bottom_nav_menu.xml +++ b/app/src/main/res/menu/bottom_nav_menu.xml @@ -6,9 +6,9 @@ - + diff --git a/app/src/main/res/menu/drawer_menu.xml b/app/src/main/res/menu/drawer_menu.xml index b6e1fdb..d52275f 100644 --- a/app/src/main/res/menu/drawer_menu.xml +++ b/app/src/main/res/menu/drawer_menu.xml @@ -17,6 +17,9 @@ + diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..7501586 --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee06fdc..657d92c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,6 +85,7 @@ Add Login Accounts Contacts + Chats Recent Transfers Transaction History Finances @@ -94,6 +95,7 @@ More View all your bank accounts Manage your transfer contacts + Your transfers with each person, as chats Send money to a contact Scan or generate a PayMV QR code View your recent transfers @@ -400,4 +402,36 @@ Please check your connection and reload Thijooree Connectivity issue with %s Drag to reorder + + + Search chats + Chats start from now. New BML transfers will show up here. + No transfers since %s. New BML transfers will show up here. + No chats found + You sent %s + Received %s + From %s + Received + Sent + To %s + Debit + Credit + ✓ Sent · %s + Today + Yesterday + From account + Send from + Transfer sent + Receipt + Not in your contacts + Save contact + Send + To + Send to + From %1$s → %2$s + Receipt no longer available + Amount + Enter an amount + Account %s + Account number not known for this chat. Save this person as a contact, or send once from Transfer, to send from here.