From a2bca462a49e1ae42f3cee3fc165369edeff473f Mon Sep 17 00:00:00 2001 From: joaomanaia Date: Sun, 31 Dec 2023 16:30:59 +0000 Subject: [PATCH 1/9] Added maze feature module, improved maze path --- app/build.gradle.kts | 1 + feature/maze/.gitignore | 1 + feature/maze/build.gradle.kts | 8 + feature/maze/src/main/AndroidManifest.xml | 2 + .../newquiz/feature/maze/MazeScreen.kt | 2 + .../feature/maze/components/MazePath.kt | 452 ++++++++++++++++++ maze-quiz/build.gradle.kts | 2 + .../newquiz/maze_quiz/MazeScreen.kt | 10 + settings.gradle.kts | 1 + 9 files changed, 479 insertions(+) create mode 100644 feature/maze/.gitignore create mode 100644 feature/maze/build.gradle.kts create mode 100644 feature/maze/src/main/AndroidManifest.xml create mode 100644 feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt create mode 100644 feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazePath.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3b0ef041..4bd6430d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -125,6 +125,7 @@ dependencies { implementation(projects.core.userServices) implementation(projects.core.userServices.ui) implementation(projects.feature.settings) + implementation(projects.feature.maze) implementation(projects.model) implementation(projects.multiChoiceQuiz) implementation(projects.wordle) diff --git a/feature/maze/.gitignore b/feature/maze/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/maze/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/maze/build.gradle.kts b/feature/maze/build.gradle.kts new file mode 100644 index 00000000..1d167afc --- /dev/null +++ b/feature/maze/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.newquiz.android.feature) + alias(libs.plugins.newquiz.android.compose.destinations) +} + +android { + namespace = "com.infinitepower.newquiz.feature.maze" +} diff --git a/feature/maze/src/main/AndroidManifest.xml b/feature/maze/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8072ee00 --- /dev/null +++ b/feature/maze/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt new file mode 100644 index 00000000..6a7f39b6 --- /dev/null +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt @@ -0,0 +1,2 @@ +package com.infinitepower.newquiz.feature.maze + diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazePath.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazePath.kt new file mode 100644 index 00000000..211b9ba9 --- /dev/null +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazePath.kt @@ -0,0 +1,452 @@ +package com.infinitepower.newquiz.feature.maze.components + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +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.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.infinitepower.newquiz.core.theme.NewQuizTheme +import com.infinitepower.newquiz.core.theme.spacing +import com.infinitepower.newquiz.core.util.collections.indexOfFirstOrNull +import com.infinitepower.newquiz.model.maze.MazeQuiz +import com.infinitepower.newquiz.model.maze.isItemPlayed +import com.infinitepower.newquiz.model.maze.isPlayableItem +import com.infinitepower.newquiz.model.question.QuestionDifficulty +import com.infinitepower.newquiz.model.wordle.WordleQuizType +import com.infinitepower.newquiz.model.wordle.WordleWord +import kotlin.math.PI +import kotlin.math.pow +import kotlin.math.sin +import kotlin.random.Random + +@Composable +fun MazePath( + modifier: Modifier = Modifier, + items: List, + colors: MazeColors = MazeDefaults.defaultColors(), + horizontalPadding: Dp = MazeDefaults.horizontalPadding, + verticalPadding: Dp = MazeDefaults.verticalPadding, + random: Random = Random.Default, + onItemClick: (item: MazeQuiz.MazeItem) -> Unit = {}, +) { + val localDensity = LocalDensity.current + val horizontalPaddingPx = with(localDensity) { horizontalPadding.toPx() } + val verticalPaddingPx = with(localDensity) { verticalPadding.toPx() } + + val yPointsSize = items.size + + val currentPlayItemIndex = remember(items) { + items.indexOfFirstOrNull { !it.played } + } + + BoxWithConstraints( + modifier = modifier.fillMaxSize() + ) { + val screenHeight = constraints.maxHeight + val screenWidth = constraints.maxWidth + + val points: List = remember(yPointsSize) { + List(yPointsSize) { i -> + val r = random.nextDouble(2.0, 5.0).toFloat() + + val x = sin((i.toFloat() / 2) * PI).toFloat() * (screenWidth - horizontalPaddingPx) / r + screenWidth / 2 + val y = localDensity.getPointY( + height = screenHeight.toFloat(), + index = i, + verticalPaddingPx = verticalPaddingPx, + ) + + Offset(x, y) + } + } + + val graphHeight = remember(yPointsSize) { + with(localDensity) { PointSpacing.toPx() * (yPointsSize - 1) } + 2 * verticalPaddingPx + } + + var topScroll by remember { mutableFloatStateOf(0f) } + val topScrollAnimated = animateFloatAsState( + targetValue = topScroll, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ), + label = "Top Scroll" + ) + + val playPainter = rememberVectorPainter(image = Icons.Rounded.PlayArrow) + val playedPainter = rememberVectorPainter(image = Icons.Rounded.Check) + val lockPainter = rememberVectorPainter(image = Icons.Rounded.Lock) + + var lastTapOffset by remember { mutableStateOf(Offset.Zero) } + var pressedOffset by remember { mutableStateOf(Offset.Zero) } + var graphLastTapOffset by remember { mutableStateOf(Offset.Zero) } + + Canvas( + modifier = modifier + .fillMaxWidth() + .height(graphHeight.dp) + .pointerInput(Unit) { + detectVerticalDragGestures { _, dragAmount -> + val newTopScroll = topScroll + dragAmount + val newGraphHeight = graphHeight - newTopScroll + + if (newTopScroll >= 0 && newGraphHeight >= screenHeight) { + topScroll += dragAmount + } + } + } + .pointerInput(currentPlayItemIndex) { + detectTapGestures( + onTap = { tapOffset -> + lastTapOffset = tapOffset + + val topScreenGraph = graphHeight - screenHeight - topScroll + graphLastTapOffset = tapOffset.copy( + y = topScreenGraph + tapOffset.y + ) + + // Find the index of the item that was tapped + val tapIndex = points.indexOfFirstOrNull { point -> + val realMazePoint = point.copy(y = point.y + topScroll) + tapOffset.isInsideCircle(realMazePoint, CircleSize.toPx()) + } + + if (tapIndex != null && tapIndex in items.indices) { + if (items.isPlayableItem(tapIndex) || items.isItemPlayed(tapIndex)) { + onItemClick(items[tapIndex]) + } + } + }, + onPress = { pressOffset -> + pressedOffset = pressOffset + awaitRelease() + pressedOffset = Offset.Zero + } + ) + } + ) { + val completedPath = Path() + val remainingPath = Path() + + points.forEachIndexed { i, point -> + val currentY = point.y + + val path = if (items.isItemPlayed(i) || items.isPlayableItem(i)) completedPath else remainingPath + + val isPlayItem = items.isPlayableItem(i - 1) + + val previousY = getPointY( + height = size.height, + index = i - 1, + verticalPaddingPx = verticalPaddingPx, + ) + + if (i == 0) { + path.moveTo(size.width / 2, currentY) + } else { + if (isPlayItem) { + path.moveTo( + x = points[i - 1].x, + y = previousY + ) + } + + val conY1 = PathSmoothness * previousY + (1 - PathSmoothness) * currentY + val conY2 = PathSmoothness * currentY + (1 - PathSmoothness) * previousY + + val conX1 = points[i - 1].x + val conX2 = points[i].x + + path.cubicTo( + x1 = conX1, + y1 = conY1, + x2 = conX2, + y2 = conY2, + x3 = points[i].x, + y3 = currentY + ) + } + } + + translate(top = topScrollAnimated.value) { + drawPath( + path = completedPath, + color = colors.pathColor(played = true), + style = Stroke( + width = LineSize.toPx(), + cap = StrokeCap.Round, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(50f, 50f)) + ), + ) + + drawPath( + path = remainingPath, + color = colors.pathColor(played = false), + style = Stroke( + width = LineSize.toPx(), + cap = StrokeCap.Round, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(50f, 50f)) + ), + ) + + points.forEachIndexed { itemIndex, pointOffset -> + val itemPlayed = items.isItemPlayed(itemIndex) + val isPlayableItem = items.isPlayableItem(itemIndex) + + val canPress = (isPlayableItem || itemPlayed) && pressedOffset.isInsideCircle( + pointOffset, + CircleSize.toPx() + ) + + drawCircle( + color = colors.circleContainerColor( + played = itemPlayed, + isPlayItem = isPlayableItem + ), + radius = if (canPress) { + CircleSize.toPx() * CirclePressScale + } else { + CircleSize.toPx() + }, + center = pointOffset + ) + + if (isPlayableItem) { + drawCircle( + color = colors.currentCircleInnerColor(), + radius = if (canPress) { + CurrentCircleInnerSize.toPx() * CirclePressScale + } else { + CurrentCircleInnerSize.toPx() + }, + center = pointOffset + ) + } + + translate( + left = pointOffset.x - IconSize.toPx() / 2, + top = pointOffset.y - IconSize.toPx() / 2 + ) { + with( + when { + !isPlayableItem && !itemPlayed -> lockPainter + !isPlayableItem && itemPlayed -> playedPainter + else -> playPainter + } + ) { + draw( + size = Size(IconSize.toPx(), IconSize.toPx()), + colorFilter = ColorFilter.tint( + colors.circleContentColor( + played = itemPlayed, + isPlayItem = isPlayableItem + ) + ) + ) + } + } + } + } + } + } +} + +private fun Offset.isInsideCircle( + center: Offset, + radius: Float +): Boolean { + val dx = x - center.x + val dy = y - center.y + return dx.pow(2) + dy.pow(2) <= radius.pow(2) +} + +private fun Density.getPointY( + height: Float, + index: Int, + verticalPaddingPx: Float, +): Float { + return height - index * PointSpacing.toPx() - verticalPaddingPx +} + +object MazeDefaults { + /** + * The horizontal padding applied to the graph. + */ + val horizontalPadding: Dp @Composable get() = 120.dp + + /** + * The vertical padding applied to the graph. + */ + val verticalPadding: Dp @Composable get() = MaterialTheme.spacing.extraLarge + + @Composable + fun defaultColors( + playedPathColor: Color = MaterialTheme.colorScheme.primary, + playedCircleContainerColor: Color = MaterialTheme.colorScheme.primary, + playedCircleContentColor: Color = MaterialTheme.colorScheme.onPrimary, + lockedPathColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.75f), + lockedCircleContainerColor: Color = MaterialTheme.colorScheme.surfaceVariant, + lockedCircleContentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), + currentCircleContainerColor: Color = playedCircleContainerColor, + currentCircleInnerColor: Color = MaterialTheme.colorScheme.surface, + currentCircleContentColor: Color = currentCircleContainerColor, + ): MazeColors = MazeColors( + playedPathColor = playedPathColor, + playedCircleContainerColor = playedCircleContainerColor, + playedCircleContentColor = playedCircleContentColor, + lockedPathColor = lockedPathColor, + lockedCircleContainerColor = lockedCircleContainerColor, + lockedCircleContentColor = lockedCircleContentColor, + currentCircleContainerColor = currentCircleContainerColor, + currentCircleInnerColor = currentCircleInnerColor, + currentCircleContentColor = currentCircleContentColor, + ) +} + +@Immutable +class MazeColors internal constructor( + private val playedPathColor: Color, + private val playedCircleContainerColor: Color, + private val playedCircleContentColor: Color, + private val lockedPathColor: Color, + private val lockedCircleContainerColor: Color, + private val lockedCircleContentColor: Color, + private val currentCircleInnerColor: Color, + private val currentCircleContainerColor: Color, + private val currentCircleContentColor: Color, +) { + internal fun pathColor(played: Boolean): Color { + return if (played) playedPathColor else lockedPathColor + } + + internal fun circleContainerColor( + played: Boolean, + isPlayItem: Boolean, + ): Color { + return when { + !isPlayItem && !played -> lockedCircleContainerColor + !isPlayItem && played -> playedCircleContainerColor + else -> currentCircleContainerColor + } + } + + internal fun circleContentColor( + played: Boolean, + isPlayItem: Boolean, + ): Color = when { + !isPlayItem && !played -> lockedCircleContentColor + !isPlayItem && played -> playedCircleContentColor + else -> currentCircleContentColor + } + + internal fun currentCircleInnerColor(): Color { + return currentCircleInnerColor + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MazeColors) return false + + if (playedPathColor != other.playedPathColor) return false + if (lockedPathColor != other.lockedPathColor) return false + if (playedCircleContainerColor != other.playedCircleContainerColor) return false + if (lockedCircleContainerColor != other.lockedCircleContainerColor) return false + if (playedCircleContentColor != other.playedCircleContentColor) return false + if (lockedCircleContentColor != other.lockedCircleContentColor) return false + + return true + } + + override fun hashCode(): Int { + var result = playedPathColor.hashCode() + result = 31 * result + lockedPathColor.hashCode() + result = 31 * result + playedCircleContainerColor.hashCode() + result = 31 * result + lockedCircleContainerColor.hashCode() + result = 31 * result + playedCircleContentColor.hashCode() + result = 31 * result + lockedCircleContentColor.hashCode() + return result + } +} + +private val PointSpacing = 100.dp +private val CircleSize = 30.dp +private val CurrentCircleInnerSize = 20.dp +private val IconSize = 30.dp +private val LineSize = 12.dp + +/** + * The smoothness of the path. + */ +private const val PathSmoothness = 0.25f + +private const val CirclePressScale = 1.1f + +@Composable +@PreviewLightDark +private fun MazeComponentPreview() { + val completedItems = List(4) { + MazeQuiz.MazeItem.Wordle( + wordleWord = WordleWord("1+1=2"), + difficulty = QuestionDifficulty.Easy, + played = true, + wordleQuizType = WordleQuizType.MATH_FORMULA, + mazeSeed = 0 + ) + } + + val otherItems = List(8) { + MazeQuiz.MazeItem.Wordle( + wordleWord = WordleWord("1+1=2"), + difficulty = QuestionDifficulty.Easy, + wordleQuizType = WordleQuizType.MATH_FORMULA, + mazeSeed = 0 + ) + } + + NewQuizTheme { + Surface { + Box( + modifier = Modifier + .fillMaxWidth() + ) { + MazePath( + items = completedItems + otherItems, + random = Random(0) + ) + } + } + } +} diff --git a/maze-quiz/build.gradle.kts b/maze-quiz/build.gradle.kts index aa9f5705..90926c06 100644 --- a/maze-quiz/build.gradle.kts +++ b/maze-quiz/build.gradle.kts @@ -38,4 +38,6 @@ dependencies { implementation(projects.model) implementation(projects.data) implementation(projects.domain) + + implementation(projects.feature.maze) } diff --git a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt index 389fffd7..a628f4e5 100644 --- a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt +++ b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt @@ -32,6 +32,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing +import com.infinitepower.newquiz.feature.maze.components.MazePath import com.infinitepower.newquiz.maze_quiz.components.GenerateMazeComponent import com.infinitepower.newquiz.maze_quiz.components.MazeComponent import com.infinitepower.newquiz.model.maze.MazeQuiz @@ -42,6 +43,7 @@ import com.infinitepower.newquiz.model.wordle.WordleWord import com.ramcosta.composedestinations.annotation.DeepLink import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import kotlin.random.Random import com.infinitepower.newquiz.core.R as CoreR @Composable @@ -162,11 +164,19 @@ private fun MazeScreenImpl( } if (formulas.isNotEmpty()) { + /* MazeComponent( modifier = Modifier.fillMaxSize(), items = formulas, onItemClick = onItemClick ) + */ + MazePath( + modifier = Modifier.fillMaxSize(), + items = formulas, + random = Random(0), + onItemClick = onItemClick, + ) } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 75418c12..2fc6c595 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,7 @@ include(":core:user-services") include(":core:user-services:ui") include(":feature:settings") +include(":feature:maze") include(":model") include(":domain") From 61f4b1e14b99cbdd18be1309623869955772c343 Mon Sep 17 00:00:00 2001 From: joaomanaia Date: Mon, 1 Jan 2024 15:48:21 +0000 Subject: [PATCH 2/9] Moved maze screen to :feature:maze module --- app/build.gradle.kts | 1 - .../newquiz/core/navigation/AppNavGraphs.kt | 2 +- .../navigation/CommonNavGraphNavigator.kt | 2 +- .../ui/navigation/NavigationContainer.kt | 2 +- feature/maze/build.gradle.kts | 11 + .../newquiz/feature/maze/MazeScreen.kt | 195 ++++++++++++++++++ .../feature/maze/MazeScreenNavigator.kt | 7 + .../newquiz/feature/maze/MazeScreenUiEvent.kt | 14 ++ .../newquiz/feature/maze/MazeScreenUiState.kt | 18 ++ .../feature/maze/MazeScreenViewModel.kt | 85 ++++++++ .../feature/maze/components/MazePath.kt | 58 ++++-- .../newquiz/maze_quiz/MazeScreen.kt | 7 - .../newquiz/model/maze/MazeQuiz.kt | 2 +- 13 files changed, 378 insertions(+), 26 deletions(-) create mode 100644 feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenNavigator.kt create mode 100644 feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiEvent.kt create mode 100644 feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiState.kt create mode 100644 feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4bd6430d..d615ec16 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -131,7 +131,6 @@ dependencies { implementation(projects.wordle) implementation(projects.data) implementation(projects.domain) - implementation(projects.mazeQuiz) implementation(projects.comparisonQuiz) implementation(projects.dailyChallenge) } diff --git a/app/src/main/java/com/infinitepower/newquiz/core/navigation/AppNavGraphs.kt b/app/src/main/java/com/infinitepower/newquiz/core/navigation/AppNavGraphs.kt index 0052fc24..a02132c1 100644 --- a/app/src/main/java/com/infinitepower/newquiz/core/navigation/AppNavGraphs.kt +++ b/app/src/main/java/com/infinitepower/newquiz/core/navigation/AppNavGraphs.kt @@ -13,7 +13,7 @@ import com.infinitepower.newquiz.core.remote_config.RemoteConfig import com.infinitepower.newquiz.core.user_services.ui.profile.destinations.ProfileScreenDestination import com.infinitepower.newquiz.daily_challenge.destinations.DailyChallengeScreenDestination import com.infinitepower.newquiz.feature.settings.destinations.SettingsScreenDestination -import com.infinitepower.newquiz.maze_quiz.destinations.MazeScreenDestination +import com.infinitepower.newquiz.feature.maze.destinations.MazeScreenDestination import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizListScreenDestination import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizResultsScreenDestination import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizScreenDestination diff --git a/app/src/main/java/com/infinitepower/newquiz/core/navigation/CommonNavGraphNavigator.kt b/app/src/main/java/com/infinitepower/newquiz/core/navigation/CommonNavGraphNavigator.kt index b2c6c9fe..b31665ac 100644 --- a/app/src/main/java/com/infinitepower/newquiz/core/navigation/CommonNavGraphNavigator.kt +++ b/app/src/main/java/com/infinitepower/newquiz/core/navigation/CommonNavGraphNavigator.kt @@ -6,7 +6,7 @@ import com.infinitepower.newquiz.core.remote_config.RemoteConfig import com.infinitepower.newquiz.core.remote_config.RemoteConfigValue import com.infinitepower.newquiz.core.remote_config.get import com.infinitepower.newquiz.daily_challenge.DailyChallengeScreenNavigator -import com.infinitepower.newquiz.maze_quiz.MazeScreenNavigator +import com.infinitepower.newquiz.feature.maze.MazeScreenNavigator import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.maze.MazeQuiz diff --git a/app/src/main/java/com/infinitepower/newquiz/ui/navigation/NavigationContainer.kt b/app/src/main/java/com/infinitepower/newquiz/ui/navigation/NavigationContainer.kt index 1f541d35..95f1c30a 100644 --- a/app/src/main/java/com/infinitepower/newquiz/ui/navigation/NavigationContainer.kt +++ b/app/src/main/java/com/infinitepower/newquiz/ui/navigation/NavigationContainer.kt @@ -27,7 +27,7 @@ import com.infinitepower.newquiz.core.navigation.ScreenType import com.infinitepower.newquiz.core.user_services.ui.profile.destinations.ProfileScreenDestination import com.infinitepower.newquiz.daily_challenge.destinations.DailyChallengeScreenDestination import com.infinitepower.newquiz.feature.settings.destinations.SettingsScreenDestination -import com.infinitepower.newquiz.maze_quiz.destinations.MazeScreenDestination +import com.infinitepower.newquiz.feature.maze.destinations.MazeScreenDestination import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizListScreenDestination import com.infinitepower.newquiz.wordle.destinations.WordleListScreenDestination import com.ramcosta.composedestinations.spec.DestinationSpec diff --git a/feature/maze/build.gradle.kts b/feature/maze/build.gradle.kts index 1d167afc..620e3ecf 100644 --- a/feature/maze/build.gradle.kts +++ b/feature/maze/build.gradle.kts @@ -6,3 +6,14 @@ plugins { android { namespace = "com.infinitepower.newquiz.feature.maze" } + +dependencies { + implementation(libs.androidx.work.ktx) + + implementation(libs.androidx.lifecycle.livedata.ktx) + + implementation(libs.androidx.compose.material.iconsExtended) + + implementation(projects.data) + implementation(projects.domain) +} diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt index 6a7f39b6..0a43dc1b 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt @@ -1,2 +1,197 @@ package com.infinitepower.newquiz.feature.maze +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.infinitepower.newquiz.core.theme.NewQuizTheme +import com.infinitepower.newquiz.core.ui.components.icon.button.BackIconButton +import com.infinitepower.newquiz.feature.maze.components.MazePath +import com.infinitepower.newquiz.model.maze.MazeQuiz +import com.infinitepower.newquiz.model.maze.MazeQuiz.MazeItem +import com.infinitepower.newquiz.model.question.QuestionDifficulty +import com.infinitepower.newquiz.model.wordle.WordleQuizType +import com.infinitepower.newquiz.model.wordle.WordleWord +import com.ramcosta.composedestinations.annotation.DeepLink +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.infinitepower.newquiz.core.R as CoreR + +@Composable +@Destination( + deepLinks = [ + DeepLink(uriPattern = "newquiz://maze") + ] +) +@OptIn(ExperimentalMaterial3Api::class) +fun MazeScreen( + navigator: DestinationsNavigator, + mazeScreenNavigator: MazeScreenNavigator, + viewModel: MazeScreenViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + MazeScreenImpl( + uiState = uiState, + navigateBack = navigator::popBackStack, + uiEvent = viewModel::onEvent, + onItemClick = mazeScreenNavigator::navigateToGame + ) +} + +@Composable +@ExperimentalMaterial3Api +private fun MazeScreenImpl( + uiState: MazeScreenUiState, + navigateBack: () -> Unit, + uiEvent: (event: MazeScreenUiEvent) -> Unit, + onItemClick: (item: MazeItem) -> Unit +) { + val clipboardManager = LocalClipboardManager.current + + var moreOptionsExpanded by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text(text = stringResource(id = CoreR.string.maze)) + }, + navigationIcon = { BackIconButton(onClick = navigateBack) }, + actions = { + IconButton(onClick = { moreOptionsExpanded = true }) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(id = CoreR.string.more_options) + ) + } + DropdownMenu( + expanded = moreOptionsExpanded, + onDismissRequest = { moreOptionsExpanded = false } + ) { + if (!uiState.isMazeEmpty) { + uiState.mazeSeed?.let { mazeSeed -> + DropdownMenuItem( + text = { Text(stringResource(id = CoreR.string.copy_maze_seed)) }, + onClick = { + clipboardManager.setText(AnnotatedString(mazeSeed.toString())) + moreOptionsExpanded = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.ContentCopy, + contentDescription = stringResource(id = CoreR.string.copy_maze_seed) + ) + } + ) + } + DropdownMenuItem( + text = { Text(stringResource(id = CoreR.string.restart_maze)) }, + onClick = { + uiEvent(MazeScreenUiEvent.RestartMaze) + moreOptionsExpanded = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.RestartAlt, + contentDescription = stringResource(id = CoreR.string.restart_maze) + ) + } + ) + } + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + ) { + if (uiState.loading || uiState.generatingMaze) CircularProgressIndicator() + + if (!uiState.loading && uiState.isMazeEmpty) { + /* + GenerateMazeComponent( + modifier = Modifier.fillMaxSize(), + onGenerateClick = { seed, multiChoiceCategories, wordleCategories -> + uiEvent(MazeScreenUiEvent.GenerateMaze(seed, multiChoiceCategories, wordleCategories)) + } + ) + + */ + } + + if (!uiState.isMazeEmpty) { + MazePath( + modifier = Modifier.fillMaxSize(), + items = uiState.maze.items, + mazeSeed = uiState.mazeSeed ?: 0, + onItemClick = onItemClick, + ) + } + } + } +} + +@Composable +@PreviewLightDark +@OptIn(ExperimentalMaterial3Api::class) +fun MazeScreenPreview() { + val completedItems = List(3) { + MazeItem.Wordle( + wordleWord = WordleWord("1+1=2"), + difficulty = QuestionDifficulty.Easy, + played = true, + wordleQuizType = WordleQuizType.MATH_FORMULA, + mazeSeed = 0 + ) + } + + val otherItems = List(8) { + MazeItem.Wordle( + wordleWord = WordleWord("1+1=2"), + difficulty = QuestionDifficulty.Easy, + wordleQuizType = WordleQuizType.MATH_FORMULA, + mazeSeed = 0 + ) + } + + val mazeItems = completedItems + otherItems + + NewQuizTheme { + MazeScreenImpl( + uiState = MazeScreenUiState( + loading = false, + maze = MazeQuiz(items = mazeItems) + ), + navigateBack = {}, + uiEvent = {}, + onItemClick = {} + ) + } +} \ No newline at end of file diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenNavigator.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenNavigator.kt new file mode 100644 index 00000000..e347f314 --- /dev/null +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenNavigator.kt @@ -0,0 +1,7 @@ +package com.infinitepower.newquiz.feature.maze + +import com.infinitepower.newquiz.model.maze.MazeQuiz + +interface MazeScreenNavigator { + fun navigateToGame(item: MazeQuiz.MazeItem) +} \ No newline at end of file diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiEvent.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiEvent.kt new file mode 100644 index 00000000..1deffb7a --- /dev/null +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiEvent.kt @@ -0,0 +1,14 @@ +package com.infinitepower.newquiz.feature.maze + +import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory +import com.infinitepower.newquiz.model.wordle.WordleCategory + +sealed interface MazeScreenUiEvent { + data class GenerateMaze( + val seed: Int?, + val selectedMultiChoiceCategories: List, + val selectedWordleCategories: List + ) : MazeScreenUiEvent + + data object RestartMaze : MazeScreenUiEvent +} \ No newline at end of file diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiState.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiState.kt new file mode 100644 index 00000000..17fcf1ef --- /dev/null +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiState.kt @@ -0,0 +1,18 @@ +package com.infinitepower.newquiz.feature.maze + +import androidx.annotation.Keep +import com.infinitepower.newquiz.model.maze.MazeQuiz +import com.infinitepower.newquiz.model.maze.emptyMaze + +@Keep +data class MazeScreenUiState( + val maze: MazeQuiz = emptyMaze(), + val loading: Boolean = true, + val generatingMaze: Boolean = false, +) { + val isMazeEmpty: Boolean + get() = maze.items.isEmpty() + + val mazeSeed: Int? + get() = maze.items.firstOrNull()?.mazeSeed +} diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt new file mode 100644 index 00000000..66cd96eb --- /dev/null +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt @@ -0,0 +1,85 @@ +package com.infinitepower.newquiz.feature.maze + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewModelScope +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.infinitepower.newquiz.core.analytics.AnalyticsEvent +import com.infinitepower.newquiz.core.analytics.AnalyticsHelper +import com.infinitepower.newquiz.data.worker.maze.CleanMazeQuizWorker +import com.infinitepower.newquiz.data.worker.maze.GenerateMazeQuizWorker +import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository +import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory +import com.infinitepower.newquiz.model.wordle.WordleCategory +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@HiltViewModel +class MazeScreenViewModel @Inject constructor( + mazeMathQuizRepository: MazeQuizRepository, + private val workManager: WorkManager, + private val analyticsHelper: AnalyticsHelper +) : ViewModel() { + private val _uiState = MutableStateFlow(MazeScreenUiState()) + val uiState = combine( + _uiState, + mazeMathQuizRepository.getSavedMazeQuizFlow() + ) { uiState, savedMazeQuiz -> + uiState.copy( + maze = savedMazeQuiz, + loading = false + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = MazeScreenUiState() + ) + + fun onEvent(event: MazeScreenUiEvent) { + when (event) { + is MazeScreenUiEvent.GenerateMaze -> generateMaze(event.seed, event.selectedMultiChoiceCategories, event.selectedWordleCategories) + is MazeScreenUiEvent.RestartMaze -> cleanSavedMaze() + } + } + + private fun generateMaze( + seed: Int?, + selectedMultiChoiceCategories: List, + selectedWordleCategories: List + ) { + val workId = GenerateMazeQuizWorker.enqueue( + workManager = workManager, + seed = seed, + multiChoiceCategories = selectedMultiChoiceCategories, + wordleCategories = selectedWordleCategories + ) + + workManager + .getWorkInfoByIdLiveData(workId) + .asFlow() + .onEach { workInfo -> + _uiState.update { currentState -> + val loading = when (workInfo.state) { + WorkInfo.State.SUCCEEDED -> false + else -> true + } + + currentState.copy(loading = loading) + } + }.launchIn(viewModelScope) + } + + private fun cleanSavedMaze() { + analyticsHelper.logEvent(AnalyticsEvent.RestartMaze) + + CleanMazeQuizWorker.enqueue(workManager) + } +} diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazePath.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazePath.kt index 211b9ba9..b3bf9525 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazePath.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazePath.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -55,18 +56,22 @@ import kotlin.random.Random fun MazePath( modifier: Modifier = Modifier, items: List, + startScrollToCurrentItem: Boolean = true, colors: MazeColors = MazeDefaults.defaultColors(), horizontalPadding: Dp = MazeDefaults.horizontalPadding, verticalPadding: Dp = MazeDefaults.verticalPadding, - random: Random = Random.Default, + mazeSeed: Int = 0, onItemClick: (item: MazeQuiz.MazeItem) -> Unit = {}, ) { + val random = remember(mazeSeed) { Random(mazeSeed) } + val localDensity = LocalDensity.current val horizontalPaddingPx = with(localDensity) { horizontalPadding.toPx() } val verticalPaddingPx = with(localDensity) { verticalPadding.toPx() } val yPointsSize = items.size + // Find the index of the current play item. val currentPlayItemIndex = remember(items) { items.indexOfFirstOrNull { !it.played } } @@ -79,6 +84,7 @@ fun MazePath( val points: List = remember(yPointsSize) { List(yPointsSize) { i -> + // Get a random number between 2 and 5 to make the horizontal offset of the points more random val r = random.nextDouble(2.0, 5.0).toFloat() val x = sin((i.toFloat() / 2) * PI).toFloat() * (screenWidth - horizontalPaddingPx) / r + screenWidth / 2 @@ -92,6 +98,7 @@ fun MazePath( } } + // Calculate the height of the graph based on the number of points. val graphHeight = remember(yPointsSize) { with(localDensity) { PointSpacing.toPx() * (yPointsSize - 1) } + 2 * verticalPaddingPx } @@ -110,9 +117,34 @@ fun MazePath( val playedPainter = rememberVectorPainter(image = Icons.Rounded.Check) val lockPainter = rememberVectorPainter(image = Icons.Rounded.Lock) - var lastTapOffset by remember { mutableStateOf(Offset.Zero) } var pressedOffset by remember { mutableStateOf(Offset.Zero) } - var graphLastTapOffset by remember { mutableStateOf(Offset.Zero) } + + // If the startScrollToCurrentItem is true, scroll to the current item. + LaunchedEffect(key1 = Unit) { + if (startScrollToCurrentItem) { + currentPlayItemIndex?.let { index -> + // Get the current play item's y position. + val currentPlayItemY = points[index].y + // Get the height of the screen divided by 2. + // This is used to center the current play item on the screen. + val halfScreenHeight = screenHeight / 2 + + val newTopScroll = -currentPlayItemY + halfScreenHeight + + // If the top scroll is less than 0, it means that the current play item is + // not above the screen. In this case, we don't need to scroll. + if (newTopScroll >= 0) { + val newGraphHeight = graphHeight - newTopScroll + + topScroll = if (newGraphHeight >= screenHeight) { + newTopScroll + } else { + graphHeight - screenHeight + } + } + } + } + } Canvas( modifier = modifier @@ -131,13 +163,6 @@ fun MazePath( .pointerInput(currentPlayItemIndex) { detectTapGestures( onTap = { tapOffset -> - lastTapOffset = tapOffset - - val topScreenGraph = graphHeight - screenHeight - topScroll - graphLastTapOffset = tapOffset.copy( - y = topScreenGraph + tapOffset.y - ) - // Find the index of the item that was tapped val tapIndex = points.indexOfFirstOrNull { point -> val realMazePoint = point.copy(y = point.y + topScroll) @@ -151,7 +176,9 @@ fun MazePath( } }, onPress = { pressOffset -> - pressedOffset = pressOffset + pressedOffset = pressOffset.copy( + y = pressOffset.y - topScroll + ) awaitRelease() pressedOffset = Offset.Zero } @@ -262,8 +289,8 @@ fun MazePath( ) { with( when { - !isPlayableItem && !itemPlayed -> lockPainter - !isPlayableItem && itemPlayed -> playedPainter + !isPlayableItem && !itemPlayed -> lockPainter // Locked + !isPlayableItem && itemPlayed -> playedPainter // Played else -> playPainter } ) { @@ -284,6 +311,9 @@ fun MazePath( } } +/** + * Returns true if the [Offset] is inside the circle with the given [center] and [radius]. + */ private fun Offset.isInsideCircle( center: Offset, radius: Float @@ -444,7 +474,7 @@ private fun MazeComponentPreview() { ) { MazePath( items = completedItems + otherItems, - random = Random(0) + startScrollToCurrentItem = false, ) } } diff --git a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt index a628f4e5..010cfd1d 100644 --- a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt +++ b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt @@ -34,7 +34,6 @@ import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.feature.maze.components.MazePath import com.infinitepower.newquiz.maze_quiz.components.GenerateMazeComponent -import com.infinitepower.newquiz.maze_quiz.components.MazeComponent import com.infinitepower.newquiz.model.maze.MazeQuiz import com.infinitepower.newquiz.model.maze.MazeQuiz.MazeItem import com.infinitepower.newquiz.model.question.QuestionDifficulty @@ -47,11 +46,6 @@ import kotlin.random.Random import com.infinitepower.newquiz.core.R as CoreR @Composable -@Destination( - deepLinks = [ - DeepLink(uriPattern = "newquiz://maze") - ] -) @OptIn(ExperimentalMaterial3Api::class) fun MazeScreen( navigator: DestinationsNavigator, @@ -174,7 +168,6 @@ private fun MazeScreenImpl( MazePath( modifier = Modifier.fillMaxSize(), items = formulas, - random = Random(0), onItemClick = onItemClick, ) } diff --git a/model/src/main/java/com/infinitepower/newquiz/model/maze/MazeQuiz.kt b/model/src/main/java/com/infinitepower/newquiz/model/maze/MazeQuiz.kt index b76c23a6..5452fbb2 100644 --- a/model/src/main/java/com/infinitepower/newquiz/model/maze/MazeQuiz.kt +++ b/model/src/main/java/com/infinitepower/newquiz/model/maze/MazeQuiz.kt @@ -66,4 +66,4 @@ fun List.isPlayableItem(index: Int): Boolean { */ infix fun List.isItemPlayed( index: Int -): Boolean = getOrNull(index)?.played == true \ No newline at end of file +): Boolean = getOrNull(index)?.played == true From a5af47c9a42c765d3a0c8ec3f6df93dfc8713ced Mon Sep 17 00:00:00 2001 From: joaomanaia Date: Mon, 1 Jan 2024 16:44:41 +0000 Subject: [PATCH 3/9] Added auto scroll to current item in maze path --- .../core/datastore/common/SettingsCommon.kt | 2 ++ feature/maze/build.gradle.kts | 1 + .../newquiz/feature/maze/MazeScreen.kt | 1 + .../newquiz/feature/maze/MazeScreenUiState.kt | 1 + .../newquiz/feature/maze/MazeScreenViewModel.kt | 17 ++++++++++++++++- .../settings/screens/general/GeneralScreen.kt | 10 ++++++++++ 6 files changed, 31 insertions(+), 1 deletion(-) diff --git a/core/datastore/src/main/kotlin/com/infinitepower/newquiz/core/datastore/common/SettingsCommon.kt b/core/datastore/src/main/kotlin/com/infinitepower/newquiz/core/datastore/common/SettingsCommon.kt index 74727bf6..d851340b 100644 --- a/core/datastore/src/main/kotlin/com/infinitepower/newquiz/core/datastore/common/SettingsCommon.kt +++ b/core/datastore/src/main/kotlin/com/infinitepower/newquiz/core/datastore/common/SettingsCommon.kt @@ -55,6 +55,8 @@ object SettingsCommon { object WordleAnimationsEnabled : PreferenceRequest(booleanPreferencesKey("wordle_animations_enabled"), true) object MultiChoiceAnimationsEnabled : PreferenceRequest(booleanPreferencesKey("multi_choice_animations_enabled"), true) + object MazeAutoScrollToCurrentItem : PreferenceRequest(booleanPreferencesKey("mazeAutoScrollToCurrentItem"), true) + /** * The translation settings. */ diff --git a/feature/maze/build.gradle.kts b/feature/maze/build.gradle.kts index 620e3ecf..10d26111 100644 --- a/feature/maze/build.gradle.kts +++ b/feature/maze/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(libs.androidx.compose.material.iconsExtended) + implementation(projects.core.datastore) implementation(projects.data) implementation(projects.domain) } diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt index 0a43dc1b..73cfb5c0 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt @@ -152,6 +152,7 @@ private fun MazeScreenImpl( items = uiState.maze.items, mazeSeed = uiState.mazeSeed ?: 0, onItemClick = onItemClick, + startScrollToCurrentItem = uiState.autoScrollToCurrentItem ) } } diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiState.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiState.kt index 17fcf1ef..859bc35f 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiState.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiState.kt @@ -7,6 +7,7 @@ import com.infinitepower.newquiz.model.maze.emptyMaze @Keep data class MazeScreenUiState( val maze: MazeQuiz = emptyMaze(), + val autoScrollToCurrentItem: Boolean = true, val loading: Boolean = true, val generatingMaze: Boolean = false, ) { diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt index 66cd96eb..ebb90fd0 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt @@ -7,6 +7,9 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import com.infinitepower.newquiz.core.analytics.AnalyticsEvent import com.infinitepower.newquiz.core.analytics.AnalyticsHelper +import com.infinitepower.newquiz.core.datastore.common.SettingsCommon +import com.infinitepower.newquiz.core.datastore.di.SettingsDataStoreManager +import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import com.infinitepower.newquiz.data.worker.maze.CleanMazeQuizWorker import com.infinitepower.newquiz.data.worker.maze.GenerateMazeQuizWorker import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository @@ -20,13 +23,15 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class MazeScreenViewModel @Inject constructor( mazeMathQuizRepository: MazeQuizRepository, private val workManager: WorkManager, - private val analyticsHelper: AnalyticsHelper + private val analyticsHelper: AnalyticsHelper, + @SettingsDataStoreManager private val settingsDataStoreManager: DataStoreManager ) : ViewModel() { private val _uiState = MutableStateFlow(MazeScreenUiState()) val uiState = combine( @@ -43,6 +48,16 @@ class MazeScreenViewModel @Inject constructor( initialValue = MazeScreenUiState() ) + init { + viewModelScope.launch { + val autoScrollToCurrentItem = settingsDataStoreManager.getPreference(SettingsCommon.MazeAutoScrollToCurrentItem) + + _uiState.update { currentState -> + currentState.copy(autoScrollToCurrentItem = autoScrollToCurrentItem) + } + } + } + fun onEvent(event: MazeScreenUiEvent) { when (event) { is MazeScreenUiEvent.GenerateMaze -> generateMaze(event.seed, event.selectedMultiChoiceCategories, event.selectedWordleCategories) diff --git a/feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/screens/general/GeneralScreen.kt b/feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/screens/general/GeneralScreen.kt index e9703a7d..45278a32 100644 --- a/feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/screens/general/GeneralScreen.kt +++ b/feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/screens/general/GeneralScreen.kt @@ -112,6 +112,16 @@ internal fun GeneralScreen( ) ) ), + Preference.PreferenceGroup( + title = "Maze", + preferenceItems = listOf( + Preference.PreferenceItem.SwitchPreference( + title = "Auto scroll to current question", + summary = "Automatically scroll to the current question when entering the maze page.", + request = SettingsCommon.MazeAutoScrollToCurrentItem, + ) + ) + ), Preference.PreferenceItem.TextPreference( title = stringResource(id = R.string.animations), icon = { From 61fce8e526a8f4e70fdb181482f3a7bf963086a1 Mon Sep 17 00:00:00 2001 From: joaomanaia Date: Mon, 1 Jan 2024 18:20:36 +0000 Subject: [PATCH 4/9] Improve readability of the code --- .../maze_quiz/MazeQuizRepositoryImpl.kt | 3 +- detekt.yml | 20 ++ feature/maze/build.gradle.kts | 1 + .../newquiz/feature/maze/MazeScreen.kt | 5 +- .../feature/maze/MazeScreenNavigator.kt | 2 +- .../newquiz/feature/maze/MazeScreenUiEvent.kt | 2 +- .../feature/maze/MazeScreenViewModel.kt | 17 +- .../feature/maze/components/MazePath.kt | 196 +++++++++++------- .../newquiz/maze_quiz/MazeScreen.kt | 3 +- model/build.gradle.kts | 2 + .../newquiz/model/maze/MazeQuiz.kt | 6 +- 11 files changed, 174 insertions(+), 83 deletions(-) diff --git a/data/src/main/java/com/infinitepower/newquiz/data/repository/maze_quiz/MazeQuizRepositoryImpl.kt b/data/src/main/java/com/infinitepower/newquiz/data/repository/maze_quiz/MazeQuizRepositoryImpl.kt index 820bdc3c..721d8cf6 100644 --- a/data/src/main/java/com/infinitepower/newquiz/data/repository/maze_quiz/MazeQuizRepositoryImpl.kt +++ b/data/src/main/java/com/infinitepower/newquiz/data/repository/maze_quiz/MazeQuizRepositoryImpl.kt @@ -6,6 +6,7 @@ import com.infinitepower.newquiz.core.database.model.toEntity import com.infinitepower.newquiz.core.database.model.toMazeQuizItem import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository import com.infinitepower.newquiz.model.maze.MazeQuiz +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -18,7 +19,7 @@ class MazeQuizRepositoryImpl @Inject constructor( override fun getSavedMazeQuizFlow(): Flow = mazeQuizDao .getAllMazeItemsFlow() .map { entities -> entities.map(MazeQuizItemEntity::toMazeQuizItem) } - .map { mazeItems -> MazeQuiz(items = mazeItems) } + .map { mazeItems -> MazeQuiz(items = mazeItems.toPersistentList()) } override suspend fun countAllItems(): Int = mazeQuizDao.countAllItems() diff --git a/detekt.yml b/detekt.yml index 5c4ac33e..ec74c2ce 100644 --- a/detekt.yml +++ b/detekt.yml @@ -2,22 +2,42 @@ config: warningsAsErrors: true complexity: + active: true LongParameterList: # It is suggested to increase this for Compose: https://detekt.dev/docs/introduction/compose/#longparameterlist functionThreshold: 8 + constructorThreshold: 20 ignoreDefaultParameters: true + ignoreAnnotated: + - "Composable" LongMethod: active: true + excludes: [ "*/generated/**" ] threshold: 100 + ignoreAnnotated: + - "Composable" + CyclomaticComplexMethod: + active: true + threshold: 15 + ignoreAnnotated: + - "Composable" naming: active: true FunctionNaming: active: true + functionPattern: '([a-z][a-zA-Z0-9]*)|(`.*`)' + excludeClassPattern: '$^' ignoreAnnotated: [ 'Composable' ] + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' style: MagicNumber: + ignoreAnnotated: [ 'Preview', 'PreviewScreenSizes', 'PreviewFontScale', 'PreviewLightDark', 'PreviewDynamicColors' ] excludes: [ '**/test/**', '**/*Test.kt', '**/demos/**', '**/androidUnitTest/**', '**/commonTest/**', '**/desktopTest/**' , '**/jsTest/**' ] MaxLineLength: excludes: [ '**/test/**', '**/*.Test.kt', '**/*.Spec.kt', '**/androidUnitTest/**', '**/commonTest/**', '**/desktopTest/**' , '**/jsTest/**' ] diff --git a/feature/maze/build.gradle.kts b/feature/maze/build.gradle.kts index 10d26111..4caec0ef 100644 --- a/feature/maze/build.gradle.kts +++ b/feature/maze/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.newquiz.android.feature) alias(libs.plugins.newquiz.android.compose.destinations) + id("newquiz.detekt") } android { diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt index 73cfb5c0..c0d123e9 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt @@ -39,6 +39,7 @@ import com.infinitepower.newquiz.model.wordle.WordleWord import com.ramcosta.composedestinations.annotation.DeepLink import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import kotlinx.collections.immutable.toPersistentList import com.infinitepower.newquiz.core.R as CoreR @Composable @@ -182,7 +183,7 @@ fun MazeScreenPreview() { ) } - val mazeItems = completedItems + otherItems + val mazeItems = (completedItems + otherItems).toPersistentList() NewQuizTheme { MazeScreenImpl( @@ -195,4 +196,4 @@ fun MazeScreenPreview() { onItemClick = {} ) } -} \ No newline at end of file +} diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenNavigator.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenNavigator.kt index e347f314..a420229b 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenNavigator.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenNavigator.kt @@ -4,4 +4,4 @@ import com.infinitepower.newquiz.model.maze.MazeQuiz interface MazeScreenNavigator { fun navigateToGame(item: MazeQuiz.MazeItem) -} \ No newline at end of file +} diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiEvent.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiEvent.kt index 1deffb7a..2c025821 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiEvent.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiEvent.kt @@ -11,4 +11,4 @@ sealed interface MazeScreenUiEvent { ) : MazeScreenUiEvent data object RestartMaze : MazeScreenUiEvent -} \ No newline at end of file +} diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt index ebb90fd0..a299dded 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt @@ -44,13 +44,19 @@ class MazeScreenViewModel @Inject constructor( ) }.stateIn( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), + started = SharingStarted.WhileSubscribed(UISTATE_STOP_TIMEOUT), initialValue = MazeScreenUiState() ) + companion object { + private const val UISTATE_STOP_TIMEOUT = 5000L + } + init { viewModelScope.launch { - val autoScrollToCurrentItem = settingsDataStoreManager.getPreference(SettingsCommon.MazeAutoScrollToCurrentItem) + val autoScrollToCurrentItem = settingsDataStoreManager.getPreference( + preferenceEntry = SettingsCommon.MazeAutoScrollToCurrentItem + ) _uiState.update { currentState -> currentState.copy(autoScrollToCurrentItem = autoScrollToCurrentItem) @@ -60,7 +66,12 @@ class MazeScreenViewModel @Inject constructor( fun onEvent(event: MazeScreenUiEvent) { when (event) { - is MazeScreenUiEvent.GenerateMaze -> generateMaze(event.seed, event.selectedMultiChoiceCategories, event.selectedWordleCategories) + is MazeScreenUiEvent.GenerateMaze -> generateMaze( + seed = event.seed, + selectedMultiChoiceCategories = event.selectedMultiChoiceCategories, + selectedWordleCategories = event.selectedWordleCategories + ) + is MazeScreenUiEvent.RestartMaze -> cleanSavedMaze() } } diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazePath.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazePath.kt index b3bf9525..3aa1d500 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazePath.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazePath.kt @@ -28,9 +28,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.vector.VectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity @@ -47,6 +52,8 @@ import com.infinitepower.newquiz.model.maze.isPlayableItem import com.infinitepower.newquiz.model.question.QuestionDifficulty import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.model.wordle.WordleWord +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList import kotlin.math.PI import kotlin.math.pow import kotlin.math.sin @@ -55,7 +62,7 @@ import kotlin.random.Random @Composable fun MazePath( modifier: Modifier = Modifier, - items: List, + items: ImmutableList, startScrollToCurrentItem: Boolean = true, colors: MazeColors = MazeDefaults.defaultColors(), horizontalPadding: Dp = MazeDefaults.horizontalPadding, @@ -85,16 +92,20 @@ fun MazePath( val points: List = remember(yPointsSize) { List(yPointsSize) { i -> // Get a random number between 2 and 5 to make the horizontal offset of the points more random - val r = random.nextDouble(2.0, 5.0).toFloat() + val r = random.nextDouble( + from = HorizontalOffsetRandomStart, + until = HorizontalOffsetRandomEnd + ).toFloat() - val x = sin((i.toFloat() / 2) * PI).toFloat() * (screenWidth - horizontalPaddingPx) / r + screenWidth / 2 + val horizontalWidth = screenWidth - horizontalPaddingPx + val x = sin((i.toFloat() / 2) * PI) * horizontalWidth / r + screenWidth / 2 val y = localDensity.getPointY( height = screenHeight.toFloat(), index = i, verticalPaddingPx = verticalPaddingPx, ) - Offset(x, y) + Offset(x.toFloat(), y) } } @@ -147,7 +158,7 @@ fun MazePath( } Canvas( - modifier = modifier + modifier = Modifier .fillMaxWidth() .height(graphHeight.dp) .pointerInput(Unit) { @@ -191,7 +202,8 @@ fun MazePath( points.forEachIndexed { i, point -> val currentY = point.y - val path = if (items.isItemPlayed(i) || items.isPlayableItem(i)) completedPath else remainingPath + val path = + if (items.isItemPlayed(i) || items.isPlayableItem(i)) completedPath else remainingPath val isPlayItem = items.isPlayableItem(i - 1) @@ -229,83 +241,111 @@ fun MazePath( } translate(top = topScrollAnimated.value) { - drawPath( + // Draw the path for the completed items + drawMazePath( path = completedPath, color = colors.pathColor(played = true), - style = Stroke( - width = LineSize.toPx(), - cap = StrokeCap.Round, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(50f, 50f)) - ), ) - drawPath( + // Draw the path for the remaining items + drawMazePath( path = remainingPath, color = colors.pathColor(played = false), - style = Stroke( - width = LineSize.toPx(), - cap = StrokeCap.Round, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(50f, 50f)) - ), ) - points.forEachIndexed { itemIndex, pointOffset -> - val itemPlayed = items.isItemPlayed(itemIndex) - val isPlayableItem = items.isPlayableItem(itemIndex) + // Draw the points + drawPoints( + points = points, + items = items, + colors = colors, + pressedOffset = pressedOffset, + lockPainter = lockPainter, + playPainter = playPainter, + playedPainter = playedPainter, + ) + } + } + } +} - val canPress = (isPlayableItem || itemPlayed) && pressedOffset.isInsideCircle( - pointOffset, - CircleSize.toPx() - ) +private fun DrawScope.drawMazePath( + path: Path, + color: Color, +) { + drawPath( + path = path, + color = color, + style = Stroke( + width = LineSize.toPx(), + cap = StrokeCap.Round, + pathEffect = PathEffect.dashPathEffect(MazePathEffectInterval) + ), + ) +} - drawCircle( - color = colors.circleContainerColor( - played = itemPlayed, - isPlayItem = isPlayableItem - ), - radius = if (canPress) { - CircleSize.toPx() * CirclePressScale - } else { - CircleSize.toPx() - }, - center = pointOffset - ) +private fun DrawScope.drawPoints( + points: List, + items: ImmutableList, + colors: MazeColors, + pressedOffset: Offset, + lockPainter: VectorPainter, + playPainter: VectorPainter, + playedPainter: VectorPainter, +) { + points.forEachIndexed { itemIndex, pointOffset -> + val itemPlayed = items.isItemPlayed(itemIndex) + val isPlayableItem = items.isPlayableItem(itemIndex) - if (isPlayableItem) { - drawCircle( - color = colors.currentCircleInnerColor(), - radius = if (canPress) { - CurrentCircleInnerSize.toPx() * CirclePressScale - } else { - CurrentCircleInnerSize.toPx() - }, - center = pointOffset - ) - } + val canPress = (isPlayableItem || itemPlayed) && pressedOffset.isInsideCircle( + pointOffset, + CircleSize.toPx() + ) - translate( - left = pointOffset.x - IconSize.toPx() / 2, - top = pointOffset.y - IconSize.toPx() / 2 - ) { - with( - when { - !isPlayableItem && !itemPlayed -> lockPainter // Locked - !isPlayableItem && itemPlayed -> playedPainter // Played - else -> playPainter - } - ) { - draw( - size = Size(IconSize.toPx(), IconSize.toPx()), - colorFilter = ColorFilter.tint( - colors.circleContentColor( - played = itemPlayed, - isPlayItem = isPlayableItem - ) - ) - ) - } - } + drawCircle( + color = colors.circleContainerColor( + played = itemPlayed, + isPlayItem = isPlayableItem + ), + radius = if (canPress) { + CircleSize.toPx() * CirclePressScale + } else { + CircleSize.toPx() + }, + center = pointOffset + ) + + if (isPlayableItem) { + drawCircle( + color = colors.currentCircleInnerColor(), + radius = if (canPress) { + CurrentCircleInnerSize.toPx() * CirclePressScale + } else { + CurrentCircleInnerSize.toPx() + }, + center = pointOffset + ) + } + + translate( + left = pointOffset.x - IconSize.toPx() / 2, + top = pointOffset.y - IconSize.toPx() / 2 + ) { + with( + when { + !isPlayableItem && !itemPlayed -> lockPainter // Locked + !isPlayableItem && itemPlayed -> playedPainter // Played + else -> playPainter } + ) { + draw( + size = Size(IconSize.toPx(), IconSize.toPx()), + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint( + colors.circleContentColor( + played = itemPlayed, + isPlayItem = isPlayableItem + ) + ) + ) } } } @@ -444,6 +484,16 @@ private const val PathSmoothness = 0.25f private const val CirclePressScale = 1.1f +private const val MazePathEffectWidth = 50f +private const val MazePathEffectSpacing = 50f +private val MazePathEffectInterval = floatArrayOf(MazePathEffectWidth, MazePathEffectSpacing) + +/** + * The range of the horizontal offset of the points. + */ +private const val HorizontalOffsetRandomStart = 2.0 +private const val HorizontalOffsetRandomEnd = 5.0 + @Composable @PreviewLightDark private fun MazeComponentPreview() { @@ -466,6 +516,8 @@ private fun MazeComponentPreview() { ) } + val items = (completedItems + otherItems).toPersistentList() + NewQuizTheme { Surface { Box( @@ -473,7 +525,7 @@ private fun MazeComponentPreview() { .fillMaxWidth() ) { MazePath( - items = completedItems + otherItems, + items = items, startScrollToCurrentItem = false, ) } diff --git a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt index 010cfd1d..fa67bbe5 100644 --- a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt +++ b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt @@ -42,6 +42,7 @@ import com.infinitepower.newquiz.model.wordle.WordleWord import com.ramcosta.composedestinations.annotation.DeepLink import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import kotlinx.collections.immutable.toPersistentList import kotlin.random.Random import com.infinitepower.newquiz.core.R as CoreR @@ -198,7 +199,7 @@ fun MazeScreenPreview() { ) } - val mazeItems = completedItems + otherItems + val mazeItems = (completedItems + otherItems).toPersistentList() NewQuizTheme { MazeScreenImpl( diff --git a/model/build.gradle.kts b/model/build.gradle.kts index 1ad843ce..670de42f 100644 --- a/model/build.gradle.kts +++ b/model/build.gradle.kts @@ -12,6 +12,8 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) + + api(libs.kotlinx.collections.immutable) } tasks.withType { diff --git a/model/src/main/java/com/infinitepower/newquiz/model/maze/MazeQuiz.kt b/model/src/main/java/com/infinitepower/newquiz/model/maze/MazeQuiz.kt index 5452fbb2..92e8fc9f 100644 --- a/model/src/main/java/com/infinitepower/newquiz/model/maze/MazeQuiz.kt +++ b/model/src/main/java/com/infinitepower/newquiz/model/maze/MazeQuiz.kt @@ -5,10 +5,12 @@ import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion import com.infinitepower.newquiz.model.question.QuestionDifficulty import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.model.wordle.WordleWord +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Keep data class MazeQuiz( - val items: List + val items: ImmutableList ) { sealed interface MazeItem { val id: Int @@ -37,7 +39,7 @@ data class MazeQuiz( } } -fun emptyMaze(): MazeQuiz = MazeQuiz(items = emptyList()) +fun emptyMaze(): MazeQuiz = MazeQuiz(items = persistentListOf()) /** * Check if an item at a given index in a list of MazeItem objects is playable. From d2aa8bff756e7ceea7790ed43ca97e24eba91b07 Mon Sep 17 00:00:00 2001 From: joaomanaia Date: Fri, 5 Jan 2024 22:08:58 +0000 Subject: [PATCH 5/9] Added new generate maze screen --- ...onnectionInfoBadge.kt => CategoryBadge.kt} | 67 +++- .../components/category/CategoryComponent.kt | 62 ++- .../worker/maze/GenerateMazeQuizWorker.kt | 50 +-- .../RecentCategoriesRepositoryImplTest.kt | 4 + detekt.yml | 3 + .../newquiz/feature/maze/MazeScreen.kt | 58 +-- .../newquiz/feature/maze/MazeScreenUiEvent.kt | 9 - .../newquiz/feature/maze/MazeScreenUiState.kt | 1 - .../feature/maze/MazeScreenViewModel.kt | 33 -- .../maze/generate/GenerateMazeScreen.kt | 359 ++++++++++++++++++ .../generate/GenerateMazeScreenUiEvent.kt | 23 ++ .../generate/GenerateMazeScreenUiState.kt | 16 + .../generate/GenerateMazeScreenViewModel.kt | 210 ++++++++++ .../newquiz/model/BaseCategory.kt | 2 +- .../infinitepower/newquiz/model/GameMode.kt | 21 + .../newquiz/model/GameModeCategory.kt | 5 + .../comparison_quiz/ComparisonQuizCategory.kt | 3 + .../multi_choice_quiz/MultiChoiceCategory.kt | 5 +- .../newquiz/model/wordle/WordleCategory.kt | 5 +- 19 files changed, 814 insertions(+), 122 deletions(-) rename core/src/main/java/com/infinitepower/newquiz/core/ui/components/category/{CategoryConnectionInfoBadge.kt => CategoryBadge.kt} (75%) create mode 100644 feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreen.kt create mode 100644 feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenUiEvent.kt create mode 100644 feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenUiState.kt create mode 100644 feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenViewModel.kt create mode 100644 model/src/main/java/com/infinitepower/newquiz/model/GameMode.kt create mode 100644 model/src/main/java/com/infinitepower/newquiz/model/GameModeCategory.kt diff --git a/core/src/main/java/com/infinitepower/newquiz/core/ui/components/category/CategoryConnectionInfoBadge.kt b/core/src/main/java/com/infinitepower/newquiz/core/ui/components/category/CategoryBadge.kt similarity index 75% rename from core/src/main/java/com/infinitepower/newquiz/core/ui/components/category/CategoryConnectionInfoBadge.kt rename to core/src/main/java/com/infinitepower/newquiz/core/ui/components/category/CategoryBadge.kt index be49d8c6..166cb11f 100644 --- a/core/src/main/java/com/infinitepower/newquiz/core/ui/components/category/CategoryConnectionInfoBadge.kt +++ b/core/src/main/java/com/infinitepower/newquiz/core/ui/components/category/CategoryBadge.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material.icons.rounded.WifiOff import androidx.compose.material3.Icon @@ -21,10 +22,10 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.R -import androidx.compose.ui.tooling.preview.PreviewLightDark import com.infinitepower.newquiz.core.common.compose.preview.BooleanPreviewParameterProvider import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing @@ -36,16 +37,12 @@ import com.infinitepower.newquiz.core.theme.spacing * @param modifier The modifier to be applied to the component. * @param requireConnection Whether the category requires an internet connection or not. * @param showTextByDefault Whether the text should be shown by default. - * @param color The color of the badge. - * @param shape The shape of the badge. */ @Composable internal fun CategoryConnectionInfoBadge( modifier: Modifier = Modifier, requireConnection: Boolean = true, showTextByDefault: Boolean = false, - color: Color = MaterialTheme.colorScheme.tertiaryContainer, - shape: Shape = MaterialTheme.shapes.medium, ) { val (showText, setShowText) = remember { mutableStateOf(showTextByDefault) @@ -59,10 +56,8 @@ internal fun CategoryConnectionInfoBadge( } ) - Surface( + CategoryBadge( modifier = modifier, - color = color, - shape = shape, onClick = { setShowText(!showText) } ) { Row( @@ -84,7 +79,10 @@ internal fun CategoryConnectionInfoBadge( }, contentDescription = null ) - AnimatedVisibility(visible = showText) { + AnimatedVisibility( + visible = showText, + label = "Connection info text" + ) { Text( text = description, style = MaterialTheme.typography.bodySmall @@ -94,6 +92,44 @@ internal fun CategoryConnectionInfoBadge( } } +/** + * A badge that indicates that the category is checked. + */ +@Composable +internal fun CategoryCheckedBadge( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + CategoryBadge( + modifier = modifier, + color = MaterialTheme.colorScheme.primary, + onClick = onClick + ) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + modifier = Modifier.padding(MaterialTheme.spacing.default) + ) + } +} + +@Composable +private fun CategoryBadge( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.tertiaryContainer, + shape: Shape = MaterialTheme.shapes.medium, + onClick: () -> Unit = {}, + badgeContent: @Composable () -> Unit +) { + Surface( + modifier = modifier, + color = color, + shape = shape, + onClick = onClick, + content = badgeContent + ) +} + @Composable @PreviewLightDark private fun CategoryConnectionInfoBadgePreview( @@ -108,3 +144,16 @@ private fun CategoryConnectionInfoBadgePreview( } } } + +@Composable +@PreviewLightDark +private fun CategoryCheckedBadgePreview() { + NewQuizTheme { + Surface { + CategoryCheckedBadge( + modifier = Modifier.padding(16.dp), + onClick = {} + ) + } + } +} diff --git a/core/src/main/java/com/infinitepower/newquiz/core/ui/components/category/CategoryComponent.kt b/core/src/main/java/com/infinitepower/newquiz/core/ui/components/category/CategoryComponent.kt index 1cff4c17..a8c59a2a 100644 --- a/core/src/main/java/com/infinitepower/newquiz/core/ui/components/category/CategoryComponent.kt +++ b/core/src/main/java/com/infinitepower/newquiz/core/ui/components/category/CategoryComponent.kt @@ -1,8 +1,11 @@ package com.infinitepower.newquiz.core.ui.components.category +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -16,6 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -36,23 +40,31 @@ fun CategoryComponent( imageUrl: Any, requireInternetConnection: Boolean = false, showConnectionInfo: ShowCategoryConnectionInfo = ShowCategoryConnectionInfo.NONE, + checked: Boolean = false, enabled: Boolean = true, textStyle: TextStyle = MaterialTheme.typography.headlineLarge, - onClick: () -> Unit = {} + shape: Shape = MaterialTheme.shapes.large, + onClick: () -> Unit = {}, + onCheckClick: () -> Unit = {} ) { - val shapeMedium = MaterialTheme.shapes.large - val containerOverlayColor = if (isSystemInDarkTheme()) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + MaterialTheme.colorScheme.primaryContainer } else { - MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) - } + MaterialTheme.colorScheme.primary + }.copy(alpha = if (checked) 0.6f else 0.5f) Surface( - modifier = modifier.alpha(if (enabled) 1f else DISABLED_ALPHA), - shape = shapeMedium, + modifier = modifier + .height(120.dp) + .alpha(if (enabled) 1f else DISABLED_ALPHA), + shape = shape, onClick = onClick, - enabled = enabled + enabled = enabled, + border = if (checked) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + } ) { AsyncImage( model = imageUrl, @@ -74,13 +86,25 @@ fun CategoryComponent( color = Color.White, textAlign = TextAlign.Center ) - if (showConnectionInfo.shouldShowBadge(requireInternetConnection)) { - CategoryConnectionInfoBadge( - modifier = Modifier - .padding(MaterialTheme.spacing.default) - .align(Alignment.TopEnd), - requireConnection = requireInternetConnection - ) + + Row( + modifier = Modifier + .padding(MaterialTheme.spacing.default) + .align(Alignment.TopEnd), + ) { + if (showConnectionInfo.shouldShowBadge(requireInternetConnection)) { + CategoryConnectionInfoBadge( + requireConnection = requireInternetConnection + ) + } + AnimatedVisibility( + visible = checked, + label = "Checked badge" + ) { + CategoryCheckedBadge( + onClick = onCheckClick + ) + } } } } @@ -96,8 +120,10 @@ private fun CategoryPreview() { imageUrl = "", modifier = Modifier .padding(16.dp) - .height(200.dp) - .fillMaxWidth() + .fillMaxWidth(), + requireInternetConnection = true, + showConnectionInfo = ShowCategoryConnectionInfo.BOTH, + checked = true ) } } diff --git a/data/src/main/java/com/infinitepower/newquiz/data/worker/maze/GenerateMazeQuizWorker.kt b/data/src/main/java/com/infinitepower/newquiz/data/worker/maze/GenerateMazeQuizWorker.kt index cbbb4912..4261a884 100644 --- a/data/src/main/java/com/infinitepower/newquiz/data/worker/maze/GenerateMazeQuizWorker.kt +++ b/data/src/main/java/com/infinitepower/newquiz/data/worker/maze/GenerateMazeQuizWorker.kt @@ -30,7 +30,6 @@ import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.maze.MazeQuiz import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory -import com.infinitepower.newquiz.model.multi_choice_quiz.toBaseCategory import com.infinitepower.newquiz.model.question.QuestionDifficulty import com.infinitepower.newquiz.model.wordle.WordleCategory import com.infinitepower.newquiz.model.wordle.WordleQuizType @@ -129,28 +128,26 @@ class GenerateMazeQuizWorker @AssistedInject constructor( fun enqueue( workManager: WorkManager, seed: Int?, - multiChoiceCategories: List, - wordleCategories: List, + multiChoiceCategories: List, + wordleCategories: List, questionSize: Int? = null ): UUID { val cleanSavedMazeRequest = OneTimeWorkRequestBuilder().build() - val multiChoiceBaseCategories = multiChoiceCategories.map { category -> - category.toBaseCategory() - } - val multiChoiceBaseCategoriesStr = Json.encodeToString(multiChoiceBaseCategories) + val multiChoiceCategoriesId = multiChoiceCategories.map { category -> + category.id + }.toTypedArray() - val wordleQuizTypes = wordleCategories.map { category -> - category.wordleQuizType - } - val wordleQuizTypesStr = Json.encodeToString(wordleQuizTypes) + val wordleCategoriesIds = wordleCategories.map { category -> + category.id + }.toTypedArray() val generateMazeRequest = OneTimeWorkRequestBuilder() .setInputData( workDataOf( INPUT_SEED to seed, - INPUT_MULTI_CHOICE_CATEGORIES to multiChoiceBaseCategoriesStr, - INPUT_WORDLE_QUIZ_TYPES to wordleQuizTypesStr, + INPUT_MULTI_CHOICE_CATEGORIES to multiChoiceCategoriesId, + INPUT_WORDLE_QUIZ_TYPES to wordleCategoriesIds, INPUT_QUESTION_SIZE to questionSize ) ).build() @@ -167,20 +164,18 @@ class GenerateMazeQuizWorker @AssistedInject constructor( override suspend fun doWork(): Result = withContext(Dispatchers.IO) { val seed = inputData.getInt(INPUT_SEED, Random.nextInt()) - val multiChoiceCategoriesStr = inputData.getString(INPUT_MULTI_CHOICE_CATEGORIES) + val multiChoiceCategoriesIds = inputData.getStringArray(INPUT_MULTI_CHOICE_CATEGORIES) ?: throw RuntimeException("Multi choice categories is null") - val multiChoiceCategories = - Json.decodeFromString>(multiChoiceCategoriesStr) - Log.i(TAG, "Multi choice categories: $multiChoiceCategoriesStr") + Log.i(TAG, "Multi choice categories: $multiChoiceCategoriesIds") - val wordleQuizTypesStr = inputData.getString(INPUT_WORDLE_QUIZ_TYPES) + val wordleCategoriesIds = inputData.getStringArray(INPUT_WORDLE_QUIZ_TYPES) ?: throw RuntimeException("Wordle categories is null") - val wordleQuizTypes = Json.decodeFromString>(wordleQuizTypesStr) - Log.i(TAG, "Wordle quiz types: $wordleQuizTypesStr") + Log.i(TAG, "Wordle quiz types: $wordleCategoriesIds") - val remoteConfigQuestionSize = remoteConfig.get(RemoteConfigValue.MAZE_QUIZ_GENERATED_QUESTIONS) + val remoteConfigQuestionSize = + remoteConfig.get(RemoteConfigValue.MAZE_QUIZ_GENERATED_QUESTIONS) val questionSize = inputData.getInt(INPUT_QUESTION_SIZE, remoteConfigQuestionSize) @@ -188,7 +183,7 @@ class GenerateMazeQuizWorker @AssistedInject constructor( val random = Random(seed) // Get the questions size per mode, this is the size of the questions that will be generated per mode - val allCategoryCount = multiChoiceCategories.count() + wordleQuizTypes.count() + val allCategoryCount = multiChoiceCategoriesIds.count() + wordleCategoriesIds.count() val questionSizePerMode = questionSize / allCategoryCount Log.i( @@ -196,16 +191,20 @@ class GenerateMazeQuizWorker @AssistedInject constructor( "Generating maze with seed: $seed, question size: $questionSize, question size per mode: $questionSizePerMode" ) - val multiChoiceMazeQuestions = multiChoiceCategories.map { category -> + val multiChoiceMazeQuestions = multiChoiceCategoriesIds.map { categoryId -> + val quizType = MultiChoiceBaseCategory.fromId(categoryId) + generateMultiChoiceMazeItems( mazeSeed = seed, questionSize = questionSizePerMode, - multiChoiceQuizType = category, + multiChoiceQuizType = quizType, random = random ) } - val wordleMazeQuestions = wordleQuizTypes.map { wordleQuizType -> + val wordleMazeQuestions = wordleCategoriesIds.map { categoryId -> + val wordleQuizType = WordleQuizType.valueOf(categoryId) + generateWordleMazeItems( mazeSeed = seed, questionSize = questionSizePerMode, @@ -323,6 +322,7 @@ class GenerateMazeQuizWorker @AssistedInject constructor( WordleWord(formula.fullFormula) } + else -> throw RuntimeException("Wordle quiz type not supported") } } diff --git a/data/src/test/java/com/infinitepower/newquiz/data/repository/home/RecentCategoriesRepositoryImplTest.kt b/data/src/test/java/com/infinitepower/newquiz/data/repository/home/RecentCategoriesRepositoryImplTest.kt index 4566c738..f7c5d7e7 100644 --- a/data/src/test/java/com/infinitepower/newquiz/data/repository/home/RecentCategoriesRepositoryImplTest.kt +++ b/data/src/test/java/com/infinitepower/newquiz/data/repository/home/RecentCategoriesRepositoryImplTest.kt @@ -10,6 +10,7 @@ import com.infinitepower.newquiz.data.local.multi_choice_quiz.category.multiChoi import com.infinitepower.newquiz.data.local.wordle.WordleCategories import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.model.BaseCategory +import com.infinitepower.newquiz.model.GameMode import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizFormatType @@ -62,6 +63,7 @@ internal class RecentCategoriesRepositoryImplTest { } private data class TestCategory( + override val gameMode: GameMode, override val id: String, override val name: UiText, override val image: String, @@ -70,6 +72,7 @@ internal class RecentCategoriesRepositoryImplTest { private val testCategories = List(10) { TestCategory( + gameMode = GameMode.MULTI_CHOICE, id = "id$it", name = "name$it".toUiText(), image = "image$it", @@ -188,6 +191,7 @@ internal class RecentCategoriesRepositoryImplTest { fun `getCategories should return expected result, when connection not available and have no recent categories and all categories require internet`() = runTest { val testCategories = List(10) { TestCategory( + gameMode = GameMode.MULTI_CHOICE, id = "id$it", name = "name$it".toUiText(), image = "image$it", diff --git a/detekt.yml b/detekt.yml index ec74c2ce..3019d322 100644 --- a/detekt.yml +++ b/detekt.yml @@ -34,6 +34,9 @@ naming: constantPattern: '[A-Za-z][_A-Za-z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9_]*)*' style: MagicNumber: diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt index c0d123e9..d41f1787 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -31,6 +32,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.ui.components.icon.button.BackIconButton import com.infinitepower.newquiz.feature.maze.components.MazePath +import com.infinitepower.newquiz.feature.maze.generate.GenerateMazeScreen import com.infinitepower.newquiz.model.maze.MazeQuiz import com.infinitepower.newquiz.model.maze.MazeQuiz.MazeItem import com.infinitepower.newquiz.model.question.QuestionDifficulty @@ -71,6 +73,26 @@ private fun MazeScreenImpl( navigateBack: () -> Unit, uiEvent: (event: MazeScreenUiEvent) -> Unit, onItemClick: (item: MazeItem) -> Unit +) { + when { + uiState.loading -> CircularProgressIndicator() + !uiState.loading && uiState.isMazeEmpty -> GenerateMazeScreen(onBackClick = navigateBack) + !uiState.loading && !uiState.isMazeEmpty -> MazePathScreen( + uiState = uiState, + navigateBack = navigateBack, + uiEvent = uiEvent, + onItemClick = onItemClick + ) + } +} + +@Composable +@ExperimentalMaterial3Api +private fun MazePathScreen( + uiState: MazeScreenUiState, + navigateBack: () -> Unit, + uiEvent: (event: MazeScreenUiEvent) -> Unit, + onItemClick: (item: MazeItem) -> Unit ) { val clipboardManager = LocalClipboardManager.current @@ -133,20 +155,6 @@ private fun MazeScreenImpl( modifier = Modifier .padding(innerPadding) ) { - if (uiState.loading || uiState.generatingMaze) CircularProgressIndicator() - - if (!uiState.loading && uiState.isMazeEmpty) { - /* - GenerateMazeComponent( - modifier = Modifier.fillMaxSize(), - onGenerateClick = { seed, multiChoiceCategories, wordleCategories -> - uiEvent(MazeScreenUiEvent.GenerateMaze(seed, multiChoiceCategories, wordleCategories)) - } - ) - - */ - } - if (!uiState.isMazeEmpty) { MazePath( modifier = Modifier.fillMaxSize(), @@ -163,7 +171,7 @@ private fun MazeScreenImpl( @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class) -fun MazeScreenPreview() { +private fun MazeScreenPreview() { val completedItems = List(3) { MazeItem.Wordle( wordleWord = WordleWord("1+1=2"), @@ -186,14 +194,16 @@ fun MazeScreenPreview() { val mazeItems = (completedItems + otherItems).toPersistentList() NewQuizTheme { - MazeScreenImpl( - uiState = MazeScreenUiState( - loading = false, - maze = MazeQuiz(items = mazeItems) - ), - navigateBack = {}, - uiEvent = {}, - onItemClick = {} - ) + Surface { + MazeScreenImpl( + uiState = MazeScreenUiState( + loading = false, + maze = MazeQuiz(items = mazeItems) + ), + navigateBack = {}, + uiEvent = {}, + onItemClick = {} + ) + } } } diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiEvent.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiEvent.kt index 2c025821..894a2172 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiEvent.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiEvent.kt @@ -1,14 +1,5 @@ package com.infinitepower.newquiz.feature.maze -import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory -import com.infinitepower.newquiz.model.wordle.WordleCategory - sealed interface MazeScreenUiEvent { - data class GenerateMaze( - val seed: Int?, - val selectedMultiChoiceCategories: List, - val selectedWordleCategories: List - ) : MazeScreenUiEvent - data object RestartMaze : MazeScreenUiEvent } diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiState.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiState.kt index 859bc35f..d33f4b03 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiState.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiState.kt @@ -9,7 +9,6 @@ data class MazeScreenUiState( val maze: MazeQuiz = emptyMaze(), val autoScrollToCurrentItem: Boolean = true, val loading: Boolean = true, - val generatingMaze: Boolean = false, ) { val isMazeEmpty: Boolean get() = maze.items.isEmpty() diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt index a299dded..5f93e4e6 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt @@ -66,43 +66,10 @@ class MazeScreenViewModel @Inject constructor( fun onEvent(event: MazeScreenUiEvent) { when (event) { - is MazeScreenUiEvent.GenerateMaze -> generateMaze( - seed = event.seed, - selectedMultiChoiceCategories = event.selectedMultiChoiceCategories, - selectedWordleCategories = event.selectedWordleCategories - ) - is MazeScreenUiEvent.RestartMaze -> cleanSavedMaze() } } - private fun generateMaze( - seed: Int?, - selectedMultiChoiceCategories: List, - selectedWordleCategories: List - ) { - val workId = GenerateMazeQuizWorker.enqueue( - workManager = workManager, - seed = seed, - multiChoiceCategories = selectedMultiChoiceCategories, - wordleCategories = selectedWordleCategories - ) - - workManager - .getWorkInfoByIdLiveData(workId) - .asFlow() - .onEach { workInfo -> - _uiState.update { currentState -> - val loading = when (workInfo.state) { - WorkInfo.State.SUCCEEDED -> false - else -> true - } - - currentState.copy(loading = loading) - } - }.launchIn(viewModelScope) - } - private fun cleanSavedMaze() { analyticsHelper.logEvent(AnalyticsEvent.RestartMaze) diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreen.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreen.kt new file mode 100644 index 00000000..e06d57ec --- /dev/null +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreen.kt @@ -0,0 +1,359 @@ +package com.infinitepower.newquiz.feature.maze.generate + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.SelectAll +import androidx.compose.material.icons.rounded.SignalWifiConnectedNoInternet4 +import androidx.compose.material3.AssistChip +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TriStateCheckbox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.infinitepower.newquiz.core.theme.NewQuizTheme +import com.infinitepower.newquiz.core.theme.spacing +import com.infinitepower.newquiz.core.ui.components.category.CategoryComponent +import com.infinitepower.newquiz.core.ui.components.icon.button.BackIconButton +import com.infinitepower.newquiz.core.util.asString +import com.infinitepower.newquiz.data.local.multi_choice_quiz.category.multiChoiceQuestionCategories +import com.infinitepower.newquiz.data.local.wordle.WordleCategories +import com.infinitepower.newquiz.model.BaseCategory +import com.infinitepower.newquiz.model.GameMode +import com.infinitepower.newquiz.model.category.ShowCategoryConnectionInfo +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Composable +@ExperimentalMaterial3Api +internal fun GenerateMazeScreen( + onBackClick: () -> Unit, + viewModel: GenerateMazeScreenViewModel = hiltViewModel() +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + + GenerateMazeScreenImpl( + uiState = uiState.value, + onEvent = viewModel::onEvent, + onBackClick = onBackClick + ) +} + +@Composable +@ExperimentalMaterial3Api +internal fun GenerateMazeScreenImpl( + uiState: GenerateMazeScreenUiState, + onEvent: (event: GenerateMazeScreenUiEvent) -> Unit = {}, + onBackClick: () -> Unit = {} +) { + val showGenerateButton = remember( + key1 = uiState.selectedMultiChoiceCategories.size, + key2 = uiState.selectedWordleCategories.size + ) { + derivedStateOf { + uiState.selectedMultiChoiceCategories.isNotEmpty() || uiState.selectedWordleCategories.isNotEmpty() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = "Create Maze") }, + navigationIcon = { BackIconButton(onClick = onBackClick) } + ) + }, + floatingActionButton = { + if (showGenerateButton.value) { + ExtendedFloatingActionButton( + onClick = { + onEvent(GenerateMazeScreenUiEvent.GenerateMaze(seed = null)) + }, + ) { + Text(text = "Generate") + } + } + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + if (uiState.loading || uiState.generatingMaze) { + CircularProgressIndicator() + } else { + HelperChipsRow( + onSelectAllClick = { + onEvent(GenerateMazeScreenUiEvent.SelectAllCategories) + }, + onOnlyOfflineClick = { + onEvent(GenerateMazeScreenUiEvent.SelectOnlyOfflineCategories) + } + ) + CategoriesContent( + multiChoiceCategories = uiState.multiChoiceCategories, + selectedMultiChoiceCategories = uiState.selectedMultiChoiceCategories, + wordleCategories = uiState.wordleCategories, + selectedWordleCategories = uiState.selectedWordleCategories, + onEvent = onEvent + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun CategoriesContent( + multiChoiceCategories: ImmutableList, + selectedMultiChoiceCategories: ImmutableList, + wordleCategories: ImmutableList, + selectedWordleCategories: ImmutableList, + onEvent: (event: GenerateMazeScreenUiEvent) -> Unit +) { + val multiChoiceParentBoxState = rememberParentBoxState( + selectedCategories = selectedMultiChoiceCategories, + categories = multiChoiceCategories + ) + + val wordleParentBoxState = rememberParentBoxState( + selectedCategories = selectedWordleCategories, + categories = wordleCategories + ) + + LazyColumn( + contentPadding = PaddingValues(vertical = MaterialTheme.spacing.medium), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium) + ) { + categoriesStickyHeader( + title = "Multi Choice", + parentBoxState = multiChoiceParentBoxState, + onSelectAllClick = { selectAll -> + onEvent( + GenerateMazeScreenUiEvent.SelectCategories( + gameMode = GameMode.MULTI_CHOICE, + selectAll = selectAll + ) + ) + } + ) + + categoriesItems( + categories = multiChoiceCategories, + selectedCategories = selectedMultiChoiceCategories, + baseItemKey = "multi-choice", + onSelectClick = { category -> + onEvent(GenerateMazeScreenUiEvent.SelectCategory(category = category)) + } + ) + + categoriesStickyHeader( + title = "Wordle", + parentBoxState = wordleParentBoxState, + onSelectAllClick = { selectAll -> + onEvent( + GenerateMazeScreenUiEvent.SelectCategories( + gameMode = GameMode.WORDLE, + selectAll = selectAll + ) + ) + } + ) + + categoriesItems( + categories = wordleCategories, + selectedCategories = selectedWordleCategories, + baseItemKey = "wordle", + onSelectClick = { category -> + onEvent(GenerateMazeScreenUiEvent.SelectCategory(category = category)) + } + ) + } +} + +@Composable +private fun rememberParentBoxState( + selectedCategories: ImmutableList, + categories: ImmutableList +): ToggleableState = remember( + key1 = selectedCategories.size, + key2 = categories.size +) { + if (selectedCategories.isEmpty()) { + ToggleableState.Off + } else if (selectedCategories.size == categories.size) { + ToggleableState.On + } else { + ToggleableState.Indeterminate + } +} + +/** + * Creates a sticky header for the categories list with a parent checkbox. + * + * @param title the title of the sticky header + * @param parentBoxState the state of the parent checkbox + * @param onSelectAllClick called when the parent checkbox is clicked + */ +@ExperimentalFoundationApi +private fun LazyListScope.categoriesStickyHeader( + title: String, + parentBoxState: ToggleableState, + onSelectAllClick: (selectAll: Boolean) -> Unit +) { + stickyHeader { + Surface( + modifier = Modifier.fillParentMaxWidth(), + checked = parentBoxState != ToggleableState.Off, + onCheckedChange = onSelectAllClick, + ) { + Row( + modifier = Modifier + .fillParentMaxWidth() + .padding( + horizontal = MaterialTheme.spacing.medium, + vertical = MaterialTheme.spacing.small + ) + .background(color = MaterialTheme.colorScheme.surface), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium + ) + TriStateCheckbox( + state = parentBoxState, + onClick = { + val selectAll = parentBoxState == ToggleableState.Off + onSelectAllClick(selectAll) + }, + ) + } + } + } +} + +/** + * Creates a list of categories. + * + * @param categories the list of categories + * @param selectedCategories the list of selected categories + * @param baseItemKey the base key for the items, it is used to generate the key for each game mode. + */ +private fun LazyListScope.categoriesItems( + categories: ImmutableList, + selectedCategories: ImmutableList, + baseItemKey: String = "", + onSelectClick: (category: T) -> Unit, +) { + items( + items = categories, + key = { category -> "$baseItemKey-${category.id}" } + ) { category -> + CategoryComponent( + title = category.name.asString(), + imageUrl = category.image, + onClick = { onSelectClick(category) }, + onCheckClick = { onSelectClick(category) }, + requireInternetConnection = category.requireInternetConnection, + showConnectionInfo = ShowCategoryConnectionInfo.BOTH, + modifier = Modifier + .fillParentMaxWidth() + .padding(horizontal = MaterialTheme.spacing.medium), + checked = category in selectedCategories, + ) + } +} + +@Composable +private fun HelperChipsRow( + modifier: Modifier = Modifier, + onSelectAllClick: () -> Unit, + onOnlyOfflineClick: () -> Unit +) { + LazyRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium), + contentPadding = PaddingValues(horizontal = MaterialTheme.spacing.medium) + ) { + item { + AssistChip( + onClick = onSelectAllClick, + label = { + Text(text = "Select All") + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.SelectAll, + contentDescription = null + ) + }, + ) + } + + item { + AssistChip( + onClick = onOnlyOfflineClick, + label = { + Text(text = "Select only Offline") + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.SignalWifiConnectedNoInternet4, + contentDescription = null + ) + }, + ) + } + } +} + +@Composable +@PreviewLightDark +@OptIn(ExperimentalMaterial3Api::class) +private fun MazeScreenPreview() { + val multiChoiceQuestionCategories = multiChoiceQuestionCategories.take(8).toImmutableList() + val selectedMultiChoiceCategories = multiChoiceQuestionCategories.take(2).toImmutableList() + + val wordleCategories = WordleCategories.allCategories.toImmutableList() + val selectedWordleCategories = wordleCategories.take(2).toImmutableList() + + NewQuizTheme { + Surface { + GenerateMazeScreenImpl( + uiState = GenerateMazeScreenUiState( + multiChoiceCategories = multiChoiceQuestionCategories, + selectedMultiChoiceCategories = selectedMultiChoiceCategories, + wordleCategories = wordleCategories, + selectedWordleCategories = selectedWordleCategories, + loading = false + ) + ) + } + } +} diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenUiEvent.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenUiEvent.kt new file mode 100644 index 00000000..bf4a942c --- /dev/null +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenUiEvent.kt @@ -0,0 +1,23 @@ +package com.infinitepower.newquiz.feature.maze.generate + +import com.infinitepower.newquiz.model.BaseCategory +import com.infinitepower.newquiz.model.GameMode + +sealed interface GenerateMazeScreenUiEvent { + data class GenerateMaze( + val seed: Int? + ) : GenerateMazeScreenUiEvent + + data object SelectAllCategories : GenerateMazeScreenUiEvent + + data object SelectOnlyOfflineCategories : GenerateMazeScreenUiEvent + + data class SelectCategories( + val gameMode: GameMode, + val selectAll: Boolean, + ) : GenerateMazeScreenUiEvent + + data class SelectCategory( + val category: BaseCategory + ) : GenerateMazeScreenUiEvent +} diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenUiState.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenUiState.kt new file mode 100644 index 00000000..5aec52ae --- /dev/null +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenUiState.kt @@ -0,0 +1,16 @@ +package com.infinitepower.newquiz.feature.maze.generate + +import androidx.annotation.Keep +import com.infinitepower.newquiz.model.BaseCategory +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Keep +data class GenerateMazeScreenUiState( + val loading: Boolean = true, + val generatingMaze: Boolean = false, + val multiChoiceCategories: ImmutableList = persistentListOf(), + val selectedMultiChoiceCategories: ImmutableList = persistentListOf(), + val wordleCategories: ImmutableList = persistentListOf(), + val selectedWordleCategories: ImmutableList = persistentListOf(), +) diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenViewModel.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenViewModel.kt new file mode 100644 index 00000000..19acd25d --- /dev/null +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenViewModel.kt @@ -0,0 +1,210 @@ +package com.infinitepower.newquiz.feature.maze.generate + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewModelScope +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.infinitepower.newquiz.core.R as CoreR +import com.infinitepower.newquiz.data.local.multi_choice_quiz.category.multiChoiceQuestionCategories +import com.infinitepower.newquiz.data.local.wordle.WordleCategories +import com.infinitepower.newquiz.data.worker.maze.GenerateMazeQuizWorker +import com.infinitepower.newquiz.model.BaseCategory +import com.infinitepower.newquiz.model.GameMode +import com.infinitepower.newquiz.model.UiText +import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory +import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory +import com.infinitepower.newquiz.model.wordle.WordleQuizType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +private class NotSupportedGameModeException : IllegalStateException("Not supported game mode") + +@HiltViewModel +class GenerateMazeScreenViewModel @Inject constructor( + private val workManager: WorkManager, +) : ViewModel() { + private val _uiState = MutableStateFlow(GenerateMazeScreenUiState()) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch { + _uiState.update { currentState -> + currentState.copy( + multiChoiceCategories = getMultiChoiceCategories(), + wordleCategories = availableWordleCategories.toImmutableList(), + loading = false + ) + } + } + } + + companion object { + private val availableMultiChoiceCategoriesIds = listOf( + MultiChoiceBaseCategory.Logo.id, + MultiChoiceBaseCategory.Flag.id, + MultiChoiceBaseCategory.CountryCapitalFlags.id, + MultiChoiceBaseCategory.GuessMathSolution.id, + ) + + private val availableWordleCategories = WordleCategories.allCategories.filter { category -> + category.id != WordleQuizType.NUMBER_TRIVIA.name + } + } + + fun onEvent(event: GenerateMazeScreenUiEvent) { + when (event) { + is GenerateMazeScreenUiEvent.SelectCategory -> selectCategory(event.category) + is GenerateMazeScreenUiEvent.GenerateMaze -> generateMaze(seed = event.seed) + is GenerateMazeScreenUiEvent.SelectAllCategories -> selectAllCategories() + is GenerateMazeScreenUiEvent.SelectOnlyOfflineCategories -> selectOnlyOffline() + is GenerateMazeScreenUiEvent.SelectCategories -> selectCategories( + gameMode = event.gameMode, + selectAll = event.selectAll + ) + } + } + + private fun getMultiChoiceCategories(): ImmutableList { + val allMultiChoiceCategories = multiChoiceQuestionCategories + + val filteredCategories = allMultiChoiceCategories.filter { category -> + availableMultiChoiceCategoriesIds.contains(category.id) + } + + // Because with the implementation with OpenTriviaDB, we can't select + // specific category, so we need to create a special category + // for this case, that contains all the categories. + val othersCategory = MultiChoiceCategory( + id = "others", + name = UiText.DynamicString("Others"), + image = CoreR.drawable.general_knowledge, + requireInternetConnection = true + ) + + return (filteredCategories + othersCategory).toImmutableList() + } + + /** + * Select or deselect a category, depending on the current state. + */ + private fun selectCategory(category: BaseCategory) { + _uiState.update { currentState -> + // Get the list of selected categories, depending on the game mode. + val selectedCategories = when (category.gameMode) { + GameMode.MULTI_CHOICE -> currentState.selectedMultiChoiceCategories + GameMode.WORDLE -> currentState.selectedWordleCategories + else -> throw NotSupportedGameModeException() + }.toMutableList() + + // If the category is already selected, then deselect it. + // If the category is not selected, then select it. + if (selectedCategories.contains(category)) { + selectedCategories.remove(category) + } else { + selectedCategories.add(category) + } + + // Update the state, depending on the game mode. + when (category.gameMode) { + GameMode.MULTI_CHOICE -> currentState.copy( + selectedMultiChoiceCategories = selectedCategories.toImmutableList() + ) + GameMode.WORDLE -> currentState.copy( + selectedWordleCategories = selectedCategories.toImmutableList() + ) + else -> throw NotSupportedGameModeException() + } + } + } + + private fun selectAllCategories() { + selectMultiChoiceCategories(selectAll = true) + selectWordleCategories(selectAll = true) + } + + private fun selectOnlyOffline() { + _uiState.update { currentState -> + val multiChoiceOnlyOffline = currentState.multiChoiceCategories.filter { category -> + !category.requireInternetConnection + } + + val wordleOnlyOffline = currentState.wordleCategories.filter { category -> + !category.requireInternetConnection + } + + currentState.copy( + selectedMultiChoiceCategories = multiChoiceOnlyOffline.toImmutableList(), + selectedWordleCategories = wordleOnlyOffline.toImmutableList() + ) + } + } + + private fun selectCategories( + gameMode: GameMode, + selectAll: Boolean, + ) { + when (gameMode) { + GameMode.MULTI_CHOICE -> selectMultiChoiceCategories(selectAll) + GameMode.WORDLE -> selectWordleCategories(selectAll) + else -> throw NotSupportedGameModeException() + } + } + + private fun selectMultiChoiceCategories(selectAll: Boolean) { + _uiState.update { currentState -> + if (selectAll) { + currentState.copy(selectedMultiChoiceCategories = currentState.multiChoiceCategories) + } else { + currentState.copy(selectedMultiChoiceCategories = persistentListOf()) + } + } + } + + private fun selectWordleCategories(selectAll: Boolean) { + _uiState.update { currentState -> + if (selectAll) { + currentState.copy(selectedWordleCategories = currentState.wordleCategories) + } else { + currentState.copy(selectedWordleCategories = persistentListOf()) + } + } + } + + private fun generateMaze(seed: Int?) { + viewModelScope.launch { + val currentState = uiState.first() + + val workId = GenerateMazeQuizWorker.enqueue( + workManager = workManager, + seed = seed, + multiChoiceCategories = currentState.selectedMultiChoiceCategories, + wordleCategories = currentState.selectedWordleCategories + ) + + workManager + .getWorkInfoByIdLiveData(workId) + .asFlow() + .onEach { workInfo -> + _uiState.update { currentState -> + val loading = when (workInfo.state) { + WorkInfo.State.SUCCEEDED -> false + else -> true + } + + currentState.copy(generatingMaze = loading) + } + }.launchIn(viewModelScope) + } + } +} diff --git a/model/src/main/java/com/infinitepower/newquiz/model/BaseCategory.kt b/model/src/main/java/com/infinitepower/newquiz/model/BaseCategory.kt index 94ad3bd5..8121c093 100644 --- a/model/src/main/java/com/infinitepower/newquiz/model/BaseCategory.kt +++ b/model/src/main/java/com/infinitepower/newquiz/model/BaseCategory.kt @@ -1,6 +1,6 @@ package com.infinitepower.newquiz.model -interface BaseCategory { +interface BaseCategory : GameModeCategory { val id: String val name: UiText diff --git a/model/src/main/java/com/infinitepower/newquiz/model/GameMode.kt b/model/src/main/java/com/infinitepower/newquiz/model/GameMode.kt new file mode 100644 index 00000000..258371ad --- /dev/null +++ b/model/src/main/java/com/infinitepower/newquiz/model/GameMode.kt @@ -0,0 +1,21 @@ +package com.infinitepower.newquiz.model + +/** + * This enum class is used to determine which game mode the user is playing. + */ +enum class GameMode { + /** + * Multiple choice game mode. + */ + MULTI_CHOICE, + + /** + * Wordle game mode. + */ + WORDLE, + + /** + * Comparison game mode. + */ + COMPARISON_QUIZ +} \ No newline at end of file diff --git a/model/src/main/java/com/infinitepower/newquiz/model/GameModeCategory.kt b/model/src/main/java/com/infinitepower/newquiz/model/GameModeCategory.kt new file mode 100644 index 00000000..26aab0d7 --- /dev/null +++ b/model/src/main/java/com/infinitepower/newquiz/model/GameModeCategory.kt @@ -0,0 +1,5 @@ +package com.infinitepower.newquiz.model + +interface GameModeCategory { + val gameMode: GameMode +} \ No newline at end of file diff --git a/model/src/main/java/com/infinitepower/newquiz/model/comparison_quiz/ComparisonQuizCategory.kt b/model/src/main/java/com/infinitepower/newquiz/model/comparison_quiz/ComparisonQuizCategory.kt index 3bae2f4a..3404875e 100644 --- a/model/src/main/java/com/infinitepower/newquiz/model/comparison_quiz/ComparisonQuizCategory.kt +++ b/model/src/main/java/com/infinitepower/newquiz/model/comparison_quiz/ComparisonQuizCategory.kt @@ -2,6 +2,7 @@ package com.infinitepower.newquiz.model.comparison_quiz import androidx.annotation.Keep import com.infinitepower.newquiz.model.BaseCategory +import com.infinitepower.newquiz.model.GameMode import com.infinitepower.newquiz.model.UiText import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable @@ -35,6 +36,8 @@ data class ComparisonQuizCategory( val helperValueSuffix: String? = null, val dataSourceAttribution: DataSourceAttribution? = null ) : BaseCategory, java.io.Serializable { + override val gameMode: GameMode = GameMode.COMPARISON_QUIZ + fun formatValueToString(value: Double): String { return formatType.formatValueToString(value, helperValueSuffix) } diff --git a/model/src/main/java/com/infinitepower/newquiz/model/multi_choice_quiz/MultiChoiceCategory.kt b/model/src/main/java/com/infinitepower/newquiz/model/multi_choice_quiz/MultiChoiceCategory.kt index 10694cff..560e455e 100644 --- a/model/src/main/java/com/infinitepower/newquiz/model/multi_choice_quiz/MultiChoiceCategory.kt +++ b/model/src/main/java/com/infinitepower/newquiz/model/multi_choice_quiz/MultiChoiceCategory.kt @@ -2,6 +2,7 @@ package com.infinitepower.newquiz.model.multi_choice_quiz import androidx.annotation.Keep import com.infinitepower.newquiz.model.BaseCategory +import com.infinitepower.newquiz.model.GameMode import com.infinitepower.newquiz.model.UiText @Keep @@ -10,6 +11,8 @@ data class MultiChoiceCategory( override val name: UiText, override val image: Any, override val requireInternetConnection: Boolean = true -) : BaseCategory +) : BaseCategory { + override val gameMode: GameMode = GameMode.MULTI_CHOICE +} fun MultiChoiceCategory.toBaseCategory() = MultiChoiceBaseCategory.fromId(id) diff --git a/model/src/main/java/com/infinitepower/newquiz/model/wordle/WordleCategory.kt b/model/src/main/java/com/infinitepower/newquiz/model/wordle/WordleCategory.kt index 28ff0bec..3d66bb45 100644 --- a/model/src/main/java/com/infinitepower/newquiz/model/wordle/WordleCategory.kt +++ b/model/src/main/java/com/infinitepower/newquiz/model/wordle/WordleCategory.kt @@ -2,6 +2,7 @@ package com.infinitepower.newquiz.model.wordle import androidx.annotation.Keep import com.infinitepower.newquiz.model.BaseCategory +import com.infinitepower.newquiz.model.GameMode import com.infinitepower.newquiz.model.UiText @Keep @@ -11,4 +12,6 @@ data class WordleCategory( override val name: UiText, override val image: Any, override val requireInternetConnection: Boolean = false -) : BaseCategory +) : BaseCategory { + override val gameMode: GameMode = GameMode.WORDLE +} From 900a79a8a978e83acd92f290bbc4684b06d6bbea Mon Sep 17 00:00:00 2001 From: joaomanaia Date: Fri, 5 Jan 2024 22:11:16 +0000 Subject: [PATCH 6/9] Remove unused imports --- .../newquiz/data/worker/maze/GenerateMazeQuizWorker.kt | 2 -- .../newquiz/feature/maze/MazeScreenViewModel.kt | 7 ------- 2 files changed, 9 deletions(-) diff --git a/data/src/main/java/com/infinitepower/newquiz/data/worker/maze/GenerateMazeQuizWorker.kt b/data/src/main/java/com/infinitepower/newquiz/data/worker/maze/GenerateMazeQuizWorker.kt index 4261a884..5aac3e00 100644 --- a/data/src/main/java/com/infinitepower/newquiz/data/worker/maze/GenerateMazeQuizWorker.kt +++ b/data/src/main/java/com/infinitepower/newquiz/data/worker/maze/GenerateMazeQuizWorker.kt @@ -39,8 +39,6 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import java.util.UUID import kotlin.random.Random diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt index 5f93e4e6..ac6d542a 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt @@ -1,9 +1,7 @@ package com.infinitepower.newquiz.feature.maze import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope -import androidx.work.WorkInfo import androidx.work.WorkManager import com.infinitepower.newquiz.core.analytics.AnalyticsEvent import com.infinitepower.newquiz.core.analytics.AnalyticsHelper @@ -11,16 +9,11 @@ import com.infinitepower.newquiz.core.datastore.common.SettingsCommon import com.infinitepower.newquiz.core.datastore.di.SettingsDataStoreManager import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import com.infinitepower.newquiz.data.worker.maze.CleanMazeQuizWorker -import com.infinitepower.newquiz.data.worker.maze.GenerateMazeQuizWorker import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository -import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory -import com.infinitepower.newquiz.model.wordle.WordleCategory import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch From c443f87eb4a8d6858dcd9f57d5332862aa82eff3 Mon Sep 17 00:00:00 2001 From: joaomanaia Date: Fri, 5 Jan 2024 22:29:26 +0000 Subject: [PATCH 7/9] Remove old maze-quiz module --- maze-quiz/.gitignore | 1 - maze-quiz/build.gradle.kts | 43 -- maze-quiz/consumer-rules.pro | 0 maze-quiz/proguard-rules.pro | 1 - .../components/GameModeComponentTest.kt | 219 ---------- .../components/GenerateCustomMazeTest.kt | 201 --------- maze-quiz/src/main/AndroidManifest.xml | 4 - .../newquiz/maze_quiz/MazeScreen.kt | 215 --------- .../newquiz/maze_quiz/MazeScreenNavigator.kt | 7 - .../newquiz/maze_quiz/MazeScreenUiEvent.kt | 14 - .../newquiz/maze_quiz/MazeScreenUiState.kt | 17 - .../newquiz/maze_quiz/MazeScreenViewModel.kt | 85 ---- .../components/GenerateCustomMaze.kt | 411 ------------------ .../components/GenerateMazeComponent.kt | 138 ------ .../components/GenerateOfflineMazeCard.kt | 65 --- .../maze_quiz/components/MazeComponent.kt | 298 ------------- settings.gradle.kts | 1 - 17 files changed, 1720 deletions(-) delete mode 100644 maze-quiz/.gitignore delete mode 100644 maze-quiz/build.gradle.kts delete mode 100644 maze-quiz/consumer-rules.pro delete mode 100644 maze-quiz/proguard-rules.pro delete mode 100644 maze-quiz/src/androidTest/java/com/infinitepower/newquiz/maze_quiz/components/GameModeComponentTest.kt delete mode 100644 maze-quiz/src/androidTest/java/com/infinitepower/newquiz/maze_quiz/components/GenerateCustomMazeTest.kt delete mode 100644 maze-quiz/src/main/AndroidManifest.xml delete mode 100644 maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt delete mode 100644 maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenNavigator.kt delete mode 100644 maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenUiEvent.kt delete mode 100644 maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenUiState.kt delete mode 100644 maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenViewModel.kt delete mode 100644 maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/GenerateCustomMaze.kt delete mode 100644 maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/GenerateMazeComponent.kt delete mode 100644 maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/GenerateOfflineMazeCard.kt delete mode 100644 maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/MazeComponent.kt diff --git a/maze-quiz/.gitignore b/maze-quiz/.gitignore deleted file mode 100644 index 42afabfd..00000000 --- a/maze-quiz/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/maze-quiz/build.gradle.kts b/maze-quiz/build.gradle.kts deleted file mode 100644 index 90926c06..00000000 --- a/maze-quiz/build.gradle.kts +++ /dev/null @@ -1,43 +0,0 @@ -plugins { - alias(libs.plugins.newquiz.android.library.compose) - alias(libs.plugins.newquiz.android.hilt) - alias(libs.plugins.newquiz.android.compose.destinations) -} - -android { - namespace = "com.infinitepower.newquiz.maze_quiz" -} - -dependencies { - implementation(libs.androidx.core.ktx) - - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.lifecycle.livedata.ktx) - implementation(libs.androidx.lifecycle.runtimeCompose) - - implementation(libs.androidx.compose.ui.tooling) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.material3.windowSizeClass) - implementation(libs.androidx.compose.material.iconsExtended) - debugImplementation(libs.androidx.compose.ui.testManifest) - implementation(libs.androidx.constraintlayout.compose) - androidTestImplementation(libs.androidx.compose.ui.test) - - androidTestImplementation(libs.androidx.test.runner) - androidTestImplementation(libs.androidx.test.rules) - - implementation(libs.hilt.navigationCompose) - ksp(libs.hilt.ext.compiler) - kspAndroidTest(libs.hilt.compiler) - - implementation(libs.androidx.work.ktx) - - implementation(projects.core) - implementation(projects.core.analytics) - implementation(projects.model) - implementation(projects.data) - implementation(projects.domain) - - implementation(projects.feature.maze) -} diff --git a/maze-quiz/consumer-rules.pro b/maze-quiz/consumer-rules.pro deleted file mode 100644 index e69de29b..00000000 diff --git a/maze-quiz/proguard-rules.pro b/maze-quiz/proguard-rules.pro deleted file mode 100644 index 74e3df95..00000000 --- a/maze-quiz/proguard-rules.pro +++ /dev/null @@ -1 +0,0 @@ --dontwarn java.lang.invoke.StringConcatFactory \ No newline at end of file diff --git a/maze-quiz/src/androidTest/java/com/infinitepower/newquiz/maze_quiz/components/GameModeComponentTest.kt b/maze-quiz/src/androidTest/java/com/infinitepower/newquiz/maze_quiz/components/GameModeComponentTest.kt deleted file mode 100644 index e02f29ea..00000000 --- a/maze-quiz/src/androidTest/java/com/infinitepower/newquiz/maze_quiz/components/GameModeComponentTest.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.infinitepower.newquiz.maze_quiz.components - -import androidx.activity.ComponentActivity -import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsToggleable -import androidx.compose.ui.test.assertValueEquals -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.SmallTest -import com.google.common.truth.Truth.assertThat -import com.infinitepower.newquiz.core.util.asString -import com.infinitepower.newquiz.core.testing.utils.setTestContent -import com.infinitepower.newquiz.data.worker.maze.GenerateMazeQuizWorker -import org.junit.Rule -import org.junit.runner.RunWith -import kotlin.test.Test - -@SmallTest -@RunWith(AndroidJUnit4::class) -internal class GameModeComponentTest { - @get:Rule - val composeRule = createAndroidComposeRule() - - @Test - fun test_itemsVisibility() { - val gameMode = GenerateMazeQuizWorker.GameModes.MultiChoice - - composeRule.setTestContent { - GameModeComponent( - gameMode = gameMode, - selectedCategories = gameMode.categories, // All categories are selected by default - onSelectChange = {}, - onParentSelectChange = {}, - ) - } - - val context = composeRule.activity.applicationContext - val gameModeName = gameMode.name.asString(context) - - // Check if the game mode checkbox parent is displayed - composeRule - .onNodeWithContentDescription("$gameModeName game mode") - .assertExists() - .assertIsDisplayed() - .assertIsEnabled() - .assertIsToggleable() - - composeRule - .onNodeWithText(gameModeName) - .assertExists() - .assertIsDisplayed() - - composeRule - .onNodeWithContentDescription("$gameModeName game mode checkbox") - .assertExists() - .assertIsDisplayed() - .assertIsEnabled() - .assertIsToggleable() - .assertValueEquals("All categories selected") - - // Test child categories - gameMode.categories.forEach { category -> - val categoryName = category.name.asString(context) - - composeRule - .onNodeWithText(categoryName) - .assertExists() - .assertIsDisplayed() - - composeRule - .onNodeWithContentDescription("$categoryName category checkbox") - .assertExists() - .assertIsDisplayed() - .assertIsEnabled() - .assertIsToggleable() - .assertValueEquals("Selected") - } - } - - @Test - fun test_changeParentCheck() { - val gameMode = GenerateMazeQuizWorker.GameModes.MultiChoice - - val selectedMultiChoiceCategories = gameMode.categories.toMutableStateList() - - composeRule.setTestContent { - GameModeComponent( - gameMode = gameMode, - selectedCategories = selectedMultiChoiceCategories, // All categories are selected by default - onSelectChange = { category -> - if (category in selectedMultiChoiceCategories) { - selectedMultiChoiceCategories.remove(category) - } else { - selectedMultiChoiceCategories.add(category) - } - }, - onParentSelectChange = { enableAll -> - selectedMultiChoiceCategories.clear() - - if (enableAll) { - selectedMultiChoiceCategories.addAll(gameMode.categories) - } - }, - ) - } - - val context = composeRule.activity.applicationContext - val gameModeName = gameMode.name.asString(context) - - assertThat(selectedMultiChoiceCategories).containsExactlyElementsIn(gameMode.categories) - - // Test parent click to disable all categories - composeRule - .onNodeWithContentDescription("$gameModeName game mode checkbox") - .assertIsDisplayed() - .assertIsEnabled() - .assertValueEquals("All categories selected") - .performClick() - - assertThat(selectedMultiChoiceCategories).isEmpty() - - // Check if all child checkboxes are unchecked - gameMode.categories.forEach { category -> - val categoryName = category.name.asString(context) - - composeRule - .onNodeWithContentDescription("$categoryName category checkbox") - .assertValueEquals("Not selected") - } - - // Test parent click to enable all categories - composeRule - .onNodeWithContentDescription("$gameModeName game mode checkbox") - .assertIsDisplayed() - .assertIsEnabled() - .assertValueEquals("No categories selected") - .performClick() - - assertThat(selectedMultiChoiceCategories).containsExactlyElementsIn(gameMode.categories) - - // Check if all child checkboxes are checked - gameMode.categories.forEach { category -> - val categoryName = category.name.asString(context) - - composeRule - .onNodeWithContentDescription("$categoryName category checkbox") - .assertValueEquals("Selected") - } - } - - @Test - fun test_changeChildCheck() { - val gameMode = GenerateMazeQuizWorker.GameModes.MultiChoice - - val selectedMultiChoiceCategories = gameMode.categories.toMutableStateList() - - composeRule.setTestContent { - GameModeComponent( - gameMode = gameMode, - selectedCategories = selectedMultiChoiceCategories, // All categories are selected by default - onSelectChange = { category -> - if (category in selectedMultiChoiceCategories) { - selectedMultiChoiceCategories.remove(category) - } else { - selectedMultiChoiceCategories.add(category) - } - }, - onParentSelectChange = {}, - ) - } - - val context = composeRule.activity.applicationContext - val gameModeName = gameMode.name.asString(context) - - // Check for parent checkbox - composeRule - .onNodeWithContentDescription("$gameModeName game mode checkbox") - .assertIsDisplayed() - .assertIsEnabled() - .assertValueEquals("All categories selected") - - // Click on a child checkbox - val randomCategory = gameMode.categories.random() - - composeRule - .onNodeWithText(randomCategory.name.asString(context)) - .assertIsDisplayed() - .assertIsEnabled() - .performClick() - - assertThat(selectedMultiChoiceCategories).doesNotContain(randomCategory) - - composeRule - .onNodeWithContentDescription("$gameModeName game mode checkbox") - .assertIsDisplayed() - .assertIsEnabled() - .assertValueEquals("Some categories selected") - - // Click on the same child checkbox - composeRule - .onNodeWithText(randomCategory.name.asString(context)) - .assertIsDisplayed() - .assertIsEnabled() - .performClick() - - assertThat(selectedMultiChoiceCategories).contains(randomCategory) - - composeRule - .onNodeWithContentDescription("$gameModeName game mode checkbox") - .assertIsDisplayed() - .assertIsEnabled() - .assertValueEquals("All categories selected") - } -} diff --git a/maze-quiz/src/androidTest/java/com/infinitepower/newquiz/maze_quiz/components/GenerateCustomMazeTest.kt b/maze-quiz/src/androidTest/java/com/infinitepower/newquiz/maze_quiz/components/GenerateCustomMazeTest.kt deleted file mode 100644 index 09a22c4f..00000000 --- a/maze-quiz/src/androidTest/java/com/infinitepower/newquiz/maze_quiz/components/GenerateCustomMazeTest.kt +++ /dev/null @@ -1,201 +0,0 @@ -package com.infinitepower.newquiz.maze_quiz.components - -import androidx.activity.ComponentActivity -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.assertHasClickAction -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.assertValueEquals -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollToNode -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import com.infinitepower.newquiz.core.util.asString -import com.infinitepower.newquiz.core.testing.utils.setTestContent -import com.infinitepower.newquiz.data.worker.maze.GenerateMazeQuizWorker -import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory -import com.infinitepower.newquiz.model.wordle.WordleCategory -import org.junit.Rule -import org.junit.runner.RunWith -import kotlin.test.Test - -@RunWith(AndroidJUnit4::class) -@OptIn(ExperimentalMaterial3Api::class) -internal class GenerateCustomMazeTest { - @get:Rule - val composeRule = createAndroidComposeRule() - - data class ComponentClickData( - val seed: Int?, - val selectedMultiChoiceCategories: List, - val selectedWordleCategories: List - ) - - @Test - fun test_generateWithNoChanges() { - var onClicked = false - var componentClickData: ComponentClickData? = null - - composeRule.setTestContent { - GenerateCustomMaze( - onClick = { seed, selectedMultiChoiceCategories, selectedWordleCategories -> - onClicked = true - componentClickData = ComponentClickData( - seed, - selectedMultiChoiceCategories, - selectedWordleCategories - ) - }, - modifier = Modifier - .verticalScroll(rememberScrollState()) - .testTag("GenerateCustomMaze") - ) - } - - composeRule - .onNodeWithText("Generate") - .assertDoesNotExist() - - // Expand the GenerateCustomMaze component - composeRule - .onNodeWithContentDescription("Expand custom options") - .assertExists() - .assertIsDisplayed() - .assertHasClickAction() - .assertIsEnabled() - .performClick() - - // Scroll to the bottom of the component - composeRule - .onNodeWithTag("GenerateCustomMaze") - .performScrollToNode(hasText("Generate")) - - composeRule - .onNodeWithText("Generate") - .assertExists() - .assertIsDisplayed() - .assertHasClickAction() - .assertIsEnabled() - .performClick() - - assertThat(onClicked).isTrue() - - assertThat(componentClickData).isNotNull() - assertThat(componentClickData?.seed).isNotNull() - - assertThat(componentClickData?.selectedMultiChoiceCategories).isNotNull() - assertThat(componentClickData?.selectedMultiChoiceCategories).hasSize(5) - - assertThat(componentClickData?.selectedWordleCategories).isNotNull() - assertThat(componentClickData?.selectedWordleCategories).hasSize(3) - } - - @Test - fun test_noCategoriesSelected_generateButtonShouldBeDisabled() { - var onClicked = false - var componentClickData: ComponentClickData? = null - - composeRule.setTestContent { - GenerateCustomMaze( - onClick = { seed, selectedMultiChoiceCategories, selectedWordleCategories -> - onClicked = true - componentClickData = ComponentClickData( - seed, - selectedMultiChoiceCategories, - selectedWordleCategories - ) - }, - modifier = Modifier - .verticalScroll(rememberScrollState()) - .testTag("GenerateCustomMaze") - ) - } - - composeRule - .onNodeWithText("Generate") - .assertDoesNotExist() - - // Expand the GenerateCustomMaze component - composeRule - .onNodeWithContentDescription("Expand custom options") - .assertIsDisplayed() - .performClick() - - val context = composeRule.activity.applicationContext - - // Disable all multi-choice categories - val multiChoiceGameMode = GenerateMazeQuizWorker.GameModes.MultiChoice - val multiChoiceGameModeName = multiChoiceGameMode.name.asString(context) - - composeRule - .onNodeWithText(multiChoiceGameModeName) - .assertIsDisplayed() - .performClick() - - composeRule - .onNodeWithContentDescription("$multiChoiceGameModeName game mode checkbox") - .assertValueEquals("No categories selected") - - // Disable all wordle categories - val wordleGameMode = GenerateMazeQuizWorker.GameModes.Wordle - val wordleGameModeName = wordleGameMode.name.asString(context) - - composeRule - .onNodeWithText(wordleGameModeName) - .assertIsDisplayed() - .performClick() - - composeRule - .onNodeWithContentDescription("$wordleGameModeName game mode checkbox") - .assertValueEquals("No categories selected") - - // Scroll to the bottom of the component - composeRule - .onNodeWithTag("GenerateCustomMaze") - .performScrollToNode(hasText("Generate")) - - composeRule - .onNodeWithText("Generate") - .assertIsDisplayed() - .assertIsNotEnabled() - .performClick() - - assertThat(onClicked).isFalse() - assertThat(componentClickData).isNull() - - // Enable a random category to see if the generate button is enabled - val randomCategory = (multiChoiceGameMode.categories + wordleGameMode.categories).random() - val randomCategoryName = randomCategory.name.asString(context) - - // Scroll to the category - composeRule - .onNodeWithTag("GenerateCustomMaze") - .performScrollToNode(hasText(randomCategoryName)) - - composeRule - .onNodeWithText(randomCategoryName) - .assertIsDisplayed() - .performClick() - - // Scroll to the bottom of the component - composeRule - .onNodeWithTag("GenerateCustomMaze") - .performScrollToNode(hasText("Generate")) - - composeRule - .onNodeWithText("Generate") - .assertIsDisplayed() - .assertIsEnabled() - .performClick() - } -} diff --git a/maze-quiz/src/main/AndroidManifest.xml b/maze-quiz/src/main/AndroidManifest.xml deleted file mode 100644 index a5918e68..00000000 --- a/maze-quiz/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt deleted file mode 100644 index fa67bbe5..00000000 --- a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreen.kt +++ /dev/null @@ -1,215 +0,0 @@ -package com.infinitepower.newquiz.maze_quiz - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBack -import androidx.compose.material.icons.rounded.ContentCopy -import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material.icons.rounded.RestartAlt -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.tooling.preview.PreviewLightDark -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.infinitepower.newquiz.core.theme.NewQuizTheme -import com.infinitepower.newquiz.core.theme.spacing -import com.infinitepower.newquiz.feature.maze.components.MazePath -import com.infinitepower.newquiz.maze_quiz.components.GenerateMazeComponent -import com.infinitepower.newquiz.model.maze.MazeQuiz -import com.infinitepower.newquiz.model.maze.MazeQuiz.MazeItem -import com.infinitepower.newquiz.model.question.QuestionDifficulty -import com.infinitepower.newquiz.model.wordle.WordleQuizType -import com.infinitepower.newquiz.model.wordle.WordleWord -import com.ramcosta.composedestinations.annotation.DeepLink -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import kotlinx.collections.immutable.toPersistentList -import kotlin.random.Random -import com.infinitepower.newquiz.core.R as CoreR - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -fun MazeScreen( - navigator: DestinationsNavigator, - mazeScreenNavigator: MazeScreenNavigator, - viewModel: MazeScreenViewModel = hiltViewModel() -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - MazeScreenImpl( - uiState = uiState, - navigateBack = navigator::popBackStack, - uiEvent = viewModel::onEvent, - onItemClick = mazeScreenNavigator::navigateToGame - ) -} - -@Composable -@ExperimentalMaterial3Api -private fun MazeScreenImpl( - uiState: MazeScreenUiState, - navigateBack: () -> Unit, - uiEvent: (event: MazeScreenUiEvent) -> Unit, - onItemClick: (item: MazeItem) -> Unit -) { - val spaceMedium = MaterialTheme.spacing.medium - - val clipboardManager = LocalClipboardManager.current - - val formulas = uiState.mathMaze.items - - var moreOptionsExpanded by remember { mutableStateOf(false) } - - Scaffold( - topBar = { - TopAppBar( - title = { - Text(text = stringResource(id = CoreR.string.maze)) - }, - navigationIcon = { - IconButton(onClick = navigateBack) { - Icon( - imageVector = Icons.Rounded.ArrowBack, - contentDescription = stringResource(id = CoreR.string.back) - ) - } - }, - actions = { - IconButton(onClick = { moreOptionsExpanded = true }) { - Icon( - imageVector = Icons.Rounded.MoreVert, - contentDescription = stringResource(id = CoreR.string.more_options) - ) - } - DropdownMenu( - expanded = moreOptionsExpanded, - onDismissRequest = { moreOptionsExpanded = false } - ) { - if (formulas.isNotEmpty()) { - DropdownMenuItem( - text = { Text(stringResource(id = CoreR.string.copy_maze_seed)) }, - onClick = { - val mazeSeed = uiState.mazeSeed - if (mazeSeed != null) { - clipboardManager.setText(AnnotatedString(mazeSeed.toString())) - } - moreOptionsExpanded = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.ContentCopy, - contentDescription = stringResource(id = CoreR.string.copy_maze_seed) - ) - } - ) - DropdownMenuItem( - text = { Text(stringResource(id = CoreR.string.restart_maze)) }, - onClick = { - uiEvent(MazeScreenUiEvent.RestartMaze) - moreOptionsExpanded = false - }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.RestartAlt, - contentDescription = stringResource(id = CoreR.string.restart_maze) - ) - } - ) - } - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .padding(horizontal = spaceMedium) - ) { - if (uiState.loading) { - CircularProgressIndicator() - } - - if (!uiState.loading && uiState.isMazeEmpty) { - GenerateMazeComponent( - modifier = Modifier.fillMaxSize(), - onGenerateClick = { seed, multiChoiceCategories, wordleCategories -> - uiEvent(MazeScreenUiEvent.GenerateMaze(seed, multiChoiceCategories, wordleCategories)) - } - ) - } - - if (formulas.isNotEmpty()) { - /* - MazeComponent( - modifier = Modifier.fillMaxSize(), - items = formulas, - onItemClick = onItemClick - ) - */ - MazePath( - modifier = Modifier.fillMaxSize(), - items = formulas, - onItemClick = onItemClick, - ) - } - } - } -} - -@Composable -@PreviewLightDark -@OptIn(ExperimentalMaterial3Api::class) -fun MazeScreenPreview() { - val completedItems = List(9) { - MazeItem.Wordle( - wordleWord = WordleWord("1+1=2"), - difficulty = QuestionDifficulty.Easy, - played = true, - wordleQuizType = WordleQuizType.MATH_FORMULA, - mazeSeed = 0 - ) - } - - val otherItems = List(20) { - MazeItem.Wordle( - wordleWord = WordleWord("1+1=2"), - difficulty = QuestionDifficulty.Easy, - wordleQuizType = WordleQuizType.MATH_FORMULA, - mazeSeed = 0 - ) - } - - val mazeItems = (completedItems + otherItems).toPersistentList() - - NewQuizTheme { - MazeScreenImpl( - uiState = MazeScreenUiState( - loading = false, - mathMaze = MazeQuiz(items = mazeItems) - ), - navigateBack = {}, - uiEvent = {}, - onItemClick = {} - ) - } -} \ No newline at end of file diff --git a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenNavigator.kt b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenNavigator.kt deleted file mode 100644 index 694f06d2..00000000 --- a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenNavigator.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.infinitepower.newquiz.maze_quiz - -import com.infinitepower.newquiz.model.maze.MazeQuiz - -interface MazeScreenNavigator { - fun navigateToGame(item: MazeQuiz.MazeItem) -} \ No newline at end of file diff --git a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenUiEvent.kt b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenUiEvent.kt deleted file mode 100644 index 754cd6ff..00000000 --- a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenUiEvent.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.infinitepower.newquiz.maze_quiz - -import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory -import com.infinitepower.newquiz.model.wordle.WordleCategory - -sealed class MazeScreenUiEvent { - data class GenerateMaze( - val seed: Int?, - val selectedMultiChoiceCategories: List, - val selectedWordleCategories: List - ) : MazeScreenUiEvent() - - data object RestartMaze : MazeScreenUiEvent() -} diff --git a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenUiState.kt b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenUiState.kt deleted file mode 100644 index 736d2fbe..00000000 --- a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenUiState.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.infinitepower.newquiz.maze_quiz - -import androidx.annotation.Keep -import com.infinitepower.newquiz.model.maze.MazeQuiz -import com.infinitepower.newquiz.model.maze.emptyMaze - -@Keep -data class MazeScreenUiState( - val mathMaze: MazeQuiz = emptyMaze(), - val loading: Boolean = true -) { - val isMazeEmpty: Boolean - get() = mathMaze.items.isEmpty() - - val mazeSeed: Int? - get() = mathMaze.items.firstOrNull()?.mazeSeed -} \ No newline at end of file diff --git a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenViewModel.kt b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenViewModel.kt deleted file mode 100644 index d8ca3100..00000000 --- a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/MazeScreenViewModel.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.infinitepower.newquiz.maze_quiz - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow -import androidx.lifecycle.viewModelScope -import androidx.work.WorkInfo -import androidx.work.WorkManager -import com.infinitepower.newquiz.core.analytics.AnalyticsEvent -import com.infinitepower.newquiz.core.analytics.AnalyticsHelper -import com.infinitepower.newquiz.data.worker.maze.CleanMazeQuizWorker -import com.infinitepower.newquiz.data.worker.maze.GenerateMazeQuizWorker -import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository -import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory -import com.infinitepower.newquiz.model.wordle.WordleCategory -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import javax.inject.Inject - -@HiltViewModel -class MazeScreenViewModel @Inject constructor( - mazeMathQuizRepository: MazeQuizRepository, - private val workManager: WorkManager, - private val analyticsHelper: AnalyticsHelper -) : ViewModel() { - private val _uiState = MutableStateFlow(MazeScreenUiState()) - val uiState = combine( - _uiState, - mazeMathQuizRepository.getSavedMazeQuizFlow() - ) { uiState, savedMazeQuiz -> - uiState.copy( - mathMaze = savedMazeQuiz, - loading = false - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = MazeScreenUiState() - ) - - fun onEvent(event: MazeScreenUiEvent) { - when (event) { - is MazeScreenUiEvent.GenerateMaze -> generateMaze(event.seed, event.selectedMultiChoiceCategories, event.selectedWordleCategories) - is MazeScreenUiEvent.RestartMaze -> cleanSavedMaze() - } - } - - private fun generateMaze( - seed: Int?, - selectedMultiChoiceCategories: List, - selectedWordleCategories: List - ) { - val workId = GenerateMazeQuizWorker.enqueue( - workManager = workManager, - seed = seed, - multiChoiceCategories = selectedMultiChoiceCategories, - wordleCategories = selectedWordleCategories - ) - - workManager - .getWorkInfoByIdLiveData(workId) - .asFlow() - .onEach { workInfo -> - _uiState.update { currentState -> - val loading = when (workInfo.state) { - WorkInfo.State.SUCCEEDED -> false - else -> true - } - - currentState.copy(loading = loading) - } - }.launchIn(viewModelScope) - } - - private fun cleanSavedMaze() { - analyticsHelper.logEvent(AnalyticsEvent.RestartMaze) - - CleanMazeQuizWorker.enqueue(workManager) - } -} \ No newline at end of file diff --git a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/GenerateCustomMaze.kt b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/GenerateCustomMaze.kt deleted file mode 100644 index cdddb4ab..00000000 --- a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/GenerateCustomMaze.kt +++ /dev/null @@ -1,411 +0,0 @@ -package com.infinitepower.newquiz.maze_quiz.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowDropDown -import androidx.compose.material.icons.rounded.ArrowDropUp -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.Divider -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TriStateCheckbox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import androidx.compose.ui.state.ToggleableState -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewLightDark -import androidx.compose.ui.unit.dp -import com.infinitepower.newquiz.core.theme.NewQuizTheme -import com.infinitepower.newquiz.core.theme.spacing -import com.infinitepower.newquiz.core.util.asString -import com.infinitepower.newquiz.data.worker.maze.GenerateMazeQuizWorker.GameModes -import com.infinitepower.newquiz.model.BaseCategory -import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory -import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory -import com.infinitepower.newquiz.model.wordle.WordleCategory -import kotlin.random.Random -import com.infinitepower.newquiz.core.R as CoreR - -@Composable -@ExperimentalMaterial3Api -internal fun GenerateCustomMaze( - modifier: Modifier = Modifier, - onClick: ( - seed: Int?, - selectedMultiChoiceCategories: List, - selectedWordleCategories: List - ) -> Unit -) { - var expanded by remember { mutableStateOf(false) } - - GenerateCustomMaze( - modifier = modifier, - onClick = onClick, - expanded = expanded, - onExpandClick = { expanded = !expanded } - ) -} - -@Composable -@ExperimentalMaterial3Api -private fun GenerateCustomMaze( - modifier: Modifier = Modifier, - expanded: Boolean, - onClick: ( - seed: Int?, - selectedMultiChoiceCategories: List, - selectedWordleCategories: List - ) -> Unit, - onExpandClick: () -> Unit -) { - val spaceSmall = MaterialTheme.spacing.small - val spaceMedium = MaterialTheme.spacing.medium - - val (seed, setSeed) = remember { - val randomSeed = Random.nextInt() - mutableStateOf(randomSeed.toString()) - } - - val dropDownIcon = remember(expanded) { - if (expanded) { - Icons.Rounded.ArrowDropUp - } else { - Icons.Rounded.ArrowDropDown - } - } - - val selectedMultiChoiceCategories = remember { - GameModes.MultiChoice.categories.toMutableStateList() - } - - val selectedWordleCategories = remember { - GameModes.Wordle.categories.toMutableStateList() - } - - val generateButtonEnabled = remember( - seed, - selectedMultiChoiceCategories.size, - selectedWordleCategories.size - ) { - seed.isNotBlank() && (selectedMultiChoiceCategories.size > 0 || selectedWordleCategories.size > 0) - } - - val showMultiChoiceSeedWarning by remember(selectedMultiChoiceCategories) { - derivedStateOf { - MultiChoiceBaseCategory.Normal().categoryId in selectedMultiChoiceCategories.map { category -> - category.id - } - } - } - - ElevatedCard( - modifier = modifier, - onClick = onExpandClick - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(spaceMedium) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = stringResource(id = CoreR.string.custom_maze), - style = MaterialTheme.typography.headlineMedium - ) - Spacer(modifier = Modifier.height(spaceSmall)) - Text( - text = stringResource(id = CoreR.string.generate_maze_with_custom_options), - style = MaterialTheme.typography.titleMedium - ) - - } - - IconButton(onClick = onExpandClick) { - Icon( - imageVector = dropDownIcon, - contentDescription = stringResource(id = CoreR.string.expand_custom_options) - ) - } - } - - AnimatedVisibility(visible = expanded) { - Column { - Divider(modifier = Modifier.padding(vertical = spaceMedium)) - GameModesComponent( - selectedMultiChoiceCategories = selectedMultiChoiceCategories, - onMultiChoiceCategoryClicked = { category -> - if (category in selectedMultiChoiceCategories) { - selectedMultiChoiceCategories.remove(category) - } else { - selectedMultiChoiceCategories.add(category) - } - }, - onMultiChoiceParentClicked = { enableAll -> - selectedMultiChoiceCategories.clear() - - if (enableAll) { - selectedMultiChoiceCategories.addAll(GameModes.MultiChoice.categories) - } - }, - selectedWordleCategories = selectedWordleCategories, - onWordleCategoryClicked = { category -> - if (category in selectedWordleCategories) { - selectedWordleCategories.remove(category) - } else { - selectedWordleCategories.add(category) - } - }, - onWordleParentClicked = { enableAll -> - selectedWordleCategories.clear() - - if (enableAll) { - selectedWordleCategories.addAll(GameModes.Wordle.categories) - } - } - ) - Divider(modifier = Modifier.padding(vertical = spaceMedium)) - OutlinedTextField( - value = seed, - onValueChange = setSeed, - label = { Text(text = stringResource(id = CoreR.string.seed)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - Spacer(modifier = Modifier.height(spaceMedium)) - if (showMultiChoiceSeedWarning) { - Text( - text = stringResource(id = CoreR.string.custom_maze_multi_choice_warning), - style = MaterialTheme.typography.bodyMedium - ) - } - Spacer(modifier = Modifier.height(spaceMedium)) - Button( - onClick = { - onClick( - seed.toIntOrNull(), - selectedMultiChoiceCategories, - selectedWordleCategories - ) - }, - modifier = Modifier.align(Alignment.End), - enabled = generateButtonEnabled - ) { - Text(text = stringResource(id = CoreR.string.generate)) - } - } - } - } - } -} - -@Composable -private fun GameModesComponent( - modifier: Modifier = Modifier, - selectedMultiChoiceCategories: List, - onMultiChoiceCategoryClicked: (category: MultiChoiceCategory) -> Unit, - onMultiChoiceParentClicked: (enableAll: Boolean) -> Unit, - selectedWordleCategories: List, - onWordleCategoryClicked: (category: WordleCategory) -> Unit, - onWordleParentClicked: (enableAll: Boolean) -> Unit -) { - val spaceSmall = MaterialTheme.spacing.small - - Column(modifier = modifier) { - Text( - text = stringResource(id = CoreR.string.categories), - style = MaterialTheme.typography.labelMedium - ) - Spacer(modifier = Modifier.height(spaceSmall)) - GameModeComponent( - gameMode = GameModes.MultiChoice, - selectedCategories = selectedMultiChoiceCategories, - onSelectChange = onMultiChoiceCategoryClicked, - onParentSelectChange = onMultiChoiceParentClicked - ) - Spacer(modifier = Modifier.height(spaceSmall)) - GameModeComponent( - gameMode = GameModes.Wordle, - selectedCategories = selectedWordleCategories, - onSelectChange = onWordleCategoryClicked, - onParentSelectChange = onWordleParentClicked - ) - } -} - -@Composable -internal fun GameModeComponent( - modifier: Modifier = Modifier, - gameMode: GameModes, - selectedCategories: List, - onSelectChange: (category: T) -> Unit, - onParentSelectChange: (enableAll: Boolean) -> Unit -) { - val parentBoxState = remember(selectedCategories.size) { - if (selectedCategories.isEmpty()) { - ToggleableState.Off - } else if (selectedCategories.size == gameMode.categories.size) { - ToggleableState.On - } else { - ToggleableState.Indeterminate - } - } - - val gameModeName = gameMode.name.asString() - - Column( - modifier = modifier, - horizontalAlignment = Alignment.Start - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .toggleable( - value = parentBoxState == ToggleableState.On, - onValueChange = { onParentSelectChange(it) }, - role = Role.Checkbox - ) - .semantics(mergeDescendants = true) { - contentDescription = "$gameModeName game mode" - } - ) { - TriStateCheckbox( - state = parentBoxState, - onClick = { - if (parentBoxState == ToggleableState.On) { - onParentSelectChange(false) - } else { - onParentSelectChange(true) - } - }, - modifier = Modifier.semantics { - contentDescription = "$gameModeName game mode checkbox" - - stateDescription = when (parentBoxState) { - ToggleableState.On -> "All categories selected" - ToggleableState.Off -> "No categories selected" - ToggleableState.Indeterminate -> "Some categories selected" - } - } - ) - Text(text = gameModeName) - } - gameMode.categories.forEach { category -> - CategoryComponent( - modifier = Modifier - .fillMaxWidth() - .padding(start = MaterialTheme.spacing.medium), - text = category.name.asString(), - selected = category in selectedCategories, - onSelectChange = { onSelectChange(category) } - ) - } - } -} - -@Composable -private fun CategoryComponent( - modifier: Modifier = Modifier, - text: String, - selected: Boolean, - onSelectChange: (Boolean) -> Unit, - enabled: Boolean = true -) { - Row( - modifier = modifier - .toggleable( - value = selected, - onValueChange = onSelectChange, - role = Role.Checkbox - ) - .semantics(mergeDescendants = true) { - contentDescription = "$text category" - }, - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = selected, - onCheckedChange = onSelectChange, - enabled = enabled, - modifier = Modifier.semantics { - contentDescription = "$text category checkbox" - - stateDescription = if (selected) { - "Selected" - } else { - "Not selected" - } - } - ) - Text(text = text) - } -} - -@Composable -@PreviewLightDark -@OptIn(ExperimentalMaterial3Api::class) -private fun GenerateCustomMazePreview() { - NewQuizTheme { - Surface { - GenerateCustomMaze( - modifier = Modifier.padding(16.dp), - onClick = { _, _, _ -> }, - expanded = true, - onExpandClick = {} - ) - } - } -} - -@Composable -@Preview( - showBackground = true, - group = "GameModeComponent", -) -private fun GameModeComponentPreview() { - NewQuizTheme { - Surface { - GameModeComponent( - modifier = Modifier.padding(16.dp), - gameMode = GameModes.MultiChoice, - selectedCategories = GameModes.MultiChoice.categories.shuffled().take(2), - onSelectChange = {}, - onParentSelectChange = {} - ) - } - } -} diff --git a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/GenerateMazeComponent.kt b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/GenerateMazeComponent.kt deleted file mode 100644 index 118986dc..00000000 --- a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/GenerateMazeComponent.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.infinitepower.newquiz.maze_quiz.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.PreviewLightDark -import androidx.compose.ui.unit.dp -import com.infinitepower.newquiz.core.R as CoreR -import com.infinitepower.newquiz.core.theme.NewQuizTheme -import com.infinitepower.newquiz.core.theme.spacing -import com.infinitepower.newquiz.data.worker.maze.GenerateMazeQuizWorker -import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory -import com.infinitepower.newquiz.model.wordle.WordleCategory - -@Composable -@ExperimentalMaterial3Api -internal fun GenerateMazeComponent( - modifier: Modifier = Modifier, - onGenerateClick: ( - seed: Int?, - selectedMultiChoiceCategories: List, - selectedWordleCategories: List - ) -> Unit -) { - val spaceMedium = MaterialTheme.spacing.medium - val spaceExtraLarge = MaterialTheme.spacing.extraLarge - - LazyColumn( - modifier = modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(spaceMedium) - ) { - item { - Text( - text = stringResource(id = CoreR.string.generate_maze), - style = MaterialTheme.typography.displaySmall, - modifier = Modifier.padding(vertical = spaceExtraLarge) - ) - } - - item { - GenerateMazeCard( - onClick = { - val allMultiChoiceCategories = GenerateMazeQuizWorker.GameModes.MultiChoice.categories - val allWordleCategories = GenerateMazeQuizWorker.GameModes.Wordle.categories - onGenerateClick(null, allMultiChoiceCategories, allWordleCategories) - } - ) - } - - item { - GenerateOfflineMazeCard( - onClick = { - val offlineMultiChoiceCategories = GenerateMazeQuizWorker.GameModes.MultiChoice.getOfflineCategories() - val offlineWordleCategories = GenerateMazeQuizWorker.GameModes.Wordle.getOfflineCategories() - onGenerateClick(null, offlineMultiChoiceCategories, offlineWordleCategories) - } - ) - } - - item { - GenerateCustomMaze(onClick = onGenerateClick) - } - } -} - -@Composable -@ExperimentalMaterial3Api -private fun GenerateMazeCard( - modifier: Modifier = Modifier, - onClick: () -> Unit -) { - val spaceSmall = MaterialTheme.spacing.small - val spaceMedium = MaterialTheme.spacing.medium - - Surface( - modifier = modifier, - color = MaterialTheme.colorScheme.primary, - shape = MaterialTheme.shapes.medium, - onClick = onClick - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(spaceMedium) - ) { - Text( - text = stringResource(id = CoreR.string.random_maze), - style = MaterialTheme.typography.headlineMedium - ) - Spacer(modifier = Modifier.height(spaceSmall)) - Text( - text = stringResource(id = CoreR.string.generate_maze_with_random_items), - style = MaterialTheme.typography.titleMedium - ) - } - } -} - -@Composable -@PreviewLightDark -@OptIn(ExperimentalMaterial3Api::class) -private fun GenerateMazeComponentPreview() { - NewQuizTheme { - Surface { - GenerateMazeComponent( - modifier = Modifier.padding(16.dp), - onGenerateClick = { _, _, _ -> } - ) - } - } -} - -@Composable -@PreviewLightDark -@OptIn(ExperimentalMaterial3Api::class) -private fun GenerateMazeCardPreview() { - NewQuizTheme { - Surface { - GenerateMazeCard( - modifier = Modifier.padding(16.dp), - onClick = {} - ) - } - } -} \ No newline at end of file diff --git a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/GenerateOfflineMazeCard.kt b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/GenerateOfflineMazeCard.kt deleted file mode 100644 index 8486ce70..00000000 --- a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/GenerateOfflineMazeCard.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.infinitepower.newquiz.maze_quiz.components - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.PreviewLightDark -import androidx.compose.ui.unit.dp -import com.infinitepower.newquiz.core.R as CoreR -import com.infinitepower.newquiz.core.theme.NewQuizTheme -import com.infinitepower.newquiz.core.theme.spacing - -@Composable -@ExperimentalMaterial3Api -internal fun GenerateOfflineMazeCard( - modifier: Modifier = Modifier, - onClick: () -> Unit -) { - val spaceSmall = MaterialTheme.spacing.small - val spaceMedium = MaterialTheme.spacing.medium - - ElevatedCard( - modifier = modifier, - onClick = onClick - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(spaceMedium) - ) { - Text( - text = stringResource(id = CoreR.string.generate_offline_maze), - style = MaterialTheme.typography.headlineMedium - ) - Spacer(modifier = Modifier.height(spaceSmall)) - Text( - text = stringResource(id = CoreR.string.generate_offline_maze_description), - style = MaterialTheme.typography.titleMedium - ) - } - } -} - -@Composable -@PreviewLightDark -@OptIn(ExperimentalMaterial3Api::class) -private fun GenerateOfflineMazeCardPreview() { - NewQuizTheme { - Surface { - GenerateOfflineMazeCard( - modifier = Modifier.padding(16.dp), - onClick = {} - ) - } - } -} \ No newline at end of file diff --git a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/MazeComponent.kt b/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/MazeComponent.kt deleted file mode 100644 index eff8a36e..00000000 --- a/maze-quiz/src/main/java/com/infinitepower/newquiz/maze_quiz/components/MazeComponent.kt +++ /dev/null @@ -1,298 +0,0 @@ -package com.infinitepower.newquiz.maze_quiz.components - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.detectVerticalDragGestures -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.Lock -import androidx.compose.material.icons.rounded.PlayArrow -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.drawscope.translate -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.tooling.preview.PreviewLightDark -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import com.infinitepower.newquiz.core.analytics.AnalyticsEvent -import com.infinitepower.newquiz.core.analytics.LocalAnalyticsHelper -import com.infinitepower.newquiz.core.theme.NewQuizTheme -import com.infinitepower.newquiz.core.theme.spacing -import com.infinitepower.newquiz.core.util.collections.indexOfFirstOrNull -import com.infinitepower.newquiz.model.maze.MazePoint -import com.infinitepower.newquiz.model.maze.MazeQuiz -import com.infinitepower.newquiz.model.maze.generateMazePointsBottomToTop -import com.infinitepower.newquiz.model.maze.isInsideCircle -import com.infinitepower.newquiz.model.maze.isItemPlayed -import com.infinitepower.newquiz.model.maze.isPlayableItem -import com.infinitepower.newquiz.model.question.QuestionDifficulty -import com.infinitepower.newquiz.model.wordle.WordleQuizType -import com.infinitepower.newquiz.model.wordle.WordleWord -import kotlin.math.abs - -@Composable -internal fun MazeComponent( - modifier: Modifier = Modifier, - items: List, - onItemClick: (item: MazeQuiz.MazeItem) -> Unit -) { - val localDensity = LocalDensity.current - val spaceLarge = MaterialTheme.spacing.large - - val analyticsHelper = LocalAnalyticsHelper.current - - BoxWithConstraints( - modifier = modifier.height(2000.dp) - ) { - val startPoint = with(localDensity) { - MazePoint( - x = maxWidth.toPx() / 2, - y = (maxHeight - spaceLarge).toPx() - ) - } - - MazeComponentImpl( - modifier = Modifier.fillMaxWidth(), - items = items, - startPoint = startPoint, - onClick = { index -> - val item = items[index] - - // Only play the item if it hasn't been played yet - if (!item.played) { - analyticsHelper.logEvent(AnalyticsEvent.MazeItemClick(index)) - onItemClick(item) - } - } - ) - } -} - -@Composable -private fun MazeComponentImpl( - modifier: Modifier = Modifier, - items: List, - startPoint: MazePoint, - onClick: (index: Int) -> Unit -) { - val localDensity = LocalDensity.current - - val colorPrimary = MaterialTheme.colorScheme.primary - val colorSecondary = MaterialTheme.colorScheme.secondary - val colorSurface = MaterialTheme.colorScheme.surface - val colorSurfaceVariant = MaterialTheme.colorScheme.surfaceVariant - - val currentPlayItemIndex = remember(items) { - items.indexOfFirstOrNull { !it.played } - } - - val incrementPoint = with(localDensity) { - MazePoint(x = 100.dp.toPx(), y = 100.dp.toPx()) - } - - val circleRadius = with(localDensity) { 30.dp.toPx() } - - val mazePoints = remember(items.size, startPoint) { - generateMazePointsBottomToTop( - startPoint = MazePoint(startPoint.x, startPoint.y), - increment = incrementPoint - ).take(items.size).toList() - } - - val height = remember(mazePoints.size) { - if (mazePoints.isEmpty()) return@remember 0.dp - - val heightPx = abs(mazePoints.first().y) + abs(mazePoints.last().y) - heightPx.dp - } - - var topScroll by remember { mutableFloatStateOf(0f) } - - val topY = with(localDensity) { - abs(mazePoints.lastOrNull()?.y ?: 0f) + 100.dp.toPx() - } - - val playPainter = rememberVectorPainter(image = Icons.Rounded.PlayArrow) - val checkPainter = rememberVectorPainter(image = Icons.Rounded.Check) - val lockPainter = rememberVectorPainter(image = Icons.Rounded.Lock) - - val iconPlaySizer = with(localDensity) { - Size(30.dp.toPx(), 30.dp.toPx()) - } - - // Value to center the play button in the circle - val dp15 = with(localDensity) { - 15.dp.toPx() - } - - Canvas( - modifier = modifier - .height(height) - .zIndex(1f) - ) { - translate(top = topScroll) { - mazePoints.forEachIndexed { index, point -> - val pointOffset = Offset(point.x, point.y) - - val circleColor = getContentColor(items, index, colorPrimary, colorSurfaceVariant) - - val startOffset = mazePoints - .getOrNull(index - 1) - ?.let { prevPoint -> - Offset( - x = prevPoint.x, - y = prevPoint.y - ) - } ?: pointOffset - - drawLine( - color = circleColor, - start = startOffset, - end = pointOffset, - strokeWidth = 12.dp.toPx() - ) - } - } - } - - Canvas( - modifier = modifier - .height(height) - .zIndex(2f) - .pointerInput(currentPlayItemIndex) { - detectTapGestures( - onTap = { tapOffset -> - val tapPoint = tapOffset.toMazePoint() - - val tapIndex = mazePoints.indexOfFirstOrNull { mazePoint -> - val realMazePoint = mazePoint.copy(y = mazePoint.y + topScroll) - tapPoint.isInsideCircle(realMazePoint, circleRadius) - } - - if (tapIndex != null) { - if (items.isPlayableItem(tapIndex)) onClick(tapIndex) - } - } - ) - } - .pointerInput(Unit) { - detectVerticalDragGestures { _, dragAmount -> - if (topScroll + dragAmount > 0 && topScroll + dragAmount < topY) { - topScroll += dragAmount - } - } - } - ) { - translate(top = topScroll) { - mazePoints.forEachIndexed { index, point -> - val pointOffset = Offset(point.x, point.y) - - val itemPlayed = items.isItemPlayed(index) - val isPlayItem = items.isPlayableItem(index) - - val circleColor = if (itemPlayed || isPlayItem) colorPrimary else colorSurfaceVariant - - drawCircle( - color = circleColor, - radius = circleRadius, - center = pointOffset - ) - - if (isPlayItem) { - drawCircle( - color = colorSurface, - radius = circleRadius / 1.5f, - center = pointOffset - ) - } - - val iconColor = when { - !isPlayItem && !itemPlayed -> colorSecondary.copy(alpha = 0.3f) - !isPlayItem && itemPlayed -> colorSurface - else -> colorPrimary - } - - translate( - left = pointOffset.x - dp15, - top = pointOffset.y - dp15 - ) { - with( - when { - !isPlayItem && !itemPlayed -> lockPainter - !isPlayItem && itemPlayed -> checkPainter - else -> playPainter - } - ) { - draw( - size = iconPlaySizer, - colorFilter = ColorFilter.tint(iconColor) - ) - } - } - } - } - } -} - -private fun getContentColor( - items: List, - index: Int, - colorPrimary: Color, - colorSurfaceVariant: Color -): Color { - val itemPlayed = items.isItemPlayed(index) - val isPlayItem = items.isPlayableItem(index) - - return if (itemPlayed || isPlayItem) colorPrimary else colorSurfaceVariant -} - -private fun Offset.toMazePoint(): MazePoint = MazePoint(x, y) - -@Composable -@PreviewLightDark -private fun MazeComponentPreview() { - val completedItems = List(9) { - MazeQuiz.MazeItem.Wordle( - wordleWord = WordleWord("1+1=2"), - difficulty = QuestionDifficulty.Easy, - played = true, - wordleQuizType = WordleQuizType.MATH_FORMULA, - mazeSeed = 0 - ) - } - - val otherItems = List(20) { - MazeQuiz.MazeItem.Wordle( - wordleWord = WordleWord("1+1=2"), - difficulty = QuestionDifficulty.Easy, - wordleQuizType = WordleQuizType.MATH_FORMULA, - mazeSeed = 0 - ) - } - - NewQuizTheme { - Surface { - MazeComponent( - modifier = Modifier.padding(16.dp), - items = completedItems + otherItems, - onItemClick = {} - ) - } - } -} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 2fc6c595..00058e38 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -44,6 +44,5 @@ include(":domain") include(":data") include(":multi-choice-quiz") include(":wordle") -include(":maze-quiz") include(":comparison-quiz") include(":daily_challenge") From 8b6eb7d076ac1c638f0c7127d7decaeb1bd20a1e Mon Sep 17 00:00:00 2001 From: joaomanaia Date: Sat, 6 Jan 2024 10:47:07 +0000 Subject: [PATCH 8/9] Hide generate maze button while generating maze --- .../core/ui/components/icon/button/BackIconButton.kt | 4 ++-- .../feature/maze/generate/GenerateMazeScreen.kt | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/com/infinitepower/newquiz/core/ui/components/icon/button/BackIconButton.kt b/core/src/main/java/com/infinitepower/newquiz/core/ui/components/icon/button/BackIconButton.kt index c7ceb889..7d905100 100644 --- a/core/src/main/java/com/infinitepower/newquiz/core/ui/components/icon/button/BackIconButton.kt +++ b/core/src/main/java/com/infinitepower/newquiz/core/ui/components/icon/button/BackIconButton.kt @@ -1,7 +1,7 @@ package com.infinitepower.newquiz.core.ui.components.icon.button import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -19,7 +19,7 @@ fun BackIconButton( modifier = modifier ) { Icon( - imageVector = Icons.Rounded.ArrowBack, + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(id = R.string.back) ) } diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreen.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreen.kt index e06d57ec..a5bf9ccc 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreen.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreen.kt @@ -71,11 +71,15 @@ internal fun GenerateMazeScreenImpl( onBackClick: () -> Unit = {} ) { val showGenerateButton = remember( - key1 = uiState.selectedMultiChoiceCategories.size, - key2 = uiState.selectedWordleCategories.size + uiState.selectedMultiChoiceCategories.size, + uiState.selectedWordleCategories.size, + uiState.loading, + uiState.generatingMaze ) { derivedStateOf { - uiState.selectedMultiChoiceCategories.isNotEmpty() || uiState.selectedWordleCategories.isNotEmpty() + val anySelectCategory = uiState.selectedMultiChoiceCategories.isNotEmpty() || uiState.selectedWordleCategories.isNotEmpty() + + !uiState.loading && !uiState.generatingMaze && anySelectCategory } } From f369d94e2fa116141478424e8726d569190aae75 Mon Sep 17 00:00:00 2001 From: joaomanaia Date: Sat, 6 Jan 2024 23:02:28 +0000 Subject: [PATCH 9/9] Update strings --- core/src/main/res/values-pt/strings.xml | 1 + core/src/main/res/values/strings.xml | 1 + .../feature/maze/generate/GenerateMazeScreen.kt | 17 +++++++++++------ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/core/src/main/res/values-pt/strings.xml b/core/src/main/res/values-pt/strings.xml index d45bfd74..a853732e 100644 --- a/core/src/main/res/values-pt/strings.xml +++ b/core/src/main/res/values-pt/strings.xml @@ -292,4 +292,5 @@ Modo difícil, Linguagem do questionário, Dicas de palavras Idioma de tradução Versão, Contacto, Licenças de código aberto + Seleciona apenas Offline \ No newline at end of file diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 3e03c8f8..39cf4a59 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -286,4 +286,5 @@ Hard mode, Quiz language, Letter hints Translation language Version, Contact, Open source licences + Select only Offline \ No newline at end of file diff --git a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreen.kt b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreen.kt index a5bf9ccc..eb54fcb8 100644 --- a/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreen.kt +++ b/feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.hilt.navigation.compose.hiltViewModel @@ -40,6 +41,7 @@ import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.components.category.CategoryComponent import com.infinitepower.newquiz.core.ui.components.icon.button.BackIconButton import com.infinitepower.newquiz.core.util.asString +import com.infinitepower.newquiz.core.R as CoreR import com.infinitepower.newquiz.data.local.multi_choice_quiz.category.multiChoiceQuestionCategories import com.infinitepower.newquiz.data.local.wordle.WordleCategories import com.infinitepower.newquiz.model.BaseCategory @@ -86,7 +88,7 @@ internal fun GenerateMazeScreenImpl( Scaffold( topBar = { TopAppBar( - title = { Text(text = "Create Maze") }, + title = { Text(text = stringResource(id = CoreR.string.generate_maze)) }, navigationIcon = { BackIconButton(onClick = onBackClick) } ) }, @@ -97,7 +99,7 @@ internal fun GenerateMazeScreenImpl( onEvent(GenerateMazeScreenUiEvent.GenerateMaze(seed = null)) }, ) { - Text(text = "Generate") + Text(text = stringResource(id = CoreR.string.generate)) } } } @@ -149,12 +151,15 @@ private fun CategoriesContent( categories = wordleCategories ) + val multiChoiceHeader = stringResource(id = CoreR.string.multi_choice_quiz) + val wordleHeader = stringResource(id = CoreR.string.wordle) + LazyColumn( contentPadding = PaddingValues(vertical = MaterialTheme.spacing.medium), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium) ) { categoriesStickyHeader( - title = "Multi Choice", + title = multiChoiceHeader, parentBoxState = multiChoiceParentBoxState, onSelectAllClick = { selectAll -> onEvent( @@ -176,7 +181,7 @@ private fun CategoriesContent( ) categoriesStickyHeader( - title = "Wordle", + title = wordleHeader, parentBoxState = wordleParentBoxState, onSelectAllClick = { selectAll -> onEvent( @@ -309,7 +314,7 @@ private fun HelperChipsRow( AssistChip( onClick = onSelectAllClick, label = { - Text(text = "Select All") + Text(text = stringResource(id = CoreR.string.select_all)) }, leadingIcon = { Icon( @@ -324,7 +329,7 @@ private fun HelperChipsRow( AssistChip( onClick = onOnlyOfflineClick, label = { - Text(text = "Select only Offline") + Text(text = stringResource(id = CoreR.string.select_only_offline)) }, leadingIcon = { Icon(