Initial commit: Pezkuwi Wallet Android

Security hardened release:
- Code obfuscation enabled (minifyEnabled=true, shrinkResources=true)
- Sensitive files excluded (google-services.json, keystores)
- Branch.io key moved to BuildConfig placeholder
- Updated dependencies: OkHttp 4.12.0, Gson 2.10.1, BouncyCastle 1.77
- Comprehensive ProGuard rules for crypto wallet
- Navigation 2.7.7, Lifecycle 2.7.0, ConstraintLayout 2.1.4
This commit is contained in:
2026-02-12 05:19:41 +03:00
commit a294aa1a6b
7687 changed files with 441811 additions and 0 deletions
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,11 @@
package io.novafoundation.nova.feature_banners_api.di
import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory
import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory
interface BannersFeatureApi {
fun sourceFactory(): BannersSourceFactory
fun mixinFactory(): PromotionBannersMixinFactory
}
@@ -0,0 +1,11 @@
package io.novafoundation.nova.feature_banners_api.domain
class PromotionBanner(
val id: String,
val title: String,
val details: String,
val backgroundUrl: String,
val imageUrl: String,
val clipToBounds: Boolean,
val actionLink: String?,
)
@@ -0,0 +1,14 @@
package io.novafoundation.nova.feature_banners_api.presentation
import android.graphics.drawable.Drawable
class BannerPageModel(
val id: String,
val title: String,
val subtitle: String,
val image: ClipableImage,
val background: Drawable,
val actionUrl: String?
)
class ClipableImage(val drawable: Drawable, val clip: Boolean)
@@ -0,0 +1,60 @@
package io.novafoundation.nova.feature_banners_api.presentation
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.novafoundation.nova.common.list.SingleItemAdapter
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.recyclerView.WithViewType
import io.novafoundation.nova.feature_banners_api.R
import io.novafoundation.nova.feature_banners_api.databinding.ItemPromotionBannerBinding
import io.novafoundation.nova.feature_banners_api.presentation.view.BannerPagerView
class PromotionBannerAdapter(
private val closable: Boolean
) : SingleItemAdapter<BannerHolder>(isShownByDefault = false) {
private var banners: List<BannerPageModel> = listOf()
private var bannerCallback: BannerPagerView.Callback? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BannerHolder {
return BannerHolder(ItemPromotionBannerBinding.inflate(parent.inflater(), parent, false), closable)
}
override fun onBindViewHolder(holder: BannerHolder, position: Int) {
holder.bind(banners, bannerCallback)
}
override fun getItemViewType(position: Int): Int {
return BannerHolder.viewType
}
fun setBanners(banners: List<BannerPageModel>) {
this.banners = banners
notifyChangedIfShown()
}
fun setCallback(bannerCallback: BannerPagerView.Callback?) {
this.bannerCallback = bannerCallback
notifyChangedIfShown()
}
}
class BannerHolder(private val binder: ItemPromotionBannerBinding, closable: Boolean) : RecyclerView.ViewHolder(binder.root) {
companion object : WithViewType {
override val viewType: Int = R.layout.item_promotion_banner
}
init {
binder.bannerPager.setClosable(closable)
}
fun bind(banners: List<BannerPageModel>, bannerCallback: BannerPagerView.Callback?) = with(binder) {
bannerPager.setCallback(bannerCallback)
showBanners(banners)
}
fun showBanners(banners: List<BannerPageModel>) = with(binder) {
bannerPager.setBanners(banners)
}
}
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_banners_api.presentation
import io.novafoundation.nova.common.domain.ExtendedLoadingState
import kotlinx.coroutines.flow.Flow
interface PromotionBannersMixin {
val bannersFlow: Flow<ExtendedLoadingState<List<BannerPageModel>>>
fun closeBanner(banner: BannerPageModel)
fun startBannerAction(page: BannerPageModel)
}
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_banners_api.presentation
import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSource
import kotlinx.coroutines.CoroutineScope
interface PromotionBannersMixinFactory {
fun create(source: BannersSource, coroutineScope: CoroutineScope): PromotionBannersMixin
}
@@ -0,0 +1,42 @@
package io.novafoundation.nova.feature_banners_api.presentation
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.domain.ExtendedLoadingState
import io.novafoundation.nova.common.domain.dataOrNull
import io.novafoundation.nova.common.domain.isLoading
import io.novafoundation.nova.feature_banners_api.presentation.view.BannerPagerView
context(BaseFragment<T, *>)
fun <T : BaseViewModel> PromotionBannersMixin.bindWithAdapter(
adapter: PromotionBannerAdapter,
onSubmitList: () -> Unit = {}
) {
adapter.setCallback(object : BannerPagerView.Callback {
override fun onBannerClicked(page: BannerPageModel) {
this@bindWithAdapter.startBannerAction(page)
}
override fun onBannerClosed(page: BannerPageModel) {
this@bindWithAdapter.closeBanner(page)
}
})
bannersFlow.observe {
adapter.show(it is ExtendedLoadingState.Loaded && it.data.isNotEmpty())
adapter.setBanners(it.dataOrNull.orEmpty())
onSubmitList()
}
}
context(BaseFragment<T, *>)
fun <T : BaseViewModel> PromotionBannersMixin.bind(
view: BannerPagerView
) {
view.setClosable(false)
bannersFlow.observe {
view.setLoadingState(it.isLoading)
view.setBanners(it.dataOrNull.orEmpty())
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_banners_api.presentation.source
import io.novafoundation.nova.feature_banners_api.BuildConfig
import io.novafoundation.nova.feature_banners_api.domain.PromotionBanner
import kotlinx.coroutines.flow.Flow
interface BannersSourceFactory {
fun create(bannersUrl: String, localisationUrl: String): BannersSource
}
interface BannersSource {
fun observeBanners(): Flow<List<PromotionBanner>>
}
fun BannersSourceFactory.forDirectory(directory: String): BannersSource {
val baseDirectory = "${BuildConfig.BANNERS_BASE_DIRECTORY}/$directory"
val suffix = if (BuildConfig.DEBUG) "_dev" else ""
val bannersUrl = "$baseDirectory/banners$suffix.json"
val localisationsUrl = "$baseDirectory/localized$suffix"
return create(bannersUrl, localisationsUrl)
}
fun BannersSourceFactory.dappsSource() = create(BuildConfig.DAPPS_BANNERS_URL, BuildConfig.DAPPS_BANNERS_LOCALISATION_URL)
fun BannersSourceFactory.assetsSource() = create(BuildConfig.ASSETS_BANNERS_URL, BuildConfig.ASSETS_BANNERS_LOCALISATION_URL)
@@ -0,0 +1,248 @@
package io.novafoundation.nova.feature_banners_api.presentation.view
import android.content.Context
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.animation.DecelerateInterpolator
import android.widget.Scroller
import io.novafoundation.nova.common.utils.dp
import kotlin.math.abs
import kotlin.math.absoluteValue
import kotlin.math.min
private const val MIN_FLING_VELOCITY = 1000 // px/s
enum class PageOffset(val scrollDirection: Int, val pageOffset: Int) {
NEXT(-1, 1), PREVIOUS(1, -1), SAME(0, 0)
}
/**
* BannerPagerScrollController tracks the banner scroll and calls a callback, passing a value from -1 to 1 depending on the scroll direction.
* Upon release, it animates the scroll to the selected page.
*
* - When the scroll animation ends, we replace the Scroller with a new one to reset its scroll value to 0. This is necessary to support infinite scrolling and to remain in the range from -1 to 1.
*/
class BannerPagerScrollController(private val context: Context, private val callback: ScrollCallback) {
interface ScrollCallback {
fun onScrollToPage(pageOffset: Float, toPage: PageOffset)
fun onScrollFinished(pageOffset: PageOffset)
fun invalidateScroll()
}
private val scrollTracking: ScrollTracking = ScrollTracking()
private var containerWidth = 0
private var isTouchable: Boolean = true
val minimumScrollDuration = 200
fun setTouchable(touchable: Boolean) {
isTouchable = touchable
}
fun setContainerWidth(width: Int) {
this.containerWidth = width
}
fun onTouchEvent(event: MotionEvent): Boolean {
if (!isTouchable) return false
return when (event.action) {
MotionEvent.ACTION_DOWN -> {
scrollTracking.onStartScroll(context, event.x)
scrollTracking.addMovement(event)
true
}
MotionEvent.ACTION_MOVE -> {
scrollTracking.addMovement(event)
scrollTracking.updateLastX(event.x)
val scrollDirection = scrollTracking.getPageOffset()
callback.onScrollToPage(currentPageOffset(), scrollDirection)
val isHorizontalScroll = scrollTracking.eventDx().absoluteValue > 8.dp(context)
isHorizontalScroll
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
scrollTracking.addMovement(event)
val velocity = scrollTracking.getVelocity()
handleSwipe(scrollTracking.currentScroll(), velocity)
scrollTracking.recycle()
true
}
else -> false
}
}
fun computeScroll() {
if (scrollTracking.computeScroll()) {
val pageOffset = scrollTracking.getPageOffset()
callback.onScrollToPage(currentPageOffset(), pageOffset)
callback.invalidateScroll()
if (scrollTracking.state == ScrollTracking.State.IDLE) {
callback.onScrollFinished(pageOffset)
scrollTracking.onFinishScroll()
}
}
}
private fun handleSwipe(dx: Float, velocityX: Float) {
val isVelocityEnough = abs(velocityX) > MIN_FLING_VELOCITY
val isSwipeEnough = abs(dx) > containerWidth / 4
val shouldFling = isVelocityEnough || isSwipeEnough
val pageOffset = when {
dx < 0 && shouldFling -> PageOffset.NEXT
dx > 0 && shouldFling -> PageOffset.PREVIOUS
else -> PageOffset.SAME
}
smoothScrollToPage(pageOffset, velocityX)
}
private fun smoothScrollToPage(page: PageOffset, velocityX: Float) {
val scrollWidth = page.scrollDirection * containerWidth
val duration = computeScrollDuration(abs(velocityX))
scrollTracking.smoothScrollToPosition(context, scrollWidth, duration)
callback.invalidateScroll()
}
private fun computeScrollDuration(velocityX: Float): Int {
val baseDuration = minimumScrollDuration
val maxDuration = 600
return (baseDuration - min(velocityX / 2, baseDuration.toFloat())).toInt().coerceIn(150, maxDuration)
}
private fun currentPageOffset(): Float {
return try {
val scrollOffset = scrollTracking.currentScroll() / containerWidth
// We flip scroll to get page offset
-scrollOffset.coerceIn(-1f, 1f)
} catch (e: ArithmeticException) {
0f
}
}
fun swipeToPage(next: PageOffset) {
smoothScrollToPage(next, 0f)
}
fun isIdle(): Boolean {
return scrollTracking.state == ScrollTracking.State.IDLE
}
}
private class ScrollTracking {
enum class State {
IDLE, SCROLLING, SCROLLING_RELEASED
}
var state = State.IDLE
private set
private var currentScroll = 0f
private var velocityTracker: VelocityTracker? = null
private var scroller: Scroller? = null
private var eventDownX = 0f
private var eventLastX = 0f
fun getVelocity(): Float {
velocityTracker?.computeCurrentVelocity(MIN_FLING_VELOCITY)
return velocityTracker?.xVelocity ?: return 0f
}
fun addMovement(event: MotionEvent) {
velocityTracker?.addMovement(event)
}
fun updateLastX(x: Float) {
val dx = x - eventLastX
currentScroll += dx
eventLastX = x
}
fun currentScroll(): Float {
return currentScroll
}
fun eventDx(): Float {
return eventLastX - eventDownX
}
fun computeScroll(): Boolean {
val scroller = scroller ?: return false
val scrollComputed = scroller.computeScrollOffset()
currentScroll = scroller.currX.toFloat()
if (scrollComputed && scroller.isFinished) { // Call only once when scroll is finished
state = State.IDLE
}
return scrollComputed
}
fun onStartScroll(context: Context, eventX: Float) {
scroller?.forceFinished(true)
if (state == State.IDLE) {
state = State.SCROLLING
currentScroll = 0f
}
eventDownX = eventX
eventLastX = eventX
velocityTracker = VelocityTracker.obtain()
createScroller(context)
}
fun onFinishScroll() {
currentScroll = 0f
scroller = null
eventDownX = 0f
eventLastX = 0f
}
fun recycle() {
velocityTracker?.recycle()
}
fun smoothScrollToPosition(context: Context, toPosition: Int, duration: Int) {
if (state == State.IDLE) {
createScroller(context)
}
state = State.SCROLLING_RELEASED
val currentPosition = currentScroll.toInt()
scroller?.startScroll(currentPosition, 0, toPosition - currentPosition, 0, duration)
}
fun getPageOffset(): PageOffset {
val currentScroll = currentScroll.toInt()
return when {
currentScroll < 0 -> PageOffset.NEXT
currentScroll > 0 -> PageOffset.PREVIOUS
else -> PageOffset.SAME
}
}
private fun createScroller(context: Context) {
scroller = Scroller(context, DecelerateInterpolator())
}
}
@@ -0,0 +1,266 @@
package io.novafoundation.nova.feature_banners_api.presentation.view
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.animation.DecelerateInterpolator
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.animation.doOnEnd
import androidx.core.view.isVisible
import io.novafoundation.nova.common.utils.ViewClickGestureDetector
import io.novafoundation.nova.common.utils.indexOfOrNull
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.mapToSet
import io.novafoundation.nova.feature_banners_api.databinding.ViewPagerBannerBinding
import io.novafoundation.nova.feature_banners_api.presentation.BannerPageModel
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.ContentSwitchingController
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.getContentSwitchingController
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.getImageSwitchingController
import kotlin.math.absoluteValue
import kotlin.time.Duration.Companion.seconds
/**
* View for viewing banner pages, supporting infinite scrolling
* BannerPagerScrollController tracks banner scrolling and triggers a callback, passing a value from -1 to 1 depending on the scroll direction
* Animates the scroll to the selected page upon release
*/
class BannerPagerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), BannerPagerScrollController.ScrollCallback {
private val binder = ViewPagerBannerBinding.inflate(inflater(), this)
private val scrollInterpolator = DecelerateInterpolator()
private val scrollController = BannerPagerScrollController(context, this)
private val gestureDetector = ViewClickGestureDetector(this)
private var currentPage = 0
private var pages: MutableList<BannerPageModel> = mutableListOf()
private val canScroll: Boolean
get() = !closeAnimator.isRunning && pages.size > 1
private val canRunScrollAnimation: Boolean
get() = canScroll && scrollController.isIdle()
private val contentController = getContentSwitchingController(scrollInterpolator)
private val backgroundSwitchingController = getImageSwitchingController(scrollInterpolator)
private var autoSwipeCallbackAdded = false
private val autoSwipeDelay = 3.seconds.inWholeMilliseconds
private val autoSwipeCallback = object : Runnable {
override fun run() {
if (canRunScrollAnimation) {
scrollController.swipeToPage(PageOffset.NEXT)
handler?.postDelayed(this, autoSwipeDelay)
}
}
}
private var callback: Callback? = null
private val closeAnimator = ValueAnimator().apply {
interpolator = scrollInterpolator
duration = scrollController.minimumScrollDuration.toLong()
}
val isClosable: Boolean
get() = binder.pagerBannerClose.isVisible
init {
contentController.attachToParent(binder.pagerBannerContent)
backgroundSwitchingController.attachToParent(binder.pagerBannerBackground)
binder.pagerBannerClose.setOnClickListener { closeCurrentPage() }
}
fun setLoadingState(loading: Boolean) {
binder.pagerBannerShimmering.isVisible = loading
binder.pagerBannerCardView.isVisible = !loading
binder.pagerBannerIndicators.isVisible = !loading
binder.pagerBannerContent.isVisible = !loading
}
fun setBanners(banners: List<BannerPageModel>) {
val newIds = banners.mapToSet { it.id }
val currentIds = pages.mapToSet { it.id }
if (newIds == currentIds) return // Check that pages not changed
this.pages.clear()
this.pages.addAll(banners)
binder.pagerBannerIndicators.setPagesSize(pages.size)
contentController.setPayloads(pages.map { ContentSwitchingController.Payload(it.title, it.subtitle, it.image) })
backgroundSwitchingController.setPayloads(pages.map { it.background })
if (pages.isNotEmpty()) {
selectPageImmediately(pages.first())
rerunAutoSwipe()
}
}
fun setClosable(closable: Boolean) {
binder.pagerBannerClose.isVisible = closable
}
fun closeCurrentPage() {
if (pages.size == 1) {
callback?.onBannerClosed(pages.first())
return // Don't run animation to let close banner from outside
}
if (isClosable && canRunScrollAnimation) {
val isLastPageAfterClose = pages.size == 2 // 2 pages befo
val nextPage = (currentPage + 1).wrapPage()
scrollController.setTouchable(false)
stopAutoSwipe()
closeAnimator.removeAllListeners()
closeAnimator.removeAllUpdateListeners()
closeAnimator.setFloatValues(0f, 1f)
closeAnimator.addUpdateListener {
if (isLastPageAfterClose) {
binder.pagerBannerIndicators.alpha = 1f - it.animatedFraction
} else {
binder.pagerBannerIndicators.setCloseAnimationProgress(it.animatedFraction, currentPage, nextPage)
}
contentController.setAnimationState(it.animatedFraction, currentPage, nextPage)
backgroundSwitchingController.setAnimationState(it.animatedFraction, currentPage, nextPage)
}
closeAnimator.doOnEnd {
closePage(currentPage)
invalidateScrolling()
startAutoSwipe()
}
closeAnimator.start()
}
}
fun setCallback(callback: Callback?) {
this.callback = callback
}
private fun closePage(index: Int) {
contentController.removePageAt(index)
backgroundSwitchingController.removePageAt(index)
val closedPage = pages.removeAt(index)
currentPage = index.wrapPage()
binder.pagerBannerIndicators.setPagesSize(pages.size)
binder.pagerBannerIndicators.selectIndicatorInstantly(currentPage)
contentController.showPageImmediately(currentPage)
backgroundSwitchingController.showPageImmediately(currentPage)
callback?.onBannerClosed(closedPage)
}
private fun invalidateScrolling() {
scrollController.setTouchable(pages.size > 1)
}
override fun onScrollToPage(pageOffset: Float, toPage: PageOffset) {
if (!canScroll) return
val nextPage = (currentPage + toPage.pageOffset).wrapPage()
binder.pagerBannerIndicators.setAnimationProgress(pageOffset.absoluteValue, currentPage, nextPage)
contentController.setAnimationState(pageOffset, currentPage, nextPage)
backgroundSwitchingController.setAnimationState(pageOffset, currentPage, nextPage)
}
override fun onScrollFinished(pageOffset: PageOffset) {
this.currentPage = (this.currentPage + pageOffset.pageOffset).wrapPage()
}
override fun invalidateScroll() {
invalidate()
}
override fun onTouchEvent(event: MotionEvent): Boolean {
gestureDetector.onTouchEvent(event)
if (event.action == MotionEvent.ACTION_UP) {
startAutoSwipe()
} else {
stopAutoSwipe()
}
val isScrollIntercepted = scrollController.onTouchEvent(event)
parent.requestDisallowInterceptTouchEvent(isScrollIntercepted)
return isScrollIntercepted
}
override fun performClick(): Boolean {
callback?.onBannerClicked(pages[currentPage])
return true
}
override fun computeScroll() {
scrollController.computeScroll()
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
scrollController.setContainerWidth(width)
}
private fun selectPageImmediately(page: BannerPageModel) {
val index = pages.indexOfOrNull(page) ?: return
contentController.showPageImmediately(index)
backgroundSwitchingController.showPageImmediately(index)
binder.pagerBannerIndicators.selectIndicatorInstantly(index)
}
private fun rerunAutoSwipe() {
stopAutoSwipe()
startAutoSwipe()
}
private fun stopAutoSwipe() {
if (autoSwipeCallbackAdded) {
removeCallbacks(autoSwipeCallback)
autoSwipeCallbackAdded = false
}
}
private fun startAutoSwipe() {
if (autoSwipeCallbackAdded) return
if (!canScroll) return
postDelayed(autoSwipeCallback, autoSwipeDelay)
autoSwipeCallbackAdded = true
}
private fun Int.wrapPage(): Int {
val min = 0
val max = pages.size - 1
if (max == 0) return 0
if (max < 0) return this
return when {
this > max -> 0
this < min -> max
else -> this
}
}
interface Callback {
fun onBannerClicked(page: BannerPageModel)
fun onBannerClosed(page: BannerPageModel)
}
}
@@ -0,0 +1,157 @@
package io.novafoundation.nova.feature_banners_api.presentation.view
import android.animation.ArgbEvaluator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import androidx.annotation.ColorInt
import io.novafoundation.nova.common.R
import io.novafoundation.nova.common.utils.dpF
import io.novafoundation.nova.common.utils.getFromTheEndOrNull
import io.novafoundation.nova.common.utils.isLast
import io.novafoundation.nova.common.utils.isNotLast
private const val NO_PAGE = -1
private class Indicator(var size: Float, var marginToNext: Float, @ColorInt var color: Int)
class PageIndicatorView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var pagesCount = 0
private val indicators = mutableListOf<Indicator>()
private val indicatorColor = context.getColor(R.color.icon_inactive)
private val goneIndicatorColor = Color.TRANSPARENT
private val indicatorRadius = 3.dpF
private val indicatorWidth = indicatorRadius * 2
private val indicatorMargin = 12.dpF
private val indicatorFullLength = 14.dpF
private var indicatorsBoxWidth = 0f
private val argbEvaluator = ArgbEvaluator()
private val indicatorPaint = Paint(Paint.ANTI_ALIAS_FLAG)
.apply {
style = Paint.Style.STROKE
strokeWidth = indicatorWidth
strokeCap = Paint.Cap.ROUND
}
fun setPagesSize(size: Int) {
pagesCount = size
indicators.clear()
if (size > 0) {
indicators.addAll(List(size) { Indicator(0f, indicatorMargin, indicatorColor) })
selectIndicatorInstantly(0)
}
invalidate()
}
fun selectIndicatorInstantly(pageIndex: Int) {
setAnimationProgress(1f, fromPage = NO_PAGE, toPage = pageIndex)
}
fun setAnimationProgress(offset: Float, fromPage: Int, toPage: Int) {
setAnimationProgressInternal(offset.coerceIn(0f, 1f), fromPage, toPage, removeFrom = false)
}
fun setCloseAnimationProgress(offset: Float, closingPage: Int, nextPage: Int) {
setAnimationProgressInternal(offset.coerceIn(0f, 1f), closingPage, nextPage, removeFrom = true)
}
private fun setAnimationProgressInternal(offset: Float, fromIndex: Int, toIndex: Int, removeFrom: Boolean) {
if (indicators.size <= 1) {
hideIndicators()
invalidate()
return
}
clearIndicatorSizeParams()
increaseSizeForAnimationOffset(toIndex, offset)
decreaseSizeForAnimationOffset(fromIndex, offset, removeFrom)
calculateIndicatorsBoxWidth()
invalidate()
}
private fun calculateIndicatorsBoxWidth() {
var newIndicatorBoxWidth = 0f
newIndicatorBoxWidth += indicatorRadius * 2 // Add start and end radius of indicator
indicators.forEach {
newIndicatorBoxWidth += it.size + it.marginToNext
}
indicatorsBoxWidth = newIndicatorBoxWidth
}
private fun increaseSizeForAnimationOffset(indicatorIndex: Int, offset: Float) {
val indicator = indicators.getOrNull(indicatorIndex) ?: return
indicator.size = offset * indicatorFullLength
if (indicators.isNotLast(indicator)) {
indicator.marginToNext = indicatorMargin
}
}
private fun decreaseSizeForAnimationOffset(indicatorIndex: Int, offset: Float, isRemovingAnimation: Boolean) {
val indicator = indicators.getOrNull(indicatorIndex) ?: return
indicator.size = indicatorFullLength - offset * indicatorFullLength
when {
indicators.isLast(indicator) && isRemovingAnimation -> {
val nextLastIndicator = indicators.getFromTheEndOrNull(1)
nextLastIndicator?.marginToNext = indicatorMargin - offset * indicatorMargin
indicator.marginToNext = 0f
}
indicators.isNotLast(indicator) -> {
val marginToNext = if (isRemovingAnimation) indicatorMargin - offset * indicatorMargin else indicatorMargin
indicator.marginToNext = marginToNext
}
}
val endColor = if (isRemovingAnimation) goneIndicatorColor else indicatorColor
indicator.color = argbEvaluator.evaluate(offset, indicatorColor, endColor) as Int
}
private fun clearIndicatorSizeParams() {
indicators.forEach {
it.size = 0f
it.marginToNext = indicatorMargin
}
indicators.lastOrNull()?.marginToNext = 0f
}
private fun hideIndicators() {
indicators.forEach { it.color = goneIndicatorColor }
}
override fun onDraw(canvas: Canvas) {
val startSpace = (measuredWidth - indicatorsBoxWidth) / 2
val startMarginIndicator = indicatorRadius
var lastEnd = startSpace + startMarginIndicator
indicators.forEachIndexed { index, indicator ->
indicatorPaint.color = indicator.color
val start = lastEnd
lastEnd = start + indicator.size
canvas.drawLine(
start,
height / 2f,
lastEnd,
height / 2f,
indicatorPaint
)
lastEnd += indicator.marginToNext
}
}
}
@@ -0,0 +1,42 @@
package io.novafoundation.nova.feature_banners_api.presentation.view
import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import io.novafoundation.nova.common.utils.RoundCornersOutlineProvider
import io.novafoundation.nova.common.utils.dpF
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.feature_banners_api.databinding.ViewPagerBannerPageBinding
class PageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val binder = ViewPagerBannerPageBinding.inflate(inflater(), this)
val title: TextView
get() = binder.pagerBannerTitle
val subtitle: TextView
get() = binder.pagerBannerSubtitle
val image: ImageView
get() = binder.pagerBannerImage
private val roundCornersOutlineProvider = RoundCornersOutlineProvider(12.dpF)
init {
outlineProvider = roundCornersOutlineProvider
clipToOutline = true
}
fun setClipMargin(rect: Rect) {
roundCornersOutlineProvider.setMargin(rect)
invalidateOutline()
}
}
@@ -0,0 +1,42 @@
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher
import android.graphics.Rect
import androidx.constraintlayout.widget.ConstraintLayout
import io.novafoundation.nova.feature_banners_api.presentation.ClipableImage
import io.novafoundation.nova.feature_banners_api.presentation.view.PageView
class ContentSwitchingController(
private val clipMargin: Rect,
rightSwitchingAnimators: InOutAnimators,
leftSwitchingAnimators: InOutAnimators,
private val viewFactory: () -> PageView
) : SwitchingController<ContentSwitchingController.Payload, PageView>(
rightSwitchingAnimators = rightSwitchingAnimators,
leftSwitchingAnimators = leftSwitchingAnimators
) {
class Payload(val title: String, val subtitle: String, val clipableImage: ClipableImage)
override fun setPayloadsInternal(payloads: List<Payload>): List<PageView> {
return payloads.map { payload ->
val view = viewFactory()
view.title.text = payload.title
view.subtitle.text = payload.subtitle
view.layoutParams = ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_PARENT, ConstraintLayout.LayoutParams.MATCH_PARENT)
val imageModel = payload.clipableImage
if (view.image.drawable != imageModel.drawable) {
view.image.setImageDrawable(imageModel.drawable)
}
if (imageModel.clip) {
view.setClipMargin(clipMargin)
} else {
view.setClipMargin(Rect())
}
view
}
}
}
@@ -0,0 +1,19 @@
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher
import android.graphics.drawable.Drawable
import android.widget.ImageView
class ImageSwitchingController(
rightSwitchingAnimators: InOutAnimators,
leftSwitchingAnimators: InOutAnimators,
private val imageViewFactory: () -> ImageView
) : SwitchingController<Drawable, ImageView>(rightSwitchingAnimators = rightSwitchingAnimators, leftSwitchingAnimators = leftSwitchingAnimators) {
override fun setPayloadsInternal(payloads: List<Drawable>): List<ImageView> {
return payloads.map {
val image = imageViewFactory()
image.setImageDrawable(it)
image
}
}
}
@@ -0,0 +1,84 @@
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher
import android.graphics.Rect
import android.view.ViewGroup
import android.view.animation.Interpolator
import android.widget.ImageView
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams
import io.novafoundation.nova.common.utils.dp
import io.novafoundation.nova.common.utils.dpF
import io.novafoundation.nova.feature_banners_api.presentation.view.BannerPagerView
import io.novafoundation.nova.feature_banners_api.presentation.view.PageView
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.AlphaInterpolatedAnimator
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.CompoundInterpolatedAnimator
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.FractionAnimator
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.InterpolationRange
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.OffsetXInterpolatedAnimator
private const val OFFSET = 36
fun BannerPagerView.getImageSwitchingController(interpolator: Interpolator): ImageSwitchingController {
return ImageSwitchingController(
rightSwitchingAnimators = alphaAnimator(interpolator),
leftSwitchingAnimators = alphaAnimator(interpolator),
imageViewFactory = {
ImageView(context).apply {
scaleType = ImageView.ScaleType.FIT_XY
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
}
}
)
}
private fun alphaAnimator(interpolator: Interpolator): InOutAnimators {
return InOutAnimators(
inAnimator = AlphaInterpolatedAnimator(interpolator, InterpolationRange(0f, 1f)),
outAnimator = AlphaInterpolatedAnimator(interpolator, InterpolationRange(1f, 0f))
)
}
fun BannerPagerView.getContentSwitchingController(interpolator: Interpolator): ContentSwitchingController {
return ContentSwitchingController(
clipMargin = Rect(0, 8.dp, 0, 8.dp),
rightSwitchingAnimators = getRightAnimator(interpolator),
leftSwitchingAnimators = getLeftAnimator(interpolator),
viewFactory = {
PageView(context).apply {
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
}
)
}
private fun BannerPagerView.getRightAnimator(interpolator: Interpolator) = InOutAnimators(
inAnimator = getContentAnimator(
interpolator = interpolator,
offsetRange = InterpolationRange(from = OFFSET.dpF, to = 0f),
alphaRange = InterpolationRange(from = 0f, to = 1f)
),
outAnimator = getContentAnimator(
interpolator = interpolator,
offsetRange = InterpolationRange(from = 0f, to = -OFFSET.dpF),
alphaRange = InterpolationRange(from = 1f, to = 0f)
)
)
private fun BannerPagerView.getLeftAnimator(interpolator: Interpolator) = InOutAnimators(
inAnimator = getContentAnimator(
interpolator = interpolator,
offsetRange = InterpolationRange(from = -OFFSET.dpF, to = 0f),
alphaRange = InterpolationRange(from = 0f, to = 1f)
),
outAnimator = getContentAnimator(
interpolator = interpolator,
offsetRange = InterpolationRange(from = 0f, to = OFFSET.dpF),
alphaRange = InterpolationRange(from = 1f, to = 0f)
)
)
private fun getContentAnimator(interpolator: Interpolator, offsetRange: InterpolationRange, alphaRange: InterpolationRange): FractionAnimator {
return CompoundInterpolatedAnimator(
OffsetXInterpolatedAnimator(interpolator, offsetRange),
AlphaInterpolatedAnimator(interpolator, alphaRange)
)
}
@@ -0,0 +1,82 @@
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isInvisible
import io.novafoundation.nova.common.utils.removed
import io.novafoundation.nova.common.utils.setVisible
import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.FractionAnimator
class InOutAnimators(
val inAnimator: FractionAnimator,
val outAnimator: FractionAnimator
)
abstract class SwitchingController<P, V : View>(
private val rightSwitchingAnimators: InOutAnimators,
private val leftSwitchingAnimators: InOutAnimators
) {
private var parent: ViewGroup? = null
private var views: List<V> = listOf()
protected abstract fun setPayloadsInternal(payloads: List<P>): List<V>
fun setPayloads(payloads: List<P>): List<V> {
views = setPayloadsInternal(payloads)
setViewsToParent()
return views
}
fun attachToParent(parent: ViewGroup) {
this.parent = parent
setViewsToParent()
}
fun setAnimationState(animationOffset: Float, from: Int, to: Int) {
if (from >= views.size || to >= views.size) return
if (from == to) {
showPageImmediately(from)
} else {
val (currentView, nextView) = showPagesByIndex(from, to)
val animators = when {
animationOffset > 0 -> rightSwitchingAnimators
else -> leftSwitchingAnimators
}
animators.outAnimator.animate(currentView, animationOffset)
animators.inAnimator.animate(nextView, animationOffset)
}
}
fun showPageImmediately(index: Int) {
if (index >= views.size) return
val (page) = showPagesByIndex(index)
rightSwitchingAnimators.outAnimator.animate(page, 0f)
}
// Pay attention that after using this method removed view is still contains in its parents
// We do it this way to have the same size of banners after remove a page
fun removePageAt(pageToRemove: Int) {
val removedView = views[pageToRemove]
views = views.removed { removedView == it }
removedView.isInvisible = true
}
private fun showPagesByIndex(vararg indexes: Int): List<V> {
views.forEachIndexed { index, view ->
view.setVisible(index in indexes, falseState = View.INVISIBLE)
}
return indexes.map { views[it] }
}
private fun setViewsToParent() {
parent?.removeAllViews()
views.forEach {
parent?.addView(it)
}
}
}
@@ -0,0 +1,14 @@
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation
import android.view.View
import android.view.animation.Interpolator
class AlphaInterpolatedAnimator(
interpolator: Interpolator,
val alphaRange: InterpolationRange
) : InterpolatedAnimator(interpolator) {
override fun animateInternal(view: View, fraction: Float) {
view.alpha = alphaRange.getValueFor(fraction)
}
}
@@ -0,0 +1,14 @@
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation
import android.view.View
class CompoundInterpolatedAnimator(
private val animators: List<FractionAnimator>
) : FractionAnimator {
constructor(vararg animators: FractionAnimator) : this(animators.toList())
override fun animate(view: View, fraction: Float) {
animators.forEach { it.animate(view, fraction) }
}
}
@@ -0,0 +1,30 @@
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation
import android.view.View
import android.view.animation.Interpolator
import io.novafoundation.nova.common.utils.signum
import kotlin.math.absoluteValue
interface FractionAnimator {
fun animate(view: View, fraction: Float)
}
abstract class InterpolatedAnimator(
private val interpolator: Interpolator
) : FractionAnimator {
override fun animate(view: View, fraction: Float) {
val interpolatedValue = interpolator.getInterpolation(fraction.absoluteValue) * fraction.signum()
animateInternal(view, interpolatedValue)
}
protected abstract fun animateInternal(view: View, fraction: Float)
}
class InterpolationRange(val from: Float, val to: Float) {
fun getValueFor(fraction: Float): Float {
return from + (to - from) * fraction.absoluteValue
}
}
@@ -0,0 +1,14 @@
package io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation
import android.view.View
import android.view.animation.Interpolator
class OffsetXInterpolatedAnimator(
interpolator: Interpolator,
private val offsetRange: InterpolationRange,
) : InterpolatedAnimator(interpolator) {
override fun animateInternal(view: View, fraction: Float) {
view.translationX = offsetRange.getValueFor(fraction)
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<io.novafoundation.nova.feature_banners_api.presentation.view.BannerPagerView
android:id="@+id/bannerPager"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</FrameLayout>
@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<com.google.android.material.card.MaterialCardView
android:id="@+id/pagerBannerCardView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginVertical="8dp"
app:cardCornerRadius="12dp"
app:layout_constraintBottom_toBottomOf="@id/pagerBannerContent"
app:layout_constraintEnd_toEndOf="@id/pagerBannerContent"
app:layout_constraintStart_toStartOf="@id/pagerBannerContent"
app:layout_constraintTop_toTopOf="@id/pagerBannerContent"
app:strokeColor="@color/container_border"
app:strokeWidth="1dp">
<FrameLayout
android:id="@+id/pagerBannerBackground"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@color/text_secondary" />
</com.google.android.material.card.MaterialCardView>
<LinearLayout
android:id="@+id/pagerBannerShimmering"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="8dp"
android:orientation="vertical"
android:padding="16dp"
android:background="@drawable/bg_block_12"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
tools:layout_editor_absoluteX="16dp"
tools:visibility="visible">
<com.facebook.shimmer.ShimmerFrameLayout
android:layout_width="168dp"
android:layout_height="14dp"
android:background="@drawable/bg_shimmering" />
<com.facebook.shimmer.ShimmerFrameLayout
android:layout_width="125dp"
android:layout_height="8dp"
android:layout_marginTop="16dp"
android:background="@drawable/bg_shimmering" />
<com.facebook.shimmer.ShimmerFrameLayout
android:layout_width="89dp"
android:layout_height="8dp"
android:layout_marginTop="16dp"
android:background="@drawable/bg_shimmering" />
</LinearLayout>
<FrameLayout
android:id="@+id/pagerBannerContent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:clipToPadding="false"
android:elevation="16dp"
android:minHeight="96dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/pagerBannerClose"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginTop="8dp"
android:elevation="17dp"
android:padding="8dp"
android:scaleType="centerInside"
android:src="@drawable/ic_close"
app:layout_constraintEnd_toEndOf="@id/pagerBannerContent"
app:layout_constraintTop_toTopOf="@id/pagerBannerContent"
app:tint="@color/icon_secondary" />
<io.novafoundation.nova.feature_banners_api.presentation.view.PageIndicatorView
android:id="@+id/pagerBannerIndicators"
android:layout_width="match_parent"
android:layout_height="6dp"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/pagerBannerCardView" />
</merge>
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<ImageView
android:id="@+id/pagerBannerImage"
android:layout_width="126dp"
android:layout_height="0dp"
android:scaleType="fitCenter"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_min="96dp"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_android_nav_bar_dapps_active" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginVertical="20dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="2dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/pagerBannerImage"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/pagerBannerTitle"
style="@style/TextAppearance.NovaFoundation.SemiBold.Body"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:textColor="@color/text_primary"
tools:text="Title" />
<TextView
android:id="@+id/pagerBannerSubtitle"
style="@style/TextAppearance.NovaFoundation.Regular.Caption1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:includeFontPadding="false"
android:textColor="@color/text_primary"
tools:text="Subtitle\nsecond line" />
</LinearLayout>
</merge>