diff --git a/app/src/main/assets/cards/mib/cards-blue-bg-w-logo-1.jpg b/app/src/main/assets/cards/mib/cards-blue-bg-w-logo-1.jpg new file mode 100644 index 0000000..765c3af Binary files /dev/null and b/app/src/main/assets/cards/mib/cards-blue-bg-w-logo-1.jpg differ diff --git a/app/src/main/assets/cards/mib/cards-platinum-bg-w-logo-1.jpg b/app/src/main/assets/cards/mib/cards-platinum-bg-w-logo-1.jpg new file mode 100644 index 0000000..ad1c19f Binary files /dev/null and b/app/src/main/assets/cards/mib/cards-platinum-bg-w-logo-1.jpg differ diff --git a/app/src/main/assets/cards/mib/visa_business.jpg b/app/src/main/assets/cards/mib/visa_business.jpg new file mode 100644 index 0000000..7fc6a1e Binary files /dev/null and b/app/src/main/assets/cards/mib/visa_business.jpg differ diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibCardsClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibCardsClient.kt new file mode 100644 index 0000000..bcb53ea --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibCardsClient.kt @@ -0,0 +1,62 @@ +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 MibCardsClient { + + 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" + + fun fetchCards(session: MibSession, loginTag: String): List { + val body = FormBody.Builder() + .add("name", "") + .add("start", "1") + .add("end", "50") + .add("includeCount", "1") + .build() + + val request = Request.Builder() + .url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos") + .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//debitCards?dashurl=1") + .build() + + return client.newCall(request).execute().use { response -> + val bodyStr = response.body?.string() ?: return emptyList() + val json = try { JSONObject(bodyStr) } catch (_: Exception) { return emptyList() } + if (!json.optBoolean("success")) return emptyList() + val data = json.optJSONArray("data") ?: return emptyList() + (0 until data.length()).map { i -> + val item = data.getJSONObject(i) + MibCard( + cardId = item.optString("cardId"), + maskedCardNumber = item.optString("maskedCardNumber"), + cardStatus = item.optString("cardStatus"), + cardType = item.optString("cardType"), + cardTypeDesc = item.optString("cardTypeDesc"), + customerId = item.optString("customerId"), + phoneNumber = item.optString("phoneNumber"), + cardHolderName = item.optString("cardHolderName"), + loginTag = loginTag + ) + } + } + } +} 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 5f97b9c..edb4c73 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 @@ -46,6 +46,18 @@ data class MibIpsAccountInfo( ) +data class MibCard( + val cardId: String, + val maskedCardNumber: String, + val cardStatus: String, + val cardType: String, + val cardTypeDesc: String, + val customerId: String, + val phoneNumber: String, + val cardHolderName: String, + val loginTag: String +) + data class MibFinanceDeal( val dealNo: String, val productDesc: String, diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/CardSettingsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/CardSettingsFragment.kt new file mode 100644 index 0000000..64969bf --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/CardSettingsFragment.kt @@ -0,0 +1,114 @@ +package sh.sar.basedbank.ui.home + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import sh.sar.basedbank.R +import sh.sar.basedbank.api.mib.MibCard +import sh.sar.basedbank.databinding.FragmentCardSettingsBinding + +class CardSettingsFragment : Fragment() { + + private var _binding: FragmentCardSettingsBinding? = null + private val binding get() = _binding!! + private val viewModel: HomeViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentCardSettingsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val adapter = CardSettingsAdapter(emptyList(), requireContext()) + 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.swipeRefresh.setOnRefreshListener { + (activity as? HomeActivity)?.triggerRefreshCards() + } + + viewModel.mibCards.observe(viewLifecycleOwner) { cards -> + if (cards == null) return@observe + adapter.update(cards) + binding.loadingView.visibility = View.GONE + binding.swipeRefresh.isRefreshing = false + binding.emptyView.visibility = if (cards.isEmpty()) View.VISIBLE else View.GONE + binding.recyclerView.visibility = if (cards.isEmpty()) View.GONE else View.VISIBLE + } + + if (viewModel.mibCards.value == null) { + binding.loadingView.visibility = View.VISIBLE + (activity as? HomeActivity)?.triggerRefreshCards() + } + } + + override fun onResume() { + super.onResume() + requireActivity().title = getString(R.string.nav_card_settings) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private inner class CardSettingsAdapter( + private var cards: List, + private val context: Context + ) : RecyclerView.Adapter() { + + fun update(newCards: List) { + cards = newCards + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = + VH(LayoutInflater.from(context).inflate(R.layout.item_card_settings_entry, parent, false)) + + override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position]) + override fun getItemCount() = cards.size + + inner class VH(view: View) : RecyclerView.ViewHolder(view) { + private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage) + private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner) + private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber) + private val tvCardType: TextView = view.findViewById(R.id.tvCardType) + private val btnChangePin: View = view.findViewById(R.id.btnChangePin) + private val btnFreeze: View = view.findViewById(R.id.btnFreeze) + private val btnBlock: View = view.findViewById(R.id.btnBlock) + + fun bind(card: MibCard) { + tvCardOwner.text = card.cardHolderName + tvCardNumber.text = PayWithCardFragment.formatMasked(card.maskedCardNumber) + tvCardType.text = card.cardTypeDesc + PayWithCardFragment.loadCardImage(ivCardImage, PayWithCardFragment.cardImageAsset(card)) + val wip = View.OnClickListener { + Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show() + } + btnChangePin.setOnClickListener(wip) + btnFreeze.setOnClickListener(wip) + btnBlock.setOnClickListener(wip) + } + } + } +} 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 e174323..bdfd2dc 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 @@ -54,6 +54,7 @@ import sh.sar.basedbank.ui.login.LoginActivity import sh.sar.basedbank.api.models.BankContact import sh.sar.basedbank.api.models.BankContactCategory import sh.sar.basedbank.api.mib.MibContactsClient +import sh.sar.basedbank.api.mib.MibCardsClient import sh.sar.basedbank.api.mib.MibFinancingClient import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibSession @@ -138,6 +139,8 @@ class HomeActivity : AppCompatActivity() { R.id.nav_finances -> FinancingFragment() R.id.nav_otp -> OtpFragment() R.id.nav_settings -> SettingsFragment() + R.id.nav_pay_with_card -> PayWithCardFragment() + R.id.nav_card_settings -> CardSettingsFragment() else -> null } if (frag != null) show(frag) @@ -307,6 +310,8 @@ fun applyNavLabelVisibility() { R.id.nav_finances -> FinancingFragment() R.id.nav_otp -> OtpFragment() R.id.nav_settings -> SettingsFragment() + R.id.nav_pay_with_card -> PayWithCardFragment() + R.id.nav_card_settings -> CardSettingsFragment() else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return } } show(dest) @@ -932,6 +937,43 @@ fun applyNavLabelVisibility() { } } + fun triggerRefreshCards() { + val app = application as BasedBankApp + for ((loginId, session) in app.mibSessions) { + val profiles = app.mibProfilesMap[loginId] ?: emptyList() + refreshMibCards(loginId, session, profiles) + } + } + + private fun refreshMibCards(loginId: String, session: MibSession, profiles: List) { + if (profiles.isEmpty()) return + val flow = (application as BasedBankApp).mibFlowFor(loginId) + val client = MibCardsClient() + lifecycleScope.launch { + try { + val cards = withContext(Dispatchers.IO) { + val result = mutableListOf() + val seen = mutableSetOf() + for (profile in profiles) { + try { + flow.switchProfile(session, profile) + for (card in client.fetchCards(session, "mib_$loginId")) { + if (seen.add(card.cardId)) result += card + } + } catch (_: Exception) { } + } + result + } + if (cards.isNotEmpty()) { + val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf() + existing.removeAll { it.loginTag == "mib_$loginId" } + existing += cards + viewModel.mibCards.postValue(existing) + } + } catch (_: Exception) { } + } + } + private fun refreshFinancing(loginId: String, session: MibSession, profiles: List) { if (profiles.isEmpty()) return val flow = (application as BasedBankApp).mibFlowFor(loginId) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt index 6ae96fd..68b4da1 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt @@ -7,6 +7,7 @@ import sh.sar.basedbank.api.bml.BmlLoanDetail import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankContact import sh.sar.basedbank.api.models.BankContactCategory +import sh.sar.basedbank.api.mib.MibCard import sh.sar.basedbank.api.mib.MibFinanceDeal class HomeViewModel : ViewModel() { @@ -20,5 +21,7 @@ class HomeViewModel : ViewModel() { data class BmlLimitsData(val userName: String, val limits: List) val bmlLimits = MutableLiveData>(emptyList()) + val mibCards = MutableLiveData?>(null) + val hideAmounts = MutableLiveData(false) } 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 ff936c8..d749bc0 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 @@ -22,6 +22,7 @@ object NavCustomization { NavItemDef(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities), NavItemDef(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history), NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances), + NavItemDef(R.id.nav_pay_with_card, R.drawable.ic_nav_card, R.string.nav_pay_with_card), NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings), NavItemDef(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp), NavItemDef(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings), diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt new file mode 100644 index 0000000..5d845ce --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt @@ -0,0 +1,141 @@ +package sh.sar.basedbank.ui.home + +import android.content.Context +import android.graphics.BitmapFactory +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import sh.sar.basedbank.R +import sh.sar.basedbank.api.mib.MibCard +import sh.sar.basedbank.databinding.FragmentPayWithCardBinding + +class PayWithCardFragment : Fragment() { + + private var _binding: FragmentPayWithCardBinding? = null + private val binding get() = _binding!! + private val viewModel: HomeViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentPayWithCardBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val adapter = CardWalletAdapter(emptyList(), requireContext()) + 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.swipeRefresh.setOnRefreshListener { + (activity as? HomeActivity)?.triggerRefreshCards() + } + + viewModel.mibCards.observe(viewLifecycleOwner) { cards -> + if (cards == null) return@observe + adapter.update(cards) + binding.loadingView.visibility = View.GONE + binding.swipeRefresh.isRefreshing = false + binding.emptyView.visibility = if (cards.isEmpty()) View.VISIBLE else View.GONE + binding.recyclerView.visibility = if (cards.isEmpty()) View.GONE else View.VISIBLE + } + + if (viewModel.mibCards.value == null) { + binding.loadingView.visibility = View.VISIBLE + (activity as? HomeActivity)?.triggerRefreshCards() + } + } + + override fun onResume() { + super.onResume() + requireActivity().title = getString(R.string.nav_pay_with_card) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private inner class CardWalletAdapter( + private var cards: List, + private val context: Context + ) : RecyclerView.Adapter() { + + fun update(newCards: List) { + cards = newCards + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = + VH(LayoutInflater.from(context).inflate(R.layout.item_card_wallet, parent, false)) + + override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position]) + override fun getItemCount() = cards.size + + inner class VH(view: View) : RecyclerView.ViewHolder(view) { + private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage) + private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner) + private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber) + private val tvCardType: TextView = view.findViewById(R.id.tvCardType) + private val btnPayQr: View = view.findViewById(R.id.btnPayQr) + private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc) + + fun bind(card: MibCard) { + tvCardOwner.text = card.cardHolderName + tvCardNumber.text = formatMasked(card.maskedCardNumber) + tvCardType.text = card.cardTypeDesc + loadCardImage(ivCardImage, cardImageAsset(card)) + btnPayQr.setOnClickListener { + Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show() + } + btnPayNfc.setOnClickListener { + Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show() + } + } + } + } + + companion object { + fun cardImageAsset(card: MibCard): String { + val desc = card.cardTypeDesc.lowercase() + return when { + "corporate" in desc || "business" in desc -> "cards/mib/visa_business.jpg" + "platinum" in desc -> "cards/mib/cards-platinum-bg-w-logo-1.jpg" + else -> "cards/mib/cards-blue-bg-w-logo-1.jpg" + } + } + + fun loadCardImage(imageView: ImageView, assetPath: String) { + try { + val bitmap = imageView.context.assets.open(assetPath).use { + BitmapFactory.decodeStream(it) + } + imageView.setImageBitmap(bitmap) + } catch (_: Exception) { + imageView.setImageResource(android.R.color.darker_gray) + } + } + + fun formatMasked(masked: String): String { + if (masked.length < 4) return masked + return "\u2022\u2022\u2022\u2022 ${masked.takeLast(4)}" + } + } +} diff --git a/app/src/main/res/drawable/bg_card_overlay_gradient.xml b/app/src/main/res/drawable/bg_card_overlay_gradient.xml new file mode 100644 index 0000000..99407c3 --- /dev/null +++ b/app/src/main/res/drawable/bg_card_overlay_gradient.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/ic_block.xml b/app/src/main/res/drawable/ic_block.xml new file mode 100644 index 0000000..a29260c --- /dev/null +++ b/app/src/main/res/drawable/ic_block.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_freeze.xml b/app/src/main/res/drawable/ic_freeze.xml new file mode 100644 index 0000000..65a7b86 --- /dev/null +++ b/app/src/main/res/drawable/ic_freeze.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_nfc.xml b/app/src/main/res/drawable/ic_nfc.xml new file mode 100644 index 0000000..9c5debd --- /dev/null +++ b/app/src/main/res/drawable/ic_nfc.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/fragment_card_settings.xml b/app/src/main/res/layout/fragment_card_settings.xml new file mode 100644 index 0000000..e6ab43c --- /dev/null +++ b/app/src/main/res/layout/fragment_card_settings.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_pay_with_card.xml b/app/src/main/res/layout/fragment_pay_with_card.xml new file mode 100644 index 0000000..e6ab43c --- /dev/null +++ b/app/src/main/res/layout/fragment_pay_with_card.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_card_settings_entry.xml b/app/src/main/res/layout/item_card_settings_entry.xml new file mode 100644 index 0000000..d635041 --- /dev/null +++ b/app/src/main/res/layout/item_card_settings_entry.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_card_wallet.xml b/app/src/main/res/layout/item_card_wallet.xml new file mode 100644 index 0000000..63c0440 --- /dev/null +++ b/app/src/main/res/layout/item_card_wallet.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/drawer_menu.xml b/app/src/main/res/menu/drawer_menu.xml index d0d893d..18c6288 100644 --- a/app/src/main/res/menu/drawer_menu.xml +++ b/app/src/main/res/menu/drawer_menu.xml @@ -29,6 +29,9 @@ + diff --git a/app/src/main/res/menu/more_nav_menu.xml b/app/src/main/res/menu/more_nav_menu.xml index c59310a..9aa6357 100644 --- a/app/src/main/res/menu/more_nav_menu.xml +++ b/app/src/main/res/menu/more_nav_menu.xml @@ -12,6 +12,9 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index da55d86..27154d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -272,4 +272,13 @@ End Date Overdue Payments %.2f%% + + + Pay with Card + QR Pay + NFC Pay + Change PIN + Freeze + Block + No cards found