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"))