Refactored KiwixShowCaseView to align with app theme and improve usability.

* Added animated pulse effect to highlight the selected view.
* Introduced support for custom width/height for the showcase circle, useful for large views that might otherwise extend off-screen. Defaults to the view's dimensions if not specified.
* Enhanced ShowCaseMessage to automatically position itself based on available space. It prefers the top, falls back to the bottom if needed, and uses left/right positioning when vertical space is insufficient — ensuring it always stays within screen bounds.
This commit is contained in:
MohitMaliFtechiz 2025-04-17 15:41:27 +05:30 committed by Kelson
parent 74216e8850
commit 0a1e5f36a1
3 changed files with 230 additions and 233 deletions

View File

@ -23,7 +23,6 @@ import android.net.wifi.p2p.WifiP2pDevice
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
@ -69,10 +68,12 @@ import org.kiwix.kiwixmobile.core.ui.models.ActionMenuItem
import org.kiwix.kiwixmobile.core.ui.theme.DodgerBlue
import org.kiwix.kiwixmobile.core.ui.theme.KiwixTheme
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FIFTEEN_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FILE_FOR_TRANSFER_SHOW_CASE_VIEW_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FILE_FOR_TRANSFER_TEXT_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FILE_ITEM_ICON_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FILE_ITEM_TEXT_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.FIVE_DP
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.NEARBY_DEVICES_SHOW_CASE_VIEW_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.NEARBY_DEVICES_TEXT_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.NEARBY_DEVICE_LIST_HEIGHT
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.NO_DEVICE_FOUND_TEXT_PADDING
@ -128,34 +129,29 @@ fun LocalFileTransferScreen(
)
}
) { padding ->
Box(
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(Color.Transparent)
) {
Column(
YourDeviceHeader(deviceName, context, targets)
HorizontalDivider(
color = DodgerBlue,
thickness = ONE_DP,
modifier = Modifier.padding(horizontal = FIVE_DP)
)
NearbyDevicesSection(peerDeviceList, isPeerSearching, onDeviceItemClick, context, targets)
HorizontalDivider(
color = DodgerBlue,
thickness = ONE_DP,
modifier = Modifier
.fillMaxSize()
.background(Color.Transparent)
) {
YourDeviceHeader(deviceName, context, targets)
HorizontalDivider(
color = DodgerBlue,
thickness = ONE_DP,
modifier = Modifier.padding(horizontal = FIVE_DP)
)
NearbyDevicesSection(peerDeviceList, isPeerSearching, onDeviceItemClick, context, targets)
HorizontalDivider(
color = DodgerBlue,
thickness = ONE_DP,
modifier = Modifier
.padding(horizontal = FIVE_DP)
)
TransferFilesSection(transferFileList, context, targets)
}
ShowShowCaseToUserIfNotShown(targets, sharedPreferenceUtil)
.padding(horizontal = FIVE_DP)
)
TransferFilesSection(transferFileList, context, targets)
}
}
ShowShowCaseToUserIfNotShown(targets, sharedPreferenceUtil)
}
}
@ -212,7 +208,8 @@ fun NearbyDevicesSection(
targets[PEER_DEVICE_LIST_SHOW_CASE_TAG] = ShowcaseProperty(
index = 2,
coordinates = coordinates,
showCaseMessage = context.getString(string.transfer_zim_files_list_message)
showCaseMessage = context.getString(string.nearby_devices_list_message),
customSizeForShowcaseViewCircle = NEARBY_DEVICES_SHOW_CASE_VIEW_SIZE
)
},
textAlign = TextAlign.Center,
@ -250,7 +247,8 @@ private fun TransferFilesSection(
targets[FILE_FOR_TRANSFER_SHOW_CASE_TAG] = ShowcaseProperty(
index = 3,
coordinates = coordinates,
showCaseMessage = context.getString(string.transfer_zim_files_list_message)
showCaseMessage = context.getString(string.transfer_zim_files_list_message),
customSizeForShowcaseViewCircle = FILE_FOR_TRANSFER_SHOW_CASE_VIEW_SIZE
)
},
textAlign = TextAlign.Center,
@ -277,7 +275,14 @@ private fun YourDeviceHeader(
fontStyle = FontStyle.Italic,
fontSize = YOUR_DEVICE_TEXT_SIZE,
modifier = Modifier
.padding(top = FIVE_DP, bottom = ONE_DP),
.padding(top = FIVE_DP, bottom = ONE_DP)
.onGloballyPositioned { coordinates ->
targets[YOUR_DEVICE_SHOW_CASE_TAG] = ShowcaseProperty(
index = 1,
coordinates = coordinates,
showCaseMessage = context.getString(string.your_device_name_message)
)
},
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.67f)
)
val contentDescription = stringResource(R.string.device_name)
@ -287,14 +292,7 @@ private fun YourDeviceHeader(
fontSize = PEER_DEVICE_ITEM_TEXT_SIZE,
modifier = Modifier
.minimumInteractiveComponentSize()
.semantics { this.contentDescription = contentDescription }
.onGloballyPositioned { coordinates ->
targets[YOUR_DEVICE_SHOW_CASE_TAG] = ShowcaseProperty(
index = 1,
coordinates = coordinates,
showCaseMessage = context.getString(string.your_device_name_message)
)
},
.semantics { this.contentDescription = contentDescription },
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.67f)
)
}

