From 9ce5f3414e7040b7dc819af8009d6f1995ffbbc2 Mon Sep 17 00:00:00 2001 From: Simonas Sankauskas Date: Sat, 22 Aug 2026 17:26:37 +0300 Subject: [PATCH] Card screen cleanup: remove dead CardScreen v1, harden gestures and small-window layout - Delete superseded CardScreen/CardViewModel/GetNextQuestion and their tests (dead code path next to CardScreen2) - Card: honor pointerInputKey and drag-listener changes via rememberUpdatedState; simplify press tracking with awaitFirstDown - CardScreen2: extract user-facing strings to resources, add screen-reader state descriptions and hide visually absent cards from accessibility (hideFromAccessibility), quantize touch-driven idle timestamps using the monotonic clock, decay drag offsets concurrently with state transitions - UISizing: scale cards down to fit small windows (multi-window, foldables) preserving aspect ratio; clamp bigSpace at zero and keep the menu fully on-screen when the width constraint binds - Relocate cards on window resize by snapping to the new position instead of replaying the last state transition - QuestionComponent: sequential AtomicInteger ids instead of UUID hashCode Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TibSLSyWAedEWR41QxCByw --- .../dev/simonas/quies/card/CardScreenTest.kt | 210 ------ .../main/java/dev/simonas/quies/UISizing.kt | 22 +- .../main/java/dev/simonas/quies/card/Card.kt | 50 +- .../java/dev/simonas/quies/card/CardScreen.kt | 619 ------------------ .../dev/simonas/quies/card/CardScreen2.kt | 219 ++++--- .../dev/simonas/quies/card/CardViewModel.kt | 76 --- .../dev/simonas/quies/card/GetNextQuestion.kt | 28 - .../simonas/quies/card/QuestionComponent.kt | 6 +- app/src/main/res/values/strings.xml | 12 + .../simonas/quies/card/CardViewModelTest.kt | 121 ---- .../simonas/quies/card/GetNextQuestionTest.kt | 58 -- 11 files changed, 197 insertions(+), 1224 deletions(-) delete mode 100644 app/src/androidTest/java/dev/simonas/quies/card/CardScreenTest.kt delete mode 100644 app/src/main/java/dev/simonas/quies/card/CardScreen.kt delete mode 100644 app/src/main/java/dev/simonas/quies/card/CardViewModel.kt delete mode 100644 app/src/main/java/dev/simonas/quies/card/GetNextQuestion.kt delete mode 100644 app/src/test/java/dev/simonas/quies/card/CardViewModelTest.kt delete mode 100644 app/src/test/java/dev/simonas/quies/card/GetNextQuestionTest.kt diff --git a/app/src/androidTest/java/dev/simonas/quies/card/CardScreenTest.kt b/app/src/androidTest/java/dev/simonas/quies/card/CardScreenTest.kt deleted file mode 100644 index 46b352d..0000000 --- a/app/src/androidTest/java/dev/simonas/quies/card/CardScreenTest.kt +++ /dev/null @@ -1,210 +0,0 @@ -package dev.simonas.quies.card - -import androidx.compose.runtime.Composable -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotDisplayed -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import dev.simonas.quies.ComponentTest -import dev.simonas.quies.data.Question -import org.junit.Ignore -import org.junit.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.verify - -@Ignore("broken due to LocalUiGuides") -internal class CardScreenTest : ComponentTest() { - - val questionClosed: (Question) -> Unit = mock() - val changeLevel: (Question.Level) -> Unit = mock() - val nextQuestion: (Question.Level) -> Unit = mock() - val back: () -> Unit = mock() - - val pickingContent: @Composable () -> Unit = { - CardScreen( - state = CardViewModel.State.Picking( - currentLevel = Question.Level.Easy, - nextLevel = Question.Level.Medium, - ), - onNextQuestion = nextQuestion, - onQuestionClosed = questionClosed, - onChangeLevel = changeLevel, - onBack = back, - prevQuestions = emptyList(), - ) - } - - val easyCoffeeQuestion = Question( - text = "Could you bring me some coffee?", - level = Question.Level.Easy, - gameSetIds = emptyList(), - levelDescription = "This is a test.", - ) - - val showingContent: @Composable () -> Unit = { - CardScreen( - state = CardViewModel.State.Showing( - question = easyCoffeeQuestion, - ), - onNextQuestion = nextQuestion, - onQuestionClosed = questionClosed, - onChangeLevel = changeLevel, - onBack = back, - prevQuestions = emptyList(), - ) - } - - val landingContent: @Composable () -> Unit = { - CardScreen( - state = CardViewModel.State.Landing, - onNextQuestion = nextQuestion, - onQuestionClosed = questionClosed, - onChangeLevel = changeLevel, - onBack = back, - prevQuestions = emptyList(), - ) - } - val answeredQuestionsContent: @Composable () -> Unit = { - CardScreen( - state = CardViewModel.State.Landing, - onNextQuestion = nextQuestion, - onQuestionClosed = questionClosed, - onChangeLevel = changeLevel, - onBack = back, - prevQuestions = listOf( - Question( - text = "", - level = Question.Level.Easy, - gameSetIds = emptyList(), - levelDescription = "" - ) - ), - ) - } - - @Test - fun init() { - setContent { landingContent() } - showsLevels() - prevQuestionsHidden() - } - - @Test - fun clicksOnEasyLevel() { - setContent { landingContent() } - onNodeWithText("LEVEL 1") - .performClick() - mainClock.advanceTimeBy(800) - - requestsNextEasyQuestion() - } - - @Test - fun clicksOnMediumLevel() { - setContent { landingContent() } - onNodeWithText("LEVEL 2") - .performClick() - mainClock.advanceTimeBy(800) - - requestsNextMediumQuestion() - } - - @Test - fun clicksOnHardLevel() { - setContent { landingContent() } - onNodeWithText("LEVEL 3") - .performClick() - mainClock.advanceTimeBy(800) - - requestsNextHardQuestion() - } - - @Test - fun clicksOnExit() { - setContent { pickingContent() } - onNodeWithTag(CardScreen.TAG_EXIT) - .performClick() - - requestsBack() - } - - @Test - fun clicksOnChangeLevel() { - setContent { pickingContent() } - onNodeWithText("LEVEL 2") - .performClick() - - requestsChangeLevel() - } - - @Test - fun clicksOnNextEasyQuestion() { - setContent { pickingContent() } - onNodeWithText("LEVEL 1") - .performClick() - mainClock.advanceTimeBy(500) - - requestsNextEasyQuestion() - } - - @Test - fun closesQuestion() { - setContent { showingContent() } - onNodeWithTag(CardScreen.TAG_CLOSE_CARD) - .performClick() - mainClock.advanceTimeBy(500) - - requestsCloseQuestion() - } - - @Test - fun withAnsweredQuestions() { - setContent(answeredQuestionsContent) - - displaysPrevQuestions() - } - - private fun showsLevels() { - onNodeWithText("LEVEL 1") - .assertIsDisplayed() - onNodeWithText("LEVEL 2") - .assertIsDisplayed() - onNodeWithText("LEVEL 3") - .assertIsDisplayed() - } - - private fun requestsNextEasyQuestion() { - verify(nextQuestion).invoke(Question.Level.Easy) - } - - private fun requestsNextMediumQuestion() { - verify(nextQuestion).invoke(Question.Level.Medium) - } - - private fun requestsNextHardQuestion() { - verify(nextQuestion).invoke(Question.Level.Hard) - } - - private fun requestsBack() { - verify(back).invoke() - } - - private fun requestsChangeLevel() { - verify(changeLevel).invoke(Question.Level.Medium) - } - - private fun requestsCloseQuestion() { - verify(questionClosed).invoke(easyCoffeeQuestion) - } - - private fun displaysPrevQuestions() { - onNodeWithTag(CardScreen.TAG_PREV_QUESTIONS) - .assertIsDisplayed() - } - - private fun prevQuestionsHidden() { - onNodeWithTag(CardScreen.TAG_PREV_QUESTIONS) - .assertDoesNotExist() - } -} \ No newline at end of file diff --git a/app/src/main/java/dev/simonas/quies/UISizing.kt b/app/src/main/java/dev/simonas/quies/UISizing.kt index b99de73..5d01362 100644 --- a/app/src/main/java/dev/simonas/quies/UISizing.kt +++ b/app/src/main/java/dev/simonas/quies/UISizing.kt @@ -21,9 +21,21 @@ fun uiGuide( spaceHeight: Float, spaceWidth: Float, ): UiGuide { + val maxCardWidth = 428.77.dp.toPx() + val maxCardHeight = 265.dp.toPx() + // Shrink the card to fit small windows (multi-window, foldables) preserving its aspect + // ratio; a no-op on typical landscape phone displays. + val fitScale = when { + spaceWidth <= 0f || spaceHeight <= 0f -> 1f + else -> minOf( + 1f, + (spaceWidth * CARD_MAX_WIDTH_FRAC) / maxCardWidth, + (spaceHeight * CARD_MAX_HEIGHT_FRAC) / maxCardHeight, + ) + } val card = Float2( - x = 428.77.dp.toPx(), - y = 265.dp.toPx(), + x = maxCardWidth * fitScale, + y = maxCardHeight * fitScale, ) val smallSpace = smallSpacing( screenHeight = spaceHeight, @@ -36,7 +48,9 @@ fun uiGuide( val minWidthReq = bigSpace + (card.x / 2f) + (card.x / 5f) if (minWidthReq > (spaceWidth / 2f)) { val offset = minWidthReq - (spaceWidth / 2f) - bigSpace -= offset + // Never go negative: that would pull "off-screen" cards toward the center and push + // the menu above the top edge. + bigSpace = (bigSpace - offset).coerceAtLeast(0f) } return UiGuide( card = card, @@ -48,6 +62,8 @@ fun uiGuide( } private const val GOLDEN_RATIO = 1.61803f +private const val CARD_MAX_WIDTH_FRAC = 0.9f +private const val CARD_MAX_HEIGHT_FRAC = 0.85f private fun bigSpacing(screenHeight: Float, cardHeight: Float): Float { val freeSpace = screenHeight - cardHeight diff --git a/app/src/main/java/dev/simonas/quies/card/Card.kt b/app/src/main/java/dev/simonas/quies/card/Card.kt index ac89870..da85b6c 100644 --- a/app/src/main/java/dev/simonas/quies/card/Card.kt +++ b/app/src/main/java/dev/simonas/quies/card/Card.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight @@ -19,6 +20,7 @@ import androidx.compose.runtime.Composable 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 androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -27,10 +29,6 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.PointerEventType.Companion.Exit -import androidx.compose.ui.input.pointer.PointerEventType.Companion.Move -import androidx.compose.ui.input.pointer.PointerEventType.Companion.Press -import androidx.compose.ui.input.pointer.PointerEventType.Companion.Release import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.testTag @@ -41,6 +39,7 @@ import androidx.compose.ui.unit.dp import dev.simonas.quies.AppTheme import dev.simonas.quies.LocalUiGuide import dev.simonas.quies.card.Card.TAG_CENTER_TEXT +import dev.simonas.quies.card.Card.TAG_DESC_TEXT import dev.simonas.quies.card.Card.TAG_SIDE_TEXT import dev.simonas.quies.uiglitter.revealingAnnotatedText import dev.simonas.quies.uiglitter.revealingTextState @@ -55,6 +54,7 @@ import dev.simonas.quies.utils.vertical internal object Card { const val TAG_CENTER_TEXT = "text_center" const val TAG_SIDE_TEXT = "text_side" + const val TAG_DESC_TEXT = "text_desc" } interface DragListener { @@ -73,7 +73,7 @@ internal fun Card( centerVerticalText: String? = null, centerVerticalTextAlpha: Float = 1f, textAlpha: Float = 1f, - isCenterTextVisible: Boolean = textAlpha != 0f, + isCenterTextVisible: Boolean = textAlpha > 0f, sideTextAlpha: Float = 1f, color: Color = AppTheme.Color.dating, onClick: (() -> Unit)? = null, @@ -81,6 +81,7 @@ internal fun Card( dragListener: DragListener? = null, isTouchScalingEnabled: Boolean = true, ) { + val currentDragListener by rememberUpdatedState(dragListener) var isTouching: Boolean by remember { mutableStateOf(false) } val scale: Float by animateFloatAsState( animationSpec = tween( @@ -131,36 +132,27 @@ internal fun Card( this.shape = shape this.shadowElevation = shadowElevation.toPx() } - .pointerInput(Unit) { + .pointerInput(pointerInputKey, dragListener != null) { if (dragListener != null) { detectDragGestures( - onDragEnd = dragListener::onStop, - onDragCancel = dragListener::onStop, - onDrag = dragListener::onDrag, + onDragEnd = { currentDragListener?.onStop() }, + onDragCancel = { currentDragListener?.onStop() }, + onDrag = { change, dragAmount -> + currentDragListener?.onDrag(change, dragAmount) + }, ) } } .pointerInput(Unit) { awaitEachGesture { - while (true) { - val event = awaitPointerEvent() - when (event.type) { - Press -> { - isTouching = true - } - - Release -> { - isTouching = false - } - - Exit -> { - isTouching = false - } - - Move -> { - event.changes - } + awaitFirstDown(requireUnconsumed = false) + isTouching = true + try { + while (awaitPointerEvent().changes.any { it.pressed }) { + // track until every pointer is lifted or the gesture is cancelled } + } finally { + isTouching = false } } } @@ -176,7 +168,7 @@ internal fun Card( .mutColor(colorTextActivenessMut) val revealingText = revealingTextState( text = centerText, - isVisible = textAlpha > 0f, + isVisible = isCenterTextVisible, ) Text( modifier = Modifier @@ -204,7 +196,7 @@ internal fun Card( .graphicsLayer { alpha = centerVerticalTextAlpha } - .testTag(TAG_SIDE_TEXT), + .testTag(TAG_DESC_TEXT), style = AppTheme.Text.secondaryDemiBold .mutColor(colorTextActivenessMut), text = centerVerticalText, diff --git a/app/src/main/java/dev/simonas/quies/card/CardScreen.kt b/app/src/main/java/dev/simonas/quies/card/CardScreen.kt deleted file mode 100644 index f1f467d..0000000 --- a/app/src/main/java/dev/simonas/quies/card/CardScreen.kt +++ /dev/null @@ -1,619 +0,0 @@ -package dev.simonas.quies.card - -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import dev.simonas.quies.AppTheme -import dev.simonas.quies.data.Question -import dev.simonas.quies.utils.KeepScreenOn -import dev.simonas.quies.utils.QDevices -import dev.simonas.quies.utils.animatePlacement -import dev.simonas.quies.utils.createTestTag -import dev.simonas.quies.utils.vertical -import kotlin.math.sin - -internal object CardScreen { - val TAG_SCREEN = createTestTag("screen") - val TAG_CLOSE_CARD = createTestTag("close_card") - val TAG_EXIT = createTestTag("exit") - val TAG_PREV_QUESTIONS = createTestTag("close_card") -} - -private fun closedCardX(index: Int): Dp = - (-470 - 64 - 4 * sin(index * 1000f) * sin(index * 300f) - 0.5 * index).dp - -private fun closedCardY(index: Int): Dp = - (4 * sin(index * 1000f) * sin(index * 300f)).dp - -/** - * Biggest drawback of [CardScreen] is that it's difficult to create smooth transitions of - * cards moving to a different states. - * - * The model is based on the whole screen transitioning to a different state. When transitioning - * from one state to another to have a smooth animation the very end of exit transition has to match - * a start animation of the new state. - * - * For example [CardViewModel.State.Landing] after selecting the wanted level starts an exit - * animation and only after the animation finishes it invokes [onNextQuestion] that finally - * changes the the state after which the [CardViewModel.State.Showing] start animation has to match - * the end of the previous state exactly – which it still doesn't. - * - * Keeping track of these starts and ends becomes exceedingly hard. - * - * Because of this decided to redo this with a different mindset – render every card as an - * independent component. Then the UI layer is responsible just for rendering the state of cards - * and view models responsibility is to mutate the states. - * - * Keeping this just for visibility since it might be useful to somebody looking at this as a - * reference. - */ -@Composable -internal fun CardScreen( - state: CardViewModel.State, - prevQuestions: List, - onNextQuestion: (Question.Level) -> Unit, - onQuestionClosed: (Question) -> Unit, - onChangeLevel: (Question.Level) -> Unit, - onBack: () -> Unit, -) { - KeepScreenOn() - - AppTheme { - Scaffold { - Box( - modifier = Modifier - .testTag(CardScreen.TAG_SCREEN) - .padding(it) - .fillMaxSize() - ) { - prevQuestions.forEachIndexed { index, _ -> - Card( - modifier = Modifier - .testTag(CardScreen.TAG_PREV_QUESTIONS) - .offset( - x = closedCardX(index), - y = closedCardY(index), - ) - .align(Alignment.Center), - ) - } - - when (state) { - is CardViewModel.State.Landing -> { - Landing( - onNextQuestion = onNextQuestion, - ) - } - is CardViewModel.State.Picking -> { - Picking( - state = state, - onBack = onBack, - onChangeLevel = onChangeLevel, - onNextQuestion = onNextQuestion, - ) - } - is CardViewModel.State.Showing -> { - Showing( - state = state, - prevQuestions = prevQuestions, - onQuestionClosed = onQuestionClosed, - ) - } - } - } - } - } -} - -@Suppress("CyclomaticComplexMethod") // I know... later. -@Composable -private fun BoxScope.Landing( - onNextQuestion: (Question.Level) -> Unit, -) { - var startupAnimation: Float by remember { mutableFloatStateOf(0f) } - LaunchedEffect(Unit) { - animate( - initialValue = 0.6f, - targetValue = 1f, - animationSpec = tween( - durationMillis = 800, - ), - block = { value, velocity -> - startupAnimation = value - } - ) - } - - var selectedLevel: Question.Level? by remember { mutableStateOf(null) } - val selectCardAnim: Float by animateFloatAsState( - animationSpec = tween( - durationMillis = 400, - ), - targetValue = when { - selectedLevel != null -> 1f - else -> 0f - }, - finishedListener = { - onNextQuestion(requireNotNull(selectedLevel)) - }, - label = "close animation", - ) - - val cardRotate: (Question.Level) -> Float = { level -> - when { - selectedLevel == level -> 90f * (1 - selectCardAnim) - else -> 90f - } - } - val cardOffsetX: (Question.Level) -> Dp = { level -> - val multiplier = when (level) { - Question.Level.Easy -> 1 - Question.Level.Medium -> 2 - Question.Level.Hard -> 3 - } - var x = 0f - x += -64 * multiplier * (1 - startupAnimation) - when { - selectedLevel == null -> Unit - level == selectedLevel -> { - x = (x * (1 - selectCardAnim)) + (-64 * 2 * (1 - startupAnimation)) - } - } - x.dp - } - - val cardOffsetY: (Question.Level) -> Dp = { level -> - val multiplier = when (level) { - Question.Level.Easy -> 1 - Question.Level.Medium -> 2 - Question.Level.Hard -> 3 - } - var y = 256f - y += 32 * multiplier * (1 - startupAnimation) - y -= (multiplier - 1) * 64 - when { - selectedLevel == null -> Unit - level != selectedLevel -> { - y += 999 * selectCardAnim - } - level == selectedLevel -> { - y *= (1 - selectCardAnim) - } - } - y.dp - } - - Box( - modifier = Modifier - .align(Alignment.Center) - .width(704.dp), - ) { - Card( - modifier = Modifier - .animatePlacement() - .align( - when (selectedLevel) { - Question.Level.Hard -> Alignment.Center - else -> Alignment.CenterEnd - } - ) - .offset( - x = cardOffsetX(Question.Level.Hard), - y = cardOffsetY(Question.Level.Hard), - ) - .vertical() - .rotate(cardRotate(Question.Level.Hard)), - sideText = "LEVEL 3", - textAlpha = 1f - selectCardAnim, - onClick = { - selectedLevel = Question.Level.Hard - }, - ) - Card( - modifier = Modifier - .animatePlacement() - .align( - when (selectedLevel) { - Question.Level.Medium -> Alignment.Center - else -> Alignment.Center - } - ) - .offset( - x = cardOffsetX(Question.Level.Medium), - y = cardOffsetY(Question.Level.Medium), - ) - .vertical() - .rotate(cardRotate(Question.Level.Medium)), - sideText = "LEVEL 2", - textAlpha = 1f - selectCardAnim, - onClick = { - selectedLevel = Question.Level.Medium - }, - ) - Card( - modifier = Modifier - .animatePlacement() - .align( - when (selectedLevel) { - Question.Level.Easy -> Alignment.Center - else -> Alignment.CenterStart - } - ) - .offset( - x = cardOffsetX(Question.Level.Easy), - y = cardOffsetY(Question.Level.Easy), - ) - .vertical() - .rotate(cardRotate(Question.Level.Easy)), - sideText = "LEVEL 1", - textAlpha = 1f - selectCardAnim, - onClick = { - selectedLevel = Question.Level.Easy - }, - ) - } -} - -@Composable -private fun BoxScope.Showing( - state: CardViewModel.State.Showing, - prevQuestions: List, - onQuestionClosed: (Question) -> Unit, -) { - var startupAnimation: Float by remember { mutableFloatStateOf(0f) } - LaunchedEffect(Unit) { - animate( - initialValue = 0.6f, - targetValue = 1f, - animationSpec = tween( - durationMillis = 800, - ), - block = { value, velocity -> - startupAnimation = value - } - ) - } - - var showCloseQuestionAnimation: Float by remember { mutableFloatStateOf(0f) } - - var isClosed: Boolean by remember { mutableStateOf(false) } - val closeCardAnim: Float by animateFloatAsState( - animationSpec = tween( - durationMillis = 400, - ), - targetValue = when { - isClosed -> 1f - else -> 0f - }, - finishedListener = { - onQuestionClosed(state.question) - }, - label = "closeCardAnim", - ) - - LaunchedEffect(Unit) { - animate( - initialValue = 0f, - targetValue = 1f, - animationSpec = tween( - delayMillis = 100, - durationMillis = 400, - ), - block = { value, velocity -> - showCloseQuestionAnimation = value - } - ) - } - - val index = prevQuestions.size - Card( - modifier = Modifier - .offset { - IntOffset( - x = (closedCardX(index) * closeCardAnim).roundToPx(), - y = (closedCardY(index) * closeCardAnim).roundToPx(), - ) - } - .align(Alignment.Center), - centerText = state.question.text, - textAlpha = startupAnimation, - ) - - Card( - modifier = Modifier - .testTag(CardScreen.TAG_CLOSE_CARD) - .align(Alignment.Center) - .offset { - val x = (470 + 64 + (128 - 128 * showCloseQuestionAnimation)) * (1 - closeCardAnim) - IntOffset(x = x.dp.roundToPx(), y = 0) - }, - sideText = state.question.level.toText(), - onClick = { - isClosed = true - }, - ) -} - -@Suppress("CyclomaticComplexMethod") // I know... later. -@Composable -private fun BoxScope.Picking( - state: CardViewModel.State.Picking, - onBack: () -> Unit, - onChangeLevel: (Question.Level) -> Unit, - onNextQuestion: (Question.Level) -> Unit, -) { - var startupAnimation: Float by remember { mutableFloatStateOf(0f) } - LaunchedEffect(Unit) { - animate( - initialValue = 0.0f, - targetValue = 1f, - animationSpec = tween( - durationMillis = 400, - ), - block = { value, velocity -> - startupAnimation = value - } - ) - } - - var isPicked: Boolean by remember(state.nextLevel) { mutableStateOf(false) } - val pickedCardAnim: Float by animateFloatAsState( - animationSpec = tween( - durationMillis = 400, - ), - targetValue = when { - isPicked -> 1f - else -> 0f - }, - finishedListener = { - onNextQuestion(state.currentLevel) - }, - label = "close animation", - ) - - val level1Anim: Float by animateFloatAsState( - animationSpec = tween( - durationMillis = 400, - ), - targetValue = when { - state.currentLevel == Question.Level.Easy -> 1f - else -> 0f - }, - label = "level1Anim", - ) - val level2Anim: Float by animateFloatAsState( - animationSpec = tween( - durationMillis = 400, - ), - targetValue = when { - state.currentLevel == Question.Level.Medium -> 1f - else -> 0f - }, - label = "level2Anim", - ) - val level3Anim: Float by animateFloatAsState( - animationSpec = tween( - durationMillis = 400, - ), - targetValue = when { - state.currentLevel == Question.Level.Hard -> 1f - else -> 0f - }, - label = "level3Anim", - ) - - Button( - modifier = Modifier - .testTag(CardScreen.TAG_EXIT) - .padding(8.dp) - .align(Alignment.BottomStart), - onClick = onBack, - colors = ButtonDefaults.outlinedButtonColors(), - content = { - Text("X") - } - ) - - Card( - modifier = Modifier - .zIndex( - when { - state.currentLevel == Question.Level.Easy -> 3f - state.nextLevel == Question.Level.Easy -> 2f - else -> 1f - } - ) - .offset { IntOffset(x = ((470 + 64) - (470 + 64) * level1Anim).dp.roundToPx(), y = 0) } - .let { - if (state.currentLevel != Question.Level.Easy) { - it.offset { - IntOffset( - x = (128 * (1 - startupAnimation) + 128 * pickedCardAnim).dp.roundToPx(), - y = 0, - ) - } - } else { - it - } - } - .align(Alignment.Center), - sideText = Question.Level.Easy.toText(), - textAlpha = (1f - pickedCardAnim), - onClick = { - when { - level1Anim != 1f && level1Anim != 0f -> Unit - state.currentLevel == Question.Level.Easy -> { - onNextQuestion(Question.Level.Easy) - } - else -> { - onChangeLevel(Question.Level.Easy) - } - } - }, - ) - - Card( - modifier = Modifier - .zIndex( - when { - state.currentLevel == Question.Level.Medium -> 3f - state.nextLevel == Question.Level.Medium -> 2f - else -> 1f - } - ) - .offset { IntOffset(x = ((470 + 64) - (470 + 64) * level2Anim).dp.roundToPx(), y = 0) } - .let { - if (state.currentLevel != Question.Level.Medium) { - it.offset { - IntOffset( - x = (128 * (1 - startupAnimation) + 128 * pickedCardAnim).dp.roundToPx(), - y = 0, - ) - } - } else { - it - } - } - .align(Alignment.Center), - sideText = Question.Level.Medium.toText(), - textAlpha = (1f - pickedCardAnim), - onClick = { - when { - level2Anim != 1f && level2Anim != 0f -> Unit - state.currentLevel == Question.Level.Medium -> { - onNextQuestion(Question.Level.Medium) - } - else -> { - onChangeLevel(Question.Level.Medium) - } - } - }, - ) - - Card( - modifier = Modifier - .zIndex( - when { - state.currentLevel == Question.Level.Hard -> 3f - state.nextLevel == Question.Level.Hard -> 2f - else -> 1f - } - ) - .offset { IntOffset(x = ((470 + 64) - (470 + 64) * level3Anim).dp.roundToPx(), y = 0) } - .let { - if (state.currentLevel != Question.Level.Hard) { - it.offset { - IntOffset( - x = (128 * (1 - startupAnimation) + 128 * pickedCardAnim).dp.roundToPx(), - y = 0, - ) - } - } else { - it - } - } - .align(Alignment.Center), - sideText = Question.Level.Hard.toText(), - textAlpha = (1f - pickedCardAnim), - onClick = { - when { - level3Anim != 1f && level3Anim != 0f -> Unit - state.currentLevel == Question.Level.Hard -> { - onNextQuestion(Question.Level.Hard) - } - else -> { - onChangeLevel(Question.Level.Hard) - } - } - }, - ) -} - -private fun Question.Level.toText(): String = - when (this) { - Question.Level.Easy -> { - "LEVEL 1" - } - Question.Level.Medium -> { - "LEVEL 2" - } - Question.Level.Hard -> { - "LEVEL 3" - } - } - -@Preview(device = QDevices.LANDSCAPE) -@Composable -internal fun PreviewLanding() { - CardScreen( - state = CardViewModel.State.Landing, - onNextQuestion = {}, - onQuestionClosed = {}, - onChangeLevel = {}, - onBack = {}, - prevQuestions = emptyList(), - ) -} - -@Preview(device = QDevices.LANDSCAPE) -@Composable -private fun PreviewShowing() { - CardScreen( - state = CardViewModel.State.Showing( - question = Question( - text = "If there's a reality show I'd binge-watch, " + - "which one do you imagine it would be?", - level = Question.Level.Easy, - gameSetIds = listOf("1"), - levelDescription = "Learn something new." - ) - ), - onNextQuestion = {}, - onQuestionClosed = {}, - onChangeLevel = {}, - onBack = {}, - prevQuestions = emptyList(), - ) -} - -@Preview(device = QDevices.LANDSCAPE) -@Composable -private fun PreviewPicking() { - CardScreen( - state = CardViewModel.State.Picking( - currentLevel = Question.Level.Medium, - nextLevel = Question.Level.Hard, - ), - onNextQuestion = {}, - onQuestionClosed = {}, - onChangeLevel = {}, - onBack = {}, - prevQuestions = emptyList(), - ) -} diff --git a/app/src/main/java/dev/simonas/quies/card/CardScreen2.kt b/app/src/main/java/dev/simonas/quies/card/CardScreen2.kt index 3deeac7..4e1a516 100644 --- a/app/src/main/java/dev/simonas/quies/card/CardScreen2.kt +++ b/app/src/main/java/dev/simonas/quies/card/CardScreen2.kt @@ -3,6 +3,7 @@ package dev.simonas.quies.card import android.annotation.SuppressLint +import android.os.SystemClock import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.Animatable @@ -27,6 +28,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableLongStateOf @@ -44,15 +46,19 @@ import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInteropFilter import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.simonas.quies.AppTheme import dev.simonas.quies.AppTheme.SCREEN_SAVER_FADE_FRAC import dev.simonas.quies.LocalUiGuide +import dev.simonas.quies.R import dev.simonas.quies.UiGuide import dev.simonas.quies.analytics.EventTracker import dev.simonas.quies.analytics.eventTracker @@ -79,6 +85,8 @@ import kotlin.math.sin import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +private const val TOUCH_SAMPLING_MILLIS = 1_000L + internal object CardScreen2 { val TAG_SCREEN = createTestTag("screen") val TAG_MENU_TOGGLE = createTestTag("menu_toggle") @@ -162,7 +170,9 @@ private fun CardScreen2( KeepScreenOn() } - var lastTouchEvent by remember { mutableLongStateOf(System.currentTimeMillis()) } + // Monotonic clock: System.currentTimeMillis() can jump backwards (user/NTP time change), + // which would silence the quantization guard below until the wall clock caught back up. + var lastTouchEvent by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) } var showMenuMessage by remember { mutableStateOf(false) } LaunchedEffect(showLevelSkipNotice) { @@ -215,7 +225,12 @@ private fun CardScreen2( modifier = Modifier .testTag(CardScreen2.TAG_SCREEN) .pointerInteropFilter { - lastTouchEvent = System.currentTimeMillis() + // Quantize updates: every write restarts the idle/screen-on effects keyed on + // this timestamp, so avoid one restart per motion event during a drag. + val now = SystemClock.elapsedRealtime() + if (now - lastTouchEvent > TOUCH_SAMPLING_MILLIS) { + lastTouchEvent = now + } false } .padding() @@ -242,10 +257,11 @@ private fun CardScreen2( .align(Alignment.Center) .alpha(endgameQuestionAlpha), style = AppTheme.Text.primaryDemiBold, - text = "How do you feel?", + text = stringResource(R.string.card_endgame_question), textAlign = TextAlign.Center, ) + val uiAlphaState = remember { derivedStateOf { uiAlpha.value } } val components = questions.value.components val size = components.size components.asReversed().forEachIndexed { index, ques -> @@ -256,14 +272,16 @@ private fun CardScreen2( component = ques, gameSetId = gameSetId, bigSpace = bigSpace, - secondaryUIAlpha = uiAlpha.value, + secondaryUIAlpha = uiAlphaState, onClick = onClick ) } } val menuHeight = Menu.height.toPx() - val menuYOffset = (bigSpace / 2f) - (menuHeight / 2f) + // Centered in the bigSpace band, but never above the top edge: in width-constrained + // windows bigSpace clamps to 0 and centering alone would clip the menu's top half. + val menuYOffset = ((bigSpace / 2f) - (menuHeight / 2f)).coerceAtLeast(0f) Menu( modifier = Modifier .align(Alignment.TopCenter) @@ -271,7 +289,7 @@ private fun CardScreen2( translationY = menuYOffset alpha = uiAlpha.value }, - message = "Are you ready to move to the next level?", + message = stringResource(R.string.card_level_skip_notice), showMessage = showMenuMessage, onClick = toggleMenu, ) @@ -296,7 +314,7 @@ private fun CardScreen2( ), content = { Text( - text = "Exit?", + text = stringResource(R.string.card_menu_exit), style = AppTheme.Text.primaryBold ) } @@ -312,7 +330,7 @@ private fun CardScreen2( ), content = { Text( - text = "Next Level?", + text = stringResource(R.string.card_menu_next_level), style = AppTheme.Text.primaryBold ) } @@ -331,9 +349,12 @@ private fun BoxScope.StatefulCard( component: QuestionComponent, gameSetId: String, bigSpace: Float, - secondaryUIAlpha: Float, + secondaryUIAlpha: State, onClick: (QuestionComponent) -> Unit, ) { + // Read here (not in the caller) so the idle-fade animation only invalidates each card's + // scope instead of the whole screen. + val secondaryAlpha = secondaryUIAlpha.value val uiGuide = LocalUiGuide.current val dragOffsetX = remember { Animatable(0f) } val dragOffsetY = remember { Animatable(0f) } @@ -378,7 +399,7 @@ private fun BoxScope.StatefulCard( QuestionComponent.State.Landing -> 1f else -> 0f }, - label = "centerTextAlpha", + label = "centerVerticalTextAlpha", ) val sideTextAlpha by animateFloatAsState( animationSpec = tween( @@ -410,7 +431,7 @@ private fun BoxScope.StatefulCard( val stateOffsetX = remember { Animatable( computeOffsetX( - index = component.modifiedAtSecs, + noiseSeed = component.modifiedAtSecs, comp = component, level = component.level, state = component.stateVector.from, @@ -418,45 +439,57 @@ private fun BoxScope.StatefulCard( ) ) } - LaunchedEffect(component) { - stateOffsetX.animateTo( - targetValue = computeOffsetX( - comp = component, - level = component.level, - state = component.state, - index = component.modifiedAtSecs, - uiGuide = uiGuide, - ), - animationSpec = tween( - durationMillis = AppTheme.ANIM_DURATION, - ), - ) - } - val stateOffsetY = remember { Animatable( computeOffsetY( comp = component, level = component.level, state = component.stateVector.from, - index = component.modifiedAtSecs, + noiseSeed = component.modifiedAtSecs, uiGuide = uiGuide, ) ) } - LaunchedEffect(component) { - stateOffsetY.animateTo( - targetValue = computeOffsetY( - comp = component, - index = component.modifiedAtSecs, - level = component.level, - state = component.state, - uiGuide = uiGuide, - ), - animationSpec = tween( - durationMillis = AppTheme.ANIM_DURATION, - ), + var animatedUiGuide by remember { mutableStateOf(uiGuide) } + LaunchedEffect(component, uiGuide) { + val targetX = computeOffsetX( + comp = component, + level = component.level, + state = component.state, + noiseSeed = component.modifiedAtSecs, + uiGuide = uiGuide, + ) + val targetY = computeOffsetY( + comp = component, + noiseSeed = component.modifiedAtSecs, + level = component.level, + state = component.state, + uiGuide = uiGuide, ) + if (uiGuide != animatedUiGuide) { + // Window resized (multi-window divider, fold posture): relocate to the position + // for the new dimensions without replaying the last state transition. + animatedUiGuide = uiGuide + stateOffsetX.snapTo(targetX) + stateOffsetY.snapTo(targetY) + } else { + launch { + stateOffsetX.animateTo( + targetValue = targetX, + animationSpec = tween( + durationMillis = AppTheme.ANIM_DURATION, + ), + ) + } + launch { + stateOffsetY.animateTo( + targetValue = targetY, + animationSpec = tween( + durationMillis = AppTheme.ANIM_DURATION, + ), + ) + } + } } val coroutineCtx = rememberCoroutineScope() @@ -484,9 +517,20 @@ private fun BoxScope.StatefulCard( ) } + val stateDesc = component.state.toStateDescription() Card( modifier = Modifier - .semantics { questionState = component.state } + .semantics { + questionState = component.state + stateDesc?.let { stateDescription = it } + // Keep screen readers off cards that are visually absent. + if (component.isOffscreen || + component.state == QuestionComponent.State.Disabled || + component.state == QuestionComponent.State.Blank + ) { + hideFromAccessibility() + } + } .align(Alignment.TopCenter) .graphicsLayer { val offset = cardTranslationOffset() @@ -501,10 +545,10 @@ private fun BoxScope.StatefulCard( 1f } component.state == QuestionComponent.State.PrimaryHidden -> { - secondaryUIAlpha.pow(1f / 2f) + secondaryAlpha.pow(1f / 2f) } else -> { - secondaryUIAlpha + secondaryAlpha } }, textActiveness = when { @@ -512,13 +556,13 @@ private fun BoxScope.StatefulCard( 1f } component.state == QuestionComponent.State.PrimaryRevealed -> { - secondaryUIAlpha.pow(1f / 4f) + secondaryAlpha.pow(1f / 4f) } component.state == QuestionComponent.State.PrimaryHidden -> { - secondaryUIAlpha.pow(1f / 8f) + secondaryAlpha.pow(1f / 8f) } else -> { - secondaryUIAlpha + secondaryAlpha } }, shadowElevation = 4.dp, @@ -543,14 +587,16 @@ private fun BoxScope.StatefulCard( override fun onStop() { isDragThresholdReached = false if (abs(dragOffsetX.value) > dragTrigger) { - val cardOffset = cardTranslationOffset() + // Let the drag offset decay while the state transition animates the card + // to its next position; the two run concurrently so the visual sum stays + // continuous from the release point. coroutineCtx.launch { - dragOffsetX.snapTo(0f) - dragOffsetY.snapTo(0f) - stateOffsetX.snapTo(cardOffset.x) - stateOffsetY.snapTo(cardOffset.y) - onClick(component) + dragOffsetX.animateTo(0f, tween(AppTheme.ANIM_DURATION)) } + coroutineCtx.launch { + dragOffsetY.animateTo(0f, tween(AppTheme.ANIM_DURATION)) + } + onClick(component) } else { coroutineCtx.launch { dragOffsetX.animateTo(0f, spring(0.5f)) @@ -562,8 +608,7 @@ private fun BoxScope.StatefulCard( } override fun onDrag(change: PointerInputChange, dragAmount: Offset) { - isDragThresholdReached = abs(dragOffsetX.value) > dragTrigger || - abs(dragOffsetY.value) > dragTrigger + isDragThresholdReached = abs(dragOffsetX.value) > dragTrigger coroutineCtx.launch { dragOffsetX.snapTo(dragOffsetX.value + dragAmount.x) dragOffsetY.snapTo(dragOffsetY.value + dragAmount.y) @@ -574,14 +619,14 @@ private fun BoxScope.StatefulCard( } private fun computeOffsetX( - index: Float, + noiseSeed: Float, comp: QuestionComponent, level: QuestionComponent.Level, state: QuestionComponent.State, uiGuide: UiGuide, ): Float { var offset = 0f - val cardHeight = uiGuide.card.x + val cardWidth = uiGuide.card.x when (state) { QuestionComponent.State.PrimaryRevealed -> { // center @@ -590,29 +635,29 @@ private fun computeOffsetX( when (level) { QuestionComponent.Level.Easy -> { offset -= uiGuide.card.y - offset += cardHeight.nthGoldenChildRatio(6) + offset += cardWidth.nthGoldenChildRatio(6) } QuestionComponent.Level.Medium -> { // nothing } QuestionComponent.Level.Hard -> { offset += uiGuide.card.y - offset -= cardHeight.nthGoldenChildRatio(6) + offset -= cardWidth.nthGoldenChildRatio(6) } } } QuestionComponent.State.OtherCard -> { - offset += cardHeight + uiGuide.bigSpace + offset += cardWidth + uiGuide.bigSpace } QuestionComponent.State.Closed -> { - offset -= cardHeight + uiGuide.bigSpace - offset += fbm(seed = index, octaves = 7) * cardHeight.nthGoldenChildRatio(7) + offset -= cardWidth + uiGuide.bigSpace + offset += fbm(seed = noiseSeed, octaves = 7) * cardWidth.nthGoldenChildRatio(7) } QuestionComponent.State.PrimaryHidden -> { // center } QuestionComponent.State.NextHidden -> { - offset += cardHeight + uiGuide.bigSpace + offset += cardWidth + uiGuide.bigSpace } QuestionComponent.State.Disabled -> { // center @@ -637,15 +682,17 @@ private fun computeRotation( } private fun computeOffsetY( - index: Float, + noiseSeed: Float, level: QuestionComponent.Level, state: QuestionComponent.State, comp: QuestionComponent, uiGuide: UiGuide, ): Float { val screenH = uiGuide.displayHeight - val cardSize = uiGuide.card.x - val cornerOffset = cardSize.nthGoldenChildRatio(2) + // Deliberately the card *width*: Landing cards are rotated 90 degrees, so their + // vertical extent on screen is the card's width. + val cardWidth = uiGuide.card.x + val cornerOffset = cardWidth.nthGoldenChildRatio(2) var offset = 0f when (state) { @@ -653,40 +700,56 @@ private fun computeOffsetY( // correction due to rotation offset += (uiGuide.card.x - uiGuide.card.y) / 2 offset += screenH - offset -= cardSize.nthGoldenChildRatio(2) - offset -= cardSize.nthGoldenChildRatio(4) * level.ordinal + offset -= cardWidth.nthGoldenChildRatio(2) + offset -= cardWidth.nthGoldenChildRatio(4) * level.ordinal } QuestionComponent.State.Closed -> { - offset += fbm(seed = index, octaves = 4) * cardSize.nthGoldenChildRatio(5) + offset += fbm(seed = noiseSeed, octaves = 4) * cardWidth.nthGoldenChildRatio(5) offset += uiGuide.bigSpace } QuestionComponent.State.Disabled -> { offset += screenH + cornerOffset } QuestionComponent.State.Blank -> { - offset += screenH + 64 + offset += screenH + cornerOffset } else -> { offset += uiGuide.bigSpace } } if (comp.isOffscreen) { - offset += screenH + 64 + offset += screenH + cornerOffset } return offset } +@Composable private fun QuestionComponent.Level.toText(): String = - when (this) { - QuestionComponent.Level.Easy -> { - "LEVEL 1" - } - QuestionComponent.Level.Medium -> { - "LEVEL 2" - } - QuestionComponent.Level.Hard -> { - "LEVEL 3" + stringResource( + when (this) { + QuestionComponent.Level.Easy -> { + R.string.card_level_easy + } + QuestionComponent.Level.Medium -> { + R.string.card_level_medium + } + QuestionComponent.Level.Hard -> { + R.string.card_level_hard + } } + ) + +@Composable +private fun QuestionComponent.State.toStateDescription(): String? = + when (this) { + QuestionComponent.State.Landing -> stringResource(R.string.card_state_landing) + QuestionComponent.State.PrimaryRevealed -> stringResource(R.string.card_state_revealed) + QuestionComponent.State.PrimaryHidden, + QuestionComponent.State.NextHidden, + QuestionComponent.State.OtherCard, + -> stringResource(R.string.card_state_hidden) + QuestionComponent.State.Closed -> stringResource(R.string.card_state_closed) + else -> null } private fun computeCardDragOffset( diff --git a/app/src/main/java/dev/simonas/quies/card/CardViewModel.kt b/app/src/main/java/dev/simonas/quies/card/CardViewModel.kt deleted file mode 100644 index 3ba045c..0000000 --- a/app/src/main/java/dev/simonas/quies/card/CardViewModel.kt +++ /dev/null @@ -1,76 +0,0 @@ -package dev.simonas.quies.card - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import dev.simonas.quies.data.Question -import dev.simonas.quies.router.NavRoutes -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update - -// Note: unlike CardViewModel2, this ViewModel is not actually obtained via hiltViewModel() / -// metroViewModel() anywhere in the app's navigation graph (CardScreen/CardViewModel appear to be -// a superseded, dead code path next to CardScreen2/CardViewModel2). Left un-annotated for Metro's -// graph (no @Inject/@ContributesIntoMap) since nothing in the graph resolves it; it's still -// referenced by CardScreenTest.kt via direct construction of CardScreen with explicit state. -internal class CardViewModel( - stateHandle: SavedStateHandle, - private val getNextQuestion: GetNextQuestion, -) : ViewModel() { - - private val gameSetId: String = requireNotNull(stateHandle[NavRoutes.ARG_GAME_SET]) - - private val _previousQuestions = MutableStateFlow(emptyList()) - - private val _state = MutableStateFlow(State.Landing) - - val state: StateFlow = _state - val previousQuestions: StateFlow> = _previousQuestions - - fun closed(question: Question) { - _previousQuestions.update { - it.plus(question) - } - changeLevel( - level = question.level, - ) - } - - fun next(level: Question.Level) { - val nextQuestion = getNextQuestion.invoke( - gameSetId = gameSetId, - level = level, - ) - _state.value = State.Showing( - question = nextQuestion - ) - } - - private fun getNextLevel(currentLevel: Question.Level): Question.Level = - when (currentLevel) { - Question.Level.Easy -> Question.Level.Medium - Question.Level.Medium -> Question.Level.Hard - Question.Level.Hard -> Question.Level.Easy - } - - fun changeLevel(level: Question.Level) { - _state.value = State.Picking( - currentLevel = level, - nextLevel = getNextLevel(level), - ) - } - - sealed class State { - - object Landing : State() - - data class Showing( - val question: Question, - ) : State() - - data class Picking( - val currentLevel: Question.Level, - val nextLevel: Question.Level, - ) : State() - } -} diff --git a/app/src/main/java/dev/simonas/quies/card/GetNextQuestion.kt b/app/src/main/java/dev/simonas/quies/card/GetNextQuestion.kt deleted file mode 100644 index ad933d0..0000000 --- a/app/src/main/java/dev/simonas/quies/card/GetNextQuestion.kt +++ /dev/null @@ -1,28 +0,0 @@ -package dev.simonas.quies.card - -import dev.simonas.quies.data.Question -import dev.simonas.quies.data.QuestionRepository -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.SingleIn -import kotlin.random.Random - -@SingleIn(AppScope::class) -@Inject -internal class GetNextQuestion( - private val random: Random, - private val questionRepository: QuestionRepository, -) { - - fun invoke( - gameSetId: String, - level: Question.Level, - ): Question { - return questionRepository.getAll() - .filter { question -> - question.level == level && - question.gameSetIds.contains(gameSetId) - } - .random(random) - } -} diff --git a/app/src/main/java/dev/simonas/quies/card/QuestionComponent.kt b/app/src/main/java/dev/simonas/quies/card/QuestionComponent.kt index 3c56e5a..9258a06 100644 --- a/app/src/main/java/dev/simonas/quies/card/QuestionComponent.kt +++ b/app/src/main/java/dev/simonas/quies/card/QuestionComponent.kt @@ -2,10 +2,12 @@ package dev.simonas.quies.card import dev.simonas.quies.seconds import dev.simonas.quies.utils.Vector -import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger + +private val nextComponentId = AtomicInteger() data class QuestionComponent constructor( - val id: Int = UUID.randomUUID().hashCode(), + val id: Int = nextComponentId.incrementAndGet(), val text: String, val level: Level, val levelDescription: String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a375edf..8756e22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,15 @@ Coalesce + + How do you feel? + Are you ready to move to the next level? + Exit? + Next Level? + LEVEL 1 + LEVEL 2 + LEVEL 3 + Level select card + Current question + Next question, face down + Answered question diff --git a/app/src/test/java/dev/simonas/quies/card/CardViewModelTest.kt b/app/src/test/java/dev/simonas/quies/card/CardViewModelTest.kt deleted file mode 100644 index 696957b..0000000 --- a/app/src/test/java/dev/simonas/quies/card/CardViewModelTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -package dev.simonas.quies.card - -import androidx.lifecycle.SavedStateHandle -import com.google.common.truth.Truth.assertThat -import dev.simonas.quies.data.Question -import dev.simonas.quies.router.NavRoutes -import dev.simonas.quies.utils.az -import dev.simonas.quies.utils.testLast -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -internal class CardViewModelTest { - - val gameSetId = "gameSetId" - val getNextQuestion: GetNextQuestion = mock() - val stateHandle = SavedStateHandle( - mapOf( - NavRoutes.ARG_GAME_SET to gameSetId, - ) - ) - - val subject = CardViewModel( - stateHandle = stateHandle, - getNextQuestion = getNextQuestion, - ) - - @Test - fun `shows landing`() = runTest { - subject.state.testLast { state -> - assertThat(state) - .isInstanceOf(CardViewModel.State.Landing::class.java) - } - } - - @Nested - inner class `next question` { - - val nextRollerQuestion = Question( - text = "Next Random question?", - gameSetIds = listOf(gameSetId), - level = Question.Level.Hard, - levelDescription = "This is a test.", - ) - - @BeforeEach - fun setUp() { - whenever(getNextQuestion.invoke(gameSetId, Question.Level.Hard)) - .thenReturn(nextRollerQuestion) - subject.next(Question.Level.Hard) - } - - @Test - fun `shows next question`() = runTest { - subject.state.testLast { state -> - assertThat(state.az().question) - .isEqualTo(nextRollerQuestion) - } - } - } - - @Nested - inner class `change level` { - - @BeforeEach - fun setUp() { - subject.changeLevel(Question.Level.Hard) - } - - @Test - fun `updates current level`() = runTest { - subject.state.testLast { state -> - assertThat(state.az().currentLevel) - .isEqualTo(Question.Level.Hard) - } - } - - @Test - fun `updates next level`() = runTest { - subject.state.testLast { state -> - assertThat(state.az().nextLevel) - .isEqualTo(Question.Level.Easy) - } - } - } - - @Nested - inner class `close question` { - - val closedQuestion = Question( - text = "", - level = Question.Level.Easy, - levelDescription = "This is a test.", - gameSetIds = emptyList(), - ) - - @BeforeEach - fun setUp() { - subject.closed(closedQuestion) - } - - @Test - fun `matches current level with closed question`() = runTest { - subject.state.testLast { state -> - assertThat(state.az().currentLevel) - .isEqualTo(closedQuestion.level) - } - } - - @Test - fun `shows next level`() = runTest { - subject.state.testLast { state -> - assertThat(state.az().nextLevel) - .isEqualTo(Question.Level.Medium) - } - } - } -} diff --git a/app/src/test/java/dev/simonas/quies/card/GetNextQuestionTest.kt b/app/src/test/java/dev/simonas/quies/card/GetNextQuestionTest.kt deleted file mode 100644 index d68d1c0..0000000 --- a/app/src/test/java/dev/simonas/quies/card/GetNextQuestionTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -package dev.simonas.quies.card - -import com.google.common.truth.Truth.assertThat -import dev.simonas.quies.data.Question -import dev.simonas.quies.data.QuestionRepository -import org.junit.jupiter.api.Test -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever -import kotlin.random.Random - -internal class GetNextQuestionTest { - - val questionRepository: QuestionRepository = mock() - val random: Random = mock() - - val subject = GetNextQuestion( - random = random, - questionRepository = questionRepository, - ) - - @Test - fun `returns question from expected game set`() { - val expectedQuestion = Question( - text = "hello?", - level = Question.Level.Easy, - levelDescription = "This is a test.", - gameSetIds = listOf("1"), - ) - val question = expectedQuestion.copy( - gameSetIds = listOf("2"), - ) - whenever(questionRepository.getAll()) - .thenReturn(listOf(expectedQuestion, question)) - - val actualQuestion = subject.invoke("1", level = Question.Level.Easy) - assertThat(actualQuestion) - .isEqualTo(expectedQuestion) - } - - @Test - fun `returns question from expected level`() { - val expectedQuestion = Question( - text = "hello?", - level = Question.Level.Medium, - levelDescription = "This is a test.", - gameSetIds = listOf("1"), - ) - val question = expectedQuestion.copy( - level = Question.Level.Hard, - ) - whenever(questionRepository.getAll()) - .thenReturn(listOf(expectedQuestion, question)) - - val actualQuestion = subject.invoke("1", level = Question.Level.Medium) - assertThat(actualQuestion) - .isEqualTo(expectedQuestion) - } -}