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 index f8c12df..1369e4a 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt @@ -14,8 +14,11 @@ import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnNextLayout import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.LinearLayoutManager @@ -41,6 +44,15 @@ class CardsFragment : Fragment() { private var pendingQrAccountNumber: String? = null private var isManageMode: Boolean = false + // Carousel snapshot captured on enter, used to reverse the exit animation + private var carouselCardLayoutTop = 0f // card layout top relative to contentLayout + private var carouselCardCenterX = 0f // card center X relative to contentLayout + private var carouselTextLayoutTop = 0f // tvSelectedCardType layout top relative to contentLayout + + // Swipe-to-dismiss tracking + private var swipeDragStartRawY = 0f + private var swipeIsDragging = false + private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult @@ -133,6 +145,50 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets -> setManageMode(!isManageMode) } + // Swipe-down on the manage card to dismiss manage mode + binding.manageCardView.root.setOnTouchListener { _, event -> + if (!isManageMode) return@setOnTouchListener false + val mgr = binding.manageCardView.root + when (event.action) { + android.view.MotionEvent.ACTION_DOWN -> { + mgr.animate().cancel() + binding.tvSelectedCardType.animate().cancel() + swipeDragStartRawY = event.rawY + swipeIsDragging = false + true + } + android.view.MotionEvent.ACTION_MOVE -> { + val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f) + if (dy > 12f || swipeIsDragging) { + swipeIsDragging = true + mgr.translationY = dy + binding.tvSelectedCardType.translationY = dy * 0.6f + val scale = 1f - (dy / (binding.contentLayout.height * 2.5f)).coerceIn(0f, 0.12f) + mgr.scaleX = scale + mgr.scaleY = scale + true + } else false + } + android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> { + if (swipeIsDragging) { + val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f) + swipeIsDragging = false + if (dy > 130f) { + setManageMode(false) + } else { + // Snap back + mgr.animate().translationY(0f).scaleX(1f).scaleY(1f) + .setDuration(280).setInterpolator(DecelerateInterpolator()).start() + binding.tvSelectedCardType.animate().translationY(0f) + .setDuration(280).setInterpolator(DecelerateInterpolator()).start() + } + true + } else false + } + else -> false + } + } + binding.btnScanToPay.setOnClickListener { val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener if (item is CardItem.Mib) { @@ -162,39 +218,148 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets -> private fun setManageMode(enabled: Boolean) { isManageMode = enabled requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card) - val gone = View.GONE - val visible = View.VISIBLE - binding.btnManageCard.visibility = if (enabled) gone else visible - binding.topSpacer.visibility = if (enabled) gone else visible - binding.rvCards.visibility = if (enabled) gone else visible - binding.pageIndicator.visibility = if (enabled) gone else visible - binding.llPayButtons.visibility = if (enabled) gone else visible - binding.llManageButtons.visibility = if (enabled) visible else gone - binding.manageCardView.root.visibility = if (enabled) visible else gone - if (!enabled) buildDots(cards.size, currentCardPosition) - if (enabled) { - val item = cards.getOrNull(currentCardPosition) ?: return - val cv = binding.manageCardView - when (item) { - is CardItem.Mib -> { - cv.tvCardOwner.text = item.card.cardHolderName - cv.tvCardNumber.text = formatMasked(item.card.maskedCardNumber) - val assetPath = cardImageAsset(item.card) - if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath) - else cv.ivCardImage.setImageDrawable(null) - bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus)) - cv.root.alpha = 1f - } - is CardItem.Bml -> { - cv.tvCardOwner.text = item.account.accountBriefName - cv.tvCardNumber.text = formatMasked(item.account.accountNumber) - loadCardImage(cv.ivCardImage, BmlCardParser.cardImageAsset(item.account)) - val isActive = item.account.statusDesc.equals("Active", ignoreCase = true) - bindCardStatus(cv.tvCardStatus, item.account.statusDesc.takeUnless { isActive }) - cv.root.alpha = if (isActive) 1f else 0.45f - } + if (enabled) enterManageMode() else exitManageMode() + } + + private fun enterManageMode() { + val item = cards.getOrNull(currentCardPosition) ?: return + + // Bind card data + val cv = binding.manageCardView + when (item) { + is CardItem.Mib -> { + cv.tvCardOwner.text = item.card.cardHolderName + cv.tvCardNumber.text = formatMasked(item.card.maskedCardNumber) + val assetPath = cardImageAsset(item.card) + if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath) + else cv.ivCardImage.setImageDrawable(null) + bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus)) + cv.root.alpha = 1f + } + is CardItem.Bml -> { + cv.tvCardOwner.text = item.account.accountBriefName + cv.tvCardNumber.text = formatMasked(item.account.accountNumber) + loadCardImage(cv.ivCardImage, BmlCardParser.cardImageAsset(item.account)) + val isActive = item.account.statusDesc.equals("Active", ignoreCase = true) + bindCardStatus(cv.tvCardStatus, item.account.statusDesc.takeUnless { isActive }) + cv.root.alpha = if (isActive) 1f else 0.45f } } + + // Capture positions BEFORE layout changes (for enter animation + exit animation later) + val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) } + val lm = binding.rvCards.layoutManager as? LinearLayoutManager + val srcView = lm?.findViewByPosition(currentCardPosition) + val srcLoc = IntArray(2).also { srcView?.getLocationOnScreen(it) ?: run { it[0] = contentLoc[0]; it[1] = contentLoc[1] } } + val srcScreenTop = (srcLoc[1] - contentLoc[1]).toFloat() + val srcCenterX = (srcLoc[0] - contentLoc[0]).toFloat() + cardWidth / 2f + + val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) } + val textSrcScreenTop = (textLoc[1] - contentLoc[1]).toFloat() + + // Apply layout changes + binding.btnManageCard.visibility = View.GONE + binding.topSpacer.visibility = View.GONE + binding.rvCards.visibility = View.GONE + binding.pageIndicator.visibility = View.GONE + binding.llPayButtons.visibility = View.GONE + binding.llManageButtons.visibility = View.VISIBLE + binding.manageCardView.root.visibility = View.VISIBLE + + // After layout pass, compute offsets, save carousel snapshot, and animate + binding.contentLayout.doOnNextLayout { + val mgr = binding.manageCardView.root + val dstLoc = IntArray(2).also { mgr.getLocationOnScreen(it) } + val dstTop = (dstLoc[1] - contentLoc[1]).toFloat() + val dstCenterX = (dstLoc[0] - contentLoc[0]).toFloat() + mgr.width / 2f + + val scaleStart = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f + val transXStart = srcCenterX - dstCenterX + val transYStart = srcScreenTop - dstTop + + // Save the carousel card's position (relative to contentLayout) for the exit animation + carouselCardLayoutTop = srcScreenTop + carouselCardCenterX = srcCenterX + carouselTextLayoutTop = textSrcScreenTop + + val textDstLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) } + val textDstTop = (textDstLoc[1] - contentLoc[1]).toFloat() + + mgr.pivotX = mgr.width / 2f + mgr.pivotY = 0f + mgr.scaleX = scaleStart + mgr.scaleY = scaleStart + mgr.translationX = transXStart + mgr.translationY = transYStart + + mgr.animate() + .scaleX(1f).scaleY(1f) + .translationX(0f).translationY(0f) + .setDuration(380) + .setInterpolator(DecelerateInterpolator()) + .start() + + binding.tvSelectedCardType.translationY = textSrcScreenTop - textDstTop + binding.tvSelectedCardType.animate() + .translationY(0f) + .setDuration(380) + .setInterpolator(DecelerateInterpolator()) + .start() + } + } + + private fun exitManageMode() { + binding.manageCardView.root.animate().cancel() + binding.tvSelectedCardType.animate().cancel() + + val mgr = binding.manageCardView.root + val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) } + + // Compute layout top of manage card (strip current translationY which may be from a swipe drag) + val mgrLoc = IntArray(2).also { mgr.getLocationOnScreen(it) } + val mgrLayoutTop = (mgrLoc[1] - contentLoc[1]).toFloat() - mgr.translationY + + val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) } + val textLayoutTop = (textLoc[1] - contentLoc[1]).toFloat() - binding.tvSelectedCardType.translationY + + // Target: animate card back to carousel position + val scaleEnd = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f + val mgrLayoutCenterX = (mgrLoc[0] - contentLoc[0]).toFloat() - mgr.translationX + mgr.width / 2f + val targetTransX = carouselCardCenterX - mgrLayoutCenterX + val targetTransY = carouselCardLayoutTop - mgrLayoutTop + + val targetTextTransY = carouselTextLayoutTop - textLayoutTop + + mgr.pivotX = mgr.width / 2f + mgr.pivotY = 0f + + mgr.animate() + .scaleX(scaleEnd).scaleY(scaleEnd) + .translationX(targetTransX) + .translationY(targetTransY) + .setDuration(320) + .setInterpolator(AccelerateInterpolator()) + .withEndAction { + mgr.scaleX = 1f; mgr.scaleY = 1f + mgr.translationX = 0f; mgr.translationY = 0f + mgr.visibility = View.GONE + binding.tvSelectedCardType.translationY = 0f + + binding.btnManageCard.visibility = View.VISIBLE + binding.topSpacer.visibility = View.VISIBLE + binding.rvCards.visibility = View.VISIBLE + binding.llPayButtons.visibility = View.VISIBLE + binding.llManageButtons.visibility = View.GONE + buildDots(cards.size, currentCardPosition) + } + .start() + + binding.tvSelectedCardType.animate() + .translationY(targetTextTransY) + .setDuration(320) + .setInterpolator(AccelerateInterpolator()) + .withEndAction { binding.tvSelectedCardType.translationY = 0f } + .start() } private fun applyCardScales() {