View File

@ -18,6 +18,7 @@
package org.kiwix.kiwixmobile.core.ui.components
import android.annotation.SuppressLint
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.RepeatMode
@ -25,12 +26,16 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -38,264 +43,245 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.IntOffset
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.downloader.downloadManager.ZERO
import org.kiwix.kiwixmobile.core.ui.theme.CornflowerBlue
import org.kiwix.kiwixmobile.core.ui.theme.White
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.PULSE_ALPHA
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.PULSE_ANIMATION_END
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.PULSE_ANIMATION_START
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.PULSE_RADIUS_EXTRA
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SHOWCASE_MESSAGE_SHADOW_BLUR_RADIUS
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SHOWCASE_MESSAGE_SHADOW_COLOR_ALPHA
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SHOWCASE_VIEW_BACKGROUND_COLOR_ALPHA
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SHOWCASE_VIEW_MESSAGE_TEXT_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SHOWCASE_VIEW_NEXT_BUTTON_TEXT_SIZE
import org.kiwix.kiwixmobile.core.utils.ComposeDimens.SIXTEEN_DP
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sqrt
import kotlin.math.roundToInt
const val SHOWCASE_VIEW_ROUND_ANIMATION_DURATION = 2000
const val ONE = 1
const val TWO = 1
const val SIXTEEN = 16
@Composable
fun KiwixShowCaseView(
targets: SnapshotStateMap<String, ShowcaseProperty>,
onShowCaseCompleted: () -> Unit
) {
val uniqueTargets = targets.values.sortedBy { it.index }
var currentTargetIndex by remember { mutableStateOf(0) }
val currentTarget = if (uniqueTargets.isNotEmpty() && currentTargetIndex < uniqueTargets.size) {
uniqueTargets[currentTargetIndex]
} else {
null
}
val orderedTargets = targets.values.sortedBy { it.index }
var currentIndex by remember { mutableStateOf(ZERO) }
val currentTarget = orderedTargets.getOrNull(currentIndex)
currentTarget?.let {
AnimatedShowCase(targets = it) {
if (++currentTargetIndex >= uniqueTargets.size) {
onShowCaseCompleted()
}
AnimatedShowCase(target = it) {
currentIndex++
if (currentIndex >= orderedTargets.size) onShowCaseCompleted()
}
}
}
@Suppress("LongMethod", "MagicNumber")
@Composable
fun AnimatedShowCase(
targets: ShowcaseProperty,
private fun AnimatedShowCase(
target: ShowcaseProperty,
onShowCaseCompleted: () -> Unit
) {
val targetRect = targets.coordinates.boundsInRoot()
val targetRadius = targetRect.maxDimension / 2f + 20
val targetRect = target.coordinates.boundsInRoot()
val innerAnimation = remember { Animatable(PULSE_ANIMATION_START) }
val density = LocalDensity.current
// Animation setup for rounded animation
val animationSpec = infiniteRepeatable<Float>(
animation = tween(2000, easing = FastOutLinearInEasing),
repeatMode = RepeatMode.Reverse
)
val animaTable = remember { Animatable(0f) }
LaunchedEffect(animaTable) {
animaTable.animateTo(1f, animationSpec = animationSpec)
val (width, height) = with(density) {
val size = target.customSizeForShowcaseViewCircle?.toPx()
Pair(size ?: targetRect.width, size ?: targetRect.height)
}
val outerAnimaTable = remember { Animatable(0.6f) }
val radiusBase = max(width, height) / TWO.toFloat()
val pulseRadius by innerAnimation.asState()
LaunchedEffect(targets) {
outerAnimaTable.snapTo(0.6f)
outerAnimaTable.animateTo(
targetValue = 1f,
animationSpec = tween(500)
LaunchedEffect(Unit) {
innerAnimation.animateTo(
targetValue = PULSE_ANIMATION_END,
animationSpec = infiniteRepeatable(
animation = tween(SHOWCASE_VIEW_ROUND_ANIMATION_DURATION, easing = FastOutLinearInEasing),
repeatMode = RepeatMode.Restart
)
)
}
// Map animation to y position of the components
val dys = animaTable.value
// Text coordinates and outer radius
var textCoordinate: LayoutCoordinates? by remember { mutableStateOf(null) }
var outerRadius by remember { mutableStateOf(0f) }
val screenHeight = LocalConfiguration.current.screenHeightDp
val textYOffset = with(LocalDensity.current) {
targets.coordinates.positionInRoot().y.toDp()
}
var outerOffset by remember { mutableStateOf(Offset(0f, 0f)) }
textCoordinate?.let {
val textRect = it.boundsInRoot()
val textHeight = it.size.height
val isInGutter = textYOffset > screenHeight.dp
outerOffset = getOuterCircleCenter(targetRect, textRect, targetRadius, textHeight, isInGutter)
outerRadius = getOuterRadius(textRect, targetRect) + targetRadius
}
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(targets) {
detectTapGestures { tapOffset ->
if (targetRect.contains(tapOffset)) {
onShowCaseCompleted()
}
.pointerInput(target) {
detectTapGestures {
if (targetRect.contains(it)) onShowCaseCompleted()
}
}
.graphicsLayer(alpha = 0.99f)
.graphicsLayer(alpha = PULSE_ALPHA)
) {
// Animated Rounded ShowCaseView
drawRect(
color = CornflowerBlue.copy(alpha = 0.8f),
size = size
)
// draw circle with animation
drawCircle(
color = Color.White,
radius = targetRect.maxDimension * dys * 2f,
center = targetRect.center,
alpha = 1 - dys
)
drawCircle(
color = Color.White,
radius = targetRadius,
center = targetRect.center,
blendMode = BlendMode.Clear
)
drawOverlay(targetRect, radiusBase, pulseRadius)
}
ShowText(currentTarget = targets, targetRect = targetRect, targetRadius = targetRadius) {
textCoordinate = it
}
// Next Button at the bottom center
NextButton(onShowCaseCompleted = onShowCaseCompleted)
ShowCaseMessage(target, targetRect, radiusBase)
NextButton(onShowCaseCompleted)
}
/**
* Draws the overlay and animated spotlight.
*/
private fun DrawScope.drawOverlay(
targetRect: Rect,
baseRadius: Float,
animatedFraction: Float
) {
drawRect(color = CornflowerBlue.copy(alpha = SHOWCASE_VIEW_BACKGROUND_COLOR_ALPHA), size = size)
drawCircle(
color = Color.White,
radius = baseRadius * (ONE + animatedFraction),
center = targetRect.center,
alpha = ONE - animatedFraction
)
drawCircle(
color = Color.White,
radius = baseRadius + PULSE_RADIUS_EXTRA,
center = targetRect.center,
blendMode = BlendMode.Clear
)
}
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
fun NextButton(onShowCaseCompleted: () -> Unit) {
private fun ShowCaseMessage(
target: ShowcaseProperty,
targetRect: Rect,
targetRadius: Float
) {
val density = LocalDensity.current
var offset by remember { mutableStateOf(Offset.Zero) }
var calculated by remember { mutableStateOf(false) }
BoxWithConstraints(Modifier.fillMaxSize()) {
val screenWidth = with(density) { maxWidth.toPx() }
val screenHeight = with(density) { maxHeight.toPx() }
if (calculated) {
Box(
modifier = Modifier.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
) {
Text(
text = target.showCaseMessage,
color = target.showCaseMessageColor,
style = TextStyle(
fontSize = SHOWCASE_VIEW_MESSAGE_TEXT_SIZE,
shadow = Shadow(
Color.Black.copy(alpha = SHOWCASE_MESSAGE_SHADOW_COLOR_ALPHA),
defaultBlurOffsetForMessageAndNextButton(),
blurRadius = SHOWCASE_MESSAGE_SHADOW_BLUR_RADIUS
)
)
)
}
}
Text(
text = target.showCaseMessage,
modifier = Modifier
.alpha(PULSE_ANIMATION_START)
.onGloballyPositioned {
val size = it.size
val width = size.width.toFloat()
val height = size.height.toFloat()
val center = targetRect.center
val posY = when {
screenHeight - (center.y + targetRadius) > height + SIXTEEN -> center.y + targetRadius + SIXTEEN
center.y - targetRadius > height + SIXTEEN -> center.y - targetRadius - height - SIXTEEN
else -> screenHeight / TWO - height / TWO
}
val posX = when {
screenWidth - targetRect.right > width + SIXTEEN -> targetRect.right + SIXTEEN
targetRect.left > width + SIXTEEN -> targetRect.left - width - SIXTEEN
else -> screenWidth / TWO - width / TWO
}
offset = Offset(posX, posY)
calculated = true
}
)
}
}
private fun defaultBlurOffsetForMessageAndNextButton() =
Offset(SHOWCASE_MESSAGE_SHADOW_COLOR_ALPHA, SHOWCASE_MESSAGE_SHADOW_COLOR_ALPHA)
/**
* Composable for the "Next" button in the showcase.
*/
@Composable
private fun NextButton(onClick: () -> Unit) {
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Bottom,
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally
.padding(SIXTEEN_DP),
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.End
) {
androidx.compose.material3.Button(
onClick = {
onShowCaseCompleted()
},
modifier = Modifier.fillMaxWidth()
) {
Text(text = "Next", fontWeight = FontWeight.Bold)
TextButton(onClick = onClick) {
Text(
text = context.getString(R.string.next),
style = LocalTextStyle.current.copy(
fontSize = SHOWCASE_VIEW_NEXT_BUTTON_TEXT_SIZE,
fontWeight = FontWeight.Bold,
color = White,
shadow = Shadow(
Color.Black.copy(alpha = SHOWCASE_MESSAGE_SHADOW_COLOR_ALPHA),
defaultBlurOffsetForMessageAndNextButton(),
blurRadius = SHOWCASE_MESSAGE_SHADOW_BLUR_RADIUS
)
)
)
}
}
}
@Suppress("MagicNumber")
@Composable
fun ShowText(
currentTarget: ShowcaseProperty,
targetRect: Rect,
targetRadius: Float,
updateCoordinates: (LayoutCoordinates) -> Unit
) {
var txtOffsetY by remember { mutableStateOf(0f) }
var txtOffsetX by remember { mutableStateOf(0f) }
var txtRightOffSet by remember { mutableStateOf(0f) }
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp.toFloat()
Column(
modifier = Modifier
.offset(
x = with(LocalDensity.current) { txtOffsetX.toDp() },
y = with(LocalDensity.current) { txtOffsetY.toDp() }
)
.onGloballyPositioned {
updateCoordinates(it)
val textHeight = it.size.height
val possibleTop =
targetRect.center.y - targetRadius - textHeight
val possibleLeft = targetRect.topLeft.x
txtOffsetY = if (possibleTop > 0) {
possibleTop
} else {
targetRect.center.y + targetRadius - 140
}
txtRightOffSet = it.boundsInRoot().topRight.x
txtOffsetX = it.boundsInRoot().topRight.x - it.size.width
txtOffsetX = if (possibleLeft >= screenWidth / 2) {
screenWidth / 2 + targetRadius
} else {
possibleLeft
}
txtRightOffSet += targetRadius
}
.padding(2.dp)
) {
Text(
text = currentTarget.showCaseMessage,
fontSize = 14.sp,
color = currentTarget.showCaseMessageColor
)
}
}
fun getOuterCircleCenter(
targetRect: Rect,
textRect: Rect,
targetRadius: Float,
textHeight: Int,
isInGutter: Boolean
): Offset {
val outerCenterX: Float
var outerCenterY: Float
val onTop = targetRect.center.y - targetRadius - textHeight > 0
val left = min(textRect.left, targetRect.left - targetRadius)
val right = max(textRect.right, targetRect.right + targetRadius)
val centerY = if (onTop) {
targetRect.center.y - targetRadius - textHeight
} else {
targetRect.center.y + targetRadius + textHeight
}
outerCenterY = centerY
outerCenterX = (left + right) / 2
// If the text is in the gutter, adjust the vertical position
if (isInGutter) {
outerCenterY = targetRect.center.y
}
return Offset(outerCenterX, outerCenterY)
}
fun getOuterRadius(textRect: Rect, targetRect: Rect): Float {
// Get outer rect that covers both target and text rect
val topLeftX = min(textRect.topLeft.x, targetRect.topLeft.x)
val topLeftY = min(textRect.topLeft.y, targetRect.topLeft.y)
val bottomRightX = max(textRect.bottomRight.x, targetRect.bottomRight.x)
val bottomRightY = max(textRect.bottomRight.y, targetRect.bottomRight.y)
val newBounds = Rect(topLeftX, topLeftY, bottomRightX, bottomRightY)
// Calculate the diagonal distance of the new bounding box
val distance =
sqrt(newBounds.height.toDouble().pow(2.0) + newBounds.width.toDouble().pow(2.0)).toFloat()
// Return the radius (half of the diagonal distance)
return (distance / 2f)
}
/**
* Represents a single item in the showcase view sequence.
*
* @param index The order in which this target should be shown in the showcase flow.
* @param coordinates Layout coordinates used to determine position and size of the target view on screen.
* @param showCaseMessage Message to be displayed near the highlighted target.
* @param showCaseMessageColor Optional color for the message text (default is white).
* @param blurOpacity Controls the opacity of the background overlay behind the highlight (default is 0.8).
* @param customSizeForShowcaseViewCircle Optional custom size for the radius of the highlight circle.
* If null, it uses the size of the target's bounds.
*/
data class ShowcaseProperty(
val index: Int,
val coordinates: LayoutCoordinates,
val showCaseMessage: String,
val showCaseMessageColor: Color = Color.White,
val blurOpacity: Float = 0.8f,
val customWidth: Dp? = null,
val customHeight: Dp? = null,
val blurOpacity: Float = SHOWCASE_VIEW_BACKGROUND_COLOR_ALPHA,
val customSizeForShowcaseViewCircle: Dp? = null,
)

View File

@ -123,4 +123,17 @@ object ComposeDimens {
val YOUR_DEVICE_TEXT_SIZE = 13.sp
val FILE_FOR_TRANSFER_TEXT_SIZE = 16.sp
val NEARBY_DEVICES_TEXT_SIZE = 16.sp
// KiwixShowCase view dimens
val SHOWCASE_VIEW_MESSAGE_TEXT_SIZE = 14.sp
val SHOWCASE_VIEW_NEXT_BUTTON_TEXT_SIZE = 16.sp
val FILE_FOR_TRANSFER_SHOW_CASE_VIEW_SIZE = 100.dp
val NEARBY_DEVICES_SHOW_CASE_VIEW_SIZE = 100.dp
const val SHOWCASE_MESSAGE_SHADOW_BLUR_RADIUS = 3f
const val SHOWCASE_MESSAGE_SHADOW_COLOR_ALPHA = 0.5f
const val SHOWCASE_VIEW_BACKGROUND_COLOR_ALPHA = 0.8f
const val PULSE_ANIMATION_START = 0f
const val PULSE_ANIMATION_END = 1f
const val PULSE_ALPHA = 0.99f
const val PULSE_RADIUS_EXTRA = 20f
}