Skip to content

ALTAPPS-1253: Android topic completion screen #1063

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package org.hyperskill.app.android.core.view.ui.widget.compose

import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color

@Stable
class ShimmerState(
val colors: List<Color> = listOf(
Color.Transparent,
Color.White.copy(alpha = 0.7f),
Color.Transparent
),
durationMillis: Int = 1200,
easing: Easing = FastOutSlowInEasing
) {

var targetValue: Float by mutableStateOf(-2f)
private set

val startOffsetXAnimationSpec: AnimationSpec<Float> = tween(
durationMillis = durationMillis,
easing = easing
)

fun runShimmerAnimation() {
targetValue = 2f
}
}

fun Modifier.shimmer(shimmerState: ShimmerState) =
composed {
val startOffsetX by animateFloatAsState(
targetValue = shimmerState.targetValue,
animationSpec = shimmerState.startOffsetXAnimationSpec,
label = "shimmer"
)
drawWithContent {
val width = size.width
val height = size.height
val offset = startOffsetX * width

drawContent()
val brush = Brush.linearGradient(
colors = shimmerState.colors,
start = Offset(offset, 0f),
end = Offset(offset + width, height)
)
drawRect(brush)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package org.hyperskill.app.android.core.view.ui.widget.compose

import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.delay

/**
* A composable function that displays a text with a typewriter-like effect, revealing characters in chunks.
*
* @param text The input text to be displayed with the typewriter effect.
* @param minDelayInMillis The minimum delay in milliseconds between revealing character chunks, defaults to 30ms.
* @param maxDelayInMillis The maximum delay in milliseconds between revealing character chunks, defaults to 80ms.
* @param minCharacterChunk The minimum number of characters to reveal at once, defaults to 1.
* @param maxCharacterChunk The maximum number of characters to reveal at once, defaults to 3.
* @param onEffectCompleted A callback function invoked when the entire text has been revealed.
* @param displayTextComposable A composable function that receives the text to display with the typewriter effect.
*
* @throws IllegalArgumentException if [minDelayInMillis] is greater than [maxDelayInMillis].
* @throws IllegalArgumentException if [minCharacterChunk] is greater than [maxCharacterChunk].
*/
@Composable
fun TypewriterTextEffect(
text: String,
startTypingDelayInMillis: Int? = null,
minDelayInMillis: Long = 30,
maxDelayInMillis: Long = 80,
minCharacterChunk: Int = 1,
maxCharacterChunk: Int = 3,
onEffectCompleted: () -> Unit = {},
displayTextComposable: @Composable (displayedText: String) -> Unit
) {
// Ensure minDelayInMillis is less than or equal to maxDelayInMillis
require(minDelayInMillis <= maxDelayInMillis) {
"TypewriterTextEffect: Invalid delay range. minDelayInMillis ($minDelayInMillis) must be less than or equal to maxDelayInMillis ($maxDelayInMillis)."
}

// Ensure minCharacterChunk is less than or equal to maxCharacterChunk
require(minCharacterChunk <= maxCharacterChunk) {
"TypewriterTextEffect: Invalid character chunk range. minCharacterChunk ($minCharacterChunk) must be less than or equal to maxCharacterChunk ($maxCharacterChunk)."
}

// Initialize and remember the displayedText
var displayedText by remember { mutableStateOf("") }

// Call the displayTextComposable with the current displayedText value
displayTextComposable(displayedText)

// Launch the effect to update the displayedText value over time
LaunchedEffect(text) {
Log.d("TypewriterTextEffect", "Start LaunchedEffect")
if (startTypingDelayInMillis != null) {
delay(startTypingDelayInMillis.milliseconds)
}
Log.d("TypewriterTextEffect", "Start typing")

val textLength = text.length
var endIndex = 0

while (endIndex < textLength) {
endIndex = minOf(
endIndex + Random.nextInt(minCharacterChunk, maxCharacterChunk + 1),
textLength
)
displayedText = text.substring(startIndex = 0, endIndex = endIndex)
delay(Random.nextLong(minDelayInMillis, maxDelayInMillis))
}
onEffectCompleted()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package org.hyperskill.app.android.topic_completion.fragment

import android.app.Dialog
import android.media.MediaPlayer
import android.os.Bundle
import android.view.SurfaceHolder
import android.view.View
import android.view.ViewGroup
import android.view.Window
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import by.kirich1409.viewbindingdelegate.viewBinding
import kotlinx.coroutines.flow.MutableStateFlow
import org.hyperskill.app.android.R
import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme
import org.hyperskill.app.android.databinding.FragmentTopicCompletedBinding
import org.hyperskill.app.android.topic_completion.model.TopicCompletedModalViewState
import org.hyperskill.app.android.topic_completion.ui.TopicCompleted

class TopicCompletedDialogFragment : DialogFragment(R.layout.fragment_topic_completed), SurfaceHolder.Callback {

companion object {
const val TAG = "TopicCompletedFragment"

fun newInstance(): TopicCompletedDialogFragment =
TopicCompletedDialogFragment()
}

private val viewBinding: FragmentTopicCompletedBinding by viewBinding(FragmentTopicCompletedBinding::bind)

private var mediaPlayer: MediaPlayer? = null

private val isVideoBackgroundPlaying: MutableStateFlow<Boolean> = MutableStateFlow(false)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.ThemeOverlay_AppTheme_Dialog_Fullscreen)
}

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
super.onCreateDialog(savedInstanceState).apply {
setCanceledOnTouchOutside(false)
setCancelable(false)
requestWindowFeature(Window.FEATURE_NO_TITLE)
}

override fun onResume() {
super.onResume()
mediaPlayer?.start()
}

override fun onStart() {
super.onStart()
dialog
?.window
?.let { window ->
window.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
window.setWindowAnimations(R.style.ThemeOverlay_AppTheme_Dialog_Fullscreen)
}
mediaPlayer = MediaPlayer()
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewBinding.topicCompletedSurfaceView.holder.addCallback(this@TopicCompletedDialogFragment)
with(viewBinding.topicCompletedComposeView) {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner))
setContent {
HyperskillTheme {
val isPlayingState by isVideoBackgroundPlaying.collectAsStateWithLifecycle()
if (isPlayingState) {
TopicCompleted(
viewState = TopicCompletedModalViewState(),
onCloseClick = ::onCloseClick,
onCTAButtonClick = ::onCTAButtonClick
)
}
}
}
}
}

private fun onCloseClick() {
dialog?.dismiss()
}

private fun onCTAButtonClick() {
TODO("Not implemented yet")
}

override fun onStop() {
super.onStop()
mediaPlayer = null
}

override fun surfaceCreated(holder: SurfaceHolder) {
mediaPlayer?.apply {
resources
.openRawResourceFd(R.raw.topic_completion_bg_1)
.use(::setDataSource)
setDisplay(holder)
prepareAsync()
isLooping = true
setOnPreparedListener {
it.start()
isVideoBackgroundPlaying.value = true
}
}
}

override fun surfaceDestroyed(holder: SurfaceHolder) {
mediaPlayer?.apply {
stop()
isVideoBackgroundPlaying.value = false
release()
}
}

override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
// no op
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.hyperskill.app.android.topic_completion.model

data class TopicCompletedModalViewState(
val title: String = "{Topic name} completed!",
val description: String = "Learning might be tough, but it brings you knowledge that lasts forever",
val earnedGemsText: String = "+ 5",
val callToActionButtonTitle: String = "Continue with next topic",
val spacebotAvatarVariantIndex: Int = 0,
val backgroundAnimationStyle: BackgroundAnimationStyle = BackgroundAnimationStyle.FIRST
) {
enum class BackgroundAnimationStyle {
FIRST, SECOND
}
}
Loading
Loading