diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a8379ee..3ebea86 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -79,7 +79,8 @@ jobs: arch: x86_64 target: google_apis disable-animations: true - script: ./gradlew connectedDebugAndroidTest + # Store screenshots are generated by screenshots.yml, not run as tests here. + script: ./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.notPackage=com.circuitqueest.app.screenshots - name: Upload instrumented test report if: always() diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml new file mode 100644 index 0000000..3313c89 --- /dev/null +++ b/.github/workflows/screenshots.yml @@ -0,0 +1,58 @@ +name: Store Screenshots + +# Regenerates Google Play screenshots on a 1080x1920 (16:9) emulator. +# Runs on demand, or on PRs that touch the screenshot test or store assets. +on: + workflow_dispatch: + pull_request: + branches: [ master ] + paths: + - 'app/src/androidTest/java/com/circuitqueest/app/screenshots/**' + - 'app/src/main/java/com/circuitqueest/app/ui/**' + - 'play-store/**' + - '.github/workflows/screenshots.yml' + +jobs: + screenshots: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Capture screenshots on emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + arch: x86_64 + target: google_apis + profile: Nexus 5X + disable-animations: true + script: | + ./gradlew installDebug installDebugAndroidTest + adb shell am instrument -w -e package com.circuitqueest.app.screenshots com.circuitqueest.app.test/androidx.test.runner.AndroidJUnitRunner + mkdir -p screenshots + adb pull /sdcard/Android/data/com.circuitqueest.app/files/screenshots/. screenshots/ + + - name: Upload screenshots + uses: actions/upload-artifact@v4 + with: + name: store-screenshots + path: screenshots/ + retention-days: 14 diff --git a/README.md b/README.md index 2f34bc2..f434e96 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ CircuitQueest is an offline-first Android app that teaches electrical engineerin - **Quest progression** — pass a topic's quiz (60%+) to unlock the next, in the order shown on the quest map; no internet required - **XP system** — +50 XP per lesson, 10 XP per point above your best quiz score, +100 bonus on your first pass - **Dual question types** — multiple choice and numeric input with tolerance-aware grading +- **Review mode** — every missed question goes into a review queue (⟲ chip on the quest map, "Review mistakes" after a quiz); replay until you get them right - **Blueprint mode** — toggleable alternate palette (deep navy + white-28% borders) - **Shared-element transitions** — topic cards morph into the lesson hero - **Offline-first** — all content and progress in a local Room database; no account needed @@ -100,12 +101,12 @@ CircuitQueest is an offline-first Android app that teaches electrical engineerin | Navigation | Navigation Compose with shared-element transitions | | Storage | Room 2.6.1 (offline-first, no backend) | | Fonts | Space Grotesk + JetBrains Mono (bundled, fully offline) | -| Min SDK | 26 (Android 8.0) · Target SDK 35 | -| Build | Gradle 8.11.1 · AGP 8.7.3 · KSP | +| Min SDK | 26 (Android 8.0) · Target SDK 36 (Android 16) | +| Build | Gradle 8.13 · AGP 8.13.2 · KSP | ## Quick Start -**Prerequisites:** JDK 17, Android SDK (platform 35) +**Prerequisites:** JDK 17, Android SDK (platform 36, build-tools 36) ```bash git clone https://github.com/HighviewOne/CircuitQueest.git diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index cb4abe0..dd28b4b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,14 @@ # Unreleased +### New +- **Review mode.** Every question you get wrong goes into a review queue. Replay them from the ⟲ chip on the quest map or "Review N mistakes" after a quiz; a correct answer clears the question. Reviews award no XP, so they can't be farmed. + +### Platform +- **Targets Android 16 (API 36)**, as Google Play requires since August 31, 2026. AGP 8.13.2, Gradle 8.13. +- Database version 2 (adds the review queue) via a tested Room auto-migration — existing progress is kept. + ### Engineering +- **Google Play launch kit** in `play-store/`: bundle signing script, listing text, icon, feature graphic, emulator-generated screenshots, and a step-by-step console guide. - **Release signing key.** Releases are now signed with a dedicated release key instead of the Android debug key, using APK Signature Scheme v3.1 key rotation so the next version still installs as an update over v2.4 and earlier (no uninstall, progress kept). See `docs/DEPLOYMENT.md` → Code Signing. --- diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 888c5f1..668ef34 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,12 +9,12 @@ plugins { android { namespace = "com.circuitqueest.app" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "com.circuitqueest.app" minSdk = 26 - targetSdk = 35 + targetSdk = 36 versionCode = 24 versionName = "2.4" @@ -53,6 +53,11 @@ android { buildFeatures { compose = true } + + // MigrationTestHelper reads the exported Room schemas as test assets. + sourceSets { + getByName("androidTest").assets.srcDir("$projectDir/schemas") + } } dependencies { diff --git a/app/schemas/com.circuitqueest.app.data.db.AppDatabase/2.json b/app/schemas/com.circuitqueest.app.data.db.AppDatabase/2.json new file mode 100644 index 0000000..871c140 --- /dev/null +++ b/app/schemas/com.circuitqueest.app.data.db.AppDatabase/2.json @@ -0,0 +1,147 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "8b22e44063a5d7932a919876b8ebf0b3", + "entities": [ + { + "tableName": "topic_progress", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`topicId` TEXT NOT NULL, `lessonCompleted` INTEGER NOT NULL, `quizCompleted` INTEGER NOT NULL, `bestScore` INTEGER NOT NULL, `totalQuestions` INTEGER NOT NULL, `xpEarned` INTEGER NOT NULL, `lastAccessedTimestamp` INTEGER NOT NULL, PRIMARY KEY(`topicId`))", + "fields": [ + { + "fieldPath": "topicId", + "columnName": "topicId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lessonCompleted", + "columnName": "lessonCompleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "quizCompleted", + "columnName": "quizCompleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bestScore", + "columnName": "bestScore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalQuestions", + "columnName": "totalQuestions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "xpEarned", + "columnName": "xpEarned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedTimestamp", + "columnName": "lastAccessedTimestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "topicId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "quiz_results", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `topicId` TEXT NOT NULL, `score` INTEGER NOT NULL, `totalQuestions` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "topicId", + "columnName": "topicId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalQuestions", + "columnName": "totalQuestions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "missed_questions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`topicId` TEXT NOT NULL, `questionId` TEXT NOT NULL, `missedAt` INTEGER NOT NULL, PRIMARY KEY(`topicId`, `questionId`))", + "fields": [ + { + "fieldPath": "topicId", + "columnName": "topicId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "questionId", + "columnName": "questionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "missedAt", + "columnName": "missedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "topicId", + "questionId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8b22e44063a5d7932a919876b8ebf0b3')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/circuitqueest/app/data/db/MigrationTest.kt b/app/src/androidTest/java/com/circuitqueest/app/data/db/MigrationTest.kt new file mode 100644 index 0000000..f673a82 --- /dev/null +++ b/app/src/androidTest/java/com/circuitqueest/app/data/db/MigrationTest.kt @@ -0,0 +1,69 @@ +package com.circuitqueest.app.data.db + +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.circuitqueest.app.data.db.entity.MissedQuestion +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Every installed copy of v2.4 has a version-1 database. If a migration is wrong, + * Room throws on launch for all of them, so each schema step is tested here. + */ +@RunWith(AndroidJUnit4::class) +class MigrationTest { + + private val dbName = "migration-test" + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java, + emptyList(), + FrameworkSQLiteOpenHelperFactory() + ) + + @Test + fun migrate1To2_keepsProgress_andAddsReviewQueue(): Unit = runBlocking { + helper.createDatabase(dbName, 1).apply { + execSQL( + "INSERT INTO topic_progress (topicId, lessonCompleted, quizCompleted, bestScore, " + + "totalQuestions, xpEarned, lastAccessedTimestamp) " + + "VALUES ('ohms_law', 1, 1, 7, 7, 220, 123)" + ) + execSQL( + "INSERT INTO quiz_results (topicId, score, totalQuestions, timestamp) " + + "VALUES ('ohms_law', 7, 7, 456)" + ) + close() + } + + // Validates the migrated schema against app/schemas/.../2.json. + helper.runMigrationsAndValidate(dbName, 2, true).close() + + val context = InstrumentationRegistry.getInstrumentation().targetContext + val db = Room.databaseBuilder(context, AppDatabase::class.java, dbName).build() + try { + val progress = db.progressDao().getProgressOnce("ohms_law") + assertNotNull(progress) + assertEquals(220, progress.xpEarned) + assertTrue(progress.quizCompleted) + assertEquals(1, db.quizResultDao().getResultsForTopic("ohms_law").first().size) + + db.missedQuestionDao().upsert(MissedQuestion("ohms_law", "ohm_q2")) + assertEquals(1, db.missedQuestionDao().count().first()) + } finally { + db.close() + context.deleteDatabase(dbName) + } + } +} diff --git a/app/src/androidTest/java/com/circuitqueest/app/screenshots/StoreScreenshots.kt b/app/src/androidTest/java/com/circuitqueest/app/screenshots/StoreScreenshots.kt new file mode 100644 index 0000000..9ba4034 --- /dev/null +++ b/app/src/androidTest/java/com/circuitqueest/app/screenshots/StoreScreenshots.kt @@ -0,0 +1,141 @@ +package com.circuitqueest.app.screenshots + +import android.content.Context +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.lifecycle.SavedStateHandle +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.circuitqueest.app.data.content.Question +import com.circuitqueest.app.data.content.TopicsService +import com.circuitqueest.app.data.db.AppDatabase +import com.circuitqueest.app.data.repository.ProgressRepository +import com.circuitqueest.app.ui.screens.HomeScreen +import com.circuitqueest.app.ui.screens.LessonScreen +import com.circuitqueest.app.ui.screens.QuizScreen +import com.circuitqueest.app.ui.screens.ResultScreen +import com.circuitqueest.app.ui.theme.CircuitQueestTheme +import com.circuitqueest.app.viewmodel.HomeViewModel +import com.circuitqueest.app.viewmodel.LessonViewModel +import com.circuitqueest.app.viewmodel.QuizViewModel +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * Generates Google Play store screenshots; not a correctness test. Excluded from the + * normal instrumented run (build.yml passes notPackage) and run by screenshots.yml, + * which pulls the PNGs from the app's external files dir. + */ +@RunWith(AndroidJUnit4::class) +class StoreScreenshots { + @get:Rule + val composeTestRule = createComposeRule() + + private val context = ApplicationProvider.getApplicationContext() + private lateinit var database: AppDatabase + private lateinit var repository: ProgressRepository + private val topics = TopicsService.allTopics + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + repository = ProgressRepository( + database.progressDao(), database.quizResultDao(), database.missedQuestionDao() + ) + // A player a few quests in: four passed, the fifth lesson read, two questions to review. + runBlocking { + listOf(7, 6, 7, 5).forEachIndexed { i, score -> + repository.markLessonCompleted(topics[i].id) + repository.recordQuizResult(topics[i].id, score, 7) + } + repository.markLessonCompleted(topics[4].id) + repository.recordAnswer(topics[1].id, topics[1].quiz.questions[2].id, correct = false) + repository.recordAnswer(topics[3].id, topics[3].quiz.questions[4].id, correct = false) + } + } + + @After + fun tearDown() { + database.close() + } + + private fun show(blueprint: Boolean = false, content: @Composable () -> Unit) { + composeTestRule.setContent { CircuitQueestTheme(blueprintMode = blueprint) { content() } } + composeTestRule.waitForIdle() + } + + private fun save(name: String) { + composeTestRule.waitForIdle() + val bitmap = composeTestRule.onRoot().captureToImage().asAndroidBitmap() + val dir = File(context.getExternalFilesDir(null), "screenshots").apply { mkdirs() } + File(dir, "$name.png").outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + } + + @Test + fun s1_questMap() { + show { HomeScreen(viewModel = HomeViewModel(repository), onTopicClick = {}) } + save("1-quest-map") + } + + @Test + fun s2_lesson() { + val vm = LessonViewModel(repository, SavedStateHandle(mapOf("topicId" to topics[4].id))) + show { LessonScreen(viewModel = vm, onBack = {}, onStartQuiz = {}) } + save("2-lesson") + } + + @Test + fun s3_quizCorrectAnswer() { + val topic = topics.first { t -> t.quiz.questions.first() is Question.MultipleChoice } + val q = topic.quiz.questions.first() as Question.MultipleChoice + val vm = QuizViewModel(repository, SavedStateHandle(mapOf("topicId" to topic.id))) + show { QuizScreen(viewModel = vm, onBack = {}, onQuizComplete = { _, _, _, _ -> }) } + composeTestRule.onAllNodesWithText(q.options[q.correctIndex]).onFirst().performClick() + composeTestRule.onNodeWithText("Submit Answer").performScrollTo().performClick() + save("3-quiz-feedback") + } + + @Test + fun s4_result() { + show { + ResultScreen( + topicId = topics[5].id, score = 6, totalQuestions = 7, xpEarned = 160, + onRetry = {}, onHome = {}, onReviewMistakes = {}, onNextLesson = {} + ) + } + save("4-result") + } + + @Test + fun s5_review() { + val vm = QuizViewModel( + repository, + SavedStateHandle(mapOf("topicId" to QuizViewModel.REVIEW_ALL, "review" to true)) + ) + show { QuizScreen(viewModel = vm, onBack = {}, onQuizComplete = { _, _, _, _ -> }) } + save("5-review") + } + + @Test + fun s6_blueprintMode() { + show(blueprint = true) { + HomeScreen(viewModel = HomeViewModel(repository), onTopicClick = {}, blueprintMode = true) + } + save("6-blueprint") + } +} diff --git a/app/src/androidTest/java/com/circuitqueest/app/ui/screens/HomeScreenTest.kt b/app/src/androidTest/java/com/circuitqueest/app/ui/screens/HomeScreenTest.kt index 17c5d0b..ab2c26d 100644 --- a/app/src/androidTest/java/com/circuitqueest/app/ui/screens/HomeScreenTest.kt +++ b/app/src/androidTest/java/com/circuitqueest/app/ui/screens/HomeScreenTest.kt @@ -22,6 +22,7 @@ import com.circuitqueest.app.data.db.AppDatabase import com.circuitqueest.app.data.repository.ProgressRepository import com.circuitqueest.app.ui.theme.CircuitQueestTheme import com.circuitqueest.app.viewmodel.HomeViewModel +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Rule @@ -37,6 +38,7 @@ class HomeScreenTest { private lateinit var database: AppDatabase private lateinit var viewModel: HomeViewModel + private lateinit var repository: ProgressRepository private val ohmsLaw = TopicsService.allTopics.first { it.id == "ohms_law" } private val mosfets = TopicsService.allTopics.first { it.id == "mosfets" } @@ -45,7 +47,10 @@ class HomeScreenTest { fun setUp() { val context = ApplicationProvider.getApplicationContext() database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() - viewModel = HomeViewModel(ProgressRepository(database.progressDao(), database.quizResultDao())) + repository = ProgressRepository( + database.progressDao(), database.quizResultDao(), database.missedQuestionDao() + ) + viewModel = HomeViewModel(repository) } @After @@ -54,7 +59,7 @@ class HomeScreenTest { } /** Mirrors MainActivity: blueprint state hoisted above the theme. */ - private fun showHome(onTopicClick: (String) -> Unit = {}) { + private fun showHome(onTopicClick: (String) -> Unit = {}, onReview: () -> Unit = {}) { composeTestRule.setContent { var blueprint by remember { mutableStateOf(false) } CircuitQueestTheme(blueprintMode = blueprint) { @@ -62,7 +67,8 @@ class HomeScreenTest { viewModel = viewModel, onTopicClick = onTopicClick, onToggleBlueprint = { blueprint = !blueprint }, - blueprintMode = blueprint + blueprintMode = blueprint, + onReview = onReview ) } } @@ -113,4 +119,28 @@ class HomeScreenTest { composeTestRule.runOnIdle { assertEquals(listOf(ohmsLaw.id), clicked) } } + + @Test + fun reviewChip_hiddenWithNoMistakes() { + showHome() + + composeTestRule.onNodeWithText("⟲", substring = true).assertDoesNotExist() + } + + @Test + fun reviewChip_showsCountAndOpensReview() { + runBlocking { + repository.recordAnswer("ohms_law", "a", correct = false) + repository.recordAnswer("mosfets", "b", correct = false) + } + var opened = false + showHome(onReview = { opened = true }) + + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText("⟲ 2").fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithText("⟲ 2").performClick() + + composeTestRule.runOnIdle { assertTrue(opened) } + } } diff --git a/app/src/androidTest/java/com/circuitqueest/app/ui/screens/QuizScreenTest.kt b/app/src/androidTest/java/com/circuitqueest/app/ui/screens/QuizScreenTest.kt index cf51e65..71ee4b9 100644 --- a/app/src/androidTest/java/com/circuitqueest/app/ui/screens/QuizScreenTest.kt +++ b/app/src/androidTest/java/com/circuitqueest/app/ui/screens/QuizScreenTest.kt @@ -20,6 +20,8 @@ import com.circuitqueest.app.data.content.TopicsService import com.circuitqueest.app.data.db.AppDatabase import com.circuitqueest.app.data.repository.ProgressRepository import com.circuitqueest.app.viewmodel.QuizViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Rule @@ -46,7 +48,9 @@ class QuizScreenTest { fun setUp() { val context = ApplicationProvider.getApplicationContext() database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() - repository = ProgressRepository(database.progressDao(), database.quizResultDao()) + repository = ProgressRepository( + database.progressDao(), database.quizResultDao(), database.missedQuestionDao() + ) } @After @@ -102,4 +106,41 @@ class QuizScreenTest { composeTestRule.runOnIdle { kotlin.test.assertTrue(left) } } + + @Test + fun review_correctAnswer_clearsQuestionFromQueue() { + runBlocking { repository.recordAnswer(topic.id, firstQuestion.id, correct = false) } + var finished = false + val viewModel = QuizViewModel( + repository, + SavedStateHandle(mapOf("topicId" to QuizViewModel.REVIEW_ALL, "review" to true)) + ) + composeTestRule.setContent { + QuizScreen(viewModel = viewModel, onBack = {}, onQuizComplete = { _, _, _, _ -> finished = true }) + } + + composeTestRule.onNodeWithText("Review · Question 1 of 1").assertIsDisplayed() + composeTestRule.onAllNodesWithText(firstQuestion.options[firstQuestion.correctIndex]).onFirst() + .performClick() + composeTestRule.onNodeWithText("Submit Answer").performScrollTo().performClick() + composeTestRule.onNodeWithText("Next →").performClick() + + composeTestRule.runOnIdle { kotlin.test.assertTrue(finished) } + composeTestRule.waitUntil(5_000) { + runBlocking { repository.getMissedCount().first() } == 0 + } + } + + @Test + fun review_emptyQueue_saysNothingToReview() { + val viewModel = QuizViewModel( + repository, + SavedStateHandle(mapOf("topicId" to QuizViewModel.REVIEW_ALL, "review" to true)) + ) + composeTestRule.setContent { + QuizScreen(viewModel = viewModel, onBack = {}, onQuizComplete = { _, _, _, _ -> }) + } + + composeTestRule.onNodeWithText("Nothing to review").assertIsDisplayed() + } } diff --git a/app/src/main/java/com/circuitqueest/app/data/db/AppDatabase.kt b/app/src/main/java/com/circuitqueest/app/data/db/AppDatabase.kt index 0c8fc55..bdceb50 100644 --- a/app/src/main/java/com/circuitqueest/app/data/db/AppDatabase.kt +++ b/app/src/main/java/com/circuitqueest/app/data/db/AppDatabase.kt @@ -1,9 +1,12 @@ package com.circuitqueest.app.data.db +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase +import com.circuitqueest.app.data.db.dao.MissedQuestionDao import com.circuitqueest.app.data.db.dao.ProgressDao import com.circuitqueest.app.data.db.dao.QuizResultDao +import com.circuitqueest.app.data.db.entity.MissedQuestion import com.circuitqueest.app.data.db.entity.QuizResult import com.circuitqueest.app.data.db.entity.TopicProgress @@ -13,13 +16,17 @@ import com.circuitqueest.app.data.db.entity.TopicProgress * throws on launch for every existing install. */ @Database( - entities = [TopicProgress::class, QuizResult::class], - version = 1, - exportSchema = true + entities = [TopicProgress::class, QuizResult::class, MissedQuestion::class], + version = 2, + exportSchema = true, + autoMigrations = [ + AutoMigration(from = 1, to = 2) // v2.5: adds missed_questions (review mode) + ] ) abstract class AppDatabase : RoomDatabase() { abstract fun progressDao(): ProgressDao abstract fun quizResultDao(): QuizResultDao + abstract fun missedQuestionDao(): MissedQuestionDao companion object { /** On-disk name; changing it orphans every existing player's progress. */ diff --git a/app/src/main/java/com/circuitqueest/app/data/db/dao/MissedQuestionDao.kt b/app/src/main/java/com/circuitqueest/app/data/db/dao/MissedQuestionDao.kt new file mode 100644 index 0000000..ff77a70 --- /dev/null +++ b/app/src/main/java/com/circuitqueest/app/data/db/dao/MissedQuestionDao.kt @@ -0,0 +1,26 @@ +package com.circuitqueest.app.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.circuitqueest.app.data.db.entity.MissedQuestion +import kotlinx.coroutines.flow.Flow + +@Dao +interface MissedQuestionDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(missed: MissedQuestion) + + @Query("DELETE FROM missed_questions WHERE topicId = :topicId AND questionId = :questionId") + suspend fun delete(topicId: String, questionId: String) + + @Query("SELECT * FROM missed_questions ORDER BY missedAt") + suspend fun getAll(): List + + @Query("SELECT * FROM missed_questions WHERE topicId = :topicId ORDER BY missedAt") + suspend fun getForTopic(topicId: String): List + + @Query("SELECT COUNT(*) FROM missed_questions") + fun count(): Flow +} diff --git a/app/src/main/java/com/circuitqueest/app/data/db/entity/MissedQuestion.kt b/app/src/main/java/com/circuitqueest/app/data/db/entity/MissedQuestion.kt new file mode 100644 index 0000000..0c013a2 --- /dev/null +++ b/app/src/main/java/com/circuitqueest/app/data/db/entity/MissedQuestion.kt @@ -0,0 +1,14 @@ +package com.circuitqueest.app.data.db.entity + +import androidx.room.Entity + +/** + * A question the player last answered wrong. Question ids are only unique within a + * topic (e.g. "sp_mc1" exists in two topics), hence the composite key. + */ +@Entity(tableName = "missed_questions", primaryKeys = ["topicId", "questionId"]) +data class MissedQuestion( + val topicId: String, + val questionId: String, + val missedAt: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/com/circuitqueest/app/data/di/RepositoryModule.kt b/app/src/main/java/com/circuitqueest/app/data/di/RepositoryModule.kt index e791111..a9f693f 100644 --- a/app/src/main/java/com/circuitqueest/app/data/di/RepositoryModule.kt +++ b/app/src/main/java/com/circuitqueest/app/data/di/RepositoryModule.kt @@ -26,6 +26,7 @@ object RepositoryModule { fun provideProgressRepository(database: AppDatabase): ProgressRepository = ProgressRepository( progressDao = database.progressDao(), - quizResultDao = database.quizResultDao() + quizResultDao = database.quizResultDao(), + missedQuestionDao = database.missedQuestionDao() ) } diff --git a/app/src/main/java/com/circuitqueest/app/data/repository/ProgressRepository.kt b/app/src/main/java/com/circuitqueest/app/data/repository/ProgressRepository.kt index a1249fd..255b834 100644 --- a/app/src/main/java/com/circuitqueest/app/data/repository/ProgressRepository.kt +++ b/app/src/main/java/com/circuitqueest/app/data/repository/ProgressRepository.kt @@ -1,7 +1,9 @@ package com.circuitqueest.app.data.repository +import com.circuitqueest.app.data.db.dao.MissedQuestionDao import com.circuitqueest.app.data.db.dao.ProgressDao import com.circuitqueest.app.data.db.dao.QuizResultDao +import com.circuitqueest.app.data.db.entity.MissedQuestion import com.circuitqueest.app.data.db.entity.QuizResult import com.circuitqueest.app.data.db.entity.TopicProgress import com.circuitqueest.app.util.QuizScoring @@ -11,7 +13,8 @@ import kotlinx.coroutines.sync.withLock class ProgressRepository( private val progressDao: ProgressDao, - private val quizResultDao: QuizResultDao + private val quizResultDao: QuizResultDao, + private val missedQuestionDao: MissedQuestionDao ) { // Progress updates are read-modify-write; serialize them so two writes to the // same topic (e.g. finishing a lesson while a quiz save is in flight) can't @@ -79,4 +82,21 @@ class ProgressRepository( fun getQuizResults(topicId: String): Flow> = quizResultDao.getResultsForTopic(topicId) + + // ── Review mode ───────────────────────────────────────────────────────── + + /** A wrong answer queues the question for review; a right one clears it. */ + suspend fun recordAnswer(topicId: String, questionId: String, correct: Boolean) { + if (correct) { + missedQuestionDao.delete(topicId, questionId) + } else { + missedQuestionDao.upsert(MissedQuestion(topicId = topicId, questionId = questionId)) + } + } + + /** Missed questions, oldest first; all topics when [topicId] is null. */ + suspend fun getMissedQuestions(topicId: String? = null): List = + if (topicId == null) missedQuestionDao.getAll() else missedQuestionDao.getForTopic(topicId) + + fun getMissedCount(): Flow = missedQuestionDao.count() } diff --git a/app/src/main/java/com/circuitqueest/app/navigation/NavGraph.kt b/app/src/main/java/com/circuitqueest/app/navigation/NavGraph.kt index c7fb2f9..b816cb0 100644 --- a/app/src/main/java/com/circuitqueest/app/navigation/NavGraph.kt +++ b/app/src/main/java/com/circuitqueest/app/navigation/NavGraph.kt @@ -30,10 +30,13 @@ object Routes { const val HOME = "home" const val LESSON = "lesson/{topicId}" const val QUIZ = "quiz/{topicId}" + /** Review mode reuses the quiz screen; topicId may be QuizViewModel.REVIEW_ALL. */ + const val REVIEW = "review/{topicId}" const val RESULT = "result/{topicId}/{score}/{total}/{xp}" fun lesson(topicId: String) = "lesson/$topicId" fun quiz(topicId: String) = "quiz/$topicId" + fun review(topicId: String = QuizViewModel.REVIEW_ALL) = "review/$topicId" fun result(topicId: String, score: Int, total: Int, xp: Int) = "result/$topicId/$score/$total/$xp" } @@ -60,7 +63,8 @@ fun CircuitQueestNavGraph( navController.navigate(Routes.lesson(topicId)) }, onToggleBlueprint = onToggleBlueprint, - blueprintMode = blueprintMode + blueprintMode = blueprintMode, + onReview = { navController.navigate(Routes.review()) } ) } } @@ -105,6 +109,22 @@ fun CircuitQueestNavGraph( } } + composable( + route = Routes.REVIEW, + arguments = listOf( + navArgument("topicId") { type = NavType.StringType }, + navArgument("review") { type = NavType.BoolType; defaultValue = true } + ) + ) { + val reviewViewModel: QuizViewModel = hiltViewModel() + QuizScreen( + viewModel = reviewViewModel, + onBack = { navController.popBackStack() }, + // Reviews award nothing to summarize; return to where they started. + onQuizComplete = { _, _, _, _ -> navController.popBackStack() } + ) + } + composable( route = Routes.RESULT, arguments = listOf( @@ -138,6 +158,11 @@ fun CircuitQueestNavGraph( popUpTo(Routes.HOME) { inclusive = true } } }, + onReviewMistakes = { id -> + navController.navigate(Routes.review(id)) { + popUpTo(Routes.HOME) + } + }, onNextLesson = { id -> navController.navigate(Routes.lesson(id)) { popUpTo(Routes.HOME) diff --git a/app/src/main/java/com/circuitqueest/app/ui/components/QuestNotFound.kt b/app/src/main/java/com/circuitqueest/app/ui/components/QuestNotFound.kt index 578c458..b5df3d3 100644 --- a/app/src/main/java/com/circuitqueest/app/ui/components/QuestNotFound.kt +++ b/app/src/main/java/com/circuitqueest/app/ui/components/QuestNotFound.kt @@ -21,9 +21,16 @@ import com.circuitqueest.app.ui.theme.LocalCqPalette import com.circuitqueest.app.ui.theme.SpaceGrotesk import com.circuitqueest.app.ui.theme.Spacing -/** Shown when a route names a topic that doesn't exist, instead of a blank screen. */ +/** + * Full-screen message with a Back button. Defaults cover a route naming a topic that + * doesn't exist; review mode reuses it for an empty review queue. + */ @Composable -fun QuestNotFound(onBack: () -> Unit) { +fun QuestNotFound( + onBack: () -> Unit, + title: String = "Quest not found", + message: String = "This quest isn't available. Head back to the quest map." +) { val pal = LocalCqPalette.current Scaffold(containerColor = pal.bg) { paddingValues -> Column( @@ -35,14 +42,14 @@ fun QuestNotFound(onBack: () -> Unit) { horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Quest not found", + text = title, fontFamily = SpaceGrotesk, fontWeight = FontWeight.Bold, fontSize = 22.sp, color = CqText ) Text( - text = "This quest isn't available. Head back to the quest map.", + text = message, fontFamily = SpaceGrotesk, fontSize = 15.sp, color = CqTextDim, diff --git a/app/src/main/java/com/circuitqueest/app/ui/components/XpBar.kt b/app/src/main/java/com/circuitqueest/app/ui/components/XpBar.kt index 2cdbeea..77ffdb4 100644 --- a/app/src/main/java/com/circuitqueest/app/ui/components/XpBar.kt +++ b/app/src/main/java/com/circuitqueest/app/ui/components/XpBar.kt @@ -27,12 +27,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.unit.dp import com.circuitqueest.app.ui.theme.CqBlue import com.circuitqueest.app.ui.theme.CqBlueLight import com.circuitqueest.app.ui.theme.CqCyan import com.circuitqueest.app.ui.theme.CqGold -import com.circuitqueest.app.ui.theme.CqTextDim +import com.circuitqueest.app.ui.theme.CqText import com.circuitqueest.app.ui.theme.LocalCqPalette import com.circuitqueest.app.ui.theme.MonoLabel import com.circuitqueest.app.ui.theme.Radius @@ -57,6 +58,11 @@ fun XpBar( val pal = LocalCqPalette.current val info = computeLevel(totalXp) val shape = RoundedCornerShape(Radius.lg) + // Labels sit over both the dark track and the bright gradient fill; a soft shadow keeps + // them readable on either. + val labelStyle = MonoLabel.copy( + shadow = Shadow(color = Color.Black.copy(alpha = 0.7f), offset = Offset(0f, 1f), blurRadius = 4f) + ) val fillProgress by animateFloatAsState( targetValue = info.progress, @@ -118,13 +124,13 @@ fun XpBar( ) { Text( text = "Level ${info.level} · ${totalXp} XP", - style = MonoLabel, - color = CqTextDim + style = labelStyle, + color = CqText ) Text( text = "→ ${info.xpToNext} to Level ${info.level + 1}", - style = MonoLabel, - color = CqTextDim + style = labelStyle, + color = CqText ) } } diff --git a/app/src/main/java/com/circuitqueest/app/ui/screens/HomeScreen.kt b/app/src/main/java/com/circuitqueest/app/ui/screens/HomeScreen.kt index 698b03f..d75021a 100644 --- a/app/src/main/java/com/circuitqueest/app/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/circuitqueest/app/ui/screens/HomeScreen.kt @@ -52,6 +52,7 @@ import com.circuitqueest.app.ui.components.TopicCard import com.circuitqueest.app.ui.components.XpBar import com.circuitqueest.app.ui.icons.SchematicIcons import com.circuitqueest.app.ui.theme.CqBlue +import com.circuitqueest.app.ui.theme.CqGold import com.circuitqueest.app.ui.theme.CqText import com.circuitqueest.app.ui.theme.CqTextDim import com.circuitqueest.app.ui.theme.CqTextFaint @@ -67,11 +68,13 @@ fun HomeScreen( viewModel: HomeViewModel, onTopicClick: (String) -> Unit, onToggleBlueprint: () -> Unit = {}, - blueprintMode: Boolean = false + blueprintMode: Boolean = false, + onReview: () -> Unit = {} ) { val pal = LocalCqPalette.current val categorizedTopics by viewModel.categorizedTopics.collectAsStateWithLifecycle() val totalXp by viewModel.totalXp.collectAsStateWithLifecycle() + val missedCount by viewModel.missedCount.collectAsStateWithLifecycle() var searchQuery by rememberSaveable { mutableStateOf("") } // Names of expanded categories; saveable so rotation doesn't collapse them. val expandedCategories = rememberSaveable( @@ -113,6 +116,19 @@ fun HomeScreen( color = CqText ) Row(horizontalArrangement = Arrangement.spacedBy(Spacing.s8)) { + // Review chip: only when there are missed questions to replay + if (missedCount > 0) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(100.dp)) + .background(CqGold.copy(alpha = 0.14f)) + .border(1.dp, CqGold, RoundedCornerShape(100.dp)) + .clickable(onClickLabel = "Review missed questions") { onReview() } + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text(text = "⟲ $missedCount", style = MonoLabel, color = CqGold) + } + } // Blueprint mode toggle Box( modifier = Modifier diff --git a/app/src/main/java/com/circuitqueest/app/ui/screens/LessonScreen.kt b/app/src/main/java/com/circuitqueest/app/ui/screens/LessonScreen.kt index 2d7f741..eb8cf65 100644 --- a/app/src/main/java/com/circuitqueest/app/ui/screens/LessonScreen.kt +++ b/app/src/main/java/com/circuitqueest/app/ui/screens/LessonScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -208,7 +209,8 @@ private fun HeroCard(topic: Topic, parallaxOffset: Float = 0f) { modifier = Modifier .fillMaxWidth() .then(sharedModifier) - .height(210.dp) + // Minimum, not fixed: two-line subtitles otherwise push the stat pills out. + .heightIn(min = 210.dp) .clip(shape) .background( Brush.linearGradient( diff --git a/app/src/main/java/com/circuitqueest/app/ui/screens/QuizScreen.kt b/app/src/main/java/com/circuitqueest/app/ui/screens/QuizScreen.kt index d179b97..4bdff36 100644 --- a/app/src/main/java/com/circuitqueest/app/ui/screens/QuizScreen.kt +++ b/app/src/main/java/com/circuitqueest/app/ui/screens/QuizScreen.kt @@ -79,13 +79,27 @@ fun QuizScreen( val feedback by viewModel.feedback.collectAsStateWithLifecycle() val quizComplete by viewModel.quizComplete.collectAsStateWithLifecycle() + if (quizState.isLoading) { + Scaffold(containerColor = pal.bg) { Box(Modifier.padding(it)) } + return + } if (quizState.totalQuestions == 0) { - QuestNotFound(onBack = onBack) + if (quizState.isReview) { + QuestNotFound( + onBack = onBack, + title = "Nothing to review", + message = "You've cleared every missed question. Wrong answers in quizzes show up here." + ) + } else { + QuestNotFound(onBack = onBack) + } return } // Leaving mid-quiz discards the attempt, so confirm first once anything is answered. - val attemptInProgress = (quizState.currentIndex > 0 || feedback != null) && !quizComplete + // Reviews save each answer as it's given, so there is nothing to lose. + val attemptInProgress = !quizState.isReview && + (quizState.currentIndex > 0 || feedback != null) && !quizComplete var showLeaveDialog by rememberSaveable { mutableStateOf(false) } val requestLeave: () -> Unit = { if (attemptInProgress) { @@ -129,7 +143,8 @@ fun QuizScreen( TopAppBar( title = { Text( - text = "Question ${quizState.currentIndex + 1} of ${quizState.totalQuestions}", + text = (if (quizState.isReview) "Review · " else "") + + "Question ${quizState.currentIndex + 1} of ${quizState.totalQuestions}", style = MonoLabel.copy(fontSize = 12.sp), color = CqTextDim ) diff --git a/app/src/main/java/com/circuitqueest/app/ui/screens/ResultScreen.kt b/app/src/main/java/com/circuitqueest/app/ui/screens/ResultScreen.kt index 4467ad3..b9d5939 100644 --- a/app/src/main/java/com/circuitqueest/app/ui/screens/ResultScreen.kt +++ b/app/src/main/java/com/circuitqueest/app/ui/screens/ResultScreen.kt @@ -72,6 +72,7 @@ fun ResultScreen( xpEarned: Int, onRetry: (String) -> Unit, onHome: () -> Unit, + onReviewMistakes: ((String) -> Unit)? = null, onNextLesson: ((String) -> Unit)? = null ) { val pal = LocalCqPalette.current @@ -212,6 +213,26 @@ fun ResultScreen( fontSize = 15.sp ) } + // Every question was just answered, so this attempt's misses are exactly the + // topic's review queue (each points 1). + val mistakes = (totalQuestions - score).coerceAtLeast(0) + if (mistakes > 0 && onReviewMistakes != null) { + Spacer(modifier = Modifier.height(Spacing.s8)) + OutlinedButton( + onClick = { onReviewMistakes(topicId) }, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(Radius.md), + colors = ButtonDefaults.outlinedButtonColors(contentColor = CqGold), + border = androidx.compose.foundation.BorderStroke(1.dp, CqGold.copy(alpha = 0.6f)) + ) { + Text( + text = if (mistakes == 1) "Review 1 mistake" else "Review $mistakes mistakes", + fontFamily = SpaceGrotesk, + fontWeight = FontWeight.Medium, + fontSize = 14.sp + ) + } + } Spacer(modifier = Modifier.height(Spacing.s8)) OutlinedButton( onClick = { onRetry(topicId) }, diff --git a/app/src/main/java/com/circuitqueest/app/viewmodel/HomeViewModel.kt b/app/src/main/java/com/circuitqueest/app/viewmodel/HomeViewModel.kt index f4a99ec..0428399 100644 --- a/app/src/main/java/com/circuitqueest/app/viewmodel/HomeViewModel.kt +++ b/app/src/main/java/com/circuitqueest/app/viewmodel/HomeViewModel.kt @@ -83,6 +83,14 @@ class HomeViewModel @Inject constructor( } ) + /** Questions waiting in review mode; drives the "Review" chip on the quest map. */ + val missedCount: StateFlow = repository.getMissedCount() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = 0 + ) + val totalXp: StateFlow = repository.getTotalXp() .stateIn( scope = viewModelScope, diff --git a/app/src/main/java/com/circuitqueest/app/viewmodel/QuizViewModel.kt b/app/src/main/java/com/circuitqueest/app/viewmodel/QuizViewModel.kt index 07663fa..15d66a2 100644 --- a/app/src/main/java/com/circuitqueest/app/viewmodel/QuizViewModel.kt +++ b/app/src/main/java/com/circuitqueest/app/viewmodel/QuizViewModel.kt @@ -20,7 +20,11 @@ data class QuizState( val currentIndex: Int = 0, val score: Int = 0, val totalQuestions: Int = 0, - val xpEarned: Int = 0 + val xpEarned: Int = 0, + /** Review mode: replays missed questions; awards no XP and records no attempt. */ + val isReview: Boolean = false, + /** True while review questions load from the database. */ + val isLoading: Boolean = false ) data class QuizFeedback( @@ -34,8 +38,12 @@ class QuizViewModel @Inject constructor( savedStateHandle: SavedStateHandle ) : ViewModel() { + /** A topic id, or [REVIEW_ALL] for a review across every topic. */ private val topicId: String = checkNotNull(savedStateHandle["topicId"]) - private val questions: List + private val isReview: Boolean = savedStateHandle.get("review") ?: false + + /** Questions paired with the topic they belong to (ids are only unique per topic). */ + private var questions: List> = emptyList() private val _quizState = MutableStateFlow(QuizState()) val quizState: StateFlow = _quizState.asStateFlow() @@ -51,54 +59,56 @@ class QuizViewModel @Inject constructor( private var isSaving = false init { - val topic = TopicsService.allTopics.find { it.id == topicId } - questions = topic?.quiz?.questions ?: emptyList() - - _quizState.value = QuizState( - topicId = topicId, - quizTitle = topic?.quiz?.title ?: "Quiz", - currentIndex = 0, - score = 0, - totalQuestions = questions.size - ) - - if (questions.isNotEmpty()) { - _currentQuestion.value = questions[0] + if (isReview) { + _quizState.value = QuizState(topicId = topicId, quizTitle = "Review", isReview = true, isLoading = true) + viewModelScope.launch { start(loadReviewQuestions()) } + } else { + val topic = TopicsService.allTopics.find { it.id == topicId } + _quizState.value = QuizState(topicId = topicId, quizTitle = topic?.quiz?.title ?: "Quiz") + start(topic?.quiz?.questions.orEmpty().map { topicId to it }) } } + private suspend fun loadReviewQuestions(): List> { + val byTopic = TopicsService.allTopics.associateBy { it.id } + val scope = topicId.takeUnless { it == REVIEW_ALL } + // Skip entries whose question no longer exists in the content. + return repository.getMissedQuestions(scope).mapNotNull { missed -> + byTopic[missed.topicId]?.quiz?.questions + ?.firstOrNull { it.id == missed.questionId } + ?.let { missed.topicId to it } + } + } + + private fun start(loaded: List>) { + questions = loaded + _quizState.value = _quizState.value.copy(totalQuestions = loaded.size, isLoading = false) + _currentQuestion.value = loaded.firstOrNull()?.second + } + fun answerMultipleChoice(selectedIndex: Int) { val question = _currentQuestion.value ?: return if (_feedback.value != null) return - - val isCorrect = QuizScoring.checkAnswer(question, selectedIndex) - if (isCorrect) { - _quizState.value = _quizState.value.copy( - score = _quizState.value.score + question.points - ) - } - - _feedback.value = QuizFeedback( - isCorrect = isCorrect, - explanation = question.explanation - ) + onAnswered(question, QuizScoring.checkAnswer(question, selectedIndex)) } fun answerNumeric(value: Double?) { val question = _currentQuestion.value ?: return if (_feedback.value != null) return + onAnswered(question, value != null && QuizScoring.checkAnswer(question, value)) + } - val isCorrect = value != null && QuizScoring.checkAnswer(question, value) + private fun onAnswered(question: Question, isCorrect: Boolean) { if (isCorrect) { _quizState.value = _quizState.value.copy( score = _quizState.value.score + question.points ) } + _feedback.value = QuizFeedback(isCorrect = isCorrect, explanation = question.explanation) - _feedback.value = QuizFeedback( - isCorrect = isCorrect, - explanation = question.explanation - ) + // Wrong answers queue the question for review; right ones clear it. + val questionTopic = questions[_quizState.value.currentIndex].first + viewModelScope.launch { repository.recordAnswer(questionTopic, question.id, isCorrect) } } fun nextQuestion() { @@ -109,6 +119,11 @@ class QuizViewModel @Inject constructor( if (nextIndex >= questions.size) { isSaving = true + if (isReview) { + // Reviews only clear mistakes (done per answer); no XP, no attempt record. + _quizComplete.value = true + return + } viewModelScope.launch { val xp = repository.recordQuizResult( topicId = topicId, @@ -122,7 +137,12 @@ class QuizViewModel @Inject constructor( } _quizState.value = _quizState.value.copy(currentIndex = nextIndex) - _currentQuestion.value = questions[nextIndex] + _currentQuestion.value = questions[nextIndex].second _feedback.value = null } + + companion object { + /** Route topic id meaning "review missed questions from every topic". */ + const val REVIEW_ALL = "all" + } } diff --git a/app/src/test/java/com/circuitqueest/app/data/QuizFlowIntegrationTest.kt b/app/src/test/java/com/circuitqueest/app/data/QuizFlowIntegrationTest.kt index a3514dd..9c00351 100644 --- a/app/src/test/java/com/circuitqueest/app/data/QuizFlowIntegrationTest.kt +++ b/app/src/test/java/com/circuitqueest/app/data/QuizFlowIntegrationTest.kt @@ -28,7 +28,7 @@ class QuizFlowIntegrationTest { fun setup() { progressDao = mock() quizResultDao = mock() - repository = ProgressRepository(progressDao, quizResultDao) + repository = ProgressRepository(progressDao, quizResultDao, mock()) } @Test diff --git a/app/src/test/java/com/circuitqueest/app/data/repository/ProgressRepositoryTest.kt b/app/src/test/java/com/circuitqueest/app/data/repository/ProgressRepositoryTest.kt index 5f3f2c3..cc09bd9 100644 --- a/app/src/test/java/com/circuitqueest/app/data/repository/ProgressRepositoryTest.kt +++ b/app/src/test/java/com/circuitqueest/app/data/repository/ProgressRepositoryTest.kt @@ -1,7 +1,9 @@ package com.circuitqueest.app.data.repository +import com.circuitqueest.app.data.db.dao.MissedQuestionDao import com.circuitqueest.app.data.db.dao.ProgressDao import com.circuitqueest.app.data.db.dao.QuizResultDao +import com.circuitqueest.app.data.db.entity.MissedQuestion import com.circuitqueest.app.data.db.entity.TopicProgress import com.circuitqueest.app.data.db.entity.QuizResult import kotlinx.coroutines.flow.flowOf @@ -16,13 +18,15 @@ class ProgressRepositoryTest { private lateinit var progressDao: ProgressDao private lateinit var quizResultDao: QuizResultDao + private lateinit var missedQuestionDao: MissedQuestionDao private lateinit var repository: ProgressRepository @Before fun setup() { progressDao = mock() quizResultDao = mock() - repository = ProgressRepository(progressDao, quizResultDao) + missedQuestionDao = mock() + repository = ProgressRepository(progressDao, quizResultDao, missedQuestionDao) } @Test @@ -267,4 +271,32 @@ class ProgressRepositoryTest { assertEquals(8, captor.firstValue.bestScore) assertEquals(0, xp) } + + @Test + fun recordAnswer_wrong_queuesQuestionForReview(): Unit = runBlocking { + repository.recordAnswer("ohms_law", "q3", correct = false) + + val captor = argumentCaptor() + verify(missedQuestionDao).upsert(captor.capture()) + assertEquals("ohms_law", captor.firstValue.topicId) + assertEquals("q3", captor.firstValue.questionId) + verify(missedQuestionDao, never()).delete(any(), any()) + } + + @Test + fun recordAnswer_correct_clearsQuestion(): Unit = runBlocking { + repository.recordAnswer("ohms_law", "q3", correct = true) + + verify(missedQuestionDao).delete("ohms_law", "q3") + verify(missedQuestionDao, never()).upsert(any()) + } + + @Test + fun getMissedQuestions_scopesToTopicOrAll(): Unit = runBlocking { + repository.getMissedQuestions("ohms_law") + repository.getMissedQuestions() + + verify(missedQuestionDao).getForTopic("ohms_law") + verify(missedQuestionDao).getAll() + } } diff --git a/app/src/test/java/com/circuitqueest/app/viewmodel/HomeViewModelTest.kt b/app/src/test/java/com/circuitqueest/app/viewmodel/HomeViewModelTest.kt index 00a458a..c5da17a 100644 --- a/app/src/test/java/com/circuitqueest/app/viewmodel/HomeViewModelTest.kt +++ b/app/src/test/java/com/circuitqueest/app/viewmodel/HomeViewModelTest.kt @@ -32,6 +32,7 @@ class HomeViewModelTest { fun setup() { Dispatchers.setMain(testDispatcher) repository = mock() + whenever(repository.getMissedCount()).thenReturn(flowOf(0)) } @After diff --git a/app/src/test/java/com/circuitqueest/app/viewmodel/QuizViewModelTest.kt b/app/src/test/java/com/circuitqueest/app/viewmodel/QuizViewModelTest.kt index cc81bd1..f8d57a7 100644 --- a/app/src/test/java/com/circuitqueest/app/viewmodel/QuizViewModelTest.kt +++ b/app/src/test/java/com/circuitqueest/app/viewmodel/QuizViewModelTest.kt @@ -3,6 +3,7 @@ package com.circuitqueest.app.viewmodel import androidx.lifecycle.SavedStateHandle import com.circuitqueest.app.data.content.Question import com.circuitqueest.app.data.content.TopicsService +import com.circuitqueest.app.data.db.entity.MissedQuestion import com.circuitqueest.app.data.repository.ProgressRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -14,12 +15,14 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -103,4 +106,86 @@ class QuizViewModelTest { assertEquals(1, viewModel.quizState.value.currentIndex) verify(repository, never()).recordQuizResult(any(), any(), any()) } + + // ── Review mode ───────────────────────────────────────────────────────── + + private fun makeReviewViewModel(repository: ProgressRepository, scope: String = QuizViewModel.REVIEW_ALL) = + QuizViewModel(repository, SavedStateHandle(mapOf("topicId" to scope, "review" to true))) + + private val otherTopic = TopicsService.allTopics.first { it.id == "series_parallel" } + + @Test + fun wrongAnswer_inNormalQuiz_isQueuedForReview() = runTest { + val repository: ProgressRepository = mock() + val viewModel = makeViewModel(repository) + val first = questions.first() + + when (first) { + is Question.MultipleChoice -> viewModel.answerMultipleChoice((first.correctIndex + 1) % first.options.size) + is Question.NumericInput -> viewModel.answerNumeric(first.correctAnswer + 1_000) + } + + verify(repository).recordAnswer(eq(topicId), eq(first.id), eq(false)) + } + + @Test + fun reviewMode_loadsMissedQuestions_acrossTopics_skippingStale() = runTest { + val repository: ProgressRepository = mock() + whenever(repository.getMissedQuestions(null)).thenReturn( + listOf( + MissedQuestion(topicId, questions[2].id), + MissedQuestion("removed_topic", "gone"), + MissedQuestion(otherTopic.id, otherTopic.quiz.questions[0].id) + ) + ) + + val viewModel = makeReviewViewModel(repository) + + val state = viewModel.quizState.value + assertTrue(state.isReview) + assertFalse(state.isLoading) + assertEquals(2, state.totalQuestions) + assertEquals(questions[2].id, viewModel.currentQuestion.value?.id) + } + + @Test + fun reviewMode_recordsAnswersAgainstEachQuestionsOwnTopic() = runTest { + val repository: ProgressRepository = mock() + val otherQuestion = otherTopic.quiz.questions[0] + whenever(repository.getMissedQuestions(null)).thenReturn( + listOf(MissedQuestion(otherTopic.id, otherQuestion.id)) + ) + val viewModel = makeReviewViewModel(repository) + + viewModel.answerCurrentCorrectly() + + verify(repository).recordAnswer(eq(otherTopic.id), eq(otherQuestion.id), eq(true)) + } + + @Test + fun reviewMode_finishes_withoutRecordingAttemptOrXp() = runTest { + val repository: ProgressRepository = mock() + whenever(repository.getMissedQuestions(topicId)).thenReturn( + listOf(MissedQuestion(topicId, questions[0].id)) + ) + val viewModel = makeReviewViewModel(repository, scope = topicId) + + viewModel.answerCurrentCorrectly() + viewModel.nextQuestion() + + assertTrue(viewModel.quizComplete.value) + assertEquals(0, viewModel.quizState.value.xpEarned) + verify(repository, never()).recordQuizResult(any(), any(), any()) + } + + @Test + fun reviewMode_emptyQueue_hasNoQuestions() = runTest { + val repository: ProgressRepository = mock() + whenever(repository.getMissedQuestions(null)).thenReturn(emptyList()) + + val viewModel = makeReviewViewModel(repository) + + assertEquals(0, viewModel.quizState.value.totalQuestions) + assertFalse(viewModel.quizState.value.isLoading) + } } diff --git a/docs/API.md b/docs/API.md index e84f61f..1c7d721 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # API Documentation -Reference for the app's internal Kotlin APIs, as of v2.4. Everything lives under +Reference for the app's internal Kotlin APIs, as of v2.5. Everything lives under `app/src/main/java/com/circuitqueest/app/`. The app is offline-only: there is no network API. ``` @@ -127,7 +127,7 @@ The only class that writes progress. Hilt provides a singleton; inject it, don't (tests construct it directly with DAOs from an in-memory database). ```kotlin -class ProgressRepository(progressDao: ProgressDao, quizResultDao: QuizResultDao) { +class ProgressRepository(progressDao: ProgressDao, quizResultDao: QuizResultDao, missedQuestionDao: MissedQuestionDao) { fun getAllProgress(): Flow> fun getProgress(topicId: String): Flow fun getTotalXp(): Flow @@ -136,6 +136,11 @@ class ProgressRepository(progressDao: ProgressDao, quizResultDao: QuizResultDao) suspend fun markLessonCompleted(topicId: String) // +LESSON_XP the first time only suspend fun recordQuizResult(topicId: String, score: Int, totalQuestions: Int): Int suspend fun saveQuizResult(topicId: String, score: Int, totalQuestions: Int) // = recordQuizResult, ignoring the XP + + // Review mode + suspend fun recordAnswer(topicId: String, questionId: String, correct: Boolean) // wrong → queue, right → clear + suspend fun getMissedQuestions(topicId: String? = null): List // null = all topics, oldest first + fun getMissedCount(): Flow } ``` @@ -153,17 +158,25 @@ Writes are serialized with a `Mutex`, so concurrent updates to the same topic ca ### `AppDatabase` (`data/db/AppDatabase.kt`) ```kotlin -@Database(entities = [TopicProgress::class, QuizResult::class], version = 1, exportSchema = true) +@Database( + entities = [TopicProgress::class, QuizResult::class, MissedQuestion::class], + version = 2, + exportSchema = true, + autoMigrations = [AutoMigration(from = 1, to = 2)] // v2.5: adds missed_questions +) abstract class AppDatabase : RoomDatabase() { abstract fun progressDao(): ProgressDao abstract fun quizResultDao(): QuizResultDao + abstract fun missedQuestionDao(): MissedQuestionDao companion object { const val DATABASE_NAME = "circuitqueest_db" } } ``` - **Never change `DATABASE_NAME`**: it would orphan every player's saved progress. -- Schemas are exported to `app/schemas/`. To change an entity, bump `version`, add a `Migration`, - and test it with `MigrationTestHelper` (`room-testing` is already an `androidTest` dependency). +- Schemas are exported to `app/schemas/` (also the androidTest assets). To change an entity, bump + `version`, add an `AutoMigration` (or a manual `Migration`), and add a case to `MigrationTest`, which + builds a database at the old version with real rows, migrates, and validates against the schema. + A missing or wrong migration crashes every existing install on launch. ### Entities (`data/db/entity/`) @@ -187,6 +200,14 @@ data class QuizResult( val totalQuestions: Int, val timestamp: Long = System.currentTimeMillis() ) + +// Review queue. Question ids are unique only within a topic, hence the composite key. +@Entity(tableName = "missed_questions", primaryKeys = ["topicId", "questionId"]) +data class MissedQuestion( + val topicId: String, + val questionId: String, + val missedAt: Long = System.currentTimeMillis() +) ``` ### DAOs (`data/db/dao/`) @@ -205,6 +226,14 @@ data class QuizResult( fun getResultsForTopic(topicId: String): Flow> // ORDER BY timestamp DESC fun getBestScore(topicId: String): Flow // MAX(score) } + +@Dao interface MissedQuestionDao { + @Insert(onConflict = REPLACE) suspend fun upsert(missed: MissedQuestion) + suspend fun delete(topicId: String, questionId: String) + suspend fun getAll(): List // ORDER BY missedAt + suspend fun getForTopic(topicId: String): List + fun count(): Flow +} ``` --- @@ -238,6 +267,7 @@ data class CategoryState(val category: TopicCategory, val topics: List> // in unlock order val categorizedTopics: StateFlow> // grouped for the quest map val totalXp: StateFlow +val missedCount: StateFlow // review queue size; drives the ⟲ chip ``` Lock rule: the first topic is always open; any other topic is locked unless the previous topic's @@ -258,7 +288,9 @@ fun markLessonComplete() data class QuizState( val topicId: String = "", val quizTitle: String = "", val currentIndex: Int = 0, val score: Int = 0, val totalQuestions: Int = 0, - val xpEarned: Int = 0 // set when the attempt is saved + val xpEarned: Int = 0, // set when the attempt is saved + val isReview: Boolean = false, // review mode: missed questions, no XP, no attempt record + val isLoading: Boolean = false // review questions loading from the database ) data class QuizFeedback(val isCorrect: Boolean, val explanation: String) @@ -277,6 +309,12 @@ fun nextQuestion() once (repeat taps are ignored), stores the awarded XP in `quizState.xpEarned`, then sets `quizComplete`. - `totalQuestions == 0` (unknown topic) → `QuizScreen` shows `QuestNotFound`. +- Every answer calls `ProgressRepository.recordAnswer` against the question's own topic, so wrong + answers queue for review and right ones clear the queue. +- **Review mode** (`SavedStateHandle["review"] == true`, set by the review route): `topicId` is a topic + or `QuizViewModel.REVIEW_ALL` (`"all"`). Questions load from the review queue (entries whose + question no longer exists are skipped); finishing awards no XP and saves no `QuizResult`. An empty + queue shows "Nothing to review". --- @@ -287,10 +325,12 @@ object Routes { const val HOME = "home" const val LESSON = "lesson/{topicId}" const val QUIZ = "quiz/{topicId}" + const val REVIEW = "review/{topicId}" // nav arg review = true; topicId may be "all" const val RESULT = "result/{topicId}/{score}/{total}/{xp}" fun lesson(topicId: String): String fun quiz(topicId: String): String + fun review(topicId: String = QuizViewModel.REVIEW_ALL): String fun result(topicId: String, score: Int, total: Int, xp: Int): String } @@ -314,10 +354,10 @@ hero can share the bounds key `"topic_card_$topicId"`. | Screen | Signature | Notes | |---|---|---| -| `HomeScreen` | `(viewModel: HomeViewModel, onTopicClick: (String) -> Unit, onToggleBlueprint: () -> Unit = {}, blueprintMode: Boolean = false)` | Collapsible categories (expanded set survives rotation), search via `Topic.matchesSearch(query)`: title, subtitle, section headings, formulas | +| `HomeScreen` | `(viewModel: HomeViewModel, onTopicClick: (String) -> Unit, onToggleBlueprint: () -> Unit = {}, blueprintMode: Boolean = false, onReview: () -> Unit = {})` | Collapsible categories (expanded set survives rotation), search via `Topic.matchesSearch(query)`: title, subtitle, section headings, formulas; gold "⟲ N" review chip when the queue isn't empty | | `LessonScreen` | `(viewModel: LessonViewModel, onBack: () -> Unit, onStartQuiz: (String) -> Unit)` | Sticky CTA fades in past the hero (or immediately if nothing scrolls); inset above the nav bar | -| `QuizScreen` | `(viewModel: QuizViewModel, onBack: () -> Unit, onQuizComplete: (topicId: String, score: Int, total: Int, xpEarned: Int) -> Unit)` | Confirms before leaving once an answer exists (close button and system back) | -| `ResultScreen` | `(topicId: String, score: Int, totalQuestions: Int, xpEarned: Int, onRetry: (String) -> Unit, onHome: () -> Unit, onNextLesson: ((String) -> Unit)? = null)` | Shows the XP passed in, never recomputes it; "Up next" only when passed | +| `QuizScreen` | `(viewModel: QuizViewModel, onBack: () -> Unit, onQuizComplete: (topicId: String, score: Int, total: Int, xpEarned: Int) -> Unit)` | Confirms before leaving once an answer exists (close button and system back); in review mode: "Review ·" title, no leave dialog | +| `ResultScreen` | `(topicId: String, score: Int, totalQuestions: Int, xpEarned: Int, onRetry: (String) -> Unit, onHome: () -> Unit, onReviewMistakes: ((String) -> Unit)? = null, onNextLesson: ((String) -> Unit)? = null)` | Shows the XP passed in, never recomputes it; "Up next" only when passed; "Review N mistakes" when any were missed | --- @@ -335,7 +375,7 @@ hero can share the bounds key `"topic_card_$topicId"`. | `NumericInputQuestion` | `(questionText, unit, questionNumber, isSubmitted, onAnswer: (Double?) -> Unit, modifier)`. Parses with `parseNumericAnswer`, which accepts `,` as the decimal separator | | `QuestionCard` | `(questionNumber, questionText, modifier)` | | `AnswerFeedback` | `(isCorrect, explanation, onNext, modifier)` | -| `QuestNotFound` | `(onBack: () -> Unit)` | +| `QuestNotFound` | `(onBack: () -> Unit, title = "Quest not found", message = …)`. Also used for "Nothing to review" | | `XpProgressBar`, `QuizScoreDisplay`, `FormulaDisplay` | `(currentXp, label = "Total XP", modifier)`, `(score, total, modifier)`, `(formula, modifier)`. Legacy, not used by current screens (`FormulaDisplay` just wraps `FormulaTile`) | Modifiers (`ModifierExtensions.kt`): `Modifier.goldBrackets()` (public) and diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 6e8cead..0594b14 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -89,8 +89,10 @@ adb install -r staging-signed.apk # must succeed as an update, not require an ``` ### Google Play -Play App Signing uses its own app-signing key; upload with the release key as the upload key. The -rotation lineage is for sideloaded APKs (GitHub releases). +Build the upload bundle with `scripts/build-play-bundle.sh` (signed with the release key as upload +key). The full Play setup — app signing choice, listing, form answers, closed testing — is in +[`play-store/README.md`](../play-store/README.md). The rotation lineage only applies to sideloaded +APKs (GitHub releases). ## Play Store Submission @@ -240,7 +242,7 @@ FirebaseAnalytics.getInstance(context).logEvent("app_opened", null) ## Supported Devices - **Min SDK:** 26 (Android 8.0) -- **Target SDK:** 35 (Android 15) +- **Target SDK:** 36 (Android 16) — Google Play's minimum for new apps and updates since 2026-08-31 - **Recommended:** Android 10+ (SDK 29+) ### Device Testing diff --git a/docs/TESTING.md b/docs/TESTING.md index 8d2514e..54c47c6 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -97,7 +97,9 @@ class ProgressDaoInstrumentedTest { ./gradlew test # Unit tests only ./gradlew connectedAndroidTest # Instrumented tests (requires device/emulator; uninstalls the app afterwards — use an emulator, not a phone with real progress) ./gradlew test connectedAndroidTest # Both -# CI runs connectedDebugAndroidTest on an API 34 emulator (build.yml, `instrumented` job) +# CI runs connectedDebugAndroidTest on an API 34 emulator (build.yml, `instrumented` job), +# excluding the store-screenshot generator (package ...screenshots, run by screenshots.yml). +# MigrationTest validates every Room schema step against app/schemas/ — add a case per new version. ``` ### Specific Tests diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 99fd525..e750e79 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -75,7 +75,7 @@ emulator -list-avds # Create new emulator # Android Studio → AVD Manager → Create Virtual Device # Or command line: -sdkmanager "system-images;android-35;google_apis;x86_64" +sdkmanager "system-images;android-36;google_apis;x86_64" ``` #### "Test fails with timeout" @@ -220,7 +220,7 @@ EXPLAIN QUERY PLAN SELECT * FROM Progress WHERE topicId = ?; sdkmanager --list # Create fresh AVD -avdmanager create avd -n test_emulator -k "system-images;android-35;google_apis;x86_64" +avdmanager create avd -n test_emulator -k "system-images;android-36;google_apis;x86_64" # Start with verbose logging emulator -avd test_emulator -verbose diff --git a/docs/documentation.html b/docs/documentation.html index 3f1ad52..cfc2bb5 100644 --- a/docs/documentation.html +++ b/docs/documentation.html @@ -197,6 +197,7 @@ homeHomeScreen — category list + search lesson/{topicId}LessonScreen — scrollable lesson sections quiz/{topicId}QuizScreen — 7-question quiz + review/{topicId}QuizScreen in review mode — missed questions for one topic, or all result/{topicId}/{score}/{total}/{xp}ResultScreen — score + XP actually awarded @@ -237,6 +238,7 @@

Progress system

  • +100 XP bonus — the first time you pass a topic's quiz
  • + Wrong answers are queued for review mode (no XP; a correct answer clears them). A quiz passes at 60%. Only a passing attempt sets TopicProgress.quizCompleted = true, which unlocks the next topic. Unlock order follows the categories on the quest map, top to bottom; each topic's order equals its position in that sequence. Any topic you've already started stays unlocked. The rules live in QuizScoring; the lock logic is in HomeViewModel.

    @@ -246,9 +248,9 @@

    Build Instructions

    Requirements

    • JDK 17 (Adoptium Temurin recommended)
    • -
    • Android SDK — platform 35, build-tools 35.0.0
    • -
    • Gradle Wrapper 8.11.1 (included in repo)
    • -
    • Android Gradle Plugin 8.7.3
    • +
    • Android SDK — platform 36, build-tools 36.0.0
    • +
    • Gradle Wrapper 8.13 (included in repo)
    • +
    • Android Gradle Plugin 8.13.2
    • Kotlin 2.0.21
    diff --git a/docs/privacy.html b/docs/privacy.html index e9a5bdd..9710263 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -67,7 +67,7 @@ Privacy-first

    Privacy Policy

    -

    Last updated: March 2026

    +

    Last updated: October 2026

    🔒
    @@ -91,15 +91,19 @@

    Data we collect

    Data stored locally on your device

    -

    The App stores the following data locally using Android's Room database. This data never leaves your device:

    +

    The App stores the following data locally using Android's Room database. The App itself never sends it anywhere:

    • Topic progress — which lessons and quizzes you have completed
    • Quiz results — your best scores for each topic
    • XP total — your accumulated experience points
    • +
    • Questions to review — which quiz questions you last answered incorrectly, for review mode

    This data is stored solely to provide the core functionality of the App (progress tracking and sequential topic unlocking). You can delete it at any time by clearing the App's data in Android Settings → Apps → CircuitQuEEst → Storage → Clear Data.

    +

    + Device backup: if you have Android's own backup turned on (for example Google One / Android Backup), Android may include this data in your device backup so your progress can be restored on a new phone. That backup is managed by Android and your Google account; the developer has no access to it. You can turn it off in Android Settings → Google → Backup. +

    Internet access

    diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d2d833d..6f8b434 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.7.3" +agp = "8.13.2" kotlin = "2.0.21" coreKtx = "1.15.0" lifecycleRuntimeKtx = "2.8.7" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c8..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/play-store/README.md b/play-store/README.md new file mode 100644 index 0000000..6a9cb6d --- /dev/null +++ b/play-store/README.md @@ -0,0 +1,98 @@ +# Google Play launch kit + +Everything needed to publish CircuitQuEEst on Google Play: listing text, graphics, form answers, and +the console steps. Text fields are within Play's limits (checked by `play-store/check-listing.py`). + +## Assets in this folder + +| File | Play Console field | Spec | +|---|---|---| +| `graphics/icon-512.png` | App icon | 512×512 PNG | +| `graphics/feature-graphic-1024x500.png` | Feature graphic | 1024×500, no alpha | +| `screenshots/*.png` | Phone screenshots | 1080×1794 (≤ 2:1), 24-bit PNG; 6 shots (2–8 allowed) | +| `listing/*.txt` | Store listing text | see below | +| Privacy policy URL | App content → Privacy policy | https://highviewone.github.io/CircuitQueest/privacy.html | + +Build the upload bundle with `scripts/build-play-bundle.sh` → `CircuitQueest-vX.Y.aab`. + +**Refreshing screenshots:** the *Store Screenshots* workflow runs on PRs that change UI code, the +screenshot test, or this folder. Download its `store-screenshots` artifact +(`gh run download -n store-screenshots -D play-store/screenshots`), then convert to 24-bit +PNG, since Android saves them with an alpha channel and Play rejects alpha: +`python3 -c "from PIL import Image;import glob;[Image.open(f).convert('RGB').save(f) for f in glob.glob('play-store/screenshots/*.png')]"` + +--- + +## Step by step + +### 1. Developer account (you) +1. Go to https://play.google.com/console/signup, choose **Personal** (just you; no D-U-N-S number needed). +2. Pay the one-time **$25** fee, verify your identity (government ID) and phone number. Google can take + a few days to verify. +3. Personal accounts must also verify they have a real Android device — the Play Console mobile app + walks you through it on your phone. + +### 2. Create the app +**Play Console → Create app** +- App name: `CircuitQuEEst: Electronics` (`listing/title.txt`) +- Default language: English (United States) +- App or game: **App** · Free or paid: **Free** +- Accept the declarations. + +### 3. App signing — choose "Use your own key" *(important, one-time)* +When you first upload a bundle (step 6), Play asks how to sign the app it delivers: +- **Recommended: "Use existing app signing key from Java keystore"**, uploading + `~/.config/circuitqueest/release.jks`. Then Play's downloads and the GitHub APKs are signed with + the same key, so players can move between them without uninstalling (on Android 13+). + Play shows a `pepk` command to run; send me its exact text and I'll run it with the right paths. +- Alternative: let Google generate the key. Simpler, but a GitHub install and a Play install can + never update each other — switching source means uninstalling and losing progress. + +The bundle itself is signed with `release.jks` as the **upload key** either way. + +### 4. Store listing +**Grow → Store presence → Main store listing** +- Short description: `listing/short-description.txt` +- Full description: `listing/full-description.txt` +- App icon, feature graphic, phone screenshots: `graphics/`, `screenshots/` +- Category: **Education** · Tags: Education, Science +- Contact email: an address you're happy to show publicly · Website: + https://highviewone.github.io/CircuitQueest/ + +### 5. App content (Policy → App content) — answers +| Section | Answer | +|---|---| +| Privacy policy | https://highviewone.github.io/CircuitQueest/privacy.html | +| Ads | **No**, the app does not contain ads | +| App access | **All functionality is available without special access** (no login) | +| Content rating | Questionnaire category **Reference, News, or Educational**. Answer **No** to violence, sexuality, language, controlled substances, gambling, user interaction/communication, sharing location, digital purchases. Expected result: Everyone / PEGI 3 | +| Target audience | **13–15, 16–17, 18+**. Leave the under-13 boxes unticked: selecting them enrols the app in the Families programme, which adds requirements this app doesn't need | +| Data safety | **Does your app collect or share any of the required user data types? → No.** (The app has no internet permission; progress is stored only on-device. Android system backup is handled by Google, not the app, and isn't "collection" for this form.) Encryption in transit: not applicable. Deletion: users clear app data in Android Settings | +| Government app | No | +| Financial features | None | +| Health | None | +| News app | No | +| Advertising ID | **No** — the app doesn't use it (no ads/analytics SDKs; target API 36 declares no AD_ID permission) | + +### 6. Closed test (required before production for new personal accounts) +Google requires **at least 12 testers opted in continuously for 14 days** before you can apply for +production. Testers who leave early don't count, so invite a few extra (15–20). +1. **Test and release → Testing → Closed testing → Create track** (or use "Alpha"). +2. **Testers:** create an email list with your testers' Google account emails. +3. **Create release:** upload `CircuitQueest-v2.5.aab`, release notes from `listing/release-notes.txt`, + review and roll out. +4. Copy the **opt-in link** and send it to testers. Each must open it, tap *Become a tester*, then + install from the Play Store link — and stay opted in for 14 days. +5. Ask testers to actually use it: Google's production application asks about their feedback. + +### 7. Apply for production (after 14 days) +**Dashboard → Apply for production**: answer questions about the test (how you recruited testers, +feedback you got, what you changed). Review usually takes a few days. Then create a production +release with the same bundle (or a newer one). + +--- + +## Updating later +1. Bump `versionCode`/`versionName`, build: `scripts/build-play-bundle.sh` (Play) and + `scripts/sign-release.sh` (GitHub APK) — see `docs/DEPLOYMENT.md`. +2. Play Console → the track → Create new release → upload → notes → roll out. diff --git a/play-store/check-listing.py b/play-store/check-listing.py new file mode 100755 index 0000000..4a9f8f5 --- /dev/null +++ b/play-store/check-listing.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +"""Check store listing text against Google Play's length limits.""" +import pathlib, sys +LIMITS = {"title.txt": 30, "short-description.txt": 80, "full-description.txt": 4000, "release-notes.txt": 500} +d = pathlib.Path(__file__).parent / "listing" +ok = True +for name, limit in LIMITS.items(): + n = len((d / name).read_text(encoding="utf-8").strip()) + flag = "OK " if n <= limit else "TOO LONG" + ok &= n <= limit + print(f"{flag} {name}: {n}/{limit}") +sys.exit(0 if ok else 1) diff --git a/play-store/graphics/feature-graphic-1024x500.png b/play-store/graphics/feature-graphic-1024x500.png new file mode 100644 index 0000000..da39201 Binary files /dev/null and b/play-store/graphics/feature-graphic-1024x500.png differ diff --git a/play-store/graphics/icon-512.png b/play-store/graphics/icon-512.png new file mode 100644 index 0000000..72b5949 Binary files /dev/null and b/play-store/graphics/icon-512.png differ diff --git a/play-store/listing/full-description.txt b/play-store/listing/full-description.txt new file mode 100644 index 0000000..cea3827 --- /dev/null +++ b/play-store/listing/full-description.txt @@ -0,0 +1,34 @@ +Learn electrical engineering one quest at a time. + +CircuitQuEEst turns EE fundamentals into a quest map. Read a short lesson, pass its quiz, and unlock the next topic — from Ohm's Law all the way to RF circuits, power electronics, and machine-learning hardware. + +⚡ 42 TOPICS IN 9 CATEGORIES +• Circuit Fundamentals — Ohm's Law, series & parallel, Kirchhoff's laws, capacitors & inductors, AC circuits, Thévenin & Norton +• Semiconductor Devices — diodes, BJTs, MOSFETs, semiconductor physics +• Analog Design — op-amps, filters, analog and audio circuits +• Digital Systems — logic gates, sequential logic, VLSI, ML hardware +• Signals & Control — signals & systems, control systems, DSP +• Power & Energy — transformers, power electronics, power systems, renewables, EVs, batteries +• RF & Communications — electromagnetics, transmission lines, antennas, RF design, fiber optics, radar +• Hardware & Embedded — PCB design, embedded systems, sensors, signal integrity, IoT +• Specialized Applications — electric machines, MEMS, biomedical electronics + +📘 LESSONS THAT RESPECT YOUR TIME +Each lesson is a handful of focused sections with the key formula highlighted and a "key insight" to remember. + +✅ QUIZZES THAT TEACH +Multiple-choice and numeric questions with instant feedback and a worked explanation for every answer. Score 60% to pass and unlock the next quest. + +🔁 REVIEW MODE +Every question you miss goes into your review queue. Replay just those questions until you get them right. + +🏆 XP AND LEVELS +Earn XP for lessons, quiz points, and your first pass on each topic. Retakes only reward beating your best score, so your level reflects what you actually know. + +🔷 BLUEPRINT MODE +Switch from the dark cockpit theme to a PCB-blueprint look with one tap. + +🔒 PRIVATE AND OFFLINE +No account, no ads, no tracking. The app doesn't even request internet access — your progress stays on your device. + +Made for students, hobbyists, makers, and anyone brushing up for exams or interviews. diff --git a/play-store/listing/release-notes.txt b/play-store/listing/release-notes.txt new file mode 100644 index 0000000..4a89237 --- /dev/null +++ b/play-store/listing/release-notes.txt @@ -0,0 +1,3 @@ +New in 2.5: +• Review mode: questions you miss go into a review queue so you can practise them until you get them right. Start from the ⟲ chip on the quest map or "Review mistakes" after a quiz. +• Updated for Android 16. \ No newline at end of file diff --git a/play-store/listing/short-description.txt b/play-store/listing/short-description.txt new file mode 100644 index 0000000..3d53860 --- /dev/null +++ b/play-store/listing/short-description.txt @@ -0,0 +1 @@ +Learn electrical engineering in bite-size quests: 42 topics, quizzes, XP. \ No newline at end of file diff --git a/play-store/listing/title.txt b/play-store/listing/title.txt new file mode 100644 index 0000000..0916b32 --- /dev/null +++ b/play-store/listing/title.txt @@ -0,0 +1 @@ +CircuitQuEEst: Electronics \ No newline at end of file diff --git a/play-store/screenshots/1-quest-map.png b/play-store/screenshots/1-quest-map.png new file mode 100644 index 0000000..5e4d12f Binary files /dev/null and b/play-store/screenshots/1-quest-map.png differ diff --git a/play-store/screenshots/2-lesson.png b/play-store/screenshots/2-lesson.png new file mode 100644 index 0000000..07f7cf8 Binary files /dev/null and b/play-store/screenshots/2-lesson.png differ diff --git a/play-store/screenshots/3-quiz-feedback.png b/play-store/screenshots/3-quiz-feedback.png new file mode 100644 index 0000000..2acd395 Binary files /dev/null and b/play-store/screenshots/3-quiz-feedback.png differ diff --git a/play-store/screenshots/4-result.png b/play-store/screenshots/4-result.png new file mode 100644 index 0000000..35864a9 Binary files /dev/null and b/play-store/screenshots/4-result.png differ diff --git a/play-store/screenshots/5-review.png b/play-store/screenshots/5-review.png new file mode 100644 index 0000000..c888a2d Binary files /dev/null and b/play-store/screenshots/5-review.png differ diff --git a/play-store/screenshots/6-blueprint.png b/play-store/screenshots/6-blueprint.png new file mode 100644 index 0000000..76d867a Binary files /dev/null and b/play-store/screenshots/6-blueprint.png differ diff --git a/scripts/build-play-bundle.sh b/scripts/build-play-bundle.sh new file mode 100755 index 0000000..103bc01 --- /dev/null +++ b/scripts/build-play-bundle.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Build and sign the Android App Bundle (.aab) for Google Play. +# +# Play re-signs the app for delivery (Play App Signing); this signs the bundle with the +# upload key, which is the CircuitQueest release key. See play-store/README.md. +# +# Usage: scripts/build-play-bundle.sh [output.aab] (default: CircuitQueest-.aab) +# Needs ~/.config/circuitqueest/keystore.properties (see scripts/sign-release.sh). +set -euo pipefail +cd "$(dirname "$0")/.." + +props=${CQ_SIGNING_DIR:-$HOME/.config/circuitqueest}/keystore.properties +[[ -f $props ]] || { echo "missing: $props" >&2; exit 1; } +prop() { grep -E "^$1=" "$props" | head -1 | cut -d= -f2-; } + +version=$(grep -oE 'versionName = "[^"]+"' app/build.gradle.kts | cut -d'"' -f2) +out=${1:-CircuitQueest-v$version.aab} + +./gradlew --console=plain -q bundleRelease +src=app/build/outputs/bundle/release/app-release.aab +cp "$src" "$out" + +export CQ_STORE_PW CQ_KEY_PW +CQ_STORE_PW=$(prop storePassword) +CQ_KEY_PW=$(prop keyPassword) +jarsigner -sigalg SHA256withRSA -digestalg SHA-256 \ + -keystore "$(prop storeFile)" -storepass:env CQ_STORE_PW -keypass:env CQ_KEY_PW \ + "$out" "$(prop keyAlias)" >/dev/null + +jarsigner -verify "$out" | grep -m1 "jar verified" +keytool -printcert -jarfile "$out" | grep -m1 "SHA256:" +echo "bundle: $out"