From a60670906dc2dc87b8f65984eab03dd1a8f8e909 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Sat, 22 Aug 2026 14:08:08 +0000
Subject: [PATCH] Add interactive math learning section with 8 math topics
- Implement MathLearningTopic enum with 8 math topics (Arithmetic, Measurement, Geometry, Data Analysis, Algebra, Trigonometry, Pre-calculus, Calculus).
- Create interactive lesson, test, and certificate screens.
- Persist passed topics, scores, and timestamps in UserStorage.
- Add MathLearningTile to bottom of MainMenuScreen.
- Add navigation routes and URL routing support.
Co-authored-by: SimonSchubert <1833337+SimonSchubert@users.noreply.github.com>
---
.../composeResources/values/strings.xml | 178 ++++++++
.../com/inspiredandroid/braincup/App.kt | 62 +++
.../braincup/api/UserStorage.kt | 36 ++
.../inspiredandroid/braincup/app/Screen.kt | 12 +
.../mathlearning/MathLearningTopic.kt | 418 ++++++++++++++++++
.../braincup/navigation/AppUrlRoutes.kt | 25 ++
.../ui/components/MathLearningTile.kt | 70 +++
.../braincup/ui/screens/MainMenuScreen.kt | 21 +
.../screens/MathLearningCertificateScreen.kt | 183 ++++++++
.../ui/screens/MathLearningLessonScreen.kt | 248 +++++++++++
.../ui/screens/MathLearningMenuScreen.kt | 173 ++++++++
.../ui/screens/MathLearningTestScreen.kt | 224 ++++++++++
.../braincup/mathlearning/MathLearningTest.kt | 52 +++
.../braincup/navigation/AppUrlRoutesTest.kt | 20 +
14 files changed, 1722 insertions(+)
create mode 100644 composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/mathlearning/MathLearningTopic.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/components/MathLearningTile.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningCertificateScreen.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningLessonScreen.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningMenuScreen.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningTestScreen.kt
create mode 100644 composeApp/src/commonTest/kotlin/com/inspiredandroid/braincup/mathlearning/MathLearningTest.kt
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 18fd3ae1..c30f90fb 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -822,4 +822,182 @@
Pattern Savant
Reach an estimated IQ of 130 or higher
+
+ Interactive Math Topics
+ Math Learning
+ Interactive lessons, practice & certificate tests
+ Start Lesson
+ Take Test
+ View Certificate
+ Passed (%1$d%%)
+ Not Passed
+ Lesson: %1$s
+ Practice Question
+ Check Answer
+ Correct!
+ Not quite right, try again.
+ Next Step
+ Finish Lesson & Take Test
+ %1$s Skill Test
+ Question %1$d of %2$d
+ Submit Test
+ Test Passed!
+ Keep Learning
+ Score: %1$d / %2$d (%3$d%%)
+ Congratulations! You demonstrated mastery in %1$s and earned a certificate.
+ You need at least 66%% to pass and earn a certificate. Review the lesson and try again!
+
+
+ Certificate of Achievement
+ Braincup Learning Academy
+ This certifies that
+ Learner
+ has successfully completed interactive learning and passed the skill assessment for
+ Score Achieved: %1$d%%
+
+
+ Arithmetic / Number & Operations
+ Master fundamental order of operations, properties, and mental calculation tricks.
+
+ Measurement
+ Learn metric unit conversions, perimeter, area, and spatial scale calculations.
+
+ Geometry
+ Explore geometric shapes, angles, Pythagorean theorem, and area formulas.
+
+ Data Analysis, Statistics & Probability
+ Understand mean, median, mode, data interpretation, and basic probability.
+
+ Algebra
+ Solve linear equations, simplify algebraic expressions, and expand polynomials.
+
+ Trigonometry
+ Learn sine, cosine, tangent ratios, right triangle problem-solving, and identities.
+
+ Pre-calculus / Functions
+ Understand functions, domain and range, logarithmic rules, and exponential behavior.
+
+ Calculus
+ Introduction to derivatives (rates of change) and integrals (area under curves).
+
+
+ Distributive Property
+ Multiplying a sum by a number is equal to multiplying each addend individually and adding the products together.
+ What is 4 * (2 + 3)?
+ 4 * (2 + 3) = 4 * 5 = 20. Or (4 * 2) + (4 * 3) = 8 + 12 = 20.
+
+ Order of Operations (PEMDAS)
+ Perform operations inside Parentheses first, then Exponents, Multiplication/Division from left to right, then Addition/Subtraction from left to right.
+ Evaluate: 2 + 3 * 4 + 2
+ Multiply first: 3 * 4 = 12. Then add: 2 + 12 + 2 = 16.
+
+ What is 3 * (4 + 3)?
+ Evaluate: 8 + 12 / 2
+ What is 5 * (6 + 3)?
+
+
+ Metric Unit Conversions
+ The metric system uses powers of 10. 1 kilometer = 1,000 meters; 1 meter = 100 centimeters; 1 centimeter = 10 millimeters.
+ How many centimeters are in 3 meters?
+ 3 meters * 100 cm/meter = 300 cm.
+
+ Area of Rectangles
+ The area of a rectangle is the amount of 2D space inside it, calculated by multiplying width by height.
+ Find the area of a rectangle with length 6 m and width 4 m.
+ Area = 6 m * 4 m = 24 m².
+
+ How many centimeters are in 2.5 meters?
+ What is the area of a garden that is 5 cm wide and 7 cm long?
+ What is the perimeter of a rectangle with sides 10 m and 20 m?
+
+
+ Pythagorean Theorem
+ In any right-angled triangle, the square of the hypotenuse (c) equals the sum of the squares of the other two sides (a and b).
+ In a right triangle, if a = 3 and b = 4, what is hypotenuse c?
+ c² = 3² + 4² = 9 + 16 = 25, so c = 5.
+
+ Area of a Triangle
+ The area of any triangle is half the base times the height.
+ Find the area of a triangle with base = 8 cm and height = 6 cm.
+ Area = (8 * 6) / 2 = 48 / 2 = 24 cm².
+
+ In a right triangle with legs 5 and 12, what is the length of the hypotenuse?
+ What is the sum of interior angles in any triangle?
+ Calculate the area of a triangle with base 10 cm and height 6 cm.
+
+
+ Mean (Average)
+ The mean is calculated by adding up all numbers in a dataset and dividing by the total count of numbers.
+ What is the mean of: 2, 4, 6, 8, 10?
+ Sum = 30. Count = 5. Mean = 30 / 5 = 6.
+
+ Probability Basics
+ Probability measures how likely an event is to happen: ratio of favorable outcomes to total possible outcomes.
+ What is the probability of rolling an even number on a standard 6-sided die?
+ Even numbers are {2, 4, 6} (3 outcomes out of 6). Probability = 3/6 = 1/2.
+
+ Find the mean of the dataset: 5, 10, 15, 18.
+ What is the probability of flipping two coins and getting two heads?
+ Which statistical measure represents the most frequently occurring value?
+
+
+ Solving Linear Equations
+ To solve ax + b = c, isolate variable x by applying inverse operations to both sides of the equation.
+ Solve for x: 2x + 5 = 13
+ Subtract 5: 2x = 8. Divide by 2: x = 4.
+
+ Expanding Binomials
+ Square of a binomial (a + b)² equals a² + 2ab + b².
+ Expand: (x + 3)²
+ (x + 3)² = x² + 2*(x*3) + 3² = x² + 6x + 9.
+
+ Solve for x: 3x - 4 = 11
+ Expand: (x + 4)²
+ Factor the difference of squares: x² - 4
+
+
+ Trigonometric Ratios (SOH CAH TOA)
+ In right triangles: sin(θ) = Opposite/Hypotenuse, cos(θ) = Adjacent/Hypotenuse, tan(θ) = Opposite/Adjacent.
+ In a 3-4-5 right triangle, what is sin(θ) for the angle opposite side 3?
+ sin(θ) = Opposite / Hypotenuse = 3 / 5.
+
+ Pythagorean Identity
+ For any angle θ, sin²(θ) + cos²(θ) = 1.
+ What is the value of sin²(30°) + cos²(30°)?
+ By the Pythagorean identity, sin²(θ) + cos²(θ) is always 1 for any angle θ.
+
+ What is sin(30°)?
+ What is tan(45°)?
+ Which expression is equal to sin(θ) / cos(θ)?
+
+
+ Functions and Evaluation
+ A function f(x) maps input x to output y. Evaluate f(c) by replacing variable x with constant c.
+ If f(x) = 2x + 5, what is f(3)?
+ f(3) = 2(3) + 5 = 6 + 5 = 11.
+
+ Logarithms
+ A logarithm log_b(x) asks: to what power must base b be raised to get x?
+ What is log_2(8)?
+ Because 2³ = 8, log_2(8) = 3.
+
+ If f(x) = 3x + 4, find f(3).
+ Evaluate log_10(100).
+ What is the domain of f(x) = x² + 1?
+
+
+ Derivatives (Power Rule)
+ A derivative measures instantaneous rate of change. By the power rule, d/dx(xⁿ) = n * xⁿ⁻¹.
+ What is the derivative of f(x) = x³?
+ By power rule with n = 3: d/dx(x³) = 3 * x³⁻¹ = 3x².
+
+ Integrals (Antiderivatives)
+ Integration is the reverse of differentiation, representing accumulation and area under a curve. ∫ xⁿ dx = xⁿ⁺¹/(n+1) + C.
+ What is ∫ 2x dx?
+ ∫ 2x dx = 2 * (x²/2) + C = x² + C.
+
+ What is the derivative of x⁴?
+ Evaluate the indefinite integral: ∫ 3x² dx
+ What geometric property does a definite integral represent?
+
diff --git a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/App.kt b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/App.kt
index 2ddf74fc..82554bbd 100644
--- a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/App.kt
@@ -30,6 +30,7 @@ import com.inspiredandroid.braincup.app.*
import com.inspiredandroid.braincup.audio.SimonPadSounds
import com.inspiredandroid.braincup.audio.rememberAudioPlayer
import com.inspiredandroid.braincup.games.getGameTypeById
+import com.inspiredandroid.braincup.mathlearning.MathLearningTopic
import com.inspiredandroid.braincup.games.tools.GameColor
import com.inspiredandroid.braincup.haptic.rememberHapticSuccess
import com.inspiredandroid.braincup.navigation.AppNavHost
@@ -200,10 +201,12 @@ fun App(
composable {
val onOpenSettings = remember(controller) { { controller.navigateToSettings() } }
val onIqTest = remember(iqTestController) { { iqTestController.navigateToIntro() } }
+ val onMathLearning = remember(navController) { { navController.navigate(MathLearningMenu) } }
MainMenuScreen(
controller = controller,
onOpenSettings = onOpenSettings,
onIqTest = onIqTest,
+ onMathLearning = onMathLearning,
useBuiltInSponsors = useBuiltInSponsors,
)
}
@@ -684,6 +687,65 @@ fun App(
)
}
}
+
+ composable {
+ MathLearningMenuScreen(
+ storage = controller.storage,
+ onSelectTopicLesson = { topic -> navController.navigate(MathLearningLesson(topic.id)) },
+ onSelectTopicTest = { topic -> navController.navigate(MathLearningTest(topic.id)) },
+ onViewCertificate = { topic -> navController.navigate(MathLearningCertificate(topic.id)) },
+ onBack = {
+ if (!navController.popBackStack()) {
+ controller.navigateToMainMenu()
+ }
+ },
+ )
+ }
+
+ composable { backStackEntry ->
+ val route: MathLearningLesson = backStackEntry.toRoute()
+ val topic = MathLearningTopic.getById(route.topicId)
+ if (topic != null) {
+ MathLearningLessonScreen(
+ topic = topic,
+ onFinishLessonTakeTest = {
+ navController.navigate(MathLearningTest(topic.id)) {
+ popUpTo { inclusive = true }
+ }
+ },
+ onBack = { navController.popBackStack() },
+ )
+ }
+ }
+
+ composable { backStackEntry ->
+ val route: MathLearningTest = backStackEntry.toRoute()
+ val topic = MathLearningTopic.getById(route.topicId)
+ if (topic != null) {
+ MathLearningTestScreen(
+ topic = topic,
+ storage = controller.storage,
+ onViewCertificate = { certTopic ->
+ navController.navigate(MathLearningCertificate(certTopic.id)) {
+ popUpTo { inclusive = true }
+ }
+ },
+ onBack = { navController.popBackStack() },
+ )
+ }
+ }
+
+ composable { backStackEntry ->
+ val route: MathLearningCertificate = backStackEntry.toRoute()
+ val topic = MathLearningTopic.getById(route.topicId)
+ if (topic != null) {
+ MathLearningCertificateScreen(
+ topic = topic,
+ storage = controller.storage,
+ onBack = { navController.popBackStack() },
+ )
+ }
+ }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/api/UserStorage.kt b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/api/UserStorage.kt
index fcc84620..ec6cb5ca 100644
--- a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/api/UserStorage.kt
+++ b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/api/UserStorage.kt
@@ -170,6 +170,7 @@ class UserStorage(
const val SESSION_COMPLETION_XP = 50
const val IQ_TEST_COMPLETION_XP = 60
+ const val MATH_TOPIC_PASS_XP = 50
/** Attempts kept on disk. The per-game score history is unbounded; do not repeat that here. */
const val IQ_TEST_HISTORY_LIMIT = 20
@@ -1004,6 +1005,41 @@ class UserStorage(
val scores: ImmutableList,
)
+ /** Math Learning helper methods */
+ private fun mathTopicScoreKey(topicId: String): String = "math_topic_score_$topicId"
+ private fun mathTopicPassedKey(topicId: String): String = "math_topic_passed_$topicId"
+ private fun mathTopicDateKey(topicId: String): String = "math_topic_date_$topicId"
+
+ fun getMathTopicScore(topicId: String): Int? = store.getIntOrNull(mathTopicScoreKey(topicId))
+
+ fun isMathTopicPassed(topicId: String): Boolean = store.getBoolean(mathTopicPassedKey(topicId), false)
+
+ fun getMathTopicTimestamp(topicId: String): Long? {
+ val raw = store.getStringOrNull(mathTopicDateKey(topicId)) ?: return null
+ return raw.toLongOrNull()
+ }
+
+ fun recordMathTopicResult(topicId: String, scorePercentage: Int, passed: Boolean): XpAward? {
+ val prevScore = getMathTopicScore(topicId) ?: 0
+ if (scorePercentage > prevScore) {
+ store.putInt(mathTopicScoreKey(topicId), scorePercentage)
+ }
+ val prevPassed = isMathTopicPassed(topicId)
+ if (passed) {
+ store.putBoolean(mathTopicPassedKey(topicId), true)
+ if (getMathTopicTimestamp(topicId) == null) {
+ store.putString(mathTopicDateKey(topicId), Clock.System.now().toEpochMilliseconds().toString())
+ }
+ }
+
+ // Award XP if passed for the first time
+ if (passed && !prevPassed) {
+ val levelChange = addXp(MATH_TOPIC_PASS_XP)
+ return XpAward(MATH_TOPIC_PASS_XP, levelChange)
+ }
+ return null
+ }
+
fun getScores(gameId: String): List {
val scoresRaw = store.getStringOrNull(getScoresKey(gameId)) ?: return listOf()
return scoresRaw
diff --git a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/app/Screen.kt b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/app/Screen.kt
index a0234ca8..ed5a0d04 100644
--- a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/app/Screen.kt
+++ b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/app/Screen.kt
@@ -78,3 +78,15 @@ object IqTestResult
@Serializable
object IqTestReview
+
+@Serializable
+object MathLearningMenu
+
+@Serializable
+data class MathLearningLesson(val topicId: String)
+
+@Serializable
+data class MathLearningTest(val topicId: String)
+
+@Serializable
+data class MathLearningCertificate(val topicId: String)
diff --git a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/mathlearning/MathLearningTopic.kt b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/mathlearning/MathLearningTopic.kt
new file mode 100644
index 00000000..0395359f
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/mathlearning/MathLearningTopic.kt
@@ -0,0 +1,418 @@
+package com.inspiredandroid.braincup.mathlearning
+
+import androidx.compose.runtime.Immutable
+import braincup.composeapp.generated.resources.*
+import org.jetbrains.compose.resources.StringResource
+
+@Immutable
+data class PracticeQuestion(
+ val questionRes: StringResource,
+ val options: List,
+ val correctIndex: Int,
+ val explanationRes: StringResource,
+)
+
+@Immutable
+data class LessonStep(
+ val titleRes: StringResource,
+ val explanationRes: StringResource,
+ val formula: String? = null,
+ val practiceQuestion: PracticeQuestion? = null,
+)
+
+@Immutable
+data class TestQuestion(
+ val questionRes: StringResource,
+ val options: List,
+ val correctIndex: Int,
+)
+
+enum class MathLearningTopic(
+ val id: String,
+ val titleRes: StringResource,
+ val descriptionRes: StringResource,
+ val accentColor: Long,
+ val lessons: List,
+ val testQuestions: List,
+) {
+ ARITHMETIC(
+ id = "arithmetic",
+ titleRes = Res.string.math_topic_arithmetic,
+ descriptionRes = Res.string.math_topic_arithmetic_desc,
+ accentColor = 0xFF4CAF50, // Green
+ lessons = listOf(
+ LessonStep(
+ titleRes = Res.string.math_arithmetic_l1_title,
+ explanationRes = Res.string.math_arithmetic_l1_desc,
+ formula = "a * (b + c) = a * b + a * c",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_arithmetic_q1_question,
+ options = listOf("14", "20", "26", "30"),
+ correctIndex = 1, // 4 * (2 + 3) = 20
+ explanationRes = Res.string.math_arithmetic_q1_exp,
+ ),
+ ),
+ LessonStep(
+ titleRes = Res.string.math_arithmetic_l2_title,
+ explanationRes = Res.string.math_arithmetic_l2_desc,
+ formula = "PEMDAS / BODMAS",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_arithmetic_q2_question,
+ options = listOf("16", "14", "20", "10"),
+ correctIndex = 0, // 2 + 3 * 4 + 2 = 2 + 12 + 2 = 16
+ explanationRes = Res.string.math_arithmetic_q2_exp,
+ ),
+ ),
+ ),
+ testQuestions = listOf(
+ TestQuestion(
+ questionRes = Res.string.math_arithmetic_t1,
+ options = listOf("15", "21", "27", "33"),
+ correctIndex = 1, // 3 * (4 + 3) = 21
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_arithmetic_t2,
+ options = listOf("10", "14", "18", "22"),
+ correctIndex = 1, // 8 + 12 / 2 = 14
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_arithmetic_t3,
+ options = listOf("25", "35", "45", "50"),
+ correctIndex = 2, // 5 * (6 + 3) = 45
+ ),
+ ),
+ ),
+ MEASUREMENT(
+ id = "measurement",
+ titleRes = Res.string.math_topic_measurement,
+ descriptionRes = Res.string.math_topic_measurement_desc,
+ accentColor = 0xFFFF9800, // Orange
+ lessons = listOf(
+ LessonStep(
+ titleRes = Res.string.math_measurement_l1_title,
+ explanationRes = Res.string.math_measurement_l1_desc,
+ formula = "1 km = 1000 m, 1 m = 100 cm",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_measurement_q1_question,
+ options = listOf("30 cm", "300 cm", "3000 cm", "30000 cm"),
+ correctIndex = 1, // 3 meters = 300 cm
+ explanationRes = Res.string.math_measurement_q1_exp,
+ ),
+ ),
+ LessonStep(
+ titleRes = Res.string.math_measurement_l2_title,
+ explanationRes = Res.string.math_measurement_l2_desc,
+ formula = "Area = width * height",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_measurement_q2_question,
+ options = listOf("12 m²", "18 m²", "24 m²", "36 m²"),
+ correctIndex = 2, // 6 * 4 = 24
+ explanationRes = Res.string.math_measurement_q2_exp,
+ ),
+ ),
+ ),
+ testQuestions = listOf(
+ TestQuestion(
+ questionRes = Res.string.math_measurement_t1,
+ options = listOf("250 cm", "2500 cm", "25000 cm", "25 cm"),
+ correctIndex = 0, // 2.5 m = 250 cm
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_measurement_t2,
+ options = listOf("20 cm²", "35 cm²", "40 cm²", "70 cm²"),
+ correctIndex = 1, // 5 * 7 = 35
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_measurement_t3,
+ options = listOf("30 m", "60 m", "120 m", "200 m"),
+ correctIndex = 1, // Perimeter of 10x20 rectangle = 2*(10+20) = 60
+ ),
+ ),
+ ),
+ GEOMETRY(
+ id = "geometry",
+ titleRes = Res.string.math_topic_geometry,
+ descriptionRes = Res.string.math_topic_geometry_desc,
+ accentColor = 0xFF2196F3, // Blue
+ lessons = listOf(
+ LessonStep(
+ titleRes = Res.string.math_geometry_l1_title,
+ explanationRes = Res.string.math_geometry_l1_desc,
+ formula = "a² + b² = c²",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_geometry_q1_question,
+ options = listOf("4", "5", "6", "7"),
+ correctIndex = 1, // sqrt(3^2 + 4^2) = 5
+ explanationRes = Res.string.math_geometry_q1_exp,
+ ),
+ ),
+ LessonStep(
+ titleRes = Res.string.math_geometry_l2_title,
+ explanationRes = Res.string.math_geometry_l2_desc,
+ formula = "Area = (base * height) / 2",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_geometry_q2_question,
+ options = listOf("16 cm²", "24 cm²", "32 cm²", "48 cm²"),
+ correctIndex = 1, // (8 * 6) / 2 = 24
+ explanationRes = Res.string.math_geometry_q2_exp,
+ ),
+ ),
+ ),
+ testQuestions = listOf(
+ TestQuestion(
+ questionRes = Res.string.math_geometry_t1,
+ options = listOf("10", "12", "13", "15"),
+ correctIndex = 2, // sqrt(5^2 + 12^2) = 13
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_geometry_t2,
+ options = listOf("180°", "360°", "540°", "720°"),
+ correctIndex = 0, // Sum of angles in triangle = 180°
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_geometry_t3,
+ options = listOf("15 cm²", "30 cm²", "60 cm²", "120 cm²"),
+ correctIndex = 1, // (10 * 6) / 2 = 30
+ ),
+ ),
+ ),
+ DATA_ANALYSIS(
+ id = "data_analysis",
+ titleRes = Res.string.math_topic_data_analysis,
+ descriptionRes = Res.string.math_topic_data_analysis_desc,
+ accentColor = 0xFF9C27B0, // Purple
+ lessons = listOf(
+ LessonStep(
+ titleRes = Res.string.math_data_l1_title,
+ explanationRes = Res.string.math_data_l1_desc,
+ formula = "Mean = Sum / Count",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_data_q1_question,
+ options = listOf("4", "6", "8", "10"),
+ correctIndex = 1, // (2 + 4 + 6 + 8 + 10) / 5 = 6
+ explanationRes = Res.string.math_data_q1_exp,
+ ),
+ ),
+ LessonStep(
+ titleRes = Res.string.math_data_l2_title,
+ explanationRes = Res.string.math_data_l2_desc,
+ formula = "P(A) = Favorable / Total",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_data_q2_question,
+ options = listOf("1/6", "1/3", "1/2", "2/3"),
+ correctIndex = 2, // 3/6 = 1/2
+ explanationRes = Res.string.math_data_q2_exp,
+ ),
+ ),
+ ),
+ testQuestions = listOf(
+ TestQuestion(
+ questionRes = Res.string.math_data_t1,
+ options = listOf("10", "12", "15", "18"),
+ correctIndex = 1, // (5 + 10 + 15 + 18) / 4 = 12
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_data_t2,
+ options = listOf("1/4", "1/2", "1/3", "1/6"),
+ correctIndex = 0, // Two coin flips, both heads = 1/4
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_data_t3,
+ options = listOf("Median", "Mean", "Mode", "Range"),
+ correctIndex = 2, // Most frequent value = Mode
+ ),
+ ),
+ ),
+ ALGEBRA(
+ id = "algebra",
+ titleRes = Res.string.math_topic_algebra,
+ descriptionRes = Res.string.math_topic_algebra_desc,
+ accentColor = 0xFFE91E63, // Pink
+ lessons = listOf(
+ LessonStep(
+ titleRes = Res.string.math_algebra_l1_title,
+ explanationRes = Res.string.math_algebra_l1_desc,
+ formula = "ax + b = c => x = (c - b) / a",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_algebra_q1_question,
+ options = listOf("3", "4", "5", "6"),
+ correctIndex = 1, // 2x + 5 = 13 => 2x = 8 => x = 4
+ explanationRes = Res.string.math_algebra_q1_exp,
+ ),
+ ),
+ LessonStep(
+ titleRes = Res.string.math_algebra_l2_title,
+ explanationRes = Res.string.math_algebra_l2_desc,
+ formula = "(a + b)² = a² + 2ab + b²",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_algebra_q2_question,
+ options = listOf("x² + 6", "x² + 6x + 9", "x² + 9", "x² + 3x + 9"),
+ correctIndex = 1, // (x + 3)^2 = x^2 + 6x + 9
+ explanationRes = Res.string.math_algebra_q2_exp,
+ ),
+ ),
+ ),
+ testQuestions = listOf(
+ TestQuestion(
+ questionRes = Res.string.math_algebra_t1,
+ options = listOf("3", "5", "7", "9"),
+ correctIndex = 1, // 3x - 4 = 11 => 3x = 15 => x = 5
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_algebra_t2,
+ options = listOf("x² + 16", "x² + 8x + 16", "x² + 4x + 16", "2x + 8"),
+ correctIndex = 1, // (x + 4)^2 = x^2 + 8x + 16
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_algebra_t3,
+ options = listOf("(x - 2)(x + 2)", "(x - 4)(x + 4)", "(x - 2)²", "x(x - 4)"),
+ correctIndex = 0, // x^2 - 4 = (x - 2)(x + 2)
+ ),
+ ),
+ ),
+ TRIGONOMETRY(
+ id = "trigonometry",
+ titleRes = Res.string.math_topic_trigonometry,
+ descriptionRes = Res.string.math_topic_trigonometry_desc,
+ accentColor = 0xFF00BCD4, // Cyan
+ lessons = listOf(
+ LessonStep(
+ titleRes = Res.string.math_trig_l1_title,
+ explanationRes = Res.string.math_trig_l1_desc,
+ formula = "sin = Opp/Hyp, cos = Adj/Hyp, tan = Opp/Adj",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_trig_q1_question,
+ options = listOf("3/5", "4/5", "3/4", "5/3"),
+ correctIndex = 0, // sin = 3/5
+ explanationRes = Res.string.math_trig_q1_exp,
+ ),
+ ),
+ LessonStep(
+ titleRes = Res.string.math_trig_l2_title,
+ explanationRes = Res.string.math_trig_l2_desc,
+ formula = "sin²(θ) + cos²(θ) = 1",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_trig_q2_question,
+ options = listOf("0", "1/2", "1", "2"),
+ correctIndex = 2, // sin^2 + cos^2 = 1
+ explanationRes = Res.string.math_trig_q2_exp,
+ ),
+ ),
+ ),
+ testQuestions = listOf(
+ TestQuestion(
+ questionRes = Res.string.math_trig_t1,
+ options = listOf("0", "1/2", "1", "undefined"),
+ correctIndex = 1, // sin(30°) = 1/2
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_trig_t2,
+ options = listOf("0", "1/2", "1", "infinity"),
+ correctIndex = 2, // tan(45°) = 1
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_trig_t3,
+ options = listOf("sin(θ)", "cos(θ)", "tan(θ)", "1"),
+ correctIndex = 2, // sin(θ) / cos(θ) = tan(θ)
+ ),
+ ),
+ ),
+ PRE_CALCULUS(
+ id = "pre_calculus",
+ titleRes = Res.string.math_topic_pre_calculus,
+ descriptionRes = Res.string.math_topic_pre_calculus_desc,
+ accentColor = 0xFF673AB7, // Deep Purple
+ lessons = listOf(
+ LessonStep(
+ titleRes = Res.string.math_precalc_l1_title,
+ explanationRes = Res.string.math_precalc_l1_desc,
+ formula = "f(x) = y",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_precalc_q1_question,
+ options = listOf("7", "9", "11", "13"),
+ correctIndex = 2, // f(x) = 2x + 5 => f(3) = 11
+ explanationRes = Res.string.math_precalc_q1_exp,
+ ),
+ ),
+ LessonStep(
+ titleRes = Res.string.math_precalc_l2_title,
+ explanationRes = Res.string.math_precalc_l2_desc,
+ formula = "log_b(x) = y <=> b^y = x",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_precalc_q2_question,
+ options = listOf("2", "3", "4", "5"),
+ correctIndex = 1, // log_2(8) = 3
+ explanationRes = Res.string.math_precalc_q2_exp,
+ ),
+ ),
+ ),
+ testQuestions = listOf(
+ TestQuestion(
+ questionRes = Res.string.math_precalc_t1,
+ options = listOf("10", "13", "16", "19"),
+ correctIndex = 1, // f(x) = 3x + 4, f(3) = 13
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_precalc_t2,
+ options = listOf("1", "2", "3", "100"),
+ correctIndex = 1, // log_10(100) = 2
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_precalc_t3,
+ options = listOf("x = 0", "All real numbers", "x > 0", "x < 0"),
+ correctIndex = 1, // Domain of f(x) = x^2 + 1 is All real numbers
+ ),
+ ),
+ ),
+ CALCULUS(
+ id = "calculus",
+ titleRes = Res.string.math_topic_calculus,
+ descriptionRes = Res.string.math_topic_calculus_desc,
+ accentColor = 0xFF3F51B5, // Indigo
+ lessons = listOf(
+ LessonStep(
+ titleRes = Res.string.math_calc_l1_title,
+ explanationRes = Res.string.math_calc_l1_desc,
+ formula = "d/dx(xⁿ) = n * xⁿ⁻¹",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_calc_q1_question,
+ options = listOf("2x", "3x²", "x³", "6x"),
+ correctIndex = 1, // d/dx(x^3) = 3x^2
+ explanationRes = Res.string.math_calc_q1_exp,
+ ),
+ ),
+ LessonStep(
+ titleRes = Res.string.math_calc_l2_title,
+ explanationRes = Res.string.math_calc_l2_desc,
+ formula = "∫ xⁿ dx = (xⁿ⁺¹ / (n + 1)) + C",
+ practiceQuestion = PracticeQuestion(
+ questionRes = Res.string.math_calc_q2_question,
+ options = listOf("x² + C", "x³ + C", "(x³ / 3) + C", "2x + C"),
+ correctIndex = 0, // ∫ 2x dx = x^2 + C
+ explanationRes = Res.string.math_calc_q2_exp,
+ ),
+ ),
+ ),
+ testQuestions = listOf(
+ TestQuestion(
+ questionRes = Res.string.math_calc_t1,
+ options = listOf("4x³", "x⁴", "12x²", "4x"),
+ correctIndex = 0, // d/dx(x^4) = 4x^3
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_calc_t2,
+ options = listOf("x³ + C", "(x³ / 3) + C", "3x² + C", "x⁴ + C"),
+ correctIndex = 0, // ∫ 3x^2 dx = x^3 + C
+ ),
+ TestQuestion(
+ questionRes = Res.string.math_calc_t3,
+ options = listOf("The rate of change", "The total area under a curve", "The maximum value", "The slope"),
+ correctIndex = 1, // A definite integral represents the area under a curve
+ ),
+ ),
+ );
+
+ companion object {
+ fun getById(id: String): MathLearningTopic? = entries.firstOrNull { it.id == id }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/navigation/AppUrlRoutes.kt b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/navigation/AppUrlRoutes.kt
index 07720a1c..207de6a7 100644
--- a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/navigation/AppUrlRoutes.kt
+++ b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/navigation/AppUrlRoutes.kt
@@ -12,6 +12,10 @@ import com.inspiredandroid.braincup.app.IqTestPlay
import com.inspiredandroid.braincup.app.IqTestResult
import com.inspiredandroid.braincup.app.IqTestReview
import com.inspiredandroid.braincup.app.Licenses
+import com.inspiredandroid.braincup.app.MathLearningCertificate
+import com.inspiredandroid.braincup.app.MathLearningLesson
+import com.inspiredandroid.braincup.app.MathLearningMenu
+import com.inspiredandroid.braincup.app.MathLearningTest
import com.inspiredandroid.braincup.app.MainMenu
import com.inspiredandroid.braincup.app.MatchstickRiddlesMenu
import com.inspiredandroid.braincup.app.MatchstickRiddlesPlay
@@ -51,6 +55,10 @@ fun navRouteToPathSuffix(route: Any): String = when (route) {
is IqTestPlay -> "iq-test/play"
is IqTestResult -> "iq-test/result"
is IqTestReview -> "iq-test/review"
+ is MathLearningMenu -> "math-learning"
+ is MathLearningLesson -> "math-learning/lesson/${route.topicId}"
+ is MathLearningTest -> "math-learning/test/${route.topicId}"
+ is MathLearningCertificate -> "math-learning/certificate/${route.topicId}"
is Instructions -> gamePathSuffix(route.gameTypeId)
is Playing -> gamePathSuffix(route.gameTypeId)
is Finish -> gamePathSuffix(route.gameTypeId)
@@ -75,6 +83,7 @@ fun pathSuffixToNavRoute(suffix: String): Any? {
"iq-test/play" -> IqTestPlay
"iq-test/result" -> IqTestResult
"iq-test/review" -> IqTestReview
+ "math-learning" -> MathLearningMenu
else -> parseParameterizedPath(suffix)
}
}
@@ -100,6 +109,10 @@ fun NavBackStackEntry.toUrlPathSuffix(): String {
destination.hasRoute() -> navRouteToPathSuffix(IqTestPlay)
destination.hasRoute() -> navRouteToPathSuffix(IqTestResult)
destination.hasRoute() -> navRouteToPathSuffix(IqTestReview)
+ destination.hasRoute() -> navRouteToPathSuffix(MathLearningMenu)
+ destination.hasRoute() -> navRouteToPathSuffix(toRoute())
+ destination.hasRoute() -> navRouteToPathSuffix(toRoute())
+ destination.hasRoute() -> navRouteToPathSuffix(toRoute())
destination.hasRoute() -> navRouteToPathSuffix(toRoute())
destination.hasRoute() -> navRouteToPathSuffix(toRoute())
destination.hasRoute() -> navRouteToPathSuffix(toRoute())
@@ -124,6 +137,18 @@ private fun parseParameterizedPath(suffix: String): Any? {
val riddleId = suffix.removePrefix("matchstick/")
return riddleId.takeIf { it.isNotEmpty() }?.let { MatchstickRiddlesPlay(it) }
}
+ if (suffix.startsWith("math-learning/lesson/")) {
+ val topicId = suffix.removePrefix("math-learning/lesson/")
+ return topicId.takeIf { it.isNotEmpty() }?.let { MathLearningLesson(it) }
+ }
+ if (suffix.startsWith("math-learning/test/")) {
+ val topicId = suffix.removePrefix("math-learning/test/")
+ return topicId.takeIf { it.isNotEmpty() }?.let { MathLearningTest(it) }
+ }
+ if (suffix.startsWith("math-learning/certificate/")) {
+ val topicId = suffix.removePrefix("math-learning/certificate/")
+ return topicId.takeIf { it.isNotEmpty() }?.let { MathLearningCertificate(it) }
+ }
if (suffix.endsWith("/scores")) {
val slug = suffix.removeSuffix("/scores")
val gameType = GameType.fromUrlSlug(slug) ?: return null
diff --git a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/components/MathLearningTile.kt b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/components/MathLearningTile.kt
new file mode 100644
index 00000000..6dbbefae
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/components/MathLearningTile.kt
@@ -0,0 +1,70 @@
+package com.inspiredandroid.braincup.ui.components
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import braincup.composeapp.generated.resources.Res
+import braincup.composeapp.generated.resources.math_learning_button
+import braincup.composeapp.generated.resources.math_learning_subtitle
+import com.inspiredandroid.braincup.ui.theme.MedalGold
+import com.inspiredandroid.braincup.ui.theme.Primary
+import org.jetbrains.compose.resources.stringResource
+
+@Composable
+fun MathLearningTile(
+ passedCount: Int,
+ totalTopics: Int,
+ onClick: () -> Unit,
+) {
+ PrismTile(
+ face = Primary,
+ modifier = Modifier
+ .fillMaxWidth()
+ .hoverHand(),
+ onClick = onClick,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(Res.string.math_learning_button),
+ style = MaterialTheme.typography.titleLarge,
+ color = Color.White,
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = stringResource(Res.string.math_learning_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.White.copy(alpha = 0.85f),
+ )
+ }
+ Spacer(Modifier.width(12.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ if (passedCount > 0) {
+ PrismTrophy(
+ tint = MedalGold,
+ modifier = Modifier.size(32.dp),
+ )
+ }
+ Text(
+ text = "$passedCount/$totalTopics",
+ style = MaterialTheme.typography.titleMedium,
+ color = Color.White,
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MainMenuScreen.kt b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MainMenuScreen.kt
index 849ca331..5a406919 100644
--- a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MainMenuScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MainMenuScreen.kt
@@ -29,6 +29,8 @@ import com.inspiredandroid.braincup.ui.components.DailyChallengeCard
import com.inspiredandroid.braincup.ui.components.GameTile
import com.inspiredandroid.braincup.ui.components.IqTestTile
import com.inspiredandroid.braincup.ui.components.MatchstickRiddlesTile
+import com.inspiredandroid.braincup.mathlearning.MathLearningTopic
+import com.inspiredandroid.braincup.ui.components.MathLearningTile
import com.inspiredandroid.braincup.ui.components.NormalChessTile
import com.inspiredandroid.braincup.ui.components.NormalSudokuTile
import com.inspiredandroid.braincup.ui.components.PegSolitaireTile
@@ -55,6 +57,7 @@ fun MainMenuScreen(
controller: GameController,
onOpenSettings: () -> Unit = {},
onIqTest: () -> Unit = {},
+ onMathLearning: () -> Unit = {},
useBuiltInSponsors: Boolean = false,
) {
val sessionState by controller.sessionState.collectAsStateWithLifecycle()
@@ -78,6 +81,9 @@ fun MainMenuScreen(
val bestIq = remember(controller, storageRevision) {
controller.storage.getBestIqTestRawScore()?.let { IqScoring.iqFor(it) }
}
+ val mathTopicsPassed = remember(controller, storageRevision) {
+ MathLearningTopic.entries.count { controller.storage.isMathTopicPassed(it.id) }
+ }
val onPlayDaily = remember(controller) { { controller.startDailySession() } }
val onPlay = remember(controller) { { gameType: GameType -> controller.navigateToInstructions(gameType) } }
@@ -107,8 +113,11 @@ fun MainMenuScreen(
matchstickRiddlesSolved = matchstickRiddlesSolved,
matchstickRiddlesTotal = matchstickRiddlesTotal,
bestIq = bestIq,
+ mathTopicsPassed = mathTopicsPassed,
+ mathTopicsTotal = MathLearningTopic.entries.size,
onOpenSettings = onOpenSettings,
onIqTest = onIqTest,
+ onMathLearning = onMathLearning,
onPlayDaily = onPlayDaily,
onPlay = onPlay,
onViewScore = onViewScore,
@@ -135,6 +144,8 @@ fun MainMenuScreenContent(
matchstickRiddlesSolved: Int = 0,
matchstickRiddlesTotal: Int = 0,
bestIq: Int? = null,
+ mathTopicsPassed: Int = 0,
+ mathTopicsTotal: Int = MathLearningTopic.entries.size,
showDailyChallenge: Boolean = true,
/**
* Optional override for which mini-game tiles appear (and in which order).
@@ -143,6 +154,7 @@ fun MainMenuScreenContent(
gameTypes: ImmutableList? = null,
onOpenSettings: () -> Unit = {},
onIqTest: () -> Unit = {},
+ onMathLearning: () -> Unit = {},
onPlayDaily: () -> Unit = {},
onPlay: (GameType) -> Unit = {},
onViewScore: (GameType) -> Unit = {},
@@ -331,6 +343,15 @@ fun MainMenuScreenContent(
PegSolitaireTile(onClick = onPegSolitaire)
}
+ // Interactive Math Topics section at the bottom of the main screen
+ item(span = { GridItemSpan(maxLineSpan) }, contentType = "math_learning") {
+ MathLearningTile(
+ passedCount = mathTopicsPassed,
+ totalTopics = mathTopicsTotal,
+ onClick = onMathLearning,
+ )
+ }
+
// Footer
item(span = { GridItemSpan(maxLineSpan) }, contentType = "footer") {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
diff --git a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningCertificateScreen.kt b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningCertificateScreen.kt
new file mode 100644
index 00000000..36990696
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningCertificateScreen.kt
@@ -0,0 +1,183 @@
+package com.inspiredandroid.braincup.ui.screens
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import braincup.composeapp.generated.resources.*
+import com.inspiredandroid.braincup.api.UserStorage
+import com.inspiredandroid.braincup.mathlearning.MathLearningTopic
+import com.inspiredandroid.braincup.ui.components.AppScaffold
+import com.inspiredandroid.braincup.ui.components.PrismCard
+import com.inspiredandroid.braincup.ui.components.PrismTrophy
+import com.inspiredandroid.braincup.ui.theme.ContentMaxWidth
+import com.inspiredandroid.braincup.ui.theme.MedalGold
+import com.inspiredandroid.braincup.ui.theme.Primary
+import com.inspiredandroid.braincup.ui.theme.PrismFacet
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.number
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Instant
+import org.jetbrains.compose.resources.painterResource
+import org.jetbrains.compose.resources.stringResource
+
+@Composable
+fun MathLearningCertificateScreen(
+ topic: MathLearningTopic,
+ storage: UserStorage,
+ onBack: () -> Unit,
+) {
+ val score = remember(storage, topic.id) { storage.getMathTopicScore(topic.id) ?: 100 }
+ val timestamp = remember(storage, topic.id) { storage.getMathTopicTimestamp(topic.id) }
+ val dateStr = remember(timestamp) {
+ if (timestamp != null) {
+ val dt = Instant.fromEpochMilliseconds(timestamp).toLocalDateTime(TimeZone.UTC)
+ "${dt.year}-${dt.month.number.toString().padStart(2, '0')}-${dt.day.toString().padStart(2, '0')}"
+ } else ""
+ }
+
+ val activeAccount = remember(storage) {
+ val snapshot = storage.accounts.snapshot.value
+ snapshot.accounts.firstOrNull { it.id == snapshot.activeId }
+ }
+ val recipientName = activeAccount?.name ?: stringResource(Res.string.certificate_recipient_default)
+
+ AppScaffold(
+ title = stringResource(Res.string.certificate_title),
+ onBack = onBack,
+ ) {
+ Column(
+ modifier = Modifier
+ .widthIn(max = ContentMaxWidth)
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // Certificate Card Frame
+ PrismCard(
+ face = MaterialTheme.colorScheme.surface,
+ facet = PrismFacet.Cell,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .border(
+ border = BorderStroke(2.dp, Color(topic.accentColor)),
+ shape = MaterialTheme.shapes.medium,
+ )
+ .background(Color(topic.accentColor).copy(alpha = 0.05f))
+ .padding(24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Image(
+ painter = painterResource(Res.drawable.ic_mascot),
+ contentDescription = null,
+ modifier = Modifier.height(90.dp),
+ )
+
+ Spacer(Modifier.height(12.dp))
+
+ Text(
+ text = stringResource(Res.string.certificate_header),
+ style = MaterialTheme.typography.labelLarge,
+ color = Primary,
+ fontWeight = FontWeight.Bold,
+ )
+
+ Spacer(Modifier.height(8.dp))
+
+ Text(
+ text = stringResource(Res.string.certificate_title),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(Res.string.certificate_presented_to),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(Modifier.height(4.dp))
+
+ Text(
+ text = recipientName,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = Primary,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(Modifier.height(12.dp))
+
+ Text(
+ text = stringResource(Res.string.certificate_body),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(Modifier.height(6.dp))
+
+ Text(
+ text = stringResource(topic.titleRes),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = Color(topic.accentColor),
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(Modifier.height(20.dp))
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column {
+ Text(
+ text = stringResource(Res.string.certificate_score_label, score),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ if (dateStr.isNotEmpty()) {
+ Text(
+ text = dateStr,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ PrismTrophy(
+ tint = MedalGold,
+ modifier = Modifier.size(44.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningLessonScreen.kt b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningLessonScreen.kt
new file mode 100644
index 00000000..a9ed242d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningLessonScreen.kt
@@ -0,0 +1,248 @@
+package com.inspiredandroid.braincup.ui.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import braincup.composeapp.generated.resources.*
+import com.inspiredandroid.braincup.mathlearning.MathLearningTopic
+import com.inspiredandroid.braincup.ui.components.AppScaffold
+import com.inspiredandroid.braincup.ui.components.MathText
+import com.inspiredandroid.braincup.ui.components.PrismCard
+import com.inspiredandroid.braincup.ui.components.hoverHand
+import com.inspiredandroid.braincup.ui.theme.ContentMaxWidth
+import com.inspiredandroid.braincup.ui.theme.Primary
+import com.inspiredandroid.braincup.ui.theme.PrismFacet
+import com.inspiredandroid.braincup.ui.theme.SuccessGreen
+import org.jetbrains.compose.resources.stringResource
+
+@Composable
+fun MathLearningLessonScreen(
+ topic: MathLearningTopic,
+ onFinishLessonTakeTest: () -> Unit,
+ onBack: () -> Unit,
+) {
+ var stepIndex by remember { mutableStateOf(0) }
+ val currentStep = topic.lessons.getOrNull(stepIndex) ?: topic.lessons.first()
+
+ // Practice question state for current step
+ var selectedOption by remember(stepIndex) { mutableStateOf(null) }
+ var isChecked by remember(stepIndex) { mutableStateOf(false) }
+
+ AppScaffold(
+ title = stringResource(Res.string.math_learning_lesson_title, stringResource(topic.titleRes)),
+ onBack = onBack,
+ scrollable = false,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // Progress indicators
+ Row(
+ modifier = Modifier.widthIn(max = ContentMaxWidth).fillMaxWidth().padding(top = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ topic.lessons.forEachIndexed { idx, _ ->
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .height(6.dp)
+ .background(
+ color = if (idx <= stepIndex) Color(topic.accentColor) else MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.small,
+ ),
+ )
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ Column(
+ modifier = Modifier
+ .widthIn(max = ContentMaxWidth)
+ .weight(1f)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ // Lesson Step Title & Explanation
+ Text(
+ text = stringResource(currentStep.titleRes),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(Modifier.height(12.dp))
+
+ Text(
+ text = stringResource(currentStep.explanationRes),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ // Formula block if present
+ currentStep.formula?.let { formulaText ->
+ Spacer(Modifier.height(16.dp))
+ PrismCard(
+ face = Color(topic.accentColor).copy(alpha = 0.15f),
+ facet = PrismFacet.Cell,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ MathText(
+ text = formulaText,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+ }
+
+ // Practice Question
+ currentStep.practiceQuestion?.let { practice ->
+ Spacer(Modifier.height(24.dp))
+
+ Text(
+ text = stringResource(Res.string.math_learning_practice_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = Primary,
+ )
+
+ Spacer(Modifier.height(8.dp))
+
+ PrismCard(
+ face = MaterialTheme.colorScheme.surface,
+ facet = PrismFacet.Cell,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ MathText(
+ text = stringResource(practice.questionRes),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(Modifier.height(12.dp))
+
+ practice.options.forEachIndexed { optionIdx, optionText ->
+ val isSelected = selectedOption == optionIdx
+ val isCorrect = optionIdx == practice.correctIndex
+
+ val buttonColor = when {
+ isChecked && isCorrect -> SuccessGreen
+ isChecked && isSelected && !isCorrect -> MaterialTheme.colorScheme.error
+ isSelected -> Primary
+ else -> MaterialTheme.colorScheme.surfaceContainer
+ }
+
+ Button(
+ onClick = {
+ if (!isChecked) {
+ selectedOption = optionIdx
+ }
+ },
+ colors = ButtonDefaults.buttonColors(containerColor = buttonColor),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .hoverHand(),
+ ) {
+ Text(
+ text = optionText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (isSelected || (isChecked && isCorrect)) Color.White else MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+
+ if (!isChecked && selectedOption != null) {
+ Spacer(Modifier.height(12.dp))
+ Button(
+ onClick = { isChecked = true },
+ colors = ButtonDefaults.buttonColors(containerColor = Primary),
+ modifier = Modifier.fillMaxWidth().hoverHand(),
+ ) {
+ Text(
+ text = stringResource(Res.string.math_learning_check_answer),
+ color = Color.White,
+ )
+ }
+ }
+
+ if (isChecked) {
+ Spacer(Modifier.height(12.dp))
+ val isRight = selectedOption == practice.correctIndex
+ Text(
+ text = if (isRight) {
+ stringResource(Res.string.math_learning_correct)
+ } else {
+ stringResource(Res.string.math_learning_incorrect)
+ },
+ style = MaterialTheme.typography.titleMedium,
+ color = if (isRight) SuccessGreen else MaterialTheme.colorScheme.error,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = stringResource(practice.explanationRes),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(Modifier.height(24.dp))
+ }
+
+ // Navigation Footer
+ Row(
+ modifier = Modifier
+ .widthIn(max = ContentMaxWidth)
+ .fillMaxWidth()
+ .padding(vertical = 16.dp),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ if (stepIndex < topic.lessons.size - 1) {
+ Button(
+ onClick = { stepIndex++ },
+ colors = ButtonDefaults.buttonColors(containerColor = Primary),
+ modifier = Modifier.fillMaxWidth().hoverHand(),
+ ) {
+ Text(
+ text = stringResource(Res.string.math_learning_next_step),
+ color = Color.White,
+ )
+ }
+ } else {
+ Button(
+ onClick = onFinishLessonTakeTest,
+ colors = ButtonDefaults.buttonColors(containerColor = Color(topic.accentColor)),
+ modifier = Modifier.fillMaxWidth().hoverHand(),
+ ) {
+ Text(
+ text = stringResource(Res.string.math_learning_finish_lesson),
+ color = Color.White,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningMenuScreen.kt b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningMenuScreen.kt
new file mode 100644
index 00000000..8442ae3f
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningMenuScreen.kt
@@ -0,0 +1,173 @@
+package com.inspiredandroid.braincup.ui.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import braincup.composeapp.generated.resources.*
+import com.inspiredandroid.braincup.api.UserStorage
+import com.inspiredandroid.braincup.mathlearning.MathLearningTopic
+import com.inspiredandroid.braincup.ui.components.AppScaffold
+import com.inspiredandroid.braincup.ui.components.PrismCard
+import com.inspiredandroid.braincup.ui.components.PrismTrophy
+import com.inspiredandroid.braincup.ui.components.hoverHand
+import com.inspiredandroid.braincup.ui.theme.ContentMaxWidth
+import com.inspiredandroid.braincup.ui.theme.MedalGold
+import com.inspiredandroid.braincup.ui.theme.Primary
+import com.inspiredandroid.braincup.ui.theme.PrismFacet
+import org.jetbrains.compose.resources.stringResource
+
+@Composable
+fun MathLearningMenuScreen(
+ storage: UserStorage,
+ onSelectTopicLesson: (MathLearningTopic) -> Unit,
+ onSelectTopicTest: (MathLearningTopic) -> Unit,
+ onViewCertificate: (MathLearningTopic) -> Unit,
+ onBack: () -> Unit,
+) {
+ val topics = remember { MathLearningTopic.entries }
+
+ AppScaffold(
+ title = stringResource(Res.string.math_learning_title),
+ onBack = onBack,
+ scrollable = false,
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .widthIn(max = ContentMaxWidth)
+ .fillMaxSize()
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp),
+ ) {
+ items(topics, key = { it.id }) { topic ->
+ val passed = remember(storage, topic.id) { storage.isMathTopicPassed(topic.id) }
+ val score = remember(storage, topic.id) { storage.getMathTopicScore(topic.id) }
+
+ MathTopicCard(
+ topic = topic,
+ passed = passed,
+ scorePercentage = score,
+ onStartLesson = { onSelectTopicLesson(topic) },
+ onTakeTest = { onSelectTopicTest(topic) },
+ onViewCertificate = { onViewCertificate(topic) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun MathTopicCard(
+ topic: MathLearningTopic,
+ passed: Boolean,
+ scorePercentage: Int?,
+ onStartLesson: () -> Unit,
+ onTakeTest: () -> Unit,
+ onViewCertificate: () -> Unit,
+) {
+ PrismCard(
+ face = MaterialTheme.colorScheme.surface,
+ facet = PrismFacet.Cell,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.weight(1f),
+ ) {
+ Box(
+ modifier = Modifier
+ .size(14.dp)
+ .background(Color(topic.accentColor), shape = MaterialTheme.shapes.extraSmall),
+ )
+ Spacer(Modifier.width(10.dp))
+ Text(
+ text = stringResource(topic.titleRes),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+ if (passed && scorePercentage != null) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ PrismTrophy(tint = MedalGold, modifier = Modifier.size(24.dp))
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = stringResource(Res.string.math_learning_passed, scorePercentage),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.height(8.dp))
+
+ Text(
+ text = stringResource(topic.descriptionRes),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Button(
+ onClick = onStartLesson,
+ modifier = Modifier.weight(1f).hoverHand(),
+ colors = ButtonDefaults.buttonColors(containerColor = Primary),
+ ) {
+ Text(
+ text = stringResource(Res.string.math_learning_start_lesson),
+ style = MaterialTheme.typography.labelMedium,
+ color = Color.White,
+ )
+ }
+
+ Button(
+ onClick = onTakeTest,
+ modifier = Modifier.weight(1f).hoverHand(),
+ colors = ButtonDefaults.buttonColors(containerColor = Color(topic.accentColor)),
+ ) {
+ Text(
+ text = stringResource(Res.string.math_learning_take_test),
+ style = MaterialTheme.typography.labelMedium,
+ color = Color.White,
+ )
+ }
+
+ if (passed) {
+ OutlinedButton(
+ onClick = onViewCertificate,
+ modifier = Modifier.hoverHand(),
+ ) {
+ PrismTrophy(tint = MedalGold, modifier = Modifier.size(20.dp))
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningTestScreen.kt b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningTestScreen.kt
new file mode 100644
index 00000000..26672b29
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/inspiredandroid/braincup/ui/screens/MathLearningTestScreen.kt
@@ -0,0 +1,224 @@
+package com.inspiredandroid.braincup.ui.screens
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import braincup.composeapp.generated.resources.*
+import com.inspiredandroid.braincup.api.UserStorage
+import com.inspiredandroid.braincup.mathlearning.MathLearningTopic
+import com.inspiredandroid.braincup.ui.components.AppScaffold
+import com.inspiredandroid.braincup.ui.components.MathText
+import com.inspiredandroid.braincup.ui.components.PrismCard
+import com.inspiredandroid.braincup.ui.components.PrismTrophy
+import com.inspiredandroid.braincup.ui.components.hoverHand
+import com.inspiredandroid.braincup.ui.theme.ContentMaxWidth
+import com.inspiredandroid.braincup.ui.theme.MedalGold
+import com.inspiredandroid.braincup.ui.theme.Primary
+import com.inspiredandroid.braincup.ui.theme.PrismFacet
+import com.inspiredandroid.braincup.ui.theme.SuccessGreen
+import org.jetbrains.compose.resources.stringResource
+
+@Composable
+fun MathLearningTestScreen(
+ topic: MathLearningTopic,
+ storage: UserStorage,
+ onViewCertificate: (MathLearningTopic) -> Unit,
+ onBack: () -> Unit,
+) {
+ val questions = topic.testQuestions
+ var userAnswers by remember { mutableStateOf(IntArray(questions.size) { -1 }) }
+ var isSubmitted by remember { mutableStateOf(false) }
+
+ val correctCount = remember(isSubmitted, userAnswers) {
+ if (!isSubmitted) 0
+ else questions.indices.count { idx -> userAnswers[idx] == questions[idx].correctIndex }
+ }
+ val scorePercentage = remember(correctCount, questions.size) {
+ if (questions.isEmpty()) 0 else (correctCount * 100) / questions.size
+ }
+ val passed = scorePercentage >= 66
+
+ LaunchedEffect(isSubmitted) {
+ if (isSubmitted) {
+ storage.recordMathTopicResult(topic.id, scorePercentage, passed)
+ }
+ }
+
+ AppScaffold(
+ title = stringResource(Res.string.math_learning_test_title, stringResource(topic.titleRes)),
+ onBack = onBack,
+ scrollable = false,
+ ) {
+ Column(
+ modifier = Modifier
+ .widthIn(max = ContentMaxWidth)
+ .fillMaxSize()
+ .padding(horizontal = 16.dp)
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Spacer(Modifier.height(8.dp))
+
+ if (isSubmitted) {
+ // Test Results Card
+ PrismCard(
+ face = if (passed) SuccessGreen.copy(alpha = 0.15f) else MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
+ facet = PrismFacet.Cell,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (passed) {
+ PrismTrophy(tint = MedalGold, modifier = Modifier.size(48.dp))
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = stringResource(Res.string.math_learning_test_passed_title),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = SuccessGreen,
+ )
+ } else {
+ Text(
+ text = stringResource(Res.string.math_learning_test_failed_title),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+
+ Spacer(Modifier.height(8.dp))
+
+ Text(
+ text = stringResource(
+ Res.string.math_learning_test_score,
+ correctCount,
+ questions.size,
+ scorePercentage,
+ ),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ )
+
+ Spacer(Modifier.height(8.dp))
+
+ Text(
+ text = if (passed) {
+ stringResource(Res.string.math_learning_test_passed_msg, stringResource(topic.titleRes))
+ } else {
+ stringResource(Res.string.math_learning_test_failed_msg)
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ if (passed) {
+ Spacer(Modifier.height(16.dp))
+ Button(
+ onClick = { onViewCertificate(topic) },
+ colors = ButtonDefaults.buttonColors(containerColor = Primary),
+ modifier = Modifier.hoverHand(),
+ ) {
+ Text(
+ text = stringResource(Res.string.math_learning_view_certificate),
+ color = Color.White,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Questions list
+ questions.forEachIndexed { qIdx, question ->
+ PrismCard(
+ face = MaterialTheme.colorScheme.surface,
+ facet = PrismFacet.Cell,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = stringResource(Res.string.math_learning_question_progress, qIdx + 1, questions.size),
+ style = MaterialTheme.typography.labelMedium,
+ color = Primary,
+ fontWeight = FontWeight.Bold,
+ )
+
+ Spacer(Modifier.height(6.dp))
+
+ MathText(
+ text = stringResource(question.questionRes),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(Modifier.height(12.dp))
+
+ question.options.forEachIndexed { optIdx, optionText ->
+ val isSelected = userAnswers[qIdx] == optIdx
+ val isCorrect = question.correctIndex == optIdx
+
+ val buttonColor = when {
+ isSubmitted && isCorrect -> SuccessGreen
+ isSubmitted && isSelected && !isCorrect -> MaterialTheme.colorScheme.error
+ isSelected -> Primary
+ else -> MaterialTheme.colorScheme.surfaceContainer
+ }
+
+ Button(
+ onClick = {
+ if (!isSubmitted) {
+ val newAnswers = userAnswers.copyOf()
+ newAnswers[qIdx] = optIdx
+ userAnswers = newAnswers
+ }
+ },
+ colors = ButtonDefaults.buttonColors(containerColor = buttonColor),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .hoverHand(),
+ ) {
+ Text(
+ text = optionText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (isSelected || (isSubmitted && isCorrect)) Color.White else MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (!isSubmitted) {
+ val allAnswered = userAnswers.none { it == -1 }
+ Button(
+ onClick = { isSubmitted = true },
+ enabled = allAnswered,
+ colors = ButtonDefaults.buttonColors(containerColor = Color(topic.accentColor)),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp)
+ .hoverHand(),
+ ) {
+ Text(
+ text = stringResource(Res.string.math_learning_submit_test),
+ color = Color.White,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ } else {
+ Spacer(Modifier.height(24.dp))
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/com/inspiredandroid/braincup/mathlearning/MathLearningTest.kt b/composeApp/src/commonTest/kotlin/com/inspiredandroid/braincup/mathlearning/MathLearningTest.kt
new file mode 100644
index 00000000..43492364
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/inspiredandroid/braincup/mathlearning/MathLearningTest.kt
@@ -0,0 +1,52 @@
+package com.inspiredandroid.braincup.mathlearning
+
+import com.inspiredandroid.braincup.api.UserStorage
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class MathLearningTest {
+
+ @Test
+ fun testTopicsCountAndEntries() {
+ assertEquals(8, MathLearningTopic.entries.size)
+ val ids = MathLearningTopic.entries.map { it.id }.toSet()
+ assertEquals(8, ids.size)
+
+ for (topic in MathLearningTopic.entries) {
+ assertNotNull(MathLearningTopic.getById(topic.id))
+ assertTrue(topic.lessons.isNotEmpty())
+ assertTrue(topic.testQuestions.isNotEmpty())
+ }
+ }
+
+ @Test
+ fun testUserStorageMathLearningPersistence() {
+ val storage = UserStorage.forPreview()
+ val topic = MathLearningTopic.ARITHMETIC
+
+ assertFalse(storage.isMathTopicPassed(topic.id))
+ assertNull(storage.getMathTopicScore(topic.id))
+
+ // Record a failing score
+ val failAward = storage.recordMathTopicResult(topic.id, 50, false)
+ assertNull(failAward)
+ assertFalse(storage.isMathTopicPassed(topic.id))
+ assertEquals(50, storage.getMathTopicScore(topic.id))
+
+ // Record a passing score
+ val passAward = storage.recordMathTopicResult(topic.id, 100, true)
+ assertNotNull(passAward)
+ assertEquals(UserStorage.MATH_TOPIC_PASS_XP, passAward.xpGained)
+ assertTrue(storage.isMathTopicPassed(topic.id))
+ assertEquals(100, storage.getMathTopicScore(topic.id))
+ assertNotNull(storage.getMathTopicTimestamp(topic.id))
+
+ // Repeat passing score does not award XP again
+ val repeatAward = storage.recordMathTopicResult(topic.id, 100, true)
+ assertNull(repeatAward)
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/com/inspiredandroid/braincup/navigation/AppUrlRoutesTest.kt b/composeApp/src/commonTest/kotlin/com/inspiredandroid/braincup/navigation/AppUrlRoutesTest.kt
index 4977e79c..29837c29 100644
--- a/composeApp/src/commonTest/kotlin/com/inspiredandroid/braincup/navigation/AppUrlRoutesTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/inspiredandroid/braincup/navigation/AppUrlRoutesTest.kt
@@ -2,6 +2,10 @@ package com.inspiredandroid.braincup.navigation
import com.inspiredandroid.braincup.app.Accounts
import com.inspiredandroid.braincup.app.Instructions
+import com.inspiredandroid.braincup.app.MathLearningCertificate
+import com.inspiredandroid.braincup.app.MathLearningLesson
+import com.inspiredandroid.braincup.app.MathLearningMenu
+import com.inspiredandroid.braincup.app.MathLearningTest
import com.inspiredandroid.braincup.app.MainMenu
import com.inspiredandroid.braincup.app.NormalSudokuPlay
import com.inspiredandroid.braincup.app.PegSolitaire
@@ -105,6 +109,22 @@ class AppUrlRoutesTest {
assertNull(pathSuffixToNavRoute(""))
}
+ @Test
+ fun navRouteToPathSuffix_mathLearning() {
+ assertEquals("math-learning", navRouteToPathSuffix(MathLearningMenu))
+ assertEquals("math-learning/lesson/arithmetic", navRouteToPathSuffix(MathLearningLesson("arithmetic")))
+ assertEquals("math-learning/test/arithmetic", navRouteToPathSuffix(MathLearningTest("arithmetic")))
+ assertEquals("math-learning/certificate/arithmetic", navRouteToPathSuffix(MathLearningCertificate("arithmetic")))
+ }
+
+ @Test
+ fun pathSuffixToNavRoute_mathLearning() {
+ assertEquals(MathLearningMenu, pathSuffixToNavRoute("math-learning"))
+ assertEquals(MathLearningLesson("arithmetic"), pathSuffixToNavRoute("math-learning/lesson/arithmetic"))
+ assertEquals(MathLearningTest("arithmetic"), pathSuffixToNavRoute("math-learning/test/arithmetic"))
+ assertEquals(MathLearningCertificate("arithmetic"), pathSuffixToNavRoute("math-learning/certificate/arithmetic"))
+ }
+
@Test
fun detectWebBasePath_githubPages() {
assertEquals("/Braincup", detectWebBasePath("/Braincup/CatQueens"))