Skip to content

Commit

Permalink
Android topic completion screen (#1063)
Browse files Browse the repository at this point in the history
  • Loading branch information
XanderZhu authored May 28, 2024
1 parent abb668d commit 956c026
Show file tree
Hide file tree
Showing 62 changed files with 939 additions and 214 deletions.
1 change: 1 addition & 0 deletions androidHyperskillApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />

<uses-permission
android:name="android.permission.WAKE_LOCK"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ open class DefaultTapUpListener : GestureDetector.OnGestureListener {
override fun onSingleTapUp(e: MotionEvent): Boolean =
false

override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean =
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean =
false

override fun onLongPress(e: MotionEvent) {
// no op
}

override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean =
override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean =
false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package org.hyperskill.app.android.core.extensions

import android.app.Activity
import android.os.Build
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver

/**
* Sets a callback to be invoked when a screenshot is captured on the screen.
* Works only on Android 14 and higher.
*
* @param block The code block to be executed when a screenshot is captured.
*/
inline fun Fragment.doOnScreenShootCaptured(
crossinline block: () -> Unit
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val callBack = Activity.ScreenCaptureCallback {
block()
}
lifecycle.addObserver(
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
requireActivity().registerScreenCaptureCallback(
/* executor = */ requireActivity().mainExecutor,
/* callback = */ callBack
)
}
Lifecycle.Event.ON_STOP -> {
requireActivity().unregisterScreenCaptureCallback(callBack)
}
else -> {
// no op
}
}
}
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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

/**
* Applies shimmer animation to the target Composable.
* Animation is playing one time.
* To start animation call [ShimmerState.runShimmerAnimation] on the [ShimmerState] instance.
*/
fun Modifier.shimmerShot(shimmerState: ShimmerState): Modifier =
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)
}
}

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

companion object {
private const val INITIAL_VALUE = -2f
private const val TARGET_VALUE = 2f
}

var targetValue: Float by mutableStateOf(INITIAL_VALUE)
private set

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

fun runShimmerAnimation() {
targetValue = TARGET_VALUE
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package org.hyperskill.app.android.core.view.ui.widget.compose

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.rememberUpdatedState
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].
*/
@Suppress("MaxLineLength")
@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)." //ktlint-disable
}

// 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)." //ktlint-disable
}

val currentOnEffectCompleted by rememberUpdatedState(newValue = onEffectCompleted)

// 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) {
if (startTypingDelayInMillis != null) {
delay(startTypingDelayInMillis.milliseconds)
}

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))
}
currentOnEffectCompleted()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import org.hyperskill.app.android.step.view.model.StepCompletionHost
import org.hyperskill.app.android.step.view.model.StepCompletionView
import org.hyperskill.app.android.step_practice.view.fragment.StepPracticeDetailsFragment
import org.hyperskill.app.android.step_quiz.view.factory.StepQuizFragmentFactory
import org.hyperskill.app.android.topic_completion.fragment.TopicCompletedDialogFragment
import org.hyperskill.app.step.domain.model.Step
import org.hyperskill.app.step.domain.model.StepRoute
import org.hyperskill.app.step.presentation.StepFeature
Expand All @@ -41,7 +42,8 @@ class StageStepWrapperFragment :
Fragment(R.layout.fragment_stage_step_wrapper),
ReduxView<StepFeature.ViewState, StepFeature.Action.ViewAction>,
StepCompletionHost,
ShareStreakDialogFragment.Callback {
ShareStreakDialogFragment.Callback,
TopicCompletedDialogFragment.Callback {

companion object {
private const val STEP_DESCRIPTION_FRAGMENT_TAG = "step_content"
Expand Down Expand Up @@ -165,4 +167,12 @@ class StageStepWrapperFragment :
override fun onRefuseStreakSharingClick(streak: Int) {
stepViewModel.onRefuseStreakSharingClick(streak)
}

override fun navigateToStudyPlan() {
onNewMessage(StepCompletionFeature.Message.TopicCompletedModalGoToStudyPlanClicked)
}

override fun navigateToNextTopic() {
onNewMessage(StepCompletionFeature.Message.TopicCompletedModalContinueNextTopicClicked)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import org.hyperskill.app.android.share_streak.fragment.ShareStreakDialogFragmen
import org.hyperskill.app.android.step.view.model.StepHost
import org.hyperskill.app.android.step.view.navigation.requireStepRouter
import org.hyperskill.app.android.step_quiz.view.dialog.CompletedStepOfTheDayDialogFragment
import org.hyperskill.app.android.topic_completion.fragment.TopicCompletedDialogFragment
import org.hyperskill.app.android.view.base.ui.extension.snackbar
import org.hyperskill.app.step.presentation.StepFeature
import org.hyperskill.app.step_completion.presentation.StepCompletionFeature
Expand Down Expand Up @@ -53,7 +54,9 @@ object StepDelegate {
fragment: TFragment,
mainScreenRouter: MainScreenRouter,
action: StepFeature.Action.ViewAction
) where TFragment : Fragment, TFragment : ShareStreakDialogFragment.Callback {
) where TFragment : Fragment,
TFragment : ShareStreakDialogFragment.Callback,
TFragment : TopicCompletedDialogFragment.Callback {
when (action) {
is StepFeature.Action.ViewAction.StepCompletionViewAction -> {
when (val stepCompletionAction = action.viewAction) {
Expand All @@ -75,16 +78,12 @@ object StepDelegate {
}

is StepCompletionFeature.Action.ViewAction.ShowTopicCompletedModal -> {
// TODO: ALTAPPS-1253 Implement new TopicCompletedModal
// TopicPracticeCompletedBottomSheet
// .newInstance(
// stepCompletionAction.modalText,
// stepCompletionAction.isNextStepAvailable
// )
// .showIfNotExists(
// fragment.childFragmentManager,
// TopicPracticeCompletedBottomSheet.Tag
// )
TopicCompletedDialogFragment
.newInstance(stepCompletionAction.params)
.showIfNotExists(
fragment.childFragmentManager,
TopicCompletedDialogFragment.TAG
)
}
is StepCompletionFeature.Action.ViewAction.ShowProblemOfDaySolvedModal -> {
CompletedStepOfTheDayDialogFragment
Expand Down
Loading

0 comments on commit 956c026

Please sign in to comment.