diff --git a/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreen.kt b/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreen.kt index 70c3aa5b0d..e3c80f27a5 100644 --- a/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreen.kt @@ -57,6 +57,7 @@ internal fun StoriesScreen( } } + // todo refactor AND-9605 StoriesScreenContent( modifier = Modifier .fillMaxSize() @@ -75,6 +76,7 @@ internal fun StoriesScreen( ) } +@Deprecated("Use StoriesContainer from core/ui") @Suppress("LongMethod") @Composable private fun StoriesScreenContent(config: StoriesScreenContentConfig, modifier: Modifier = Modifier) { diff --git a/app/src/main/java/com/tangem/tap/features/home/redux/HomeState.kt b/app/src/main/java/com/tangem/tap/features/home/redux/HomeState.kt index b8b7db01f8..35c5945c0e 100644 --- a/app/src/main/java/com/tangem/tap/features/home/redux/HomeState.kt +++ b/app/src/main/java/com/tangem/tap/features/home/redux/HomeState.kt @@ -4,6 +4,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import org.rekotlin.StateType +// todo refactor AND-9605 data class HomeState( val scanInProgress: Boolean = false, val stories: ImmutableList = Stories.entries.toImmutableList(), diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/progressbar/LinearProgressIndicator.kt b/core/ui/src/main/java/com/tangem/core/ui/components/progressbar/LinearProgressIndicator.kt index 43164cbd41..f6fe0ce78b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/progressbar/LinearProgressIndicator.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/progressbar/LinearProgressIndicator.kt @@ -2,7 +2,9 @@ package com.tangem.core.ui.components.progressbar +import android.content.res.Configuration import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -15,10 +17,12 @@ import androidx.compose.ui.layout.layout import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.offset import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview import kotlin.math.abs /** @@ -124,3 +128,17 @@ private fun DrawScope.drawLinearIndicator( private fun DrawScope.drawLinearIndicatorBackground(color: Color, strokeWidth: Float, strokeCap: StrokeCap) = drawLinearIndicator(0f, 1f, color, strokeWidth, strokeCap) + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun LinearProgressIndicator_Preview() { + TangemThemePreview { + LinearProgressIndicator( + progress = { 0.5f }, + modifier = Modifier.fillMaxWidth(), + ) + } +} +// endregion diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/stories/StoriesContainer.kt b/core/ui/src/main/java/com/tangem/core/ui/components/stories/StoriesContainer.kt new file mode 100644 index 0000000000..3912cdc665 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/stories/StoriesContainer.kt @@ -0,0 +1,106 @@ +package com.tangem.core.ui.components.stories + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.stories.inner.StoriesClickableArea +import com.tangem.core.ui.components.stories.inner.StoriesProgressBar +import com.tangem.core.ui.components.stories.inner.StoriesStepStateMachine +import com.tangem.core.ui.components.stories.model.StoriesContentConfig +import com.tangem.core.ui.components.stories.model.StoryConfig +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +/** + * Stories container component. + * Implements base logic for switching story pages and handling clicks + * + * @param config stories configuration + * @param modifier composable modifier + * @param isPauseStories pause stories progression externally + * @param currentStoryContent content of current displaying story + */ +@Composable +inline fun StoriesContainer( + config: StoriesContentConfig, + modifier: Modifier = Modifier, + isPauseStories: Boolean = false, + crossinline currentStoryContent: @Composable BoxScope.(T, Boolean) -> Unit, +) { + var isPressed by remember { mutableStateOf(value = false) } + val storyState by remember(config) { + mutableStateOf( + StoriesStepStateMachine( + stories = config.stories, + isRepeatable = config.isRestartable, + ), + ) + } + + val isPaused = isPressed || isPauseStories + + Box(modifier = modifier) { + StoriesClickableArea( + onPress = { isPressed = it }, + onPreviousStory = storyState::prevStory, + onNextStory = storyState::nextStory, + ) + + StoriesProgressBar( + steps = storyState.steps, + currentStep = storyState.currentIndex.intValue, + stepDuration = storyState.currentStory.duration, + paused = isPaused, + onStepFinish = storyState::nextStory, + ) + + currentStoryContent(storyState.currentStory, isPaused) + } +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun StoriesContainer_Preview() { + TangemThemePreview { + StoriesContainer( + config = StoryContainerPreviewProviderData(), + modifier = Modifier.background(Color(0xFF010101)), + isPauseStories = false, + currentStoryContent = { pageConfig, _ -> + Text( + text = pageConfig.title, + color = TangemColorPalette.White, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.Center), + ) + }, + ) + } +} + +private data class StoryContainerPreviewProviderData( + override val stories: ImmutableList = persistentListOf( + StoryConfigPreviewProviderData(title = "Tangem"), + StoryConfigPreviewProviderData(title = "Cold"), + StoryConfigPreviewProviderData(title = "Wallet"), + ), + override val isRestartable: Boolean = true, +) : StoriesContentConfig + +private data class StoryConfigPreviewProviderData( + override val duration: Int = 3_000, + val title: String, +) : StoryConfig +// endregion diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesClickableArea.kt b/core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesClickableArea.kt new file mode 100644 index 0000000000..0f099c5fca --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesClickableArea.kt @@ -0,0 +1,56 @@ +package com.tangem.core.ui.components.stories.inner + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput + +private const val STORY_HOLD_DELAY = 200 + +@Composable +fun StoriesClickableArea(onPress: (Boolean) -> Unit, onPreviousStory: () -> Unit, onNextStory: () -> Unit) { + Row( + modifier = Modifier.fillMaxSize(), + ) { + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .pointerInput(Unit) { + detectTapGestures( + onPress = { + val pressStartTime = System.currentTimeMillis() + onPress(true) + this.tryAwaitRelease() + val pressEndTime = System.currentTimeMillis() + val totalPressTime = pressEndTime - pressStartTime + if (totalPressTime < STORY_HOLD_DELAY) onPreviousStory() + onPress(false) + }, + ) + }, + ) + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .pointerInput(Unit) { + detectTapGestures( + onPress = { + val pressStartTime = System.currentTimeMillis() + onPress(true) + this.tryAwaitRelease() + val pressEndTime = System.currentTimeMillis() + val totalPressTime = pressEndTime - pressStartTime + if (totalPressTime < STORY_HOLD_DELAY) onNextStory() + onPress(false) + }, + ) + }, + ) + } +} diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesProgressBar.kt b/core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesProgressBar.kt new file mode 100644 index 0000000000..fcfbada0d1 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesProgressBar.kt @@ -0,0 +1,127 @@ +package com.tangem.core.ui.components.stories.inner + +import android.provider.Settings +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SpacerW4 +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import kotlinx.coroutines.delay + +private const val STORIES_ANIMATION_SPEED_ZERO_DURATION = 3000L +const val STORY_DURATION = 8_000 + +private suspend fun animationProgress( + paused: Boolean, + animatorSpeed: Float, + stepDuration: Int, + progress: Animatable, + onStepFinish: () -> Unit, +) { + if (paused) { + progress.stop() + } else { + if (animatorSpeed == 0f) { + progress.snapTo(1f) + delay(STORIES_ANIMATION_SPEED_ZERO_DURATION) + } else { + progress.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = (stepDuration * (1f - progress.value)).toInt(), + easing = LinearEasing, + ), + ) + progress.snapTo(0f) + } + onStepFinish() + } +} + +@Composable +fun StoriesProgressBar( + steps: Int, + currentStep: Int, + paused: Boolean = false, + stepDuration: Int = STORY_DURATION, + onStepFinish: () -> Unit = {}, +) { + val progress = remember(currentStep) { Animatable(initialValue = 0f) } + + val context = LocalContext.current + val animatorSpeed = Settings.Global.getFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) + + LaunchedEffect(paused, currentStep, animatorSpeed) { + animationProgress( + paused = paused, + animatorSpeed = animatorSpeed, + stepDuration = stepDuration, + progress = progress, + onStepFinish = onStepFinish, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding( + start = 16.dp, + end = 16.dp, + top = 16.dp, + ), + ) { + for (index in 0..steps) { + Row( + modifier = Modifier + .height(2.dp) + .weight(1f) + .clip(RoundedCornerShape(2.dp)) + .background(TangemColorPalette.White.copy(alpha = .2f)), + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(2.dp)) + .background(TangemColorPalette.White) + .fillMaxHeight() + .let { + when (index) { + currentStep -> it.fillMaxWidth(progress.value) + in 0..currentStep -> it.fillMaxWidth(fraction = 1f) + else -> it + } + }, + ) + } + SpacerW4() + } + } +} + +@Preview +@Composable +private fun StoriesProgressBarPreview() { + Box( + modifier = Modifier + .wrapContentSize() + .background(TangemColorPalette.Black) + .padding(vertical = TangemTheme.dimens.spacing16), + ) { + StoriesProgressBar(steps = 5, currentStep = 3, paused = false) + } +} diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesStepStateMachine.kt b/core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesStepStateMachine.kt new file mode 100644 index 0000000000..592289eca5 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesStepStateMachine.kt @@ -0,0 +1,39 @@ +package com.tangem.core.ui.components.stories.inner + +import androidx.compose.runtime.IntState +import androidx.compose.runtime.asIntState +import androidx.compose.runtime.mutableIntStateOf +import kotlinx.collections.immutable.ImmutableList + +class StoriesStepStateMachine( + private val stories: ImmutableList, + private val isRepeatable: Boolean, +) { + + private var _currentIndex = mutableIntStateOf(FIRST_INDEX) + + val steps = stories.lastIndex + val currentIndex: IntState + get() = _currentIndex.asIntState() + + val currentStory + get() = stories[currentIndex.intValue.coerceIn(FIRST_INDEX, steps)] + + fun nextStory(): Boolean { + _currentIndex.intValue = if (currentIndex.intValue == steps && isRepeatable) { + FIRST_INDEX + } else { + currentIndex.intValue.inc().coerceAtMost(stories.size) + } + + return currentIndex.intValue > steps + } + + fun prevStory() { + _currentIndex.intValue = currentIndex.intValue.dec().coerceAtLeast(FIRST_INDEX) + } + + private companion object { + const val FIRST_INDEX = 0 + } +} diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/stories/model/StoriesContentConfig.kt b/core/ui/src/main/java/com/tangem/core/ui/components/stories/model/StoriesContentConfig.kt new file mode 100644 index 0000000000..a0b50a2562 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/stories/model/StoriesContentConfig.kt @@ -0,0 +1,18 @@ +package com.tangem.core.ui.components.stories.model + +import kotlinx.collections.immutable.ImmutableList + +/** + * Config for stories component + * + * @property stories configuration list + * @property isRestartable indicates than stories progressions starts + */ +interface StoriesContentConfig { + val stories: ImmutableList + val isRestartable: Boolean +} + +interface StoryConfig { + val duration: Int +}