Repository: joaomanaia/newquiz
Branch: main
Commit: c6f3748ce80e
Files: 659
Total size: 2.2 MB
Directory structure:
gitextract_3fcvki05/
├── .github/
│ ├── actions/
│ │ └── get-avd-info/
│ │ └── action.yml
│ └── workflows/
│ ├── android.yml
│ └── android_old.yml
├── .gitignore
├── LICENCE
├── README.md
├── app/
│ ├── .gitignore
│ ├── build.gradle.kts
│ ├── lint-baseline.xml
│ ├── proguard-rules.pro
│ └── src/
│ ├── androidTest/
│ │ └── AndroidManifest.xml
│ └── main/
│ ├── AndroidManifest.xml
│ ├── java/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ ├── NewQuizApp.kt
│ │ ├── core/
│ │ │ ├── navigation/
│ │ │ │ ├── AppNavGraphs.kt
│ │ │ │ ├── CommonNavGraphNavigator.kt
│ │ │ │ └── NavigationItem.kt
│ │ │ └── workers/
│ │ │ └── AppStartLoggingAnalyticsWorker.kt
│ │ ├── initializer/
│ │ │ ├── EnqueueStartWorksInitializer.kt
│ │ │ └── WorkManagerInitializer.kt
│ │ └── ui/
│ │ ├── components/
│ │ │ ├── DataCollectionConsentDialog.kt
│ │ │ └── DiamondsCounter.kt
│ │ ├── main/
│ │ │ ├── MainActivity.kt
│ │ │ ├── MainScreenUiEvent.kt
│ │ │ ├── MainScreenUiState.kt
│ │ │ └── MainViewModel.kt
│ │ └── navigation/
│ │ ├── CompactNavigationContainer.kt
│ │ ├── ExpandedNavigationContainer.kt
│ │ ├── MediumNavigationContainer.kt
│ │ ├── NavDrawerContent.kt
│ │ └── NavigationContainer.kt
│ └── res/
│ ├── drawable/
│ │ ├── round_password_24.xml
│ │ ├── round_play_circle_24.xml
│ │ └── round_quiz_24.xml
│ ├── resources.properties
│ ├── values/
│ │ ├── colors.xml
│ │ ├── leak_canary.xml
│ │ ├── splash_screen.xml
│ │ └── strings.xml
│ ├── values-night/
│ │ └── splash_screen.xml
│ └── xml-v25/
│ └── shortcuts.xml
├── build-logic/
│ ├── .gitignore
│ ├── README.md
│ ├── convention/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── main/
│ │ └── kotlin/
│ │ ├── AndroidApplicationComposeConventionPlugin.kt
│ │ ├── AndroidApplicationConventionPlugin.kt
│ │ ├── AndroidApplicationFirebaseConventionPlugin.kt
│ │ ├── AndroidComposeDestinationsConventionPlugin.kt
│ │ ├── AndroidFeatureConventionPlugin.kt
│ │ ├── AndroidHiltConventionPlugin.kt
│ │ ├── AndroidLibraryComposeConventionPlugin.kt
│ │ ├── AndroidLibraryConventionPlugin.kt
│ │ ├── AndroidRoomConventionPlugin.kt
│ │ ├── DetektConventionPlugin.kt
│ │ ├── JvmLibraryConventionPlugin.kt
│ │ ├── KotlinSerializationConventionPlugin.kt
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ ├── AndroidCompose.kt
│ │ ├── AndroidInstrumentedTests.kt
│ │ ├── Flavors.kt
│ │ ├── KotlinAndroid.kt
│ │ ├── ProjectConfig.kt
│ │ ├── ProjectExtensions.kt
│ │ └── Utils.kt
│ ├── gradle.properties
│ └── settings.gradle.kts
├── build.gradle.kts
├── comparison-quiz/
│ ├── .gitignore
│ ├── README.md
│ ├── build.gradle.kts
│ ├── consumer-rules.pro
│ ├── proguard-rules.pro
│ └── src/
│ ├── androidTest/
│ │ ├── AndroidManifest.xml
│ │ └── java/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── comparison_quiz/
│ │ ├── data/
│ │ │ └── comparison_quiz/
│ │ │ └── FakeComparisonQuizRepositoryImpl.kt
│ │ ├── list/
│ │ │ └── components/
│ │ │ ├── ComparisonModeComponentTest.kt
│ │ │ └── ComparisonModeComponentsTest.kt
│ │ └── ui/
│ │ ├── ComparisonQuizScreenTest.kt
│ │ └── components/
│ │ └── ComparisonItemTest.kt
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ └── java/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── comparison_quiz/
│ │ ├── core/
│ │ │ ├── ComparisonQuizCoreImpl.kt
│ │ │ └── workers/
│ │ │ └── ComparisonQuizEndGameWorker.kt
│ │ ├── di/
│ │ │ └── ComparisonQuizModule.kt
│ │ ├── list/
│ │ │ ├── ComparisonQuizListScreen.kt
│ │ │ ├── ComparisonQuizListScreenUiEvent.kt
│ │ │ ├── ComparisonQuizListScreenUiState.kt
│ │ │ ├── ComparisonQuizListScreenViewModel.kt
│ │ │ └── components/
│ │ │ ├── ComparisonModeComponent.kt
│ │ │ └── ComparisonModeComponents.kt
│ │ └── ui/
│ │ ├── AnimationState.kt
│ │ ├── ComparisonQuizScreen.kt
│ │ ├── ComparisonQuizUiEvent.kt
│ │ ├── ComparisonQuizUiState.kt
│ │ ├── ComparisonQuizViewModel.kt
│ │ └── components/
│ │ ├── ComparisonItem.kt
│ │ ├── ComparisonMidContent.kt
│ │ ├── GameOverContent.kt
│ │ └── MiddleCircle.kt
│ └── test/
│ └── java/
│ └── com/
│ └── infinitepower/
│ └── newquiz/
│ └── comparison_quiz/
│ └── core/
│ └── ComparisonQuizCoreImplTest.kt
├── compose_compiler_config.conf
├── core/
│ ├── .gitignore
│ ├── analytics/
│ │ ├── .gitignore
│ │ ├── LOGGING_ANALYTICS.md
│ │ ├── README.md
│ │ ├── build.gradle.kts
│ │ ├── consumer-rules.pro
│ │ ├── proguard-rules.pro
│ │ └── src/
│ │ ├── foss/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── analytics/
│ │ │ └── FossAnalyticsModule.kt
│ │ ├── main/
│ │ │ ├── AndroidManifest.xml
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── analytics/
│ │ │ ├── AnalyticsEvent.kt
│ │ │ ├── AnalyticsHelper.kt
│ │ │ ├── LocalDebugAnalyticsHelper.kt
│ │ │ ├── NoOpAnalyticsHelper.kt
│ │ │ ├── UiHelpers.kt
│ │ │ └── UserProperty.kt
│ │ └── normal/
│ │ └── kotlin/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── core/
│ │ └── analytics/
│ │ ├── FirebaseAnalyticsHelper.kt
│ │ └── NormalAnalyticsModule.kt
│ ├── build.gradle.kts
│ ├── consumer-rules.pro
│ ├── database/
│ │ ├── .gitignore
│ │ ├── build.gradle.kts
│ │ ├── consumer-rules.pro
│ │ ├── proguard-rules.pro
│ │ ├── schemas/
│ │ │ └── com.infinitepower.newquiz.core.database.AppDatabase/
│ │ │ ├── 1.json
│ │ │ ├── 2.json
│ │ │ ├── 3.json
│ │ │ ├── 4.json
│ │ │ ├── 5.json
│ │ │ ├── 6.json
│ │ │ └── 7.json
│ │ └── src/
│ │ ├── androidTest/
│ │ │ ├── AndroidManifest.xml
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── database/
│ │ │ └── dao/
│ │ │ └── DailyChallengeDaoTest.kt
│ │ └── main/
│ │ ├── AndroidManifest.xml
│ │ └── kotlin/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── core/
│ │ └── database/
│ │ ├── AppDatabase.kt
│ │ ├── dao/
│ │ │ ├── DailyChallengeDao.kt
│ │ │ ├── GameResultDao.kt
│ │ │ ├── MazeQuizDao.kt
│ │ │ └── SavedMultiChoiceQuestionsDao.kt
│ │ ├── di/
│ │ │ ├── DaoModule.kt
│ │ │ └── DatabaseModule.kt
│ │ ├── model/
│ │ │ ├── DailyChallengeTaskEntity.kt
│ │ │ ├── MazeQuizItemEntity.kt
│ │ │ ├── MultiChoiceQuestionEntity.kt
│ │ │ └── user/
│ │ │ ├── BaseGameResultEntity.kt
│ │ │ ├── ComparisonQuizGameResultEntity.kt
│ │ │ ├── MultiChoiceGameResultEntity.kt
│ │ │ └── WordleGameResultEntity.kt
│ │ └── util/
│ │ ├── converters/
│ │ │ ├── ListConverter.kt
│ │ │ ├── LocalDateConverter.kt
│ │ │ ├── MathFormulaConverter.kt
│ │ │ └── QuestionDifficultyConverter.kt
│ │ └── mappers/
│ │ └── MultiChoiceQuestionMapper.kt
│ ├── datastore/
│ │ ├── .gitignore
│ │ ├── build.gradle.kts
│ │ ├── consumer-rules.pro
│ │ ├── proguard-rules.pro
│ │ └── src/
│ │ ├── main/
│ │ │ ├── AndroidManifest.xml
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── datastore/
│ │ │ ├── PreferenceRequest.kt
│ │ │ ├── common/
│ │ │ │ ├── LocalUserCommon.kt
│ │ │ │ ├── RecentCategoryDataStoreCommon.kt
│ │ │ │ └── SettingsCommon.kt
│ │ │ ├── di/
│ │ │ │ ├── LocalUserDatastoreModule.kt
│ │ │ │ ├── RecentCategoriesDatastoreModule.kt
│ │ │ │ └── SettingsDataStoreModule.kt
│ │ │ └── manager/
│ │ │ ├── DataStoreManager.kt
│ │ │ └── PreferencesDatastoreManager.kt
│ │ ├── normal/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── datastore/
│ │ │ ├── common/
│ │ │ │ ├── DataAnalyticsCommon.kt
│ │ │ │ └── TranslationCommon.kt
│ │ │ └── di/
│ │ │ └── DataAnalyticsDatastoreModule.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── core/
│ │ └── datastore/
│ │ └── manager/
│ │ └── PreferencesDatastoreManagerTest.kt
│ ├── proguard-rules.pro
│ ├── remote-config/
│ │ ├── .gitignore
│ │ ├── README.md
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── foss/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── remote_config/
│ │ │ └── initializer/
│ │ │ └── RemoteConfigInitializer.kt
│ │ ├── main/
│ │ │ ├── AndroidManifest.xml
│ │ │ ├── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── infinitepower/
│ │ │ │ └── newquiz/
│ │ │ │ └── core/
│ │ │ │ └── remote_config/
│ │ │ │ ├── LocalDefaultsRemoteConfig.kt
│ │ │ │ ├── RemoteConfig.kt
│ │ │ │ ├── RemoteConfigValue.kt
│ │ │ │ └── RemoteConfigXmlParser.kt
│ │ │ └── res/
│ │ │ └── xml/
│ │ │ └── remote_config_defaults.xml
│ │ ├── normal/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── remote_config/
│ │ │ ├── FirebaseRemoteConfigImpl.kt
│ │ │ └── initializer/
│ │ │ └── RemoteConfigInitializer.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── core/
│ │ └── remote_config/
│ │ ├── LocalRemoteConfig.kt
│ │ └── RemoteConfigTest.kt
│ ├── src/
│ │ ├── androidTest/
│ │ │ ├── AndroidManifest.xml
│ │ │ └── java/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── ui/
│ │ │ └── components/
│ │ │ ├── RemainingTimeComponentTest.kt
│ │ │ ├── category/
│ │ │ │ ├── CategoryComponentTest.kt
│ │ │ │ └── CategoryConnectionInfoBadgeTest.kt
│ │ │ ├── icon/
│ │ │ │ └── button/
│ │ │ │ └── BackIconButtonTest.kt
│ │ │ └── skip_question/
│ │ │ └── SkipQuestionDialogTest.kt
│ │ ├── main/
│ │ │ ├── AndroidManifest.xml
│ │ │ ├── java/
│ │ │ │ └── com/
│ │ │ │ └── infinitepower/
│ │ │ │ └── newquiz/
│ │ │ │ └── core/
│ │ │ │ ├── NumberFormatter.kt
│ │ │ │ ├── common/
│ │ │ │ │ ├── BaseApiUrls.kt
│ │ │ │ │ ├── Common.kt
│ │ │ │ │ ├── compose/
│ │ │ │ │ │ └── preview/
│ │ │ │ │ │ └── BooleanPreviewParameterProvider.kt
│ │ │ │ │ └── database/
│ │ │ │ │ └── DatabaseCommon.kt
│ │ │ │ ├── compose/
│ │ │ │ │ └── preferences/
│ │ │ │ │ └── LocalPreferenceEnabledStatus.kt
│ │ │ │ ├── di/
│ │ │ │ │ ├── KtorModule.kt
│ │ │ │ │ └── NetworkStatusModule.kt
│ │ │ │ ├── game/
│ │ │ │ │ ├── ComparisonQuizCore.kt
│ │ │ │ │ ├── GameCore.kt
│ │ │ │ │ └── SkipGame.kt
│ │ │ │ ├── math/
│ │ │ │ │ └── evaluator/
│ │ │ │ │ ├── Expressions.kt
│ │ │ │ │ └── internal/
│ │ │ │ │ ├── Evaluator.kt
│ │ │ │ │ ├── Expr.kt
│ │ │ │ │ ├── Function.kt
│ │ │ │ │ ├── Parser.kt
│ │ │ │ │ ├── Scanner.kt
│ │ │ │ │ ├── Token.kt
│ │ │ │ │ └── TokenType.kt
│ │ │ │ ├── navigation/
│ │ │ │ │ └── MazeNavigator.kt
│ │ │ │ ├── network/
│ │ │ │ │ ├── NetworkStatusTracker.kt
│ │ │ │ │ └── NetworkStatusTrackerImpl.kt
│ │ │ │ ├── theme/
│ │ │ │ │ ├── ExtendedColor.kt
│ │ │ │ │ ├── LocalAnimationsEnabled.kt
│ │ │ │ │ ├── Spacing.kt
│ │ │ │ │ ├── Theme.kt
│ │ │ │ │ └── Type.kt
│ │ │ │ ├── ui/
│ │ │ │ │ ├── DisabledEmphasisWrappers.kt
│ │ │ │ │ ├── ObserveAsEvents.kt
│ │ │ │ │ ├── SnackbarController.kt
│ │ │ │ │ ├── components/
│ │ │ │ │ │ ├── AppNameWithLogo.kt
│ │ │ │ │ │ ├── RemainingTimeComponent.kt
│ │ │ │ │ │ ├── RoundedPolygonShape.kt
│ │ │ │ │ │ ├── category/
│ │ │ │ │ │ │ ├── CategoryBadge.kt
│ │ │ │ │ │ │ └── CategoryComponent.kt
│ │ │ │ │ │ ├── icon/
│ │ │ │ │ │ │ └── button/
│ │ │ │ │ │ │ └── BackIconButton.kt
│ │ │ │ │ │ └── skip_question/
│ │ │ │ │ │ ├── SkipButton.kt
│ │ │ │ │ │ └── SkipQuestionDialog.kt
│ │ │ │ │ ├── home/
│ │ │ │ │ │ ├── ExpandCategoriesButton.kt
│ │ │ │ │ │ ├── HomeCategoriesItems.kt
│ │ │ │ │ │ └── HomeLazyColumn.kt
│ │ │ │ │ ├── home_card/
│ │ │ │ │ │ ├── HomeListContent.kt
│ │ │ │ │ │ ├── components/
│ │ │ │ │ │ │ ├── HomeCardIcon.kt
│ │ │ │ │ │ │ ├── HomeCardItemContent.kt
│ │ │ │ │ │ │ ├── HomeGroupTitle.kt
│ │ │ │ │ │ │ ├── HomeHorizontalItems.kt
│ │ │ │ │ │ │ ├── HomeLargeCard.kt
│ │ │ │ │ │ │ ├── HomeMediumCard.kt
│ │ │ │ │ │ │ └── PlayRandomQuizCard.kt
│ │ │ │ │ │ └── model/
│ │ │ │ │ │ └── HomeCardItem.kt
│ │ │ │ │ ├── icons/
│ │ │ │ │ │ └── TrophyIcon.kt
│ │ │ │ │ └── text/
│ │ │ │ │ └── CompactDecimalText.kt
│ │ │ │ └── util/
│ │ │ │ ├── ComposeUtils.kt
│ │ │ │ ├── PackageUtils.kt
│ │ │ │ ├── UiTextUtils.kt
│ │ │ │ ├── UriUtils.kt
│ │ │ │ ├── android/
│ │ │ │ │ ├── DrawableUtils.kt
│ │ │ │ │ └── resources/
│ │ │ │ │ └── ResourcesUtil.kt
│ │ │ │ ├── collections/
│ │ │ │ │ └── Collections.kt
│ │ │ │ ├── kotlin/
│ │ │ │ │ ├── BooleanUtils.kt
│ │ │ │ │ ├── CollectionsUtils.kt
│ │ │ │ │ ├── Math.kt
│ │ │ │ │ ├── NumberUtils.kt
│ │ │ │ │ └── SetUtils.kt
│ │ │ │ └── model/
│ │ │ │ └── QuestionDifficultyUtil.kt
│ │ │ └── res/
│ │ │ ├── drawable/
│ │ │ │ ├── github_logo.xml
│ │ │ │ ├── logo_monochromatic.xml
│ │ │ │ ├── round_android_24.xml
│ │ │ │ └── round_flag_circle_24.xml
│ │ │ ├── mipmap-anydpi-v26/
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── raw/
│ │ │ │ ├── trophy2.json
│ │ │ │ ├── trophy_winner.json
│ │ │ │ ├── wordle_list.txt
│ │ │ │ ├── wordle_list_es.txt
│ │ │ │ ├── wordle_list_fr.txt
│ │ │ │ └── wordle_list_pt.txt
│ │ │ ├── values/
│ │ │ │ ├── ic_launcher_background.xml
│ │ │ │ ├── strings.xml
│ │ │ │ └── themes.xml
│ │ │ ├── values-de/
│ │ │ │ └── strings.xml
│ │ │ ├── values-es/
│ │ │ │ └── strings.xml
│ │ │ ├── values-et/
│ │ │ │ └── strings.xml
│ │ │ ├── values-fr/
│ │ │ │ └── strings.xml
│ │ │ ├── values-nb-rNO/
│ │ │ │ └── strings.xml
│ │ │ ├── values-night/
│ │ │ │ └── themes.xml
│ │ │ ├── values-pt/
│ │ │ │ └── strings.xml
│ │ │ ├── values-ta/
│ │ │ │ └── strings.xml
│ │ │ └── values-v29/
│ │ │ └── themes.xml
│ │ ├── normal/
│ │ │ ├── AndroidManifest.xml
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── initializer/
│ │ │ └── CoreFirebaseInitializer.kt
│ │ └── test/
│ │ └── java/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── core/
│ │ ├── NumberFormatterTest.kt
│ │ ├── game/
│ │ │ └── ComparisonQuizDataTest.kt
│ │ └── util/
│ │ ├── UiTextTests.kt
│ │ ├── collections/
│ │ │ └── CollectionsTest.kt
│ │ └── kotlin/
│ │ ├── BooleanUtilsTest.kt
│ │ ├── CollectionsUtilsTest.kt
│ │ ├── MathTest.kt
│ │ ├── NumberUtilsTest.kt
│ │ └── SetUtilsTest.kt
│ ├── testing/
│ │ ├── .gitignore
│ │ ├── README.md
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── foss/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── testing/
│ │ │ └── di/
│ │ │ └── TestRemoteConfigModule.kt
│ │ ├── main/
│ │ │ ├── AndroidManifest.xml
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── testing/
│ │ │ ├── NewQuizTestRunner.kt
│ │ │ ├── ScreenshotComparator.kt
│ │ │ ├── data/
│ │ │ │ ├── fake/
│ │ │ │ │ ├── FakeComparisonQuizData.kt
│ │ │ │ │ └── FakeData.kt
│ │ │ │ └── repository/
│ │ │ │ ├── comparison_quiz/
│ │ │ │ │ └── FakeComparisonQuizRepositoryImpl.kt
│ │ │ │ ├── multi_choice_quiz/
│ │ │ │ │ └── TestMultiChoiceQuestionRepositoryImpl.kt
│ │ │ │ └── numbers/
│ │ │ │ └── FakeNumberTriviaQuestionApiImpl.kt
│ │ │ ├── di/
│ │ │ │ ├── TestDatabaseModule.kt
│ │ │ │ ├── TestKtorModule.kt
│ │ │ │ ├── TestRepositoryModule.kt
│ │ │ │ └── WorkManagerModule.kt
│ │ │ ├── domain/
│ │ │ │ ├── FakeDailyChallengeDao.kt
│ │ │ │ └── FakeGameResultDao.kt
│ │ │ ├── ui/
│ │ │ │ └── theme/
│ │ │ │ └── TestTheme.kt
│ │ │ └── utils/
│ │ │ ├── ComposeRule.kt
│ │ │ ├── LocaleUtils.kt
│ │ │ └── LogUtils.kt
│ │ └── normal/
│ │ └── kotlin/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── core/
│ │ └── testing/
│ │ └── di/
│ │ ├── RemoteConfigModule.kt
│ │ └── TestAnalyticsModule.kt
│ ├── translation/
│ │ ├── .gitignore
│ │ ├── README.md
│ │ ├── build.gradle.kts
│ │ ├── consumer-rules.pro
│ │ ├── proguard-rules.pro
│ │ └── src/
│ │ ├── androidTestNormal/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── translation/
│ │ │ └── GoogleTranslatorUtilTest.kt
│ │ ├── foss/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── translation/
│ │ │ ├── NoTranslatorUtil.kt
│ │ │ └── di/
│ │ │ └── TranslatorModule.kt
│ │ ├── main/
│ │ │ ├── AndroidManifest.xml
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── translation/
│ │ │ ├── TranslatorLanguageSettings.kt
│ │ │ ├── TranslatorModelState.kt
│ │ │ └── TranslatorUtil.kt
│ │ ├── normal/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── translation/
│ │ │ ├── GoogleTranslatorUtil.kt
│ │ │ └── di/
│ │ │ └── GoogleTranslatorModule.kt
│ │ ├── testFoss/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── core/
│ │ │ └── translation/
│ │ │ └── NoTranslatorUtilTest.kt
│ │ └── testNormal/
│ │ └── kotlin/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── core/
│ │ └── translation/
│ │ └── GoogleTranslatorUtilTest.kt
│ └── user-services/
│ ├── .gitignore
│ ├── build.gradle.kts
│ └── src/
│ ├── androidTest/
│ │ ├── AndroidManifest.xml
│ │ └── kotlin/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── core/
│ │ └── user_services/
│ │ └── LocalUserServiceImplTest.kt
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ └── kotlin/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── core/
│ │ └── user_services/
│ │ ├── DateTimeRangeFormatter.kt
│ │ ├── GameResultTracker.kt
│ │ ├── InsufficientDiamondsException.kt
│ │ ├── LocalUserService.kt
│ │ ├── LocalUserServiceImpl.kt
│ │ ├── UserService.kt
│ │ ├── XpManager.kt
│ │ ├── data/
│ │ │ └── xp/
│ │ │ ├── ComparisonQuizXpGeneratorImpl.kt
│ │ │ ├── MultiChoiceQuizXpGeneratorImpl.kt
│ │ │ └── WordleXpGeneratorImpl.kt
│ │ ├── di/
│ │ │ ├── UserModule.kt
│ │ │ └── XpGeneratorsModule.kt
│ │ ├── domain/
│ │ │ └── xp/
│ │ │ ├── ComparisonQuizXpGenerator.kt
│ │ │ ├── MultiChoiceQuizXpGenerator.kt
│ │ │ ├── WordleXpGenerator.kt
│ │ │ └── XpGenerator.kt
│ │ ├── model/
│ │ │ └── User.kt
│ │ └── workers/
│ │ └── MultiChoiceQuizEndGameWorker.kt
│ └── test/
│ └── kotlin/
│ └── com/
│ └── infinitepower/
│ └── newquiz/
│ └── core/
│ └── user_services/
│ ├── DateTimeRangeFormatterTest.kt
│ ├── LocalUserServiceImplUnitTest.kt
│ ├── data/
│ │ └── xp/
│ │ ├── ComparisonQuizXpGeneratorImplTest.kt
│ │ ├── MultiChoiceQuizXpGeneratorImplTest.kt
│ │ └── WordleXpGeneratorImplTest.kt
│ └── model/
│ └── UserTest.kt
├── data/
│ ├── .gitignore
│ ├── build.gradle.kts
│ ├── consumer-rules.pro
│ ├── proguard-rules.pro
│ └── src/
│ ├── androidTest/
│ │ ├── AndroidManifest.xml
│ │ └── java/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── data/
│ │ ├── daily_challenge/
│ │ │ └── DailyChallengeRepositoryImplTest.kt
│ │ ├── repository/
│ │ │ ├── comparison_quiz/
│ │ │ │ └── ComparisonQuizRepositoryImplTest.kt
│ │ │ └── country/
│ │ │ └── CountryRepositoryImplTest.kt
│ │ └── worker/
│ │ ├── daily_challenge/
│ │ │ └── VerifyDailyChallengeWorkerTest.kt
│ │ └── maze/
│ │ ├── CleanMazeQuizWorkerTest.kt
│ │ └── GenerateMazeQuizWorkerTest.kt
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── data/
│ │ │ ├── di/
│ │ │ │ ├── MathModule.kt
│ │ │ │ └── RepositoryModule.kt
│ │ │ ├── local/
│ │ │ │ ├── multi_choice_quiz/
│ │ │ │ │ └── category/
│ │ │ │ │ └── MultiChoiceQuestionCategories.kt
│ │ │ │ └── wordle/
│ │ │ │ └── WordleCategories.kt
│ │ │ ├── repository/
│ │ │ │ ├── UserConfigRepositoryImpl.kt
│ │ │ │ ├── comparison_quiz/
│ │ │ │ │ ├── ComparisonQuizApi.kt
│ │ │ │ │ ├── ComparisonQuizApiImpl.kt
│ │ │ │ │ └── ComparisonQuizRepositoryImpl.kt
│ │ │ │ ├── country/
│ │ │ │ │ ├── CountryEntity.kt
│ │ │ │ │ └── CountryRepositoryImpl.kt
│ │ │ │ ├── daily_challenge/
│ │ │ │ │ ├── DailyChallengeRepositoryImpl.kt
│ │ │ │ │ └── util/
│ │ │ │ │ └── DailyChallengeTypeTitleUtil.kt
│ │ │ │ ├── home/
│ │ │ │ │ └── RecentCategoriesRepositoryImpl.kt
│ │ │ │ ├── math_quiz/
│ │ │ │ │ └── MathQuizCoreRepositoryImpl.kt
│ │ │ │ ├── maze_quiz/
│ │ │ │ │ └── MazeQuizRepositoryImpl.kt
│ │ │ │ ├── multi_choice_quiz/
│ │ │ │ │ ├── CountryCapitalFlagsQuizRepositoryImpl.kt
│ │ │ │ │ ├── FlagQuizRepositoryImpl.kt
│ │ │ │ │ ├── GuessMathSolutionRepositoryImpl.kt
│ │ │ │ │ ├── LogoQuizRepositoryImpl.kt
│ │ │ │ │ ├── MultiChoiceQuestionRepositoryImpl.kt
│ │ │ │ │ ├── dto/
│ │ │ │ │ │ └── OpenTDBQuestionResponse.kt
│ │ │ │ │ └── saved_questions/
│ │ │ │ │ └── SavedMultiChoiceQuestionsRepositoryImpl.kt
│ │ │ │ ├── numbers/
│ │ │ │ │ ├── NumberTriviaQuestionApiImpl.kt
│ │ │ │ │ └── NumberTriviaQuestionRepositoryImpl.kt
│ │ │ │ └── wordle/
│ │ │ │ ├── InvalidWordError.kt
│ │ │ │ └── WordleRepositoryImpl.kt
│ │ │ ├── util/
│ │ │ │ ├── mappers/
│ │ │ │ │ ├── comparisonquiz/
│ │ │ │ │ │ └── ComparisonQuizMapper.kt
│ │ │ │ │ ├── daily_challenge/
│ │ │ │ │ │ └── DailyChallengeTaskMapper.kt
│ │ │ │ │ └── maze/
│ │ │ │ │ └── MazeQuizMappers.kt
│ │ │ │ └── translation/
│ │ │ │ └── WordleTitleUtil.kt
│ │ │ └── worker/
│ │ │ ├── UpdateGlobalEventDataWorker.kt
│ │ │ ├── daily_challenge/
│ │ │ │ └── VerifyDailyChallengeWorker.kt
│ │ │ ├── maze/
│ │ │ │ ├── CleanMazeQuizWorker.kt
│ │ │ │ └── GenerateMazeQuizWorker.kt
│ │ │ └── multichoicequiz/
│ │ │ └── DownloadMultiChoiceQuestionsWorker.kt
│ │ └── res/
│ │ └── raw/
│ │ └── all_countries.json
│ └── test/
│ └── java/
│ └── com/
│ └── infinitepower/
│ └── newquiz/
│ └── data/
│ ├── local/
│ │ ├── math_quiz/
│ │ │ └── MathQuizCoreRepositoryImplTest.kt
│ │ └── wordle/
│ │ └── WordleCategoriesTest.kt
│ └── repository/
│ ├── comparison_quiz/
│ │ ├── ComparisonQuizApiImplTest.kt
│ │ └── ComparisonQuizRepositoryImplTest.kt
│ ├── country/
│ │ ├── CountryRepositoryImplTest.kt
│ │ └── TestCountryRepositoryImpl.kt
│ ├── daily_challenge/
│ │ └── DailyChallengeRepositoryImplTest.kt
│ ├── home/
│ │ └── RecentCategoriesRepositoryImplTest.kt
│ ├── maze_quiz/
│ │ └── MazeQuizRepositoryImplTest.kt
│ ├── multi_choice_quiz/
│ │ ├── CountryCapitalFlagsQuizRepositoryImplTest.kt
│ │ └── FlagQuizRepositoryImplTest.kt
│ └── wordle/
│ └── WordleRepositoryImplTest.kt
├── detekt-compose.yml
├── detekt.yml
├── domain/
│ ├── .gitignore
│ ├── build.gradle.kts
│ └── src/
│ ├── main/
│ │ └── java/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── domain/
│ │ ├── repository/
│ │ │ ├── CountryRepository.kt
│ │ │ ├── UserConfigRepository.kt
│ │ │ ├── comparison_quiz/
│ │ │ │ └── ComparisonQuizRepository.kt
│ │ │ ├── daily_challenge/
│ │ │ │ └── DailyChallengeRepository.kt
│ │ │ ├── home/
│ │ │ │ └── RecentCategoriesRepository.kt
│ │ │ ├── math_quiz/
│ │ │ │ └── MathQuizCoreRepository.kt
│ │ │ ├── maze/
│ │ │ │ └── MazeQuizRepository.kt
│ │ │ ├── multi_choice_quiz/
│ │ │ │ ├── CountryCapitalFlagsQuizRepository.kt
│ │ │ │ ├── FlagQuizRepository.kt
│ │ │ │ ├── GuessMathSolutionRepository.kt
│ │ │ │ ├── LogoQuizRepository.kt
│ │ │ │ ├── MultiChoiceQuestionBaseRepository.kt
│ │ │ │ ├── MultiChoiceQuestionRepository.kt
│ │ │ │ └── saved_questions/
│ │ │ │ └── SavedMultiChoiceQuestionsRepository.kt
│ │ │ ├── numbers/
│ │ │ │ ├── NumberTriviaQuestionApi.kt
│ │ │ │ └── NumberTriviaQuestionRepository.kt
│ │ │ └── wordle/
│ │ │ └── WordleRepository.kt
│ │ └── use_case/
│ │ └── question/
│ │ ├── GetRandomMultiChoiceQuestionUseCase.kt
│ │ └── IsQuestionSavedUseCase.kt
│ └── test/
│ └── kotlin/
│ └── com/
│ └── infinitepower/
│ └── newquiz/
│ └── domain/
│ └── use_case/
│ └── question/
│ └── IsQuestionSavedUseCaseTest.kt
├── feature/
│ ├── daily-challenge/
│ │ ├── .gitignore
│ │ ├── README.md
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── main/
│ │ ├── AndroidManifest.xml
│ │ └── kotlin/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── feature/
│ │ └── daily_challenge/
│ │ ├── DailyChallengeScreen.kt
│ │ ├── DailyChallengeScreenNavigator.kt
│ │ ├── DailyChallengeScreenUiEvent.kt
│ │ ├── DailyChallengeScreenUiState.kt
│ │ ├── DailyChallengeScreenViewModel.kt
│ │ └── components/
│ │ └── DailyChallengeCard.kt
│ ├── maze/
│ │ ├── .gitignore
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── main/
│ │ │ ├── AndroidManifest.xml
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── infinitepower/
│ │ │ └── newquiz/
│ │ │ └── feature/
│ │ │ └── maze/
│ │ │ ├── MazeScreen.kt
│ │ │ ├── MazeScreenUiEvent.kt
│ │ │ ├── MazeScreenUiState.kt
│ │ │ ├── MazeScreenViewModel.kt
│ │ │ ├── categories_info/
│ │ │ │ ├── MazeCategoriesInfoScreen.kt
│ │ │ │ ├── MazeCategoriesInfoUiState.kt
│ │ │ │ └── MazeCategoriesInfoViewModel.kt
│ │ │ ├── common/
│ │ │ │ └── MazeCategories.kt
│ │ │ ├── components/
│ │ │ │ ├── CategoriesInfoBottomSheet.kt
│ │ │ │ ├── InvalidCategoriesCard.kt
│ │ │ │ ├── MazeCompletedCard.kt
│ │ │ │ ├── MazeItemButton.kt
│ │ │ │ ├── MazePath.kt
│ │ │ │ └── ScrollToCurrentQuestionButton.kt
│ │ │ ├── generate/
│ │ │ │ ├── GenerateMazeScreen.kt
│ │ │ │ ├── GenerateMazeScreenUiEvent.kt
│ │ │ │ ├── GenerateMazeScreenUiState.kt
│ │ │ │ └── GenerateMazeScreenViewModel.kt
│ │ │ └── level_results/
│ │ │ ├── LevelResultsScreen.kt
│ │ │ ├── LevelResultsScreenUiState.kt
│ │ │ ├── LevelResultsScreenViewModel.kt
│ │ │ └── components/
│ │ │ ├── LevelCompletedContent.kt
│ │ │ └── LevelFailedContent.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── feature/
│ │ └── maze/
│ │ └── MazeScreenUiStateTest.kt
│ ├── profile/
│ │ ├── .gitignore
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── main/
│ │ ├── AndroidManifest.xml
│ │ └── kotlin/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── feature/
│ │ └── profile/
│ │ ├── ProfileScreen.kt
│ │ ├── ProfileScreenUiEvent.kt
│ │ ├── ProfileScreenUiState.kt
│ │ ├── ProfileViewModel.kt
│ │ └── components/
│ │ ├── GoodDayText.kt
│ │ ├── MainUserCard.kt
│ │ ├── UserXpAndLevelCard.kt
│ │ ├── XpEarnedByDayCard.kt
│ │ └── chart/
│ │ └── Marker.kt
│ └── settings/
│ ├── .gitignore
│ ├── README.md
│ ├── build.gradle.kts
│ └── src/
│ ├── foss/
│ │ └── kotlin/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── feature/
│ │ └── settings/
│ │ ├── common/
│ │ │ └── BuildVariant.kt
│ │ └── screens/
│ │ └── PreferencesScreen.kt
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ └── kotlin/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── feature/
│ │ └── settings/
│ │ ├── SettingsScreen.kt
│ │ ├── components/
│ │ │ ├── AboutAndHelpButtons.kt
│ │ │ └── preferences/
│ │ │ ├── PreferenceGroupHeader.kt
│ │ │ ├── PreferenceItem.kt
│ │ │ └── widgets/
│ │ │ ├── CustomPreferenceWidget.kt
│ │ │ ├── DropDownPreferenceWidget.kt
│ │ │ ├── ListPreferenceWidget.kt
│ │ │ ├── MultiSelectListPreferenceWidget.kt
│ │ │ ├── NavigationButtonWidget.kt
│ │ │ ├── SeekBarPreferenceWidget.kt
│ │ │ ├── SwitchPreferenceWidget.kt
│ │ │ └── TextPreferenceWidget.kt
│ │ ├── model/
│ │ │ ├── Preference.kt
│ │ │ └── ScreenKey.kt
│ │ ├── screens/
│ │ │ ├── PreferenceScreen.kt
│ │ │ ├── about_and_help/
│ │ │ │ └── AboutAndHelpScreen.kt
│ │ │ ├── animations/
│ │ │ │ └── AnimationsScreen.kt
│ │ │ ├── general/
│ │ │ │ ├── GeneralScreen.kt
│ │ │ │ ├── GeneralScreenUiEvent.kt
│ │ │ │ ├── GeneralScreenUiState.kt
│ │ │ │ └── GeneralScreenViewModel.kt
│ │ │ ├── main/
│ │ │ │ └── MainScreen.kt
│ │ │ ├── multi_choice_quiz/
│ │ │ │ └── MultiChoiceQuizScreen.kt
│ │ │ └── wordle/
│ │ │ └── WordleScreen.kt
│ │ └── util/
│ │ ├── ShowCategoryConnectionInfoUtils.kt
│ │ └── datastore/
│ │ └── DatastoreUtils.kt
│ └── normal/
│ └── kotlin/
│ └── com/
│ └── infinitepower/
│ └── newquiz/
│ └── feature/
│ └── settings/
│ ├── common/
│ │ └── BuildVariant.kt
│ └── screens/
│ ├── PreferencesScreen.kt
│ ├── analytics/
│ │ └── AnimationsScreen.kt
│ └── translation/
│ ├── TranslationScreen.kt
│ ├── TranslationScreenUiEvent.kt
│ ├── TranslationScreenUiState.kt
│ └── TranslationScreenViewModel.kt
├── gradle/
│ ├── libs.versions.toml
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── lint.xml
├── model/
│ ├── .gitignore
│ ├── build.gradle.kts
│ └── src/
│ ├── main/
│ │ └── java/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── model/
│ │ ├── BaseCategory.kt
│ │ ├── DataAnalyticsConsentState.kt
│ │ ├── GameMode.kt
│ │ ├── GameModeCategory.kt
│ │ ├── Language.kt
│ │ ├── NumberFormatType.kt
│ │ ├── RemainingTime.kt
│ │ ├── Resource.kt
│ │ ├── TimestampWithValue.kt
│ │ ├── UiText.kt
│ │ ├── XP.kt
│ │ ├── category/
│ │ │ └── ShowCategoryConnectionInfo.kt
│ │ ├── comparison_quiz/
│ │ │ ├── ComparisonMode.kt
│ │ │ ├── ComparisonQuizCategory.kt
│ │ │ ├── ComparisonQuizCategoryEntity.kt
│ │ │ ├── ComparisonQuizHelperValueState.kt
│ │ │ ├── ComparisonQuizItem.kt
│ │ │ ├── ComparisonQuizItemEntity.kt
│ │ │ └── ComparisonQuizQuestion.kt
│ │ ├── country/
│ │ │ ├── Continent.kt
│ │ │ └── Country.kt
│ │ ├── daily_challenge/
│ │ │ └── DailyChallengeTask.kt
│ │ ├── global_event/
│ │ │ └── GameEvent.kt
│ │ ├── math_quiz/
│ │ │ └── MathFormula.kt
│ │ ├── maze/
│ │ │ ├── MazePoint.kt
│ │ │ └── MazeQuiz.kt
│ │ ├── multi_choice_quiz/
│ │ │ ├── MultiChoiceBaseCategory.kt
│ │ │ ├── MultiChoiceCategory.kt
│ │ │ ├── MultiChoiceQuestion.kt
│ │ │ ├── MultiChoiceQuestionStep.kt
│ │ │ ├── MultiChoiceQuestionType.kt
│ │ │ ├── QuestionLanguage.kt
│ │ │ ├── SelectedAnswer.kt
│ │ │ ├── logo_quiz/
│ │ │ │ └── LogoQuizBaseItem.kt
│ │ │ └── saved/
│ │ │ └── SortSavedQuestionsBy.kt
│ │ ├── number/
│ │ │ ├── NumberTriviaQuestion.kt
│ │ │ └── NumberTriviaQuestionsEntity.kt
│ │ ├── question/
│ │ │ └── QuestionDifficulty.kt
│ │ ├── regional_preferences/
│ │ │ ├── DistanceUnitType.kt
│ │ │ ├── RegionalPreferences.kt
│ │ │ └── TemperatureUnit.kt
│ │ ├── util/
│ │ │ ├── base64/
│ │ │ │ ├── Base64.kt
│ │ │ │ ├── Base64Encoding.kt
│ │ │ │ └── Base64Url.kt
│ │ │ └── serializers/
│ │ │ └── URISerializer.kt
│ │ └── wordle/
│ │ ├── WordleCategory.kt
│ │ ├── WordleItem.kt
│ │ ├── WordleQuizType.kt
│ │ ├── WordleRowItem.kt
│ │ └── WordleWord.kt
│ └── test/
│ └── java/
│ └── com/
│ └── infinitepower/
│ └── newquiz/
│ └── model/
│ ├── RemainingTimeTest.kt
│ ├── category/
│ │ └── ShowCategoryConnectionInfoTest.kt
│ ├── comparison_quiz/
│ │ ├── ComparisonQuizCategoryTest.kt
│ │ └── ComparisonQuizQuestionTest.kt
│ ├── daily_challenge/
│ │ ├── DailyChallengeTaskTest.kt
│ │ └── DailyChallengeTaskTypeTest.kt
│ ├── math_quiz/
│ │ └── maze/
│ │ ├── MazePointTest.kt
│ │ └── MazeQuizTest.kt
│ ├── multi_choice_quiz/
│ │ └── MultiChoiceQuestionTest.kt
│ ├── util/
│ │ └── base64/
│ │ ├── Base64Test.kt
│ │ └── Base64UrlTest.kt
│ └── wordle/
│ ├── WordleItemTest.kt
│ └── WordleRowItemTest.kt
├── multi-choice-quiz/
│ ├── .gitignore
│ ├── README.md
│ ├── build.gradle.kts
│ ├── consumer-rules.pro
│ ├── proguard-rules.pro
│ └── src/
│ ├── androidTest/
│ │ └── java/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── multi_choice_quiz/
│ │ ├── MultiChoiceQuizScreenTest.kt
│ │ └── components/
│ │ ├── CardQuestionOptionTest.kt
│ │ ├── QuizStepViewRowTest.kt
│ │ └── QuizStepViewTest.kt
│ └── main/
│ ├── AndroidManifest.xml
│ ├── java/
│ │ └── com/
│ │ └── infinitepower/
│ │ └── newquiz/
│ │ └── multi_choice_quiz/
│ │ ├── MultiChoiceQuizScreen.kt
│ │ ├── MultiChoiceQuizScreenUiEvent.kt
│ │ ├── MultiChoiceQuizScreenUiState.kt
│ │ ├── MultiChoiceQuizScreenViewModel.kt
│ │ ├── components/
│ │ │ ├── CardQuestionAnswer.kt
│ │ │ ├── MultiChoiceQuizContainer.kt
│ │ │ ├── QuizStepView.kt
│ │ │ ├── QuizTopBar.kt
│ │ │ └── difficulty/
│ │ │ ├── BaseCardDifficultyContent.kt
│ │ │ ├── FilledCardDifficulty.kt
│ │ │ ├── OutlinedCardDifficulty.kt
│ │ │ └── SelectableDifficultyRow.kt
│ │ ├── list/
│ │ │ ├── MultiChoiceQuizListScreen.kt
│ │ │ ├── MultiChoiceQuizListScreenUiState.kt
│ │ │ └── MultiChoiceQuizListScreenViewModel.kt
│ │ ├── results/
│ │ │ └── MultiChoiceQuizResultsScreen.kt
│ │ └── saved_questions/
│ │ ├── SavedMultiChoiceQuestionsScreen.kt
│ │ ├── SavedMultiChoiceQuestionsScreenNavigator.kt
│ │ ├── SavedMultiChoiceQuestionsUiEvent.kt
│ │ ├── SavedMultiChoiceQuestionsUiState.kt
│ │ ├── SavedMultiChoiceQuestionsViewModel.kt
│ │ └── components/
│ │ └── SavedQuestionItem.kt
│ └── res/
│ └── values/
│ └── strings.xml
├── settings.gradle.kts
└── wordle/
├── .gitignore
├── README.md
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src/
├── androidTest/
│ └── java/
│ └── com/
│ └── infinitepower/
│ └── newquiz/
│ └── wordle/
│ ├── WordleScreenTest.kt
│ └── components/
│ ├── WordleKeyBoardTest.kt
│ └── WordleRowComponentTest.kt
├── main/
│ ├── AndroidManifest.xml
│ └── java/
│ └── com/
│ └── infinitepower/
│ └── newquiz/
│ └── wordle/
│ ├── WordleScreen.kt
│ ├── WordleScreenUiEvent.kt
│ ├── WordleScreenUiState.kt
│ ├── WordleScreenViewModel.kt
│ ├── components/
│ │ ├── InfoDialog.kt
│ │ ├── WordleKeyBoard.kt
│ │ └── WordleRowComponent.kt
│ ├── list/
│ │ ├── WordleListScreen.kt
│ │ ├── WordleListScreenViewModel.kt
│ │ └── WordleListUiState.kt
│ └── util/
│ ├── InvalidWordErrorUiText.kt
│ ├── word/
│ │ └── WordUtil.kt
│ └── worker/
│ └── WordleEndGameWorker.kt
└── test/
└── java/
└── com/
└── infinitepower/
└── newquiz/
└── wordle/
└── util/
└── word/
└── WordUtilTest.kt
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/actions/get-avd-info/action.yml
================================================
# From: coil-kt/coil
name: 'Get AVD Info'
description: 'Get the AVD info based on its API level.'
inputs:
api-level:
required: true
outputs:
arch:
value: ${{ steps.get-avd-arch.outputs.arch }}
target:
value: ${{ steps.get-avd-target.outputs.target }}
runs:
using: "composite"
steps:
- id: get-avd-arch
run: echo "::set-output name=arch::$(if [ ${{ inputs.api-level }} -ge 30 ]; then echo x86_64; else echo x86; fi)"
shell: bash
- id: get-avd-target
run: echo "::set-output name=target::$(echo default)"
shell: bash
================================================
FILE: .github/workflows/android.yml
================================================
name: Android CI
on:
push:
branches: [ "main" ]
paths-ignore:
- '**.md'
pull_request:
branches: [ "main" ]
paths-ignore:
- '**.md'
concurrency:
group: android-ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
checks: write
id-token: write
env:
JAVA_VERSION: "17"
JAVA_DISTR: 'zulu'
jobs:
tests_and_apk:
name: "🤖 Local Tests and 📦 APKs"
runs-on: ubuntu-20.04
timeout-minutes: 60
steps:
- name: Checkout sources
uses: actions/checkout@v4
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v3
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: ${{ env.JAVA_DISTR }}
java-version: ${{ env.JAVA_VERSION }}
- name: Create google-services.json file
run: cat /home/runner/work/newquiz/newquiz/app/google-services.json | base64
- name: Put google-services.json data
env:
DATA: ${{ secrets.GOOGLE_SERVICES_JSON }}
run: echo $DATA > /home/runner/work/newquiz/newquiz/app/google-services.json
- name: Workaround for Android Gradle Plugin issue
run: 'echo "ndk.dir=${ANDROID_HOME}/ndk-bundle" > local.properties'
- name: Setup gradle
uses: gradle/actions/setup-gradle@v3
- name: Run unit tests
run: ./gradlew testAllUnitTest --stacktrace
- name: Publish Test Report
uses: mikepenz/action-junit-report@v4
if: success() || failure() # always run even if the previous step fails
with:
report_paths: '**/build/test-results/test*/TEST-*.xml'
- name: Lint sources
run: ./gradlew lint --stacktrace
- name: Generate GitHub annotations
uses: yutailang0119/action-android-lint@v4
with:
report-path: '**/build/reports/*.xml'
- name: Run Detekt
run: ./gradlew detekt --stacktrace
- name: Assemble debug APKs
run: ./gradlew assembleNormalDebug assembleFossDebug --stacktrace
- name: Upload APKs
uses: actions/upload-artifact@v4
with:
name: APKs
path: app/build/outputs/apk/**/app-*-universal-debug.apk
================================================
FILE: .github/workflows/android_old.yml
================================================
name: Android CI
on:
workflow_dispatch:
permissions:
contents: read
checks: write
id-token: write
env:
JAVA_VERSION: "17"
JAVA_DISTR: 'corretto'
jobs:
test:
name: "🤖 Unit Tests"
runs-on: ubuntu-20.04
steps:
- name: Checkout sources
uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v3
with:
distribution: ${{ env.JAVA_DISTR }}
java-version: ${{ env.JAVA_VERSION }}
- name: Create google-services.json file
run: cat /home/runner/work/newquiz/newquiz/app/google-services.json | base64
- name: Put google-services.json data
env:
DATA: ${{ secrets.GOOGLE_SERVICES_JSON }}
run: echo $DATA > /home/runner/work/newquiz/newquiz/app/google-services.json
- name: Workaround for Android Gradle Plugin issue
run: 'echo "ndk.dir=${ANDROID_HOME}/ndk-bundle" > local.properties'
- name: Setup gradle
uses: gradle/gradle-build-action@v2.5.1
- name: Run tests
run: ./gradlew testAllUnitTest --stacktrace
# run: ./gradlew testNormalDebug testFossDebug --stacktrace
- name: Publish Test Report
uses: mikepenz/action-junit-report@v3
if: success() || failure() # always run even if the previous step fails
with:
report_paths: '**/build/test-results/test*/TEST-*.xml'
android-lint:
name: "🔍 Android Lint"
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v3
with:
distribution: ${{ env.JAVA_DISTR }}
java-version: ${{ env.JAVA_VERSION }}
- name: Create google-services.json file
run: cat /home/runner/work/newquiz/newquiz/app/google-services.json | base64
- name: Put google-services.json data
env:
DATA: ${{ secrets.GOOGLE_SERVICES_JSON }}
run: echo $DATA > /home/runner/work/newquiz/newquiz/app/google-services.json
- name: Increase gradle daemon memory
run: "echo \"org.gradle.jvmargs=-Xmx4096m\" >> gradle.properties"
- name: Workaround for Android Gradle Plugin issue
run: 'echo "ndk.dir=${ANDROID_HOME}/ndk-bundle" > local.properties'
- name: Setup gradle
uses: gradle/gradle-build-action@v2.5.1
- name: Lint sources
run: ./gradlew lint --stacktrace
- name: Generate GitHub annotations
uses: yutailang0119/action-android-lint@v3
with:
report-path: '**/build/reports/*.xml'
assemble-apk:
name: "📦 Assemble APKs"
needs:
- test
- android-lint
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v3
with:
distribution: ${{ env.JAVA_DISTR }}
java-version: ${{ env.JAVA_VERSION }}
- name: Create google-services.json file
run: cat /home/runner/work/newquiz/newquiz/app/google-services.json | base64
- name: Put google-services.json data
env:
DATA: ${{ secrets.GOOGLE_SERVICES_JSON }}
run: echo $DATA > /home/runner/work/newquiz/newquiz/app/google-services.json
- name: Workaround for Android Gradle Plugin issue
run: 'echo "ndk.dir=${ANDROID_HOME}/ndk-bundle" > local.properties'
- name: Setup gradle
uses: gradle/gradle-build-action@v2.5.1
- name: Assemble debug APKs
run: ./gradlew assembleNormalDebug assembleFossDebug --stacktrace
- name: Upload APKs
uses: actions/upload-artifact@v3
with:
name: APKs
path: app/build/outputs/apk/**/app-*-universal-debug.apk
# app/build/outputs/apk/debug/app-universal-debug.apk
================================================
FILE: .gitignore
================================================
.gradle
/local.properties
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/app/build
/app/release
/buildSrc/build
# built application files
*.apk
*.ap_
# files for the dex VM
*.dex
# Java class files
*.class
# generated files
bin/
gen/
out/
# Eclipse project files
.classpath
.project
# IDEA/Android Studio project files, because
# the project can be imported from settings.gradle.kts
*.iml
.idea/*
!.idea/copyright
# Keep the code styles.
!/.idea/codeStyles
/.idea/codeStyles/*
!/.idea/codeStyles/Project.xml
!/.idea/codeStyles/codeStyleConfig.xml
!.idea/runConfigurations/
.kotlin
================================================
FILE: LICENCE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: README.md
================================================
# New Quiz
[](https://github.com/joaomanaia/newquiz/releases/tag/1.6.2)
[](https://github.com/joaomanaia/newquiz/actions/workflows/android.yml)
[](https://www.apache.org/licenses/LICENSE-2.0)
[](https://hosted.weblate.org/engage/newquiz)
Do you like to challenge your knowledge? So NewQuiz is the ideal game for you.

New quiz is optimized to material you, the theme of new quiz will adapt to your background.

# Features
- Maze: Game mode with all other NewQuiz game modes in one quiz.
- Multi choice quiz
- Logo quiz
- Flag quiz
- Solve the formula equation
- Number trivia
- Wordle
- Guess the text word
- Guess the number
- Guess the math formula
- Number trivia
- Comparison Quiz
- Compare the country population
# Build With
- [MVVM](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) is an architectural pattern in computer software that facilitates the separation of the development of the graphical user interface
- [Jetpack Compose:](https://developer.android.com/jetpack/compose) Jetpack Compose is Android’s modern toolkit for building native UI.
- [Material 3:](https://m3.material.io/) Design and build beautiful, usable products with Material3.
- [Kotlin:](https://kotlinlang.org/) A modern programming language that makes developers happier.
- [Kotlin Coroutines:](https://github.com/Kotlin/kotlinx.coroutines) Asynchronous or non-blocking programming
- [Dagger Hilt:](https://github.com/google/dagger) A fast dependency injector for Java and Android.
- [Ktor:](https://ktor.io/) For asynchronous HTTP client requests.
- [Lottie Android:](https://github.com/airbnb/lottie-android/) Lottie is a library that parses Adobe After Effects animations exported as json.
- [Compose destinations:](https://github.com/raamcosta/compose-destinations) Annotation processing library for type-safe Jetpack Compose navigation with no boilerplate.
# Question Data Source
- [FlagCDN:](https://flagcdn.com/) country flag images
- Multi choice quiz
- [OpenTDB:](https://opentdb.com/) multi choice questions
- [NumbersAPI:](http://numbersapi.com) api for number trivia questions
- Wordle
- [Spanish and French words](https://github.com/lorenbrichter/Words)
- Comparison Quiz
- [Rest Countries:](https://restcountries.com/) country information
- [TMDB:](https://www.themoviedb.org/) data about movies
# Translation
Hello and thank you for your interest — NewQuiz is being translated using [Weblate](https://weblate.org/), a web tool designed to ease translating for both developers and translators.
[](https://hosted.weblate.org/engage/newquiz/)
# Run the project locally
## Requirements
- [Android Studio](https://developer.android.com/studio) Hedgehog 2023.1.1 Canary 16 or later. Or use an other version of [Android Studio](https://developer.android.com/studio) but you need to change the gradle version to be compatible with your version of [Android Studio](https://developer.android.com/studio).
- Java 17 or later.
## Build and Run
1. First clone the repository
```bash
git clone https://github.com/joaomanaia/newquiz.git
```
2. Open the project in [Android Studio](https://developer.android.com/studio).
3. Add firebase to the project
1. Go to [Firebase](https://firebase.google.com/) and create a new project.
2. Add an android app to the project.
3. Download the google-services.json file.
4. Copy the file to the app folder.
4. Click the run button.
> **Warning**: FOSS Builds still contains proprietary code, such as Firebase, Crashlytics, and Google Play Services. In the future all proprietary code will be removed from the FOSS Builds.
> **Warning**: To run the project locally you need to add firebase to the project, otherwise the you cannot build and run the project.
================================================
FILE: app/.gitignore
================================================
/build
google-services.json
*.apk
*.aab
*.dm
output-metadata.json
================================================
FILE: app/build.gradle.kts
================================================
plugins {
alias(libs.plugins.newquiz.android.application)
alias(libs.plugins.newquiz.android.application.compose)
alias(libs.plugins.newquiz.android.hilt)
alias(libs.plugins.newquiz.android.compose.destinations)
alias(libs.plugins.newquiz.kotlin.serialization)
id("kotlin-parcelize")
id("com.google.android.gms.oss-licenses-plugin")
alias(libs.plugins.newquiz.detekt)
}
android {
namespace = "com.infinitepower.newquiz"
compileSdk = 35
defaultConfig {
applicationId = "com.infinitepower.newquiz"
minSdk = 21
targetSdk = 35
versionCode = 16
versionName = "2.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
// resourceConfigurations += setOf("en", "pt", "fr", "es", "nb")
}
androidResources {
generateLocaleConfig = true
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
isDebuggable = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
debug {
extra["enableCrashlytics"] = false
rootProject.ext.set("firebasePerformanceInstrumentationEnabled", "false")
}
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
splits {
abi {
isEnable = true
reset()
include("x86", "x86_64", "arm64-v8a", "armeabi-v7a")
// Generate universal APK
isUniversalApk = true
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.lifecycle.viewModelCompose)
implementation(libs.androidx.startup.runtime)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.windowSizeClass)
debugImplementation(libs.androidx.compose.ui.testManifest)
androidTestImplementation(libs.androidx.compose.ui.test)
implementation(libs.google.material)
implementation(libs.hilt.navigationCompose)
implementation(libs.hilt.ext.work)
ksp(libs.hilt.ext.compiler)
testImplementation(libs.kotlinx.coroutines.test)
implementation(libs.kotlinx.coroutines.playServices)
implementation(libs.lottie.compose)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.serialization)
implementation(libs.androidx.work.ktx)
androidTestImplementation(libs.androidx.work.testing)
implementation(libs.kotlinx.datetime)
implementation(libs.slf4j.simple)
implementation(libs.google.oss.licenses)
implementation(libs.kotlinx.collections.immutable)
implementation(projects.core)
implementation(projects.core.analytics)
implementation(projects.core.datastore)
implementation(projects.core.translation)
implementation(projects.core.remoteConfig)
implementation(projects.core.userServices)
implementation(projects.feature.dailyChallenge)
implementation(projects.feature.settings)
implementation(projects.feature.maze)
implementation(projects.feature.profile)
implementation(projects.model)
implementation(projects.multiChoiceQuiz)
implementation(projects.wordle)
implementation(projects.data)
implementation(projects.domain)
implementation(projects.comparisonQuiz)
}
================================================
FILE: app/lint-baseline.xml
================================================
================================================
FILE: app/proguard-rules.pro
================================================
# Keep `Companion` object fields of serializable classes.
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
-if @kotlinx.serialization.Serializable class ** {
static **$* *;
}
-keepclassmembers class <2>$<3> {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep `INSTANCE.serializer()` of serializable objects.
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
-keepclassmembers @kotlinx.serialization.Serializable class ** {
*** Companion;
}
# Keep `INSTANCE.serializer()` of serializable objects.
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
-keep class kotlin.reflect.** { *; }
-dontwarn kotlin.reflect.**
-keep class org.jetbrains.** { *; }
# Firebase
-keep class com.google.firebase.** { *; }
-keep class com.firebase.** { *; }
-keep class org.apache.** { *; }
-keepnames class com.fasterxml.jackson.** { *; }
-keepnames class javax.servlet.** { *; }
-keepnames class org.ietf.jgss.** { *; }
-dontwarn org.w3c.dom.**
-dontwarn org.joda.time.**
-dontwarn org.shaded.apache.**
-dontwarn org.ietf.jgss.**
-keep class com.google.android.gms.** { *; }
-keep class org.apache** { *; }
# Only necessary if you downloaded the SDK jar directly instead of from maven.
-keep class com.shaded.fasterxml.jackson.** { *; }
-dontwarn java.lang.invoke.StringConcatFactory
-dontwarn kotlin.**
-dontwarn org.w3c.dom.events.*
-dontwarn org.jetbrains.kotlin.di.InjectorForRuntimeDescriptorLoader
-keepattributes SourceFile,LineNumberTable
-keep class kotlin.** { *; }
#-keep class kotlin.reflect.** { *; }
#-keep class org.jetbrains.kotlin.** { *; }
-keepclassmembers,allowoptimization enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
**[] $VALUES;
public *;
}
-keepattributes InnerClasses
# Ktor
-keep class io.ktor.** { *; }
-keep class kotlinx.coroutines.** { *; }
-dontwarn kotlinx.atomicfu.**
-dontwarn io.netty.**
-dontwarn com.typesafe.**
-dontwarn org.slf4j.**
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses class com.infinitepower.newsocial.compose.**$$serializer { *; }
-keep class kotlin.reflect.** { *; }
-dontwarn kotlin.reflect.**
-keep class org.jetbrains.** { *; }
-dontwarn kotlin.**
-dontwarn org.w3c.dom.events.*
-dontwarn org.jetbrains.kotlin.di.InjectorForRuntimeDescriptorLoader
-keepattributes SourceFile,LineNumberTable
-keep class kotlin.** { *; }
#-keep class kotlin.reflect.** { *; }
#-keep class org.jetbrains.kotlin.** { *; }
-keepclassmembers,allowoptimization enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
**[] $VALUES;
public *;
}
-keepattributes InnerClasses
# Ktor
-keep class io.ktor.** { *; }
-keep class kotlinx.coroutines.** { *; }
-dontwarn kotlinx.atomicfu.**
-dontwarn io.netty.**
-dontwarn com.typesafe.**
-dontwarn org.slf4j.**
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses class com.infinitepower.newsocial.compose.**$$serializer { *; }
-keep class kotlin.reflect.** { *; }
-dontwarn kotlin.reflect.**
-keep class org.jetbrains.** { *; }
-dontwarn java.lang.management.ManagementFactory
-dontwarn java.lang.management.RuntimeMXBean
================================================
FILE: app/src/androidTest/AndroidManifest.xml
================================================
================================================
FILE: app/src/main/AndroidManifest.xml
================================================
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/NewQuizApp.kt
================================================
package com.infinitepower.newquiz
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class NewQuizApp : Application()
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/core/navigation/AppNavGraphs.kt
================================================
package com.infinitepower.newquiz.core.navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import com.infinitepower.newquiz.comparison_quiz.destinations.ComparisonQuizListScreenDestination
import com.infinitepower.newquiz.comparison_quiz.destinations.ComparisonQuizScreenDestination
import com.infinitepower.newquiz.core.remote_config.RemoteConfig
import com.infinitepower.newquiz.feature.daily_challenge.destinations.DailyChallengeScreenDestination
import com.infinitepower.newquiz.feature.maze.destinations.LevelResultsScreenDestination
import com.infinitepower.newquiz.feature.maze.destinations.MazeCategoriesInfoScreenDestination
import com.infinitepower.newquiz.feature.maze.destinations.MazeScreenDestination
import com.infinitepower.newquiz.feature.profile.destinations.ProfileScreenDestination
import com.infinitepower.newquiz.feature.settings.destinations.SettingsScreenDestination
import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizListScreenDestination
import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizResultsScreenDestination
import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizScreenDestination
import com.infinitepower.newquiz.multi_choice_quiz.destinations.SavedMultiChoiceQuestionsScreenDestination
import com.infinitepower.newquiz.ui.navigation.NavigationContainer
import com.infinitepower.newquiz.wordle.destinations.WordleListScreenDestination
import com.infinitepower.newquiz.wordle.destinations.WordleScreenDestination
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations
import com.ramcosta.composedestinations.navigation.dependency
import com.ramcosta.composedestinations.rememberNavHostEngine
import com.ramcosta.composedestinations.scope.DestinationScopeWithNoDependencies
import com.ramcosta.composedestinations.spec.DestinationSpec
import com.ramcosta.composedestinations.spec.NavGraphSpec
import com.ramcosta.composedestinations.spec.Route
@Immutable
internal data class NavGraph(
override val route: String,
override val startRoute: Route,
val destinations: List>,
override val nestedNavGraphs: List = emptyList()
) : NavGraphSpec {
override val destinationsByRoute: Map> =
destinations.associateBy { it.route }
}
internal object AppNavGraphs {
val mainNavGraph = NavGraph(
route = "main_nav_graph",
startRoute = MultiChoiceQuizListScreenDestination,
destinations = listOf(
MultiChoiceQuizScreenDestination,
MultiChoiceQuizListScreenDestination,
MultiChoiceQuizResultsScreenDestination,
SettingsScreenDestination,
SavedMultiChoiceQuestionsScreenDestination,
WordleScreenDestination,
WordleListScreenDestination,
MazeScreenDestination,
MazeCategoriesInfoScreenDestination,
LevelResultsScreenDestination,
ComparisonQuizScreenDestination,
ComparisonQuizListScreenDestination,
DailyChallengeScreenDestination,
ProfileScreenDestination
)
)
}
internal fun DestinationScopeWithNoDependencies<*>.currentNavigator(
remoteConfig: RemoteConfig
): CommonNavGraphNavigator {
return CommonNavGraphNavigator(destinationsNavigator, navController, remoteConfig)
}
@Composable
@ExperimentalMaterial3Api
internal fun AppNavigation(
modifier: Modifier = Modifier,
navController: NavHostController,
windowSizeClass: WindowSizeClass,
remoteConfig: RemoteConfig,
dailyChallengeClaimCount: Int,
userDiamonds: UInt
) {
NavigationContainer(
navController = navController,
windowWidthSize = windowSizeClass.widthSizeClass,
dailyChallengeClaimCount = dailyChallengeClaimCount,
userDiamonds = userDiamonds
) { innerPadding ->
DestinationsNavHost(
navGraph = AppNavGraphs.mainNavGraph,
navController = navController,
modifier = modifier.padding(innerPadding),
dependenciesContainerBuilder = {
dependency(currentNavigator(remoteConfig))
dependency(windowSizeClass)
},
engine = rememberNavHostEngine(
rootDefaultAnimations = RootNavGraphDefaultAnimations.ACCOMPANIST_FADING,
)
)
}
}
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/core/navigation/CommonNavGraphNavigator.kt
================================================
package com.infinitepower.newquiz.core.navigation
import androidx.navigation.NavController
import com.infinitepower.newquiz.comparison_quiz.destinations.ComparisonQuizScreenDestination
import com.infinitepower.newquiz.core.remote_config.RemoteConfig
import com.infinitepower.newquiz.core.remote_config.RemoteConfigValue
import com.infinitepower.newquiz.core.remote_config.get
import com.infinitepower.newquiz.data.util.mappers.comparisonquiz.toEntity
import com.infinitepower.newquiz.feature.daily_challenge.DailyChallengeScreenNavigator
import com.infinitepower.newquiz.feature.maze.destinations.LevelResultsScreenDestination
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItem
import com.infinitepower.newquiz.model.maze.MazeQuiz
import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory
import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion
import com.infinitepower.newquiz.model.wordle.WordleQuizType
import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizScreenDestination
import com.infinitepower.newquiz.multi_choice_quiz.saved_questions.SavedQuestionsScreenNavigator
import com.infinitepower.newquiz.wordle.destinations.WordleScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.utils.destination
class CommonNavGraphNavigator(
private val navigator: DestinationsNavigator,
private val navController: NavController,
private val remoteConfig: RemoteConfig
) : SavedQuestionsScreenNavigator,
MazeNavigator,
DailyChallengeScreenNavigator {
override fun navigateToWordleQuiz(type: WordleQuizType) {
navigator.navigate(WordleScreenDestination(quizType = type))
}
override fun navigateToComparisonQuiz(
category: ComparisonQuizCategory,
mode: ComparisonMode
) {
navigator.navigate(ComparisonQuizScreenDestination(category.id, mode))
}
override fun navigateToMultiChoiceQuiz(initialQuestions: ArrayList) {
val remoteConfigDifficulty = remoteConfig
.get(RemoteConfigValue.MULTICHOICE_QUICKQUIZ_DIFFICULTY)
.run {
if (this == "random") null else this
}
navigator.navigate(
MultiChoiceQuizScreenDestination(
initialQuestions = initialQuestions,
difficulty = remoteConfigDifficulty
)
)
}
override fun navigateToMultiChoiceQuiz(category: MultiChoiceBaseCategory) {
val remoteConfigDifficulty = remoteConfig
.get(RemoteConfigValue.MULTICHOICE_QUICKQUIZ_DIFFICULTY)
.run {
if (this == "random") null else this
}
navigator.navigate(
MultiChoiceQuizScreenDestination(
difficulty = remoteConfigDifficulty,
category = category
)
)
}
override fun navigateToGame(item: MazeQuiz.MazeItem) {
val destination = when (item) {
is MazeQuiz.MazeItem.Wordle -> {
WordleScreenDestination(
word = item.wordleWord.word,
quizType = item.wordleQuizType,
mazeItemId = item.id.toString(),
textHelper = item.wordleWord.textHelper
)
}
is MazeQuiz.MazeItem.MultiChoice -> {
MultiChoiceQuizScreenDestination(
initialQuestions = arrayListOf(item.question),
mazeItemId = item.id.toString()
)
}
is MazeQuiz.MazeItem.ComparisonQuiz -> {
val initialItems = item.question.questions
.toList()
.map(ComparisonQuizItem::toEntity)
ComparisonQuizScreenDestination(
categoryId = item.question.categoryId,
initialItems = ArrayList(initialItems),
mazeItemId = item.id
)
}
}
navigator.navigate(destination) {
popUpTo(LevelResultsScreenDestination) {
inclusive = true
}
}
}
override fun navigateToMazeResults(itemId: Int) {
navigator.navigate(LevelResultsScreenDestination(itemId)) {
launchSingleTop = true
// Remove current screen from back stack
val currentDestination = navController.currentBackStackEntry?.destination()
if (currentDestination != null) {
popUpTo(currentDestination) {
inclusive = true
}
}
}
}
}
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/core/navigation/NavigationItem.kt
================================================
package com.infinitepower.newquiz.core.navigation
import androidx.annotation.Keep
import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.vector.ImageVector
import com.ramcosta.composedestinations.spec.Direction
sealed class NavigationItem {
@get:StringRes
abstract val text: Int
abstract val group: NavDrawerItemGroup?
@Immutable
data class Label(
@StringRes override val text: Int,
override val group: NavDrawerItemGroup? = null
) : NavigationItem()
/**
* @param primary items to navigation bar
*/
@Immutable
data class Item(
@StringRes override val text: Int,
override val group: NavDrawerItemGroup? = null,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector? = null,
val badge: NavDrawerBadgeItem? = null,
val direction: Direction,
val primary: Boolean = false,
val screenType: ScreenType = ScreenType.NORMAL
) : NavigationItem() {
fun getIcon(selected: Boolean): ImageVector {
return if (selected || unselectedIcon == null) selectedIcon else unselectedIcon
}
}
}
enum class ScreenType {
/** When using this type navigation items will be visible and have a top bar */
NORMAL,
/** When using this type all navigation items will be invisible and have no top bar */
NAVIGATION_HIDDEN
}
@JvmInline
value class NavDrawerItemGroup(val key: String)
@Keep
data class NavDrawerBadgeItem(
val value: Int,
val description: String
)
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/core/workers/AppStartLoggingAnalyticsWorker.kt
================================================
package com.infinitepower.newquiz.core.workers
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.infinitepower.newquiz.core.analytics.AnalyticsHelper
import com.infinitepower.newquiz.core.analytics.UserProperty
import com.infinitepower.newquiz.core.datastore.common.SettingsCommon
import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager
import com.infinitepower.newquiz.core.datastore.di.SettingsDataStoreManager
import com.infinitepower.newquiz.core.translation.TranslatorUtil
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@HiltWorker
class AppStartLoggingAnalyticsWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
@SettingsDataStoreManager private val settingsDataStoreManager: DataStoreManager,
private val translatorUtil: TranslatorUtil,
private val analyticsHelper: AnalyticsHelper
) : CoroutineWorker(appContext, workerParams) {
companion object {
fun enqueue(workManager: WorkManager) {
val appStartLoggingAnalyticsWorker = OneTimeWorkRequestBuilder().build()
workManager.enqueue(appStartLoggingAnalyticsWorker)
}
}
override suspend fun doWork(): Result {
val wordleLang = settingsDataStoreManager.getPreference(SettingsCommon.InfiniteWordleQuizLanguage)
analyticsHelper.setUserProperty(UserProperty.WordleLanguage(wordleLang))
val isTranslatorModelDownloaded = translatorUtil.isModelDownloaded()
analyticsHelper.setUserProperty(UserProperty.TranslatorModelDownloaded(isTranslatorModelDownloaded))
return Result.success()
}
}
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/initializer/EnqueueStartWorksInitializer.kt
================================================
package com.infinitepower.newquiz.initializer
import android.content.Context
import androidx.startup.Initializer
import androidx.work.WorkManager
import com.infinitepower.newquiz.core.remote_config.initializer.RemoteConfigInitializer
import com.infinitepower.newquiz.core.workers.AppStartLoggingAnalyticsWorker
import com.infinitepower.newquiz.data.worker.daily_challenge.VerifyDailyChallengeWorker
@Suppress("unused")
class EnqueueStartWorksInitializer : Initializer {
override fun create(context: Context) {
val workManager = WorkManager.getInstance(context)
VerifyDailyChallengeWorker.enqueueUniquePeriodicWork(workManager)
AppStartLoggingAnalyticsWorker.enqueue(workManager)
}
override fun dependencies(): List>> = listOf(
WorkManagerInitializer::class.java,
RemoteConfigInitializer::class.java
)
}
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/initializer/WorkManagerInitializer.kt
================================================
package com.infinitepower.newquiz.initializer
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.startup.Initializer
import androidx.work.Configuration
import androidx.work.WorkManager
import dagger.Module
import dagger.Provides
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class WorkManagerInitializer : Initializer {
@Provides
@Singleton
override fun create(@ApplicationContext context: Context): WorkManager {
if (!WorkManager.isInitialized()) {
val entryPoint = EntryPointAccessors.fromApplication(context)
val configuration = Configuration
.Builder()
.setWorkerFactory(entryPoint.hiltWorkerFactory())
.setMinimumLoggingLevel(Log.INFO)
.build()
WorkManager.initialize(context, configuration)
}
return WorkManager.getInstance(context)
}
override fun dependencies(): List>> {
return emptyList()
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WorkManagerInitializerEntryPoint {
fun hiltWorkerFactory(): HiltWorkerFactory
}
}
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/ui/components/DataCollectionConsentDialog.kt
================================================
package com.infinitepower.newquiz.ui.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.infinitepower.newquiz.core.theme.NewQuizTheme
import com.infinitepower.newquiz.core.theme.spacing
import com.infinitepower.newquiz.core.R as CoreR
@Composable
@ExperimentalMaterial3Api
internal fun DataCollectionConsentDialog(
onAgreeClick: () -> Unit = {},
onDisagreeClick: () -> Unit = {}
) {
AlertDialog(
title = { Text(text = stringResource(id = CoreR.string.data_collection_consent)) },
text = {
DialogConsentContent(
onAgreeClick = onAgreeClick,
onDisagreeClick = onDisagreeClick
)
},
onDismissRequest = {},
confirmButton = {},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
}
@Composable
private fun DialogConsentContent(
modifier: Modifier = Modifier,
onAgreeClick: () -> Unit = {},
onDisagreeClick: () -> Unit = {}
) {
val spaceLarge = MaterialTheme.spacing.large
val scrollState = rememberScrollState()
Column(
modifier = modifier.height(400.dp)
) {
Text(
text = stringResource(id = CoreR.string.data_collection_consent_description),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.weight(1f)
.verticalScroll(scrollState)
)
Spacer(modifier = Modifier.height(spaceLarge))
ConsentButtons(
onAgreeClick = onAgreeClick,
onDisagreeClick = onDisagreeClick
)
}
}
@Composable
private fun ConsentButtons(
modifier: Modifier = Modifier,
onAgreeClick: () -> Unit = {},
onDisagreeClick: () -> Unit = {}
) {
Column(
modifier = modifier.fillMaxWidth()
) {
ConsentButton(
text = stringResource(id = CoreR.string.data_collection_consent_agree),
onClick = onAgreeClick,
shape = RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = 8.dp,
bottomEnd = 8.dp
)
)
Spacer(modifier = Modifier.height(MaterialTheme.spacing.small))
ConsentButton(
text = stringResource(id = CoreR.string.data_collection_consent_disagree),
onClick = onDisagreeClick,
shape = RoundedCornerShape(
topStart = 8.dp,
topEnd = 8.dp,
bottomStart = 16.dp,
bottomEnd = 16.dp
)
)
}
}
@Composable
private fun ConsentButton(
text: String,
shape: Shape = MaterialTheme.shapes.medium,
onClick: () -> Unit
) {
val buttonColor = if (isSystemInDarkTheme()) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.primaryContainer
}
Surface(
onClick = onClick,
color = buttonColor,
shape = shape,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
modifier = Modifier.padding(20.dp),
text = text,
style = MaterialTheme.typography.titleMedium
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@PreviewLightDark
private fun AnalyticsCollectionConsentContentPreview() {
NewQuizTheme {
Surface {
DataCollectionConsentDialog()
}
}
}
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/ui/components/DiamondsCounter.kt
================================================
package com.infinitepower.newquiz.ui.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.infinitepower.newquiz.core.theme.NewQuizTheme
import com.infinitepower.newquiz.core.theme.spacing
@Composable
internal fun DiamondsCounter(
modifier: Modifier = Modifier,
diamonds: UInt
) {
Surface(
modifier = modifier,
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp
) {
Text(
text = "$diamonds \uD83D\uDC8E",
modifier = Modifier.padding(
horizontal = MaterialTheme.spacing.small,
vertical = MaterialTheme.spacing.extraSmall
),
)
}
}
@Composable
@PreviewLightDark
private fun DiamondsCounterPreview() {
NewQuizTheme {
Surface {
DiamondsCounter(
modifier = Modifier.padding(16.dp),
diamonds = 100u
)
}
}
}
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/ui/main/MainActivity.kt
================================================
package com.infinitepower.newquiz.ui.main
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.compose.rememberNavController
import com.infinitepower.newquiz.core.analytics.AnalyticsHelper
import com.infinitepower.newquiz.core.analytics.LocalAnalyticsHelper
import com.infinitepower.newquiz.core.navigation.AppNavigation
import com.infinitepower.newquiz.core.remote_config.RemoteConfig
import com.infinitepower.newquiz.core.theme.NewQuizTheme
import com.infinitepower.newquiz.ui.components.DataCollectionConsentDialog
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalMaterial3Api::class)
class MainActivity : ComponentActivity() {
@Inject
lateinit var analyticsHelper: AnalyticsHelper
@Inject
lateinit var remoteConfig: RemoteConfig
private val viewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
var uiState: MainScreenUiState by mutableStateOf(MainScreenUiState())
// Update the uiState
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState
.onEach { uiState = it }
.collect()
}
}
// Keep the splash screen until the uiState is loaded
splashScreen.setKeepOnScreenCondition { uiState.loading }
enableEdgeToEdge()
setContent {
CompositionLocalProvider(
LocalAnalyticsHelper provides analyticsHelper
) {
NewQuizTheme(
animationsEnabled = uiState.animationsEnabled,
) {
val windowSize = calculateWindowSizeClass(activity = this)
Surface(
color = MaterialTheme.colorScheme.background
) {
AppNavigation(
navController = rememberNavController(),
modifier = Modifier.fillMaxSize(),
windowSizeClass = windowSize,
remoteConfig = remoteConfig,
dailyChallengeClaimCount = uiState.dailyChallengeClaimableCount,
userDiamonds = uiState.userDiamonds,
)
if (uiState.showDataAnalyticsConsentDialog && !uiState.loading) {
DataCollectionConsentDialog(
onAgreeClick = {
viewModel.onEvent(
MainScreenUiEvent.OnDataAnalyticsConsentClick(
agreed = true
)
)
},
onDisagreeClick = {
viewModel.onEvent(
MainScreenUiEvent.OnDataAnalyticsConsentClick(
agreed = false
)
)
}
)
}
}
}
}
}
}
}
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/ui/main/MainScreenUiEvent.kt
================================================
package com.infinitepower.newquiz.ui.main
import androidx.annotation.Keep
sealed interface MainScreenUiEvent {
@Keep
data class OnDataAnalyticsConsentClick(
val agreed: Boolean
) : MainScreenUiEvent
}
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/ui/main/MainScreenUiState.kt
================================================
package com.infinitepower.newquiz.ui.main
import androidx.annotation.Keep
import com.infinitepower.newquiz.core.theme.AnimationsEnabled
/**
* Represents the state of the main screen.
*
* @param loading True if the main screen state is loading. The splash screen will be shown until this is false.
*/
@Keep
data class MainScreenUiState(
val loading: Boolean = true,
val showDataAnalyticsConsentDialog: Boolean = false,
val dailyChallengeClaimableCount: Int = 0,
val animationsEnabled: AnimationsEnabled = AnimationsEnabled(),
val userDiamonds: UInt = 0u
)
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/ui/main/MainViewModel.kt
================================================
package com.infinitepower.newquiz.ui.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.infinitepower.newquiz.core.analytics.AnalyticsHelper
import com.infinitepower.newquiz.core.datastore.common.SettingsCommon
import com.infinitepower.newquiz.core.datastore.di.SettingsDataStoreManager
import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager
import com.infinitepower.newquiz.core.theme.AnimationsEnabled
import com.infinitepower.newquiz.core.user_services.UserService
import com.infinitepower.newquiz.domain.repository.daily_challenge.DailyChallengeRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
dailyChallengeRepository: DailyChallengeRepository,
@SettingsDataStoreManager private val settingsDataStoreManager: DataStoreManager,
private val analyticsHelper: AnalyticsHelper,
private val userService: UserService
) : ViewModel() {
private val animationsEnabledFlow = combine(
settingsDataStoreManager.getPreferenceFlow(SettingsCommon.GlobalAnimationsEnabled),
settingsDataStoreManager.getPreferenceFlow(SettingsCommon.WordleAnimationsEnabled),
settingsDataStoreManager.getPreferenceFlow(SettingsCommon.MultiChoiceAnimationsEnabled),
) { globalAnimationsEnabled, wordleAnimationsEnabled, multiChoiceAnimationsEnabled ->
AnimationsEnabled(
global = globalAnimationsEnabled,
wordle = wordleAnimationsEnabled && globalAnimationsEnabled,
multiChoice = multiChoiceAnimationsEnabled && globalAnimationsEnabled
)
}
val uiState: StateFlow = combine(
animationsEnabledFlow,
analyticsHelper.showDataAnalyticsConsentDialog,
dailyChallengeRepository.getClaimableTasksCountFlow(),
userService.getUserDiamondsFlow()
) { animationsEnabled, showDataAnalyticsConsentDialog, dailyChallengeClaimableCount, userDiamonds ->
MainScreenUiState(
loading = false,
animationsEnabled = animationsEnabled,
showDataAnalyticsConsentDialog = showDataAnalyticsConsentDialog,
dailyChallengeClaimableCount = dailyChallengeClaimableCount,
userDiamonds = userDiamonds
)
}.stateIn(
scope = viewModelScope,
initialValue = MainScreenUiState(),
started = SharingStarted.WhileSubscribed(UI_STATE_STOP_TIMEOUT)
)
companion object {
private const val UI_STATE_STOP_TIMEOUT = 5_000L
}
fun onEvent(event: MainScreenUiEvent) {
when (event) {
is MainScreenUiEvent.OnDataAnalyticsConsentClick -> viewModelScope.launch(Dispatchers.IO) {
analyticsHelper.updateDataConsent(event.agreed)
}
}
}
}
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/ui/navigation/CompactNavigationContainer.kt
================================================
package com.infinitepower.newquiz.ui.navigation
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import com.infinitepower.newquiz.core.navigation.NavigationItem
import com.infinitepower.newquiz.core.theme.NewQuizTheme
import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizScreenDestination
import com.infinitepower.newquiz.ui.components.DiamondsCounter
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
/**
* Container with navigation bar and modal drawer
*/
@Composable
@ExperimentalMaterial3Api
internal fun CompactContainer(
navigator: DestinationsNavigator,
navController: NavController,
primaryItems: ImmutableList,
otherItems: ImmutableList,
selectedItem: NavigationItem.Item?,
userDiamonds: UInt = 0u,
drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
snackbarHostState: SnackbarHostState,
content: @Composable (PaddingValues) -> Unit
) {
val scope = rememberCoroutineScope()
val text = selectedItem?.text?.let { id ->
stringResource(id = id)
} ?: "NewQuiz"
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
rememberTopAppBarState()
)
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
NavigationDrawerContent(
modifier = Modifier.fillMaxHeight(),
permanent = false,
selectedItem = selectedItem,
items = otherItems,
onItemClick = { item ->
scope.launch { drawerState.close() }
navigator.navigate(item.direction)
},
)
}
) {
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
topBar = {
CenterAlignedTopAppBar(
title = {
Text(text = text)
},
scrollBehavior = scrollBehavior,
navigationIcon = {
IconButton(
onClick = {
scope.launch { drawerState.open() }
}
) {
Icon(
imageVector = Icons.Rounded.Menu,
contentDescription = "Open menu"
)
}
},
actions = {
DiamondsCounter(
diamonds = userDiamonds,
modifier = Modifier
)
}
)
},
bottomBar = {
CompactBottomBar(
selectedItem = selectedItem,
primaryItems = primaryItems,
navController = navController
)
},
content = content
)
}
}
@Composable
private fun CompactBottomBar(
modifier: Modifier = Modifier,
selectedItem: NavigationItem.Item?,
primaryItems: ImmutableList,
navController: NavController
) {
NavigationBar(
modifier = modifier,
) {
primaryItems.forEach { item ->
NavigationBarItem(
selected = item == selectedItem,
onClick = {
navController.navigate(item.direction.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
val startDestination = navController.graph.startDestinationRoute
popUpTo(startDestination ?: MultiChoiceQuizScreenDestination.route)
// Avoid multiple copies of the same destination when re-selecting the same item
launchSingleTop = true
}
},
icon = {
Icon(
imageVector = item.getIcon(item == selectedItem),
contentDescription = stringResource(id = item.text)
)
}
)
}
}
}
@Composable
@PreviewLightDark
@OptIn(ExperimentalMaterial3Api::class)
private fun CompactContainerPreview() {
val otherItems = getOtherItems()
val selectedItem = otherItems
.filterIsInstance()
.firstOrNull()
NewQuizTheme {
Surface {
CompactContainer(
navController = rememberNavController(),
navigator = EmptyDestinationsNavigator,
content = {
Text(text = "NewQuiz")
},
primaryItems = getPrimaryItems(),
otherItems = otherItems,
selectedItem = selectedItem,
userDiamonds = 100u,
snackbarHostState = SnackbarHostState()
)
}
}
}
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/ui/navigation/ExpandedNavigationContainer.kt
================================================
package com.infinitepower.newquiz.ui.navigation
import android.content.res.Configuration
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.PermanentNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.infinitepower.newquiz.core.navigation.NavigationItem
import com.infinitepower.newquiz.core.theme.NewQuizTheme
import com.infinitepower.newquiz.ui.components.DiamondsCounter
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Container with permanent navigation drawer
*/
@Composable
@ExperimentalMaterial3Api
internal fun ExpandedContainer(
navigator: DestinationsNavigator,
primaryItems: ImmutableList,
otherItems: ImmutableList,
selectedItem: NavigationItem.Item?,
userDiamonds: UInt = 0u,
snackbarHostState: SnackbarHostState,
content: @Composable (PaddingValues) -> Unit
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
rememberTopAppBarState()
)
val text = selectedItem?.text?.let { id ->
stringResource(id = id)
} ?: "NewQuiz"
val allNavigationItems = remember(primaryItems, otherItems) {
(primaryItems + otherItems).toImmutableList()
}
PermanentNavigationDrawer(
drawerContent = {
NavigationDrawerContent(
modifier = Modifier.fillMaxHeight(),
permanent = true,
selectedItem = selectedItem,
items = allNavigationItems,
onItemClick = { item ->
navigator.navigate(item.direction)
},
)
}
) {
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
topBar = {
CenterAlignedTopAppBar(
title = {
Text(text = text)
},
scrollBehavior = scrollBehavior,
actions = {
DiamondsCounter(
diamonds = userDiamonds,
modifier = Modifier
)
}
)
},
content = content
)
}
}
@Composable
@Preview(
showBackground = true,
device = "spec:width=1280dp,height=800dp,dpi=480",
group = "Expanded"
)
@Preview(
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES,
device = "spec:width=1280dp,height=800dp,dpi=480",
group = "Expanded"
)
@OptIn(ExperimentalMaterial3Api::class)
private fun MediumContainerPreview() {
val otherItems = getOtherItems()
val selectedItem = otherItems
.filterIsInstance()
.firstOrNull()
NewQuizTheme {
Surface {
ExpandedContainer(
navigator = EmptyDestinationsNavigator,
content = {
Text(text = "NewQuiz")
},
primaryItems = getPrimaryItems(),
otherItems = otherItems,
selectedItem = selectedItem,
userDiamonds = 100u,
snackbarHostState = SnackbarHostState()
)
}
}
}
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/ui/navigation/MediumNavigationContainer.kt
================================================
package com.infinitepower.newquiz.ui.navigation
import android.content.res.Configuration
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.infinitepower.newquiz.core.navigation.NavigationItem
import com.infinitepower.newquiz.core.theme.NewQuizTheme
import com.infinitepower.newquiz.ui.components.DiamondsCounter
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
/**
* Container with navigation rail and modal drawer
*/
@Composable
@ExperimentalMaterial3Api
internal fun MediumContainer(
navigator: DestinationsNavigator,
primaryItems: ImmutableList,
otherItems: ImmutableList,
selectedItem: NavigationItem.Item?,
userDiamonds: UInt = 0u,
drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
snackbarHostState: SnackbarHostState,
content: @Composable (PaddingValues) -> Unit
) {
val scope = rememberCoroutineScope()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
rememberTopAppBarState()
)
val text = selectedItem?.text?.let { id ->
stringResource(id = id)
} ?: "NewQuiz"
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
NavigationDrawerContent(
modifier = Modifier.fillMaxHeight(),
permanent = false,
selectedItem = selectedItem,
items = otherItems,
onItemClick = { item ->
scope.launch { drawerState.close() }
navigator.navigate(item.direction)
}
)
}
) {
Row {
NavigationRail(
header = {
IconButton(
onClick = {
scope.launch { drawerState.open() }
}
) {
Icon(
imageVector = Icons.Rounded.Menu,
contentDescription = "Open menu"
)
}
}
) {
primaryItems.forEach { item ->
NavigationRailItem(
selected = item == selectedItem,
onClick = { navigator.navigate(item.direction) },
icon = {
Icon(
imageVector = item.getIcon(item == selectedItem),
contentDescription = stringResource(id = item.text)
)
}
)
}
}
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
topBar = {
CenterAlignedTopAppBar(
scrollBehavior = scrollBehavior,
title = {
Text(text = text)
},
actions = {
DiamondsCounter(
diamonds = userDiamonds,
modifier = Modifier
)
}
)
},
content = content
)
}
}
}
@Composable
@Preview(
showBackground = true,
device = "spec:width=673.5dp,height=841dp,dpi=480",
group = "Medium"
)
@Preview(
showBackground = true,
uiMode = Configuration.UI_MODE_NIGHT_YES,
device = "spec:width=673.5dp,height=841dp,dpi=480",
group = "Medium"
)
@OptIn(ExperimentalMaterial3Api::class)
private fun MediumContainerPreview() {
val otherItems = getOtherItems(dailyChallengeClaimCount = 5)
val selectedItem = otherItems
.filterIsInstance()
.firstOrNull()
NewQuizTheme {
Surface {
MediumContainer(
navigator = EmptyDestinationsNavigator,
content = {
Text(text = "NewQuiz")
},
primaryItems = getPrimaryItems(),
otherItems = otherItems,
selectedItem = selectedItem,
snackbarHostState = SnackbarHostState()
)
}
}
}
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/ui/navigation/NavDrawerContent.kt
================================================
package com.infinitepower.newquiz.ui.navigation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Badge
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.PermanentDrawerSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import com.infinitepower.newquiz.core.navigation.NavigationItem
import com.infinitepower.newquiz.core.theme.spacing
import kotlinx.collections.immutable.ImmutableList
import com.infinitepower.newquiz.core.R as CoreR
@Composable
@ExperimentalMaterial3Api
internal fun NavigationDrawerContent(
modifier: Modifier = Modifier,
onItemClick: (item: NavigationItem.Item) -> Unit,
permanent: Boolean = false,
items: ImmutableList,
selectedItem: NavigationItem.Item?
) {
NavigationDrawerContainer(
modifier = modifier,
permanent = permanent
) {
NavigationDrawerContent(
items = items,
selectedItem = selectedItem,
onItemClick = onItemClick,
)
}
}
@Composable
@ExperimentalMaterial3Api
private fun NavigationDrawerContainer(
modifier: Modifier = Modifier,
permanent: Boolean,
content: @Composable ColumnScope.() -> Unit
) {
if (permanent) {
PermanentDrawerSheet(
modifier = modifier,
content = content
)
} else {
ModalDrawerSheet(
modifier = modifier,
content = content
)
}
}
@Composable
@ExperimentalMaterial3Api
private fun NavigationDrawerContent(
modifier: Modifier = Modifier,
onItemClick: (item: NavigationItem.Item) -> Unit,
items: ImmutableList,
selectedItem: NavigationItem.Item?,
) {
LazyColumn(
modifier = modifier,
contentPadding = NavigationDrawerItemDefaults.ItemPadding
) {
item {
Column(
modifier = Modifier.padding(vertical = MaterialTheme.spacing.large)
) {
Text(
text = stringResource(id = CoreR.string.app_name),
style = MaterialTheme.typography.headlineSmall
)
}
}
itemsIndexed(items = items) { index, item ->
val text = stringResource(id = item.text)
when (item) {
is NavigationItem.Label -> {
if (items.getOrNull(index - 1) is NavigationItem.Item) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.medium))
}
NavigationDrawerLabel(text = text)
Spacer(modifier = Modifier.height(MaterialTheme.spacing.medium))
}
is NavigationItem.Item -> {
NavigationDrawerItem(
icon = {
Icon(
imageVector = item.getIcon(item == selectedItem),
contentDescription = text
)
},
label = { Text(text = text) },
selected = item == selectedItem,
onClick = { onItemClick(item) },
badge = if (item.badge != null && item.badge.value > 0) {
{
Badge {
Text(
text = item.badge.value.toString(),
modifier = Modifier.semantics {
contentDescription = item.badge.description
}
)
}
}
} else {
null
}
)
}
}
}
}
}
@Composable
private fun NavigationDrawerLabel(
text: String
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge
)
}
================================================
FILE: app/src/main/java/com/infinitepower/newquiz/ui/navigation/NavigationContainer.kt
================================================
package com.infinitepower.newquiz.ui.navigation
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.List
import androidx.compose.material.icons.automirrored.rounded.ListAlt
import androidx.compose.material.icons.outlined.Compare
import androidx.compose.material.icons.outlined.ViewModule
import androidx.compose.material.icons.rounded.Image
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.ViewModule
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController
import com.infinitepower.newquiz.comparison_quiz.destinations.ComparisonQuizListScreenDestination
import com.infinitepower.newquiz.core.R
import com.infinitepower.newquiz.core.navigation.NavDrawerBadgeItem
import com.infinitepower.newquiz.core.navigation.NavigationItem
import com.infinitepower.newquiz.core.navigation.ScreenType
import com.infinitepower.newquiz.core.ui.ObserveAsEvents
import com.infinitepower.newquiz.core.ui.SnackbarController
import com.infinitepower.newquiz.core.util.asString
import com.infinitepower.newquiz.feature.daily_challenge.destinations.DailyChallengeScreenDestination
import com.infinitepower.newquiz.feature.maze.destinations.MazeScreenDestination
import com.infinitepower.newquiz.feature.profile.destinations.ProfileScreenDestination
import com.infinitepower.newquiz.feature.settings.destinations.SettingsScreenDestination
import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizListScreenDestination
import com.infinitepower.newquiz.wordle.destinations.WordleListScreenDestination
import com.ramcosta.composedestinations.spec.DestinationSpec
import com.ramcosta.composedestinations.utils.currentDestinationAsState
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
internal fun getPrimaryItems(): ImmutableList = persistentListOf(
NavigationItem.Item(
text = R.string.multi_choice_quiz,
selectedIcon = Icons.AutoMirrored.Rounded.ListAlt,
unselectedIcon = Icons.AutoMirrored.Rounded.List,
direction = MultiChoiceQuizListScreenDestination,
primary = true
),
NavigationItem.Item(
text = R.string.wordle,
selectedIcon = Icons.Rounded.ViewModule,
unselectedIcon = Icons.Outlined.ViewModule,
direction = WordleListScreenDestination,
primary = true
),
NavigationItem.Item(
text = R.string.comparison_quiz,
selectedIcon = Icons.Rounded.Image,
unselectedIcon = Icons.Outlined.Compare,
direction = ComparisonQuizListScreenDestination,
primary = true
),
)
internal fun getOtherItems(
dailyChallengeClaimCount: Int = 0
): ImmutableList = persistentListOf(
NavigationItem.Item(
text = R.string.maze,
selectedIcon = Icons.Rounded.Route,
direction = MazeScreenDestination,
screenType = ScreenType.NAVIGATION_HIDDEN
),
NavigationItem.Item(
text = R.string.daily_challenge,
selectedIcon = Icons.Rounded.Today,
direction = DailyChallengeScreenDestination,
screenType = ScreenType.NAVIGATION_HIDDEN,
badge = NavDrawerBadgeItem(
value = dailyChallengeClaimCount,
description = "Daily challenge claim count"
)
),
NavigationItem.Label(text = R.string.user),
NavigationItem.Item(
text = R.string.profile,
selectedIcon = Icons.Rounded.Person,
direction = ProfileScreenDestination,
screenType = ScreenType.NAVIGATION_HIDDEN
),
NavigationItem.Label(text = R.string.other),
NavigationItem.Item(
text = R.string.settings,
selectedIcon = Icons.Rounded.Settings,
direction = SettingsScreenDestination(),
screenType = ScreenType.NAVIGATION_HIDDEN
)
)
private fun List.getNavigationItemBy(
route: DestinationSpec<*>?
): NavigationItem.Item? = filterIsInstance()
.find { item -> item.direction == route }
@Composable
@ExperimentalMaterial3Api
internal fun NavigationContainer(
navController: NavController,
windowWidthSize: WindowWidthSizeClass,
dailyChallengeClaimCount: Int,
userDiamonds: UInt,
content: @Composable (PaddingValues) -> Unit
) {
val scope = rememberCoroutineScope()
val navigator = navController.rememberDestinationsNavigator()
val destination by navController.currentDestinationAsState()
val primaryItems = remember { getPrimaryItems() }
val otherItems = remember(dailyChallengeClaimCount) {
getOtherItems(dailyChallengeClaimCount)
}
val selectedItem = remember(primaryItems, otherItems, destination) {
primaryItems.getNavigationItemBy(destination)
?: otherItems.getNavigationItemBy(destination)
}
val navigationVisible = remember(selectedItem) {
selectedItem != null && selectedItem.screenType == ScreenType.NORMAL
}
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
ObserveAsEvents(flow = SnackbarController.events, snackbarHostState) { event ->
scope.launch {
snackbarHostState.currentSnackbarData?.dismiss()
val result = snackbarHostState.showSnackbar(
message = event.message.asString(context),
actionLabel = event.action?.name?.asString(context),
withDismissAction = event.withDismissAction,
duration = event.duration
)
if (result == SnackbarResult.ActionPerformed) {
event.action?.action?.invoke()
}
}
}
if (navigationVisible) {
when (windowWidthSize) {
WindowWidthSizeClass.Compact -> CompactContainer(
navigator = navigator,
navController = navController,
primaryItems = primaryItems,
otherItems = otherItems,
selectedItem = selectedItem,
userDiamonds = userDiamonds,
snackbarHostState = snackbarHostState,
content = content
)
WindowWidthSizeClass.Medium -> MediumContainer(
navigator = navigator,
primaryItems = primaryItems,
otherItems = otherItems,
selectedItem = selectedItem,
userDiamonds = userDiamonds,
snackbarHostState = snackbarHostState,
content = content
)
WindowWidthSizeClass.Expanded -> ExpandedContainer(
navigator = navigator,
primaryItems = primaryItems,
otherItems = otherItems,
selectedItem = selectedItem,
userDiamonds = userDiamonds,
snackbarHostState = snackbarHostState,
content = content
)
}
} else {
Scaffold(
content = content,
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
// This Scaffold only manages Snackbar display and shouldn't handle window insets.
// Insets are delegated to parent layouts or other Scaffolds in the composable hierarchy.
contentWindowInsets = WindowInsets(0, 0, 0, 0)
)
}
}
================================================
FILE: app/src/main/res/drawable/round_password_24.xml
================================================
================================================
FILE: app/src/main/res/drawable/round_play_circle_24.xml
================================================
================================================
FILE: app/src/main/res/drawable/round_quiz_24.xml
================================================
================================================
FILE: app/src/main/res/resources.properties
================================================
unqualifiedResLocale=en
================================================
FILE: app/src/main/res/values/colors.xml
================================================
#FF000000
#FFFFFFFF
================================================
FILE: app/src/main/res/values/leak_canary.xml
================================================
================================================
FILE: app/src/main/res/values/splash_screen.xml
================================================
================================================
FILE: app/src/main/res/values/strings.xml
================================================
NewQuiz
================================================
FILE: app/src/main/res/values-night/splash_screen.xml
================================================
================================================
FILE: app/src/main/res/xml-v25/shortcuts.xml
================================================
================================================
FILE: build-logic/.gitignore
================================================
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/convention/build
/captures
.externalNativeBuild
.cxx
local.properties
================================================
FILE: build-logic/README.md
================================================
# Build logic
The build-logic folder defines project-specific convention plugins, used to keep a single source of truth for common module configurations.
Based in the [Now in Android](https://github.com/android/nowinandroid) project.
================================================
FILE: build-logic/convention/build.gradle.kts
================================================
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
`kotlin-dsl`
alias(libs.plugins.detekt)
}
group = "com.infinitepower.newquiz.buildlogic"
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
tasks {
validatePlugins {
enableStricterValidation = true
failOnWarning = true
}
}
dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.firebase.crashlytics.gradlePlugin)
compileOnly(libs.firebase.performance.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin)
compileOnly(libs.compose.gradlePlugin)
implementation(libs.detekt.gradlePlugin)
}
detekt {
buildUponDefaultConfig = true
config.from(file("../../detekt.yml"))
}
gradlePlugin {
plugins {
register("androidApplicationCompose") {
id = "newquiz.android.application.compose"
implementationClass = "AndroidApplicationComposeConventionPlugin"
}
register("androidApplication") {
id = "newquiz.android.application"
implementationClass = "AndroidApplicationConventionPlugin"
}
register("androidLibraryCompose") {
id = "newquiz.android.library.compose"
implementationClass = "AndroidLibraryComposeConventionPlugin"
}
register("androidLibrary") {
id = "newquiz.android.library"
implementationClass = "AndroidLibraryConventionPlugin"
}
register("androidHilt") {
id = "newquiz.android.hilt"
implementationClass = "AndroidHiltConventionPlugin"
}
register("androidRoom") {
id = "newquiz.android.room"
implementationClass = "AndroidRoomConventionPlugin"
}
register("androidFirebase") {
id = "newquiz.android.application.firebase"
implementationClass = "AndroidApplicationFirebaseConventionPlugin"
}
register("jvmLibrary") {
id = "newquiz.jvm.library"
implementationClass = "JvmLibraryConventionPlugin"
}
register("kotlinSerialization") {
id = "newquiz.kotlin.serialization"
implementationClass = "KotlinSerializationConventionPlugin"
}
register("androidComposeDestinations") {
id = "newquiz.android.compose.destinations"
implementationClass = "AndroidComposeDestinationsConventionPlugin"
}
register("androidFeature") {
id = "newquiz.android.feature"
implementationClass = "AndroidFeatureConventionPlugin"
}
register("detekt") {
id = "newquiz.detekt"
implementationClass = "DetektConventionPlugin"
}
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt
================================================
import com.android.build.api.dsl.ApplicationExtension
import com.infinitepower.newquiz.configureAndroidCompose
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.getByType
class AndroidApplicationComposeConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.application")
val extension = extensions.getByType()
configureAndroidCompose(extension)
}
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
================================================
import com.android.build.api.dsl.ApplicationExtension
import com.infinitepower.newquiz.NewQuizFlavor
import com.infinitepower.newquiz.ProjectConfig
import com.infinitepower.newquiz.configureFlavors
import com.infinitepower.newquiz.configureKotlinAndroid
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.support.uppercaseFirstChar
class AndroidApplicationConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.application")
apply("org.jetbrains.kotlin.android")
}
extensions.configure {
configureKotlinAndroid(this)
configureFlavors(this)
defaultConfig {
targetSdk = ProjectConfig.targetSdk
}
}
// Apply the Firebase plugin only for the "Normal" build type
val gradleTaskRequests = gradle.startParameter.taskRequests.toString()
val normalFlavor = NewQuizFlavor.normal.name.uppercaseFirstChar()
// Check if the flavor name is in the gradle task requests,
// like "assembleNormalDebug" or "assembleFossDebug"
if (gradleTaskRequests.contains(normalFlavor)) {
apply(plugin = "newquiz.android.application.firebase")
logger.info("Applied Firebase plugin for normal build type")
}
tasks.register("testAllUnitTest") {
// Only run debug tests
dependsOn(
getTasksByName("testNormalDebugUnitTest", true),
getTasksByName("testFossDebugUnitTest", true),
)
}
}
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt
================================================
import com.infinitepower.newquiz.implementation
import com.infinitepower.newquiz.libs
import com.infinitepower.newquiz.normalImplementation
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class AndroidApplicationFirebaseConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.google.gms.google-services")
apply("com.google.firebase.firebase-perf")
apply("com.google.firebase.crashlytics")
}
dependencies {
val bom = libs.findLibrary("firebase-bom").get()
implementation(platform(bom))
normalImplementation(libs.findLibrary("firebase-analytics").get())
normalImplementation(libs.findLibrary("firebase-crashlytics").get())
normalImplementation(libs.findLibrary("firebase-perf").get())
}
}
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/AndroidComposeDestinationsConventionPlugin.kt
================================================
import com.google.devtools.ksp.gradle.KspExtension
import com.infinitepower.newquiz.implementation
import com.infinitepower.newquiz.ksp
import com.infinitepower.newquiz.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
class AndroidComposeDestinationsConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
plugins.apply("com.google.devtools.ksp")
dependencies {
implementation(libs.findLibrary("compose.destinations.core").get())
ksp(libs.findLibrary("compose.destinations.ksp").get())
}
extensions.configure {
val mode = "destinations"
val moduleName = target.name
logger.info("Configuring compose-destinations for $moduleName, with mode: $mode")
arg("compose-destinations.mode", mode)
arg("compose-destinations.moduleName", moduleName)
}
}
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
================================================
import com.infinitepower.newquiz.debugImplementation
import com.infinitepower.newquiz.implementation
import com.infinitepower.newquiz.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class AndroidFeatureConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
pluginManager.apply {
apply("newquiz.android.library.compose")
apply("newquiz.android.hilt")
}
dependencies {
implementation(project(":model"))
implementation(project(":core"))
implementation(project(":core:analytics"))
implementation(libs.findLibrary("androidx.compose.foundation").get())
implementation(libs.findLibrary("androidx.compose.material3").get())
implementation(libs.findLibrary("androidx.compose.runtime").get())
implementation(libs.findLibrary("androidx.compose.ui.tooling.preview").get())
debugImplementation(libs.findLibrary("androidx.compose.ui.tooling").get())
implementation(libs.findLibrary("hilt.navigationCompose").get())
implementation(libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
implementation(libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
implementation(libs.findLibrary("kotlinx.coroutines.android").get())
}
}
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt
================================================
import com.infinitepower.newquiz.androidTestImplementation
import com.infinitepower.newquiz.implementation
import com.infinitepower.newquiz.ksp
import com.infinitepower.newquiz.kspAndroidTest
import com.infinitepower.newquiz.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class AndroidHiltConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("dagger.hilt.android.plugin")
apply("com.google.devtools.ksp")
}
dependencies {
// Base dependencies of hilt
implementation(libs.findLibrary("hilt.android").get())
ksp(libs.findLibrary("hilt.compiler").get())
// Dependencies for testing
kspAndroidTest(libs.findLibrary("hilt.compiler").get())
androidTestImplementation(libs.findLibrary("hilt.android.testing").get())
}
}
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt
================================================
import com.android.build.api.dsl.LibraryExtension
import com.infinitepower.newquiz.configureAndroidCompose
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
class AndroidLibraryComposeConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("newquiz.android.library")
}
extensions.configure {
configureAndroidCompose(this)
}
}
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
================================================
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.infinitepower.newquiz.ProjectConfig
import com.infinitepower.newquiz.androidTestImplementation
import com.infinitepower.newquiz.configureFlavors
import com.infinitepower.newquiz.configureKotlinAndroid
import com.infinitepower.newquiz.disableUnnecessaryAndroidTests
import com.infinitepower.newquiz.libs
import com.infinitepower.newquiz.testImplementation
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.kotlin
import org.gradle.kotlin.dsl.withType
class AndroidLibraryConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
}
extensions.configure {
configureKotlinAndroid(this)
configureFlavors(this)
defaultConfig {
targetSdk = ProjectConfig.targetSdk
testInstrumentationRunner = "com.infinitepower.newquiz.core.testing.NewQuizTestRunner"
}
}
extensions.configure {
disableUnnecessaryAndroidTests(target)
}
dependencies {
// Test libraries
testImplementation(kotlin("test"))
testImplementation(libs.findLibrary("google.truth").get())
testImplementation(libs.findLibrary("mockk").get())
testImplementation(libs.findLibrary("kotlinx.coroutines.test").get())
testImplementation(libs.findLibrary("junit.jupiter.params").get())
testImplementation(libs.findLibrary("turbine").get())
androidTestImplementation(kotlin("test"))
androidTestImplementation(libs.findLibrary("google.truth").get())
androidTestImplementation(libs.findLibrary("mockk.android").get())
androidTestImplementation(libs.findLibrary("kotlinx.coroutines.test").get())
androidTestImplementation(libs.findLibrary("androidx.test.runner").get())
androidTestImplementation(libs.findLibrary("androidx.test.rules").get())
androidTestImplementation(libs.findLibrary("androidx.compose.ui.test").get())
androidTestImplementation(libs.findLibrary("turbine").get())
androidTestImplementation(project(":core:testing"))
// Fix for problem of duplicate classes with guava
modules {
module("com.google.guava:listenablefuture") {
replacedBy("com.google.guava:guava", "listenablefuture is part of guava")
}
}
}
tasks.withType {
useJUnitPlatform()
}
tasks.register("testAllUnitTest") {
// Only run debug tests
dependsOn(
getTasksByName("testNormalDebugUnitTest", true),
getTasksByName("testFossDebugUnitTest", true),
)
}
}
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt
================================================
import com.google.devtools.ksp.gradle.KspExtension
import com.infinitepower.newquiz.implementation
import com.infinitepower.newquiz.ksp
import com.infinitepower.newquiz.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.process.CommandLineArgumentProvider
import java.io.File
class AndroidRoomConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.google.devtools.ksp")
extensions.configure {
// The schemas directory contains a schema file for each version of the Room database.
// This is required to enable Room auto migrations.
// See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration.
arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}
dependencies {
implementation(libs.findLibrary("room.runtime").get())
implementation(libs.findLibrary("room.ktx").get())
ksp(libs.findLibrary("room.compiler").get())
}
}
}
class RoomSchemaArgProvider(
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
val schemaDir: File,
) : CommandLineArgumentProvider {
override fun asArguments() = listOf("room.schemaLocation=${schemaDir.path}")
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/DetektConventionPlugin.kt
================================================
import com.infinitepower.newquiz.libs
import io.gitlab.arturbosch.detekt.Detekt
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.withType
class DetektConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
pluginManager.apply(
libs.findLibrary("detekt.gradlePlugin").get().get().group.toString()
)
tasks.withType {
buildUponDefaultConfig = true
basePath = target.rootProject.projectDir.absolutePath
val localDetektConfig = target.file("detekt.yml")
val rootDetektConfig = target.rootProject.file("detekt.yml")
val rootDetektComposeConfig = target.rootProject.file("detekt-compose.yml")
if (localDetektConfig.exists()) {
config.from(
localDetektConfig,
rootDetektConfig,
rootDetektComposeConfig
)
} else {
config.from(rootDetektConfig, rootDetektComposeConfig)
}
reports {
sarif.required.set(true)
}
}
dependencies {
add("detektPlugins", libs.findLibrary("detekt.compose").get())
}
}
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt
================================================
import com.infinitepower.newquiz.configureKotlinJvm
import com.infinitepower.newquiz.libs
import com.infinitepower.newquiz.testImplementation
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.kotlin
import org.gradle.kotlin.dsl.register
import org.gradle.kotlin.dsl.withType
class JvmLibraryConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("org.jetbrains.kotlin.jvm") // kotlin("jvm")
}
configureKotlinJvm()
dependencies {
// Test libraries
testImplementation(kotlin("test"))
testImplementation(libs.findLibrary("google.truth").get())
testImplementation(libs.findLibrary("mockk").get())
testImplementation(libs.findLibrary("kotlinx.coroutines.test").get())
testImplementation(libs.findLibrary("junit.jupiter.params").get())
testImplementation(libs.findLibrary("turbine").get())
}
tasks.withType {
useJUnitPlatform()
}
tasks.register("testAllUnitTest") {
dependsOn(
getTasksByName("test", true),
)
}
}
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/KotlinSerializationConventionPlugin.kt
================================================
import com.infinitepower.newquiz.implementation
import com.infinitepower.newquiz.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class KotlinSerializationConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
plugins.apply("kotlinx-serialization")
dependencies {
implementation(libs.findLibrary("kotlinx.serialization.json").get())
}
}
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/com/infinitepower/newquiz/AndroidCompose.kt
================================================
package com.infinitepower.newquiz
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
internal fun Project.configureAndroidCompose(
applicationExtension: CommonExtension<*, *, *, *, *, *>
) {
pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
applicationExtension.apply {
buildFeatures {
compose = true
}
dependencies {
val bom = libs.findLibrary("androidx-compose-bom").get()
implementation(platform(bom))
androidTestImplementation(platform(bom))
implementation(libs.findLibrary("androidx-compose-ui-tooling-preview").get())
// Add ComponentActivity to debug manifest
debugImplementation(libs.findLibrary("androidx-compose-ui-tooling").get())
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
extensions.configure {
fun Provider.onlyIfTrue() = flatMap { provider { it.takeIf(String::toBoolean) } }
fun Provider<*>.relativeToRootProject(dir: String) = flatMap {
rootProject.layout.buildDirectory.dir(projectDir.toRelativeString(rootDir))
}.map { it.dir(dir) }
project.providers.gradleProperty("enableComposeCompilerMetrics").onlyIfTrue()
.relativeToRootProject("compose-metrics")
.let(metricsDestination::set)
project.providers.gradleProperty("enableComposeCompilerReports").onlyIfTrue()
.relativeToRootProject("compose-reports")
.let(reportsDestination::set)
stabilityConfigurationFile.set(rootProject.layout.projectDirectory.file("compose_compiler_config.conf"))
enableStrongSkippingMode.set(true)
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/com/infinitepower/newquiz/AndroidInstrumentedTests.kt
================================================
package com.infinitepower.newquiz
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import org.gradle.api.Project
/**
* Disable android tests if there is no androidTest folder.
*/
internal fun LibraryAndroidComponentsExtension.disableUnnecessaryAndroidTests(
project: Project,
) = beforeVariants {
it.enableAndroidTest = it.enableAndroidTest
&& project.projectDir.resolve("src/androidTest").exists()
}
================================================
FILE: build-logic/convention/src/main/kotlin/com/infinitepower/newquiz/Flavors.kt
================================================
package com.infinitepower.newquiz
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.ApplicationProductFlavor
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.ProductFlavor
@Suppress("EnumEntryName", "EnumNaming")
enum class FlavorDimension {
distribution,
// translation
}
@Suppress("EnumEntryName", "EnumNaming")
enum class NewQuizFlavor(
val dimension: FlavorDimension,
val applicationIdSuffix: String? = null
) {
normal(FlavorDimension.distribution),
foss(FlavorDimension.distribution),
// translation(FlavorDimension.translation),
// noTranslation(FlavorDimension.translation),
}
fun configureFlavors(
commonExtension: CommonExtension<*, *, *, *, *, *>,
flavorConfigurationBlock: ProductFlavor.(flavor: NewQuizFlavor) -> Unit = {}
) {
commonExtension.apply {
flavorDimensions += FlavorDimension.values().map { it.name }
productFlavors {
NewQuizFlavor.values().forEach { flavor ->
create(flavor.name) {
dimension = flavor.dimension.name
flavorConfigurationBlock(this, flavor)
if (this@apply is ApplicationExtension && this is ApplicationProductFlavor) {
if (flavor.applicationIdSuffix != null) {
applicationIdSuffix = flavor.applicationIdSuffix
}
}
}
}
}
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/com/infinitepower/newquiz/KotlinAndroid.kt
================================================
package com.infinitepower.newquiz
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, * , * , *, *, *>
) {
commonExtension.apply {
compileSdk = ProjectConfig.compileSdk
defaultConfig {
minSdk = ProjectConfig.minSdk
}
compileOptions {
sourceCompatibility = ProjectConfig.javaVersionCompatibility
targetCompatibility = ProjectConfig.javaVersionCompatibility
isCoreLibraryDesugaringEnabled = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "/META-INF/LICENSE.md"
excludes += "/META-INF/LICENSE-notice.md"
}
}
}
configureKotlinAndroid()
dependencies {
coreLibraryDesugaring(libs.findLibrary("android.desugarJdkLibs").get())
}
}
internal fun Project.configureKotlinJvm() {
extensions.apply {
configure {
sourceCompatibility = ProjectConfig.javaVersionCompatibility
targetCompatibility = ProjectConfig.javaVersionCompatibility
}
configure {
jvmToolchain(ProjectConfig.jvmToolchainVersion)
}
}
}
private fun Project.configureKotlinAndroid() {
tasks.withType().configureEach {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn")
}
}
extensions.configure {
jvmToolchain(ProjectConfig.jvmToolchainVersion)
}
}
================================================
FILE: build-logic/convention/src/main/kotlin/com/infinitepower/newquiz/ProjectConfig.kt
================================================
package com.infinitepower.newquiz
import org.gradle.api.JavaVersion
object ProjectConfig {
const val compileSdk = 35
const val minSdk = 21
const val targetSdk = 35
val javaVersionCompatibility = JavaVersion.VERSION_17
const val jvmTargetVersion = "17"
const val jvmToolchainVersion = 17
}
================================================
FILE: build-logic/convention/src/main/kotlin/com/infinitepower/newquiz/ProjectExtensions.kt
================================================
package com.infinitepower.newquiz
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
val Project.libs
get(): VersionCatalog = extensions.getByType().named("libs")
================================================
FILE: build-logic/convention/src/main/kotlin/com/infinitepower/newquiz/Utils.kt
================================================
package com.infinitepower.newquiz
import org.gradle.api.artifacts.dsl.DependencyHandler
internal fun DependencyHandler.implementation(dependencyNotation: Any) =
add("implementation", dependencyNotation)
internal fun DependencyHandler.testImplementation(dependencyNotation: Any) =
add("testImplementation", dependencyNotation)
internal fun DependencyHandler.androidTestImplementation(dependencyNotation: Any) =
add("androidTestImplementation", dependencyNotation)
internal fun DependencyHandler.ksp(dependencyNotation: Any) =
add("ksp", dependencyNotation)
internal fun DependencyHandler.kspAndroidTest(dependencyNotation: Any) =
add("kspAndroidTest", dependencyNotation)
internal fun DependencyHandler.normalImplementation(dependencyNotation: Any) =
add("normalImplementation", dependencyNotation)
internal fun DependencyHandler.coreLibraryDesugaring(dependencyNotation: Any) =
add("coreLibraryDesugaring", dependencyNotation)
internal fun DependencyHandler.debugImplementation(dependencyNotation: Any) =
add("debugImplementation", dependencyNotation)
================================================
FILE: build-logic/gradle.properties
================================================
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configureondemand=true
================================================
FILE: build-logic/settings.gradle.kts
================================================
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
include(":convention")
================================================
FILE: build.gradle.kts
================================================
import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath(libs.google.oss.licenses.plugin) {
exclude(group = "com.google.protobuf")
}
}
}
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.firebase.crashlytics) apply false
alias(libs.plugins.firebase.perf) apply false
alias(libs.plugins.gms) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.compose.compiler) apply false
id("com.github.ben-manes.versions") version "0.51.0"
}
tasks.register("clean", Delete::class) {
delete(layout.buildDirectory)
}
tasks.withType {
rejectVersionIf {
// Don't allow non-stable versions, unless we are already using one for this dependency
isNonStable(candidate.version) && !isNonStable(currentVersion)
}
}
/**
* Decides if this version is stable or not.
*/
fun isNonStable(version: String): Boolean {
val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) }
val regex = "^[0-9,.v-]+(-r)?$".toRegex()
val isStable = stableKeyword || regex.matches(version)
return !isStable
}
================================================
FILE: comparison-quiz/.gitignore
================================================
/build
================================================
FILE: comparison-quiz/README.md
================================================
# Comparison quiz
This is the code for the comparison game mode.

================================================
FILE: comparison-quiz/build.gradle.kts
================================================
plugins {
alias(libs.plugins.newquiz.android.library.compose)
alias(libs.plugins.newquiz.android.hilt)
alias(libs.plugins.newquiz.android.compose.destinations)
alias(libs.plugins.newquiz.kotlin.serialization)
alias(libs.plugins.newquiz.detekt)
}
android {
namespace = "com.infinitepower.newquiz.comparison_quiz"
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.compose.material.iconsExtended)
debugImplementation(libs.androidx.compose.ui.testManifest)
implementation(libs.androidx.constraintlayout.compose)
androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
implementation(libs.hilt.navigationCompose)
implementation(libs.kotlinx.serialization.json)
implementation(libs.coil.kt.compose)
implementation(libs.coil.kt.svg)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.serialization)
implementation(libs.androidx.work.ktx)
androidTestImplementation(libs.androidx.work.testing)
implementation(libs.lottie.compose)
// Hilt work manager
implementation(libs.hilt.ext.work)
ksp(libs.hilt.ext.compiler)
implementation(projects.core)
implementation(projects.core.analytics)
implementation(projects.core.remoteConfig)
implementation(projects.core.userServices)
implementation(projects.model)
implementation(projects.domain)
implementation(projects.data)
testImplementation(projects.core.testing)
androidTestImplementation(projects.core.testing)
}
tasks.withType {
useJUnitPlatform()
}
ksp {
arg("compose-destinations.mode", "destinations")
arg("compose-destinations.moduleName", "comparison-quiz")
}
================================================
FILE: comparison-quiz/consumer-rules.pro
================================================
================================================
FILE: comparison-quiz/proguard-rules.pro
================================================
# Temporary fix
-dontwarn java.lang.invoke.StringConcatFactory
================================================
FILE: comparison-quiz/src/androidTest/AndroidManifest.xml
================================================
================================================
FILE: comparison-quiz/src/androidTest/java/com/infinitepower/newquiz/comparison_quiz/data/comparison_quiz/FakeComparisonQuizRepositoryImpl.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.data.comparison_quiz
import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository
import com.infinitepower.newquiz.model.FlowResource
import com.infinitepower.newquiz.model.Resource
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlin.random.Random
class FakeComparisonQuizRepositoryImpl(
private val initialQuestions: List = emptyList(),
private val initialCategories: List = emptyList()
) : ComparisonQuizRepository {
private val questions = mutableListOf()
private val categories = mutableListOf()
private val highestPosition = MutableStateFlow(0)
override fun getCategories(): List = categories.toList()
init {
questions.addAll(initialQuestions)
categories.addAll(initialCategories)
}
override fun getQuestions(
category: ComparisonQuizCategory,
size: Int,
random: Random
): Flow> = flowOf(questions.take(size))
override suspend fun getHighestPosition(categoryId: String): Int {
return highestPosition.first()
}
override fun getHighestPositionFlow(categoryId: String): Flow {
return highestPosition.map { it }
}
}
================================================
FILE: comparison-quiz/src/androidTest/java/com/infinitepower/newquiz/comparison_quiz/list/components/ComparisonModeComponentTest.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.list.components
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.google.common.truth.Truth.assertThat
import com.infinitepower.newquiz.core.testing.ui.theme.NewQuizTestTheme
import com.infinitepower.newquiz.core.testing.utils.setTestDeviceLocale
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@SmallTest
@RunWith(AndroidJUnit4::class)
internal class ComparisonModeComponentTest {
@get:Rule
val componentRule = createComposeRule()
@Test
fun comparisonModeComponent_greater_shouldDisplayTitleAndIcon() {
componentRule.setContent {
com.infinitepower.newquiz.core.testing.utils.setTestDeviceLocale()
com.infinitepower.newquiz.core.testing.ui.theme.NewQuizTestTheme {
ComparisonModeComponent(
selected = true,
mode = ComparisonMode.GREATER
)
}
}
// Assert that the title is displayed and has the correct text
componentRule
.onNodeWithText("Greater")
.assertExists()
.assertIsDisplayed()
// Assert that the icon is displayed and has the correct content description
componentRule
.onNodeWithContentDescription("Icon of Greater")
.assertExists()
.assertIsDisplayed()
}
@Test
fun comparisonModeComponent_lesser_shouldDisplayTitleAndIcon() {
componentRule.setContent {
com.infinitepower.newquiz.core.testing.utils.setTestDeviceLocale()
com.infinitepower.newquiz.core.testing.ui.theme.NewQuizTestTheme {
ComparisonModeComponent(
selected = true,
mode = ComparisonMode.LESSER
)
}
}
// Assert that the title is displayed and has the correct text
componentRule
.onNodeWithText("Lesser")
.assertExists()
.assertIsDisplayed()
// Assert that the icon is displayed and has the correct content description
componentRule
.onNodeWithContentDescription("Icon of Lesser")
.assertExists()
.assertIsDisplayed()
}
@Test
fun comparisonModeComponent_shouldInvokeOnClick_whenEnabled() {
var clicked = false
componentRule.setContent {
com.infinitepower.newquiz.core.testing.utils.setTestDeviceLocale()
com.infinitepower.newquiz.core.testing.ui.theme.NewQuizTestTheme {
ComparisonModeComponent(
selected = true,
enabled = true,
mode = ComparisonMode.LESSER,
onClick = { clicked = true },
modifier = Modifier.testTag("ComparisonModeComponent")
)
}
}
// Click on the Composable
componentRule
.onNodeWithTag("ComparisonModeComponent")
.assertIsDisplayed()
.assertIsEnabled()
.assertHasClickAction()
.performClick()
// Assert that the onClick callback was invoked
assertThat(clicked).isTrue()
}
@Test
fun comparisonModeComponent_shouldNotInvokeOnClick_whenNotEnabled() {
var clicked = false
componentRule.setContent {
com.infinitepower.newquiz.core.testing.utils.setTestDeviceLocale()
com.infinitepower.newquiz.core.testing.ui.theme.NewQuizTestTheme {
ComparisonModeComponent(
selected = true,
enabled = false,
mode = ComparisonMode.LESSER,
onClick = { clicked = true },
modifier = Modifier.testTag("ComparisonModeComponent")
)
}
}
// Click on the Composable
componentRule
.onNodeWithTag("ComparisonModeComponent")
.assertIsDisplayed()
.assertIsNotEnabled()
.performClick()
// Assert that the onClick callback was invoked
assertThat(clicked).isFalse()
}
@Test
fun comparisonModeComponent_shouldBeSelected() {
componentRule.setContent {
com.infinitepower.newquiz.core.testing.utils.setTestDeviceLocale()
com.infinitepower.newquiz.core.testing.ui.theme.NewQuizTestTheme {
ComparisonModeComponent(
selected = true,
mode = ComparisonMode.LESSER,
modifier = Modifier.testTag("ComparisonModeComponent")
)
}
}
componentRule
.onNodeWithTag("ComparisonModeComponent")
.assertIsDisplayed()
.assertIsSelected()
}
@Test
fun comparisonModeComponent_should_not_beSelected() {
componentRule.setContent {
com.infinitepower.newquiz.core.testing.utils.setTestDeviceLocale()
com.infinitepower.newquiz.core.testing.ui.theme.NewQuizTestTheme {
ComparisonModeComponent(
selected = false,
mode = ComparisonMode.LESSER,
modifier = Modifier.testTag("ComparisonModeComponent")
)
}
}
componentRule
.onNodeWithTag("ComparisonModeComponent")
.assertIsDisplayed()
.assertIsNotSelected()
}
}
================================================
FILE: comparison-quiz/src/androidTest/java/com/infinitepower/newquiz/comparison_quiz/list/components/ComparisonModeComponentsTest.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.list.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.google.common.truth.Truth.assertThat
import com.infinitepower.newquiz.core.testing.utils.setTestContent
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@SmallTest
@RunWith(AndroidJUnit4::class)
internal class ComparisonModeComponentsTest {
@get:Rule
val componentRule = createComposeRule()
@Test
fun comparisonModeComponent_greater_shouldBeSelected() {
componentRule.setTestContent {
ComparisonModeComponents(
modifier = Modifier.fillMaxWidth(),
selectedMode = ComparisonMode.GREATER
)
}
// Assert that the greater text and icon are displayed and content selected
componentRule
.onNodeWithText("Greater")
.assertExists()
.assertIsDisplayed()
.assertIsSelected()
componentRule
.onNodeWithContentDescription("Icon of Greater")
.assertExists()
.assertIsDisplayed()
// Assert that the lesser text and icon are displayed and content not selected
componentRule
.onNodeWithText("Lesser")
.assertExists()
.assertIsDisplayed()
.assertIsNotSelected()
componentRule
.onNodeWithContentDescription("Icon of Lesser")
.assertExists()
.assertIsDisplayed()
}
@Test
fun comparisonModeComponent_lesser_shouldBeSelected() {
componentRule.setTestContent {
ComparisonModeComponents(
modifier = Modifier.fillMaxWidth(),
selectedMode = ComparisonMode.LESSER
)
}
// Assert that the greater text and icon are displayed and content selected
componentRule
.onNodeWithText("Greater")
.assertExists()
.assertIsDisplayed()
.assertIsNotSelected()
componentRule
.onNodeWithContentDescription("Icon of Greater")
.assertExists()
.assertIsDisplayed()
// Assert that the lesser text and icon are displayed and content not selected
componentRule
.onNodeWithText("Lesser")
.assertExists()
.assertIsDisplayed()
.assertIsSelected()
componentRule
.onNodeWithContentDescription("Icon of Lesser")
.assertExists()
.assertIsDisplayed()
}
@Test
fun row_hasCorrectModifiers() {
componentRule.setTestContent {
ComparisonModeComponents(
modifier = Modifier
.fillMaxWidth()
.testTag("ComparisonModeComponents"),
selectedMode = ComparisonMode.GREATER
)
}
componentRule
.onNodeWithTag("ComparisonModeComponents")
.onChildren()
.assertCountEquals(2)
componentRule
.onNodeWithTag("ComparisonModeComponents")
.onChildren()
.onFirst()
.assertTextEquals("Greater")
.assertIsDisplayed()
.assertIsSelected()
componentRule
.onNodeWithTag("ComparisonModeComponents")
.onChildren()
.onLast()
.assertTextEquals("Lesser")
.assertIsDisplayed()
.assertIsNotSelected()
}
@Test
fun onModeClick_greater_onClick_when_otherModeIsSelected() {
var selectedMode by mutableStateOf(ComparisonMode.GREATER)
componentRule.setTestContent {
ComparisonModeComponents(
selectedMode = selectedMode,
onModeClick = { mode -> selectedMode = mode }
)
}
componentRule
.onNodeWithText("Greater")
.assertIsDisplayed()
.assertIsEnabled()
.assertIsSelected()
// Click on the LESSER mode.
componentRule
.onNodeWithText("Lesser")
.assertIsDisplayed()
.assertIsEnabled()
.assertIsNotSelected()
.assertHasClickAction()
.performClick() // Click on the mode that is not selected.
.assertIsSelected() // This should select the mode.
componentRule
.onNodeWithText("Greater")
.assertIsDisplayed()
.assertIsEnabled()
.assertIsNotSelected()
// Verify that the onModeClick callback is called with the correct mode.
assertThat(selectedMode).isNotNull()
assertThat(selectedMode).isEqualTo(ComparisonMode.LESSER)
}
@Test
fun onModeClick_greater_onClick_when_sameModeIsSelected() {
var selectedMode by mutableStateOf(ComparisonMode.GREATER)
componentRule.setTestContent {
ComparisonModeComponents(
selectedMode = selectedMode,
onModeClick = { mode -> selectedMode = mode }
)
}
componentRule
.onNodeWithText("Lesser")
.assertIsDisplayed()
.assertIsEnabled()
.assertIsNotSelected()
// Click on the GREATER mode.
componentRule
.onNodeWithText("Greater")
.assertIsDisplayed()
.assertIsEnabled()
.assertIsSelected()
.assertHasClickAction()
.performClick() // Click on the mode that is already selected.
.assertIsSelected() // This should not do anything since the mode is already selected.
componentRule
.onNodeWithText("Lesser")
.assertIsDisplayed()
.assertIsEnabled()
.assertIsNotSelected()
// Verify that the onModeClick callback is called with the correct mode.
assertThat(selectedMode).isNotNull()
assertThat(selectedMode).isEqualTo(ComparisonMode.GREATER)
}
}
================================================
FILE: comparison-quiz/src/androidTest/java/com/infinitepower/newquiz/comparison_quiz/ui/ComparisonQuizScreenTest.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.ui
import androidx.activity.ComponentActivity
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.WorkManager
import androidx.work.testing.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import com.google.common.truth.Truth.assertThat
import com.infinitepower.newquiz.comparison_quiz.core.ComparisonQuizCoreImpl
import com.infinitepower.newquiz.comparison_quiz.data.comparison_quiz.FakeComparisonQuizRepositoryImpl
import com.infinitepower.newquiz.core.analytics.LocalDebugAnalyticsHelper
import com.infinitepower.newquiz.core.game.ComparisonQuizCore
import com.infinitepower.newquiz.core.remote_config.RemoteConfig
import com.infinitepower.newquiz.core.user_services.UserService
import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository
import com.infinitepower.newquiz.domain.repository.home.RecentCategoriesRepository
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory
import com.infinitepower.newquiz.model.NumberFormatType
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizHelperValueState
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItem
import com.infinitepower.newquiz.model.toUiText
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockk
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.net.URI
import javax.inject.Inject
/**
* Tests for [ComparisonQuizScreen].
*/
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
@OptIn(
ExperimentalMaterial3WindowSizeClassApi::class,
ExperimentalTestApi::class
)
internal class ComparisonQuizScreenTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@get:Rule
val composeTestRule = createAndroidComposeRule()
private lateinit var comparisonQuizRepository: ComparisonQuizRepository
@Inject lateinit var userService: UserService
private val remoteConfig = mockk(relaxed = true)
private val recentCategoriesRepository = mockk(relaxed = true)
private lateinit var comparisonQuizCore: ComparisonQuizCore
private lateinit var viewModel: ComparisonQuizViewModel
private lateinit var workManager: WorkManager
private val comparisonMode by lazy { ComparisonMode.GREATER }
private val category by lazy {
ComparisonQuizCategory(
id = "numbers",
name = "Numbers".toUiText(),
description = "Numbers description",
image = "",
questionDescription = ComparisonQuizCategory.QuestionDescription(
greater = "Which number is greater?",
less = "Which number is lesser?",
),
formatType = NumberFormatType.DEFAULT,
dataSourceAttribution = ComparisonQuizCategory.DataSourceAttribution(
text = "NewQuiz API",
logo = ""
)
)
}
private val firstItemHelperValueState = ComparisonQuizHelperValueState.HIDDEN
@Before
fun setUp() {
hiltRule.inject()
comparisonQuizRepository = FakeComparisonQuizRepositoryImpl(
initialQuestions = listOf(
ComparisonQuizItem(
title = "Question 1",
value = 1.0,
imgUri = URI("")
),
ComparisonQuizItem(
title = "Question 2",
value = 2.0,
imgUri = URI("")
),
ComparisonQuizItem(
title = "Question 3",
value = 3.0,
imgUri = URI("")
),
),
initialCategories = listOf(category)
)
every {
remoteConfig.getString("comparison_quiz_first_item_helper_value")
} returns firstItemHelperValueState.name
comparisonQuizCore = ComparisonQuizCoreImpl(
comparisonQuizRepository = comparisonQuizRepository,
remoteConfig = remoteConfig,
analyticsHelper = LocalDebugAnalyticsHelper(),
userService = userService
)
val context = InstrumentationRegistry.getInstrumentation().context
val config = Configuration.Builder()
.setMinimumLoggingLevel(android.util.Log.DEBUG)
.setExecutor(SynchronousExecutor())
.build()
// Initialize WorkManager for instrumentation tests.
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
workManager = WorkManager.getInstance(context)
viewModel = ComparisonQuizViewModel(
comparisonQuizCore = comparisonQuizCore,
savedStateHandle = SavedStateHandle(
mapOf(
ComparisonQuizScreenNavArg::comparisonMode.name to comparisonMode,
ComparisonQuizScreenNavArg::category.name to category.toEntity()
)
),
comparisonQuizRepository = comparisonQuizRepository,
workManager = workManager,
recentCategoriesRepository = recentCategoriesRepository,
userService = userService
)
composeTestRule.setContent {
val windowSizeClass = calculateWindowSizeClass(activity = composeTestRule.activity)
com.infinitepower.newquiz.core.testing.ui.theme.NewQuizTestTheme {
com.infinitepower.newquiz.core.testing.utils.setTestDeviceLocale()
ComparisonQuizScreen(
windowSizeClass = windowSizeClass,
navigator = EmptyDestinationsNavigator,
viewModel = viewModel
)
}
}
}
@Test
fun comparisonQuizScreen_testComponentsDisplayed() {
val questionDescription = category.getQuestionDescription(comparisonMode)
composeTestRule.waitUntilAtLeastOneExists(
matcher = hasText(questionDescription),
timeoutMillis = 5000
)
// Check if the question description is displayed
composeTestRule
.onNodeWithText(questionDescription)
.assertIsDisplayed()
// Check if the first item is displayed
composeTestRule
.onNodeWithText("Question 1")
.assertIsDisplayed()
// Check if first item helper text is displayed
composeTestRule
.onNodeWithText("1")
.apply {
if (firstItemHelperValueState == ComparisonQuizHelperValueState.HIDDEN) {
assertDoesNotExist()
} else {
assertIsDisplayed()
}
}
// Check if the second item is displayed
composeTestRule
.onNodeWithText("Question 2")
.assertIsDisplayed()
// Check if second item helper text is not displayed
composeTestRule
.onNodeWithText("2")
.assertDoesNotExist()
// Check if data source attribution is displayed
composeTestRule
.onNodeWithText("NewQuiz API")
.assertIsDisplayed()
// Check if current position is displayed
composeTestRule
.onNodeWithText("Position: 1")
.assertIsDisplayed()
// Check if highest position is displayed
composeTestRule
.onNodeWithText("Highest: 1")
.assertIsDisplayed()
}
@Test
fun comparisonQuizScreen_testWrongAnswerClick() {
// Click on the first item (wrong answer)
// The value of the first item is 1.0
// The value of the second item is 2.0
composeTestRule
.onNodeWithText("Question 1")
.assertIsDisplayed()
.assertIsEnabled()
.assertHasClickAction()
.performClick()
// When the wrong answer is clicked should end the game
.assertDoesNotExist()
// Check if the game is over
assertThat(viewModel.uiState.value.isGameOver).isTrue()
// Check if the game over screen is displayed
composeTestRule
.onNodeWithText("Game Over")
.assertIsDisplayed()
}
@Test
fun comparisonQuizScreen_testCorrectAnswerClick() {
// Click on the second item (correct answer)
// The value of the first item is 1.0
// The value of the second item is 2.0
composeTestRule
.onNodeWithText("Question 2")
.assertIsDisplayed()
.assertIsEnabled()
.assertHasClickAction()
.performClick()
// When the correct answer is clicked should not end the game
.assertIsDisplayed()
.assertHasClickAction()
// Check if the game is not over
assertThat(viewModel.uiState.value.isGameOver).isFalse()
// Check if the game over screen is not displayed
composeTestRule
.onNodeWithText("Game Over")
.assertDoesNotExist()
// Now the first item should be the second item
// and the second item should be a new item
composeTestRule
.onNodeWithText("Question 3")
.assertIsDisplayed()
.assertHasClickAction()
}
}
================================================
FILE: comparison-quiz/src/androidTest/java/com/infinitepower/newquiz/comparison_quiz/ui/components/ComparisonItemTest.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.ui.components
import android.net.Uri
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.google.common.truth.Truth.assertThat
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizHelperValueState
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@SmallTest
@RunWith(AndroidJUnit4::class)
internal class ComparisonItemTest {
@get:Rule
val componentRule = createComposeRule()
@Test
fun comparisonItem_titleAndHelperValueDisplayed() {
val title = "Item title"
val helperValue = "Helper value"
val helperValueState = ComparisonQuizHelperValueState.NORMAL
var onClickCalled = false
componentRule.setContent {
ComparisonItem(
title = title,
image = Uri.EMPTY,
helperValue = helperValue,
helperContentAlignment = Alignment.BottomEnd,
helperValueState = helperValueState,
onClick = { onClickCalled = true },
modifier = Modifier.testTag("ComparisonItem")
)
}
componentRule
.onNode(hasText(title) or hasText(helperValue))
.assertExists()
.assertIsDisplayed()
componentRule
.onNodeWithTag("ComparisonItem")
.assertIsDisplayed()
.assertIsEnabled()
.assertHasClickAction()
// Click on the item
.performClick()
// Check that the item is still displayed and enabled after the click
.assertIsDisplayed()
.assertIsEnabled()
assertThat(onClickCalled).isTrue()
}
@Test
fun comparisonItem_titleDisplayed_andHelperValueNotDisplayedWhenStateHidden() {
val title = "Item title"
val helperValue = "Helper value"
val helperValueState = ComparisonQuizHelperValueState.HIDDEN
var onClickCalled = false
componentRule.setContent {
ComparisonItem(
title = title,
image = Uri.EMPTY,
helperValue = helperValue,
helperContentAlignment = Alignment.BottomEnd,
helperValueState = helperValueState,
onClick = { onClickCalled = true },
modifier = Modifier.testTag("ComparisonItem")
)
}
componentRule
.onNodeWithText(title)
.assertExists()
.assertIsDisplayed()
componentRule
.onNodeWithText(helperValue)
.assertDoesNotExist()
componentRule
.onNodeWithTag("ComparisonItem")
.assertIsDisplayed()
.assertIsEnabled()
.assertHasClickAction()
// Click on the item
.performClick()
// Check that the item is still displayed and enabled after the click
.assertIsDisplayed()
.assertIsEnabled()
assertThat(onClickCalled).isTrue()
}
}
================================================
FILE: comparison-quiz/src/main/AndroidManifest.xml
================================================
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/core/ComparisonQuizCoreImpl.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.core
import android.util.Log
import com.infinitepower.newquiz.core.analytics.AnalyticsEvent
import com.infinitepower.newquiz.core.analytics.AnalyticsHelper
import com.infinitepower.newquiz.core.game.ComparisonQuizCore
import com.infinitepower.newquiz.core.game.ComparisonQuizCore.InitializationData
import com.infinitepower.newquiz.core.game.ComparisonQuizCore.QuizData
import com.infinitepower.newquiz.core.game.GameOverException
import com.infinitepower.newquiz.core.remote_config.RemoteConfig
import com.infinitepower.newquiz.core.remote_config.RemoteConfigValue
import com.infinitepower.newquiz.core.remote_config.get
import com.infinitepower.newquiz.core.user_services.InsufficientDiamondsException
import com.infinitepower.newquiz.core.user_services.UserService
import com.infinitepower.newquiz.data.util.mappers.comparisonquiz.toModel
import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItem
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItemEntity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
private const val TAG = "ComparisonQuizCoreImpl"
/**
* Represents the implementation of the [ComparisonQuizCore] interface.
*
* @param comparisonQuizRepository The repository for retrieving comparison quiz data.
*/
class ComparisonQuizCoreImpl @Inject constructor(
private val comparisonQuizRepository: ComparisonQuizRepository,
private val remoteConfig: RemoteConfig,
private val analyticsHelper: AnalyticsHelper,
private val userService: UserService
) : ComparisonQuizCore {
private val _quizData = MutableStateFlow(QuizData())
override val quizDataFlow = _quizData.asStateFlow()
override suspend fun initializeGame(initializationData: InitializationData) {
val category = initializationData.category
val questions = initializationData.initialItems
.map(ComparisonQuizItemEntity::toModel)
.ifEmpty {
// If there are no initial items, get the questions from the repository
comparisonQuizRepository.getQuestions(initializationData.category)
}
if (questions.isEmpty()) {
Log.w(TAG, "No questions found for category ${initializationData.category.id}")
return endGame()
}
val comparisonMode = initializationData.comparisonMode
val questionDescription = category.getQuestionDescription(comparisonMode)
val firstItemHelperValue =
remoteConfig.get(RemoteConfigValue.COMPARISON_QUIZ_FIRST_ITEM_HELPER_VALUE)
val quizData = QuizData(
category = category,
questions = questions,
comparisonMode = comparisonMode,
questionDescription = questionDescription,
firstItemHelperValueState = firstItemHelperValue,
)
analyticsHelper.logEvent(
AnalyticsEvent.ComparisonQuizGameStart(
category = category.id,
comparisonMode = comparisonMode.name,
)
)
_quizData.emit(quizData)
startGame()
}
override fun startGame() {
_quizData.update { currentData ->
try {
currentData.getNextQuestion()
} catch (e: GameOverException) {
e.printStackTrace()
return endGame()
}
}
}
override fun onAnswerClicked(answer: ComparisonQuizItem) {
Log.d(TAG, "Answer clicked: $answer")
_quizData.update { currentData ->
val currentQuestion = currentData.currentQuestion
val isQuestionCorrect = currentQuestion != null && currentQuestion.isCorrectAnswer(answer)
Log.d(TAG, "Is question correct: $isQuestionCorrect")
// If the current question is null or the answer is correct, get the next question
if (currentQuestion == null || isQuestionCorrect) {
try {
currentData.getNextQuestion()
} catch (e: GameOverException) {
e.printStackTrace()
currentData.copy(
currentQuestion = null,
isGameOver = true,
isLastQuestionCorrect = isQuestionCorrect
)
}
} else {
currentData.copy(
currentQuestion = null,
isGameOver = true,
isLastQuestionCorrect = false
)
}
}
}
override fun endGame() {
_quizData.update { currentData ->
currentData.copy(
currentQuestion = null,
isGameOver = true
)
}
}
override val skipCost: UInt
get() = remoteConfig.get(RemoteConfigValue.COMPARISON_QUIZ_SKIP_COST).toUInt()
override suspend fun getUserDiamonds(): UInt = userService.getUserDiamonds()
override suspend fun skip() {
Log.d(TAG, "Skipping question")
val userDiamonds = getUserDiamonds()
val skipCost = skipCost
// Check if the user has enough diamonds to skip the question
if (userDiamonds < skipCost) {
throw InsufficientDiamondsException(
diamondsNeeded = skipCost,
diamondsAvailable = userDiamonds
)
}
// Update the user's diamond count
userService.addRemoveDiamonds(-skipCost.toInt())
// Update the quiz data to the next question
_quizData.update { currentData ->
try {
currentData.getNextQuestion(skipped = true)
} catch (e: GameOverException) {
e.printStackTrace()
return endGame()
}
}
}
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/core/workers/ComparisonQuizEndGameWorker.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.core.workers
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.infinitepower.newquiz.core.analytics.AnalyticsEvent
import com.infinitepower.newquiz.core.analytics.AnalyticsHelper
import com.infinitepower.newquiz.core.user_services.UserService
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@HiltWorker
class ComparisonQuizEndGameWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val analyticsHelper: AnalyticsHelper,
private val userService: UserService,
) : CoroutineWorker(appContext, workerParams) {
companion object {
private const val CATEGORY_ID = "category_id"
private const val COMPARISON_MODE_NAME = "comparison_mode_name"
private const val END_POSITION = "end_position"
private const val SKIPPED_ANSWERS = "skipped_answers"
fun enqueueWork(
workManager: WorkManager,
categoryId: String,
comparisonMode: ComparisonMode,
endPosition: Int,
skippedAnswers: Int
) {
val data = workDataOf(
CATEGORY_ID to categoryId,
COMPARISON_MODE_NAME to comparisonMode.name,
END_POSITION to endPosition,
SKIPPED_ANSWERS to skippedAnswers
)
val workRequest = OneTimeWorkRequestBuilder()
.setInputData(data)
.build()
workManager.enqueue(workRequest)
}
}
override suspend fun doWork(): Result {
val categoryId = inputData.getString(CATEGORY_ID) ?: return Result.failure()
val comparisonModeName = inputData.getString(COMPARISON_MODE_NAME) ?: return Result.failure()
val endPosition = inputData.getInt(END_POSITION, 0)
val skippedAnswers = inputData.getInt(SKIPPED_ANSWERS, 0)
analyticsHelper.logEvent(
AnalyticsEvent.ComparisonQuizGameEnd(
category = categoryId,
comparisonMode = comparisonModeName,
score = endPosition,
)
)
userService.saveComparisonQuizGame(
categoryId = categoryId,
comparisonMode = comparisonModeName,
endPosition = endPosition.toUInt(),
skippedAnswers = skippedAnswers.toUInt(),
generateXp = true
)
return Result.success()
}
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/di/ComparisonQuizModule.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.di
import com.infinitepower.newquiz.comparison_quiz.core.ComparisonQuizCoreImpl
import com.infinitepower.newquiz.core.game.ComparisonQuizCore
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
@Module
@InstallIn(ViewModelComponent::class)
abstract class ComparisonQuizModule {
@Binds
abstract fun bindComparisonQuizCore(impl: ComparisonQuizCoreImpl): ComparisonQuizCore
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/list/ComparisonQuizListScreen.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.list
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.infinitepower.newquiz.comparison_quiz.destinations.ComparisonQuizScreenDestination
import com.infinitepower.newquiz.comparison_quiz.list.components.ComparisonModeComponents
import com.infinitepower.newquiz.core.analytics.AnalyticsEvent
import com.infinitepower.newquiz.core.analytics.LocalAnalyticsHelper
import com.infinitepower.newquiz.core.theme.NewQuizTheme
import com.infinitepower.newquiz.core.theme.spacing
import com.infinitepower.newquiz.core.ui.home.HomeLazyColumn
import com.infinitepower.newquiz.core.ui.home.homeCategoriesItems
import com.infinitepower.newquiz.domain.repository.home.HomeCategories
import com.infinitepower.newquiz.model.NumberFormatType
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory
import com.infinitepower.newquiz.model.toUiText
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.collections.immutable.persistentListOf
import com.infinitepower.newquiz.core.R as CoreR
@Composable
@Destination
fun ComparisonQuizListScreen(
navigator: DestinationsNavigator,
viewModel: ComparisonQuizListScreenViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val analyticsHelper = LocalAnalyticsHelper.current
ComparisonQuizListScreenImpl(
uiState = uiState,
onCategoryClick = { category ->
analyticsHelper.logEvent(
AnalyticsEvent.CategoryClicked(
game = AnalyticsEvent.Game.COMPARISON_QUIZ,
categoryId = category.id,
otherData = mapOf(
"comparison_mode" to uiState.selectedMode.name.lowercase(),
)
)
)
navigator.navigate(
ComparisonQuizScreenDestination(
categoryId = category.id,
comparisonMode = uiState.selectedMode
)
)
},
onSelectMode = { mode ->
viewModel.onEvent(ComparisonQuizListScreenUiEvent.SelectMode(mode))
}
)
}
@Composable
private fun ComparisonQuizListScreenImpl(
uiState: ComparisonQuizListScreenUiState,
onCategoryClick: (category: ComparisonQuizCategory) -> Unit,
onSelectMode: (mode: ComparisonMode) -> Unit
) {
val spaceMedium = MaterialTheme.spacing.medium
var seeAllCategories by remember { mutableStateOf(false) }
HomeLazyColumn {
item {
Column {
Text(
text = stringResource(id = CoreR.string.select_a_comparison_mode_for_the_first_item),
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(spaceMedium))
ComparisonModeComponents(
modifier = Modifier.fillParentMaxWidth(),
onModeClick = onSelectMode,
selectedMode = uiState.selectedMode
)
}
}
item {
Text(
text = stringResource(id = CoreR.string.categories),
style = MaterialTheme.typography.bodyMedium
)
}
homeCategoriesItems(
seeAllCategories = seeAllCategories,
recentCategories = uiState.homeCategories.recentCategories,
otherCategories = uiState.homeCategories.otherCategories,
isInternetAvailable = uiState.internetConnectionAvailable,
onCategoryClick = onCategoryClick,
onSeeAllCategoriesClick = { seeAllCategories = !seeAllCategories },
showConnectionInfo = uiState.showCategoryConnectionInfo
)
}
}
@Composable
@PreviewLightDark
private fun ComparisonQuizListScreenPreview() {
NewQuizTheme {
Surface {
ComparisonQuizListScreenImpl(
uiState = ComparisonQuizListScreenUiState(
homeCategories = HomeCategories(
recentCategories = persistentListOf(
ComparisonQuizCategory(
id = "test",
description = "Description",
name = "Title".toUiText(),
image = "",
questionDescription = ComparisonQuizCategory.QuestionDescription(
greater = "Greater",
less = "Less"
),
formatType = NumberFormatType.DEFAULT
)
),
otherCategories = persistentListOf()
)
),
onCategoryClick = {},
onSelectMode = {}
)
}
}
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/list/ComparisonQuizListScreenUiEvent.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.list
import androidx.annotation.Keep
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
interface ComparisonQuizListScreenUiEvent {
@Keep
data class SelectMode(
val mode: ComparisonMode
) : ComparisonQuizListScreenUiEvent
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/list/ComparisonQuizListScreenUiState.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.list
import androidx.annotation.Keep
import com.infinitepower.newquiz.domain.repository.home.HomeCategories
import com.infinitepower.newquiz.domain.repository.home.emptyHomeCategories
import com.infinitepower.newquiz.model.category.ShowCategoryConnectionInfo
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory
@Keep
data class ComparisonQuizListScreenUiState(
val homeCategories: HomeCategories = emptyHomeCategories(),
val selectedMode: ComparisonMode = ComparisonMode.GREATER,
val internetConnectionAvailable: Boolean = true,
val showCategoryConnectionInfo: ShowCategoryConnectionInfo = ShowCategoryConnectionInfo.NONE
)
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/list/ComparisonQuizListScreenViewModel.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.list
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.infinitepower.newquiz.core.network.NetworkStatusTracker
import com.infinitepower.newquiz.domain.repository.home.RecentCategoriesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@HiltViewModel
class ComparisonQuizListScreenViewModel @Inject constructor(
recentCategoriesRepository: RecentCategoriesRepository,
networkStatusTracker: NetworkStatusTracker
) : ViewModel() {
private val _uiState = MutableStateFlow(ComparisonQuizListScreenUiState())
val uiState = combine(
_uiState,
recentCategoriesRepository.getComparisonCategories(
isInternetAvailable = networkStatusTracker.isCurrentlyConnected()
),
recentCategoriesRepository.getShowCategoryConnectionInfoFlow()
) { uiState, recentCategories, showCategoryConnectionInfo ->
uiState.copy(
homeCategories = recentCategories,
internetConnectionAvailable = networkStatusTracker.isCurrentlyConnected(),
showCategoryConnectionInfo = showCategoryConnectionInfo
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = ComparisonQuizListScreenUiState()
)
fun onEvent(event: ComparisonQuizListScreenUiEvent) {
when (event) {
is ComparisonQuizListScreenUiEvent.SelectMode -> {
_uiState.update { currentState ->
currentState.copy(selectedMode = event.mode)
}
}
}
}
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/list/components/ComparisonModeComponent.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.list.components
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ChevronLeft
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.infinitepower.newquiz.core.R
import com.infinitepower.newquiz.core.common.compose.preview.BooleanPreviewParameterProvider
import com.infinitepower.newquiz.core.theme.NewQuizTheme
import com.infinitepower.newquiz.core.theme.spacing
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
@Composable
internal fun ComparisonModeComponent(
modifier: Modifier = Modifier,
selected: Boolean,
enabled: Boolean = true,
mode: ComparisonMode,
shape: Shape = ComparisonModeDefaults.shape,
colors: ComparisonModeColors = ComparisonModeDefaults.defaultColors(),
onClick: () -> Unit = {}
) {
val title = when (mode) {
ComparisonMode.GREATER -> stringResource(id = R.string.greater)
ComparisonMode.LESSER -> stringResource(id = R.string.lesser)
}
val icon = when (mode) {
ComparisonMode.GREATER -> Icons.Rounded.ChevronRight
ComparisonMode.LESSER -> Icons.Rounded.ChevronLeft
}
val transition = updateTransition(targetState = selected, label = "$title Mode")
val containerColor = colors.containerColor(transition).value
val contentColor = colors.contentColor(transition).value
val iconColor = colors.iconColor(transition).value
val iconContainerColor = colors.iconContainerColor(transition).value
val border = if (selected) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
val spaceMedium = MaterialTheme.spacing.medium
Surface(
modifier = modifier,
onClick = onClick,
shape = shape,
color = containerColor,
contentColor = contentColor,
border = border,
selected = selected,
enabled = enabled
) {
Column(
modifier = Modifier.padding(spaceMedium)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(spaceMedium))
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterEnd
) {
Surface(
shape = CircleShape,
color = iconContainerColor,
contentColor = iconColor
) {
Icon(
imageVector = icon,
contentDescription = stringResource(id = R.string.icon_of_s, title),
modifier = Modifier.padding(MaterialTheme.spacing.extraSmall)
)
}
}
}
}
}
object ComparisonModeDefaults {
val shape: Shape @Composable get() = MaterialTheme.shapes.large
@Composable
fun defaultColors(
containerColor: Color = MaterialTheme.colorScheme.surface,
selectedContainerColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
selectedContentColor: Color = MaterialTheme.colorScheme.onPrimary,
iconColor: Color = MaterialTheme.colorScheme.onSurface,
selectedIconColor: Color = MaterialTheme.colorScheme.primary,
iconContainerColor: Color = MaterialTheme.colorScheme.surfaceVariant,
selectedIconContainerColor: Color = MaterialTheme.colorScheme.onPrimary
): ComparisonModeColors = ComparisonModeColors(
containerColor = containerColor,
selectedContainerColor = selectedContainerColor,
contentColor = contentColor,
selectedContentColor = selectedContentColor,
iconColor = iconColor,
selectedIconColor = selectedIconColor,
iconContainerColor = iconContainerColor,
selectedIconContainerColor = selectedIconContainerColor
)
}
@Immutable
class ComparisonModeColors internal constructor(
private val containerColor: Color,
private val selectedContainerColor: Color,
private val contentColor: Color,
private val selectedContentColor: Color,
private val iconColor: Color,
private val selectedIconColor: Color,
private val iconContainerColor: Color,
private val selectedIconContainerColor: Color
) {
@Composable
internal fun containerColor(
transition: Transition
): State {
return transition.animateColor(
label = "Container Color"
) { selected ->
if (selected) selectedContainerColor else containerColor
}
}
@Composable
internal fun contentColor(
transition: Transition
): State {
return transition.animateColor(
label = "Content Color"
) { selected ->
if (selected) selectedContentColor else contentColor
}
}
@Composable
internal fun iconColor(
transition: Transition
): State {
return transition.animateColor(
label = "Icon Color"
) { selected ->
if (selected) selectedIconColor else iconColor
}
}
@Composable
internal fun iconContainerColor(
transition: Transition
): State {
return transition.animateColor(
label = "Icon Container Color"
) { selected ->
if (selected) selectedIconContainerColor else iconContainerColor
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ComparisonModeColors) return false
if (containerColor != other.containerColor) return false
if (selectedContainerColor != other.selectedContainerColor) return false
if (contentColor != other.contentColor) return false
if (selectedContentColor != other.selectedContentColor) return false
if (iconColor != other.iconColor) return false
if (selectedIconColor != other.selectedIconColor) return false
if (iconContainerColor != other.iconContainerColor) return false
if (selectedIconContainerColor != other.selectedIconContainerColor) return false
return true
}
override fun hashCode(): Int {
var result = containerColor.hashCode()
result = 31 * result + selectedContainerColor.hashCode()
result = 31 * result + contentColor.hashCode()
result = 31 * result + selectedContentColor.hashCode()
result = 31 * result + iconColor.hashCode()
result = 31 * result + selectedIconColor.hashCode()
result = 31 * result + iconContainerColor.hashCode()
result = 31 * result + selectedIconContainerColor.hashCode()
return result
}
}
@Composable
@PreviewLightDark
private fun ComparisonModeComponentPreview(
@PreviewParameter(BooleanPreviewParameterProvider::class) selected: Boolean
) {
NewQuizTheme {
Surface {
ComparisonModeComponent(
modifier = Modifier
.padding(16.dp)
.width(120.dp),
mode = ComparisonMode.GREATER,
onClick = {},
selected = selected
)
}
}
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/list/components/ComparisonModeComponents.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.list.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.infinitepower.newquiz.core.theme.NewQuizTheme
import com.infinitepower.newquiz.core.theme.spacing
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
@Composable
internal fun ComparisonModeComponents(
modifier: Modifier = Modifier,
selectedMode: ComparisonMode = ComparisonMode.GREATER,
onModeClick: (mode: ComparisonMode) -> Unit = {}
) {
val spaceMedium = MaterialTheme.spacing.medium
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(spaceMedium)
) {
ComparisonModeComponent(
mode = ComparisonMode.GREATER,
modifier = Modifier.weight(1f),
selected = selectedMode == ComparisonMode.GREATER,
onClick = { onModeClick(ComparisonMode.GREATER) }
)
ComparisonModeComponent(
mode = ComparisonMode.LESSER,
modifier = Modifier.weight(1f),
selected = selectedMode == ComparisonMode.LESSER,
onClick = { onModeClick(ComparisonMode.LESSER) }
)
}
}
@Composable
@PreviewLightDark
private fun ComparisonModeComponentsPreview() {
NewQuizTheme {
Surface {
ComparisonModeComponents(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
}
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/ui/AnimationState.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.ui
/**
* Represents the state of the animation in the comparison quiz game.
*/
internal enum class AnimationState {
/**
* This is the state before the game starts.
* Animates the items into the screen.
*
* Next [AnimationState]: [Normal]
*/
StartGame,
/**
* This is the state for when no animation is running.
*
* Next [AnimationState]: [NextQuestion]
*/
Normal,
/**
* This animation state runs when the user selects the correct answer
* and the question is transitioning to the next question.
*
* Next [AnimationState]: [Normal]
*/
NextQuestion;
/**
* Returns the next [AnimationState] in the sequence.
*/
fun next(): AnimationState {
return when (this) {
StartGame -> Normal
Normal -> NextQuestion
NextQuestion -> Normal
}
}
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/ui/ComparisonQuizScreen.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.ui
import androidx.annotation.Keep
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.infinitepower.newquiz.comparison_quiz.destinations.ComparisonQuizScreenDestination
import com.infinitepower.newquiz.comparison_quiz.ui.components.ComparisonItem
import com.infinitepower.newquiz.comparison_quiz.ui.components.ComparisonMidContent
import com.infinitepower.newquiz.comparison_quiz.ui.components.GameOverContent
import com.infinitepower.newquiz.core.NumberFormatter
import com.infinitepower.newquiz.core.navigation.MazeNavigator
import com.infinitepower.newquiz.core.theme.NewQuizTheme
import com.infinitepower.newquiz.core.theme.spacing
import com.infinitepower.newquiz.core.ui.components.icon.button.BackIconButton
import com.infinitepower.newquiz.core.ui.components.skip_question.SkipIconButton
import com.infinitepower.newquiz.core.ui.components.skip_question.SkipQuestionDialog
import com.infinitepower.newquiz.core.util.emptyJavaURI
import com.infinitepower.newquiz.core.util.plus
import com.infinitepower.newquiz.model.NumberFormatType
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizHelperValueState
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItem
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItemEntity
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizQuestion
import com.infinitepower.newquiz.model.regional_preferences.RegionalPreferences
import com.infinitepower.newquiz.model.toUiText
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.coroutines.delay
@Composable
@Destination(navArgsDelegate = ComparisonQuizScreenNavArg::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
fun ComparisonQuizScreen(
windowSizeClass: WindowSizeClass,
navigator: DestinationsNavigator,
mazeNavigator: MazeNavigator,
navController: NavController,
viewModel: ComparisonQuizViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var animationState by remember { mutableStateOf(AnimationState.StartGame) }
LaunchedEffect(key1 = uiState.currentPosition) {
if (uiState.currentPosition == 1) {
animationState = AnimationState.Normal
} else if (uiState.currentPosition > 1) {
animationState = AnimationState.NextQuestion
delay(ANIMATIONS_DELAY)
animationState = AnimationState.Normal
}
}
val mazeItemId = remember(navController) {
val backStackEntry = navController.getBackStackEntry(ComparisonQuizScreenDestination.route)
val args = ComparisonQuizScreenDestination.argsFrom(backStackEntry)
args.mazeItemId
}
ComparisonQuizScreenImpl(
uiState = uiState,
windowSizeClass = windowSizeClass,
animationState = animationState,
isFromMaze = mazeItemId != null,
onEvent = viewModel::onEvent,
onBackClick = navigator::popBackStack,
onPlayAgainClick = {
val category = uiState.gameCategory ?: return@ComparisonQuizScreenImpl
val comparisonMode = uiState.comparisonMode ?: return@ComparisonQuizScreenImpl
navigator.navigate(
ComparisonQuizScreenDestination(
categoryId = category.id,
comparisonMode = comparisonMode
)
) {
popUpTo(ComparisonQuizScreenDestination) {
inclusive = true
}
}
}
)
// If the game is over and is from maze, navigate to maze results
LaunchedEffect(uiState.isGameOver) {
if (uiState.isGameOver) {
delay(NAV_TO_RESULTS_DELAY_MILLIS)
if (uiState.gameCategory == null) {
navigator.popBackStack()
} else if (mazeItemId != null) {
mazeNavigator.navigateToMazeResults(mazeItemId)
}
}
}
}
@Composable
@ExperimentalMaterial3Api
@ExperimentalAnimationApi
internal fun ComparisonQuizScreenImpl(
uiState: ComparisonQuizUiState,
windowSizeClass: WindowSizeClass,
animationState: AnimationState,
isFromMaze: Boolean = false,
onBackClick: () -> Unit = {},
onPlayAgainClick: () -> Unit = {},
onEvent: (event: ComparisonQuizUiEvent) -> Unit = {}
) {
val verticalContent = remember(windowSizeClass) {
windowSizeClass.heightSizeClass > WindowHeightSizeClass.Compact &&
windowSizeClass.widthSizeClass < WindowWidthSizeClass.Expanded
}
when {
uiState.currentQuestion != null && uiState.gameDescription != null && uiState.gameCategory != null -> {
ComparisonQuizContent(
modifier = Modifier.fillMaxSize(),
currentQuestion = uiState.currentQuestion,
gameDescription = uiState.gameDescription,
questionPosition = uiState.currentPosition,
highestPosition = uiState.highestPosition,
verticalContent = verticalContent,
onBackClick = onBackClick,
gameCategory = uiState.gameCategory,
userAvailable = uiState.userAvailable,
firstItemHelperValueState = uiState.firstItemHelperValueState,
animationState = animationState,
regionalPreferences = uiState.regionalPreferences,
onAnswerClick = { onEvent(ComparisonQuizUiEvent.OnAnswerClick(it)) },
onSkipClick = { onEvent(ComparisonQuizUiEvent.ShowSkipQuestionDialog) },
)
}
uiState.isGameOver && uiState.gameCategory != null && !isFromMaze -> {
GameOverContent(
scorePosition = uiState.currentPosition,
highestPosition = uiState.highestPosition,
onBackClick = onBackClick,
onPlayAgainClick = onPlayAgainClick
)
}
else -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
SkipQuestionDialog(
userDiamonds = uiState.userDiamonds,
skipCost = uiState.skipCost.toInt(),
loading = uiState.userDiamondsLoading,
onSkipClick = { onEvent(ComparisonQuizUiEvent.SkipQuestion) },
onDismissClick = { onEvent(ComparisonQuizUiEvent.DismissSkipQuestionDialog) }
)
}
@Keep
data class ComparisonQuizScreenNavArg(
val categoryId: String,
val comparisonMode: ComparisonMode = ComparisonMode.GREATER,
val initialItems: ArrayList = arrayListOf(),
val mazeItemId: Int? = null
)
@Composable
@ExperimentalAnimationApi
private fun ComparisonQuizContent(
modifier: Modifier = Modifier,
onBackClick: () -> Unit,
onSkipClick: () -> Unit,
onAnswerClick: (ComparisonQuizItem) -> Unit,
currentQuestion: ComparisonQuizQuestion,
gameCategory: ComparisonQuizCategory,
gameDescription: String,
questionPosition: Int,
highestPosition: Int,
verticalContent: Boolean,
userAvailable: Boolean,
firstItemHelperValueState: ComparisonQuizHelperValueState,
animationState: AnimationState,
regionalPreferences: RegionalPreferences = RegionalPreferences()
) {
val valueFormatter = remember(gameCategory) {
NumberFormatter.from(gameCategory.formatType)
}
ComparisonQuizContainer(
modifier = modifier.fillMaxSize(),
verticalContent = verticalContent,
animationState = animationState,
descriptionContent = {
Text(
text = gameDescription,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
},
firstQuestionContent = {
val item = currentQuestion.questions.first
val helperValue = remember(item, gameCategory, regionalPreferences, valueFormatter) {
valueFormatter.formatValueToString(
value = item.value,
helperValueSuffix = gameCategory.helperValueSuffix,
regionalPreferences = regionalPreferences
)
}
ComparisonItem(
item = item,
helperValue = helperValue,
onClick = { onAnswerClick(currentQuestion.questions.first) },
helperContentAlignment = Alignment.BottomCenter,
helperValueState = firstItemHelperValueState,
)
},
secondQuestionContent = {
ComparisonItem(
item = currentQuestion.questions.second,
helperValue = "", // No helper value for the second question
onClick = { onAnswerClick(currentQuestion.questions.second) },
helperContentAlignment = if (verticalContent) Alignment.TopCenter else Alignment.BottomCenter,
helperValueState = ComparisonQuizHelperValueState.HIDDEN,
)
},
midContent = {
ComparisonMidContent(
questionPosition = questionPosition,
highestPosition = highestPosition,
verticalContent = verticalContent,
animationState = animationState
)
},
backIconContent = { BackIconButton(onClick = onBackClick) },
skipButtonContent = {
if (userAvailable) {
SkipIconButton(onClick = onSkipClick)
}
},
attributionContent = gameCategory.dataSourceAttribution?.let { data ->
{
DataSourceAttributionContent(
text = data.text,
imageUrl = data.logo
)
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ExperimentalAnimationApi
private fun ComparisonQuizContainer(
modifier: Modifier = Modifier,
verticalContent: Boolean,
animationState: AnimationState,
backIconContent: @Composable () -> Unit,
descriptionContent: @Composable () -> Unit,
firstQuestionContent: @Composable BoxScope.() -> Unit,
secondQuestionContent: @Composable BoxScope.() -> Unit,
midContent: @Composable () -> Unit,
attributionContent: (@Composable () -> Unit)? = null,
skipButtonContent: (@Composable () -> Unit)? = null
) {
val spaceMedium = MaterialTheme.spacing.medium
val mainContentTransition = updateTransition(
targetState = animationState,
label = "Main Content"
)
val reusableMidContent = remember(midContent) { movableContentOf(midContent) }
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = descriptionContent,
navigationIcon = backIconContent,
actions = {
if (skipButtonContent != null) {
skipButtonContent()
}
}
)
}
) { innerPadding ->
if (verticalContent) {
Column(
modifier = Modifier
.padding(innerPadding + PaddingValues(spaceMedium))
.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween
) {
mainContentTransition.AnimatedVisibility(
visible = { state -> state != AnimationState.StartGame },
modifier = Modifier
.fillMaxWidth()
.weight(1f),
enter = fadeIn() + slideInHorizontally(),
exit = fadeOut() + slideOutHorizontally()
) {
Box(
modifier = Modifier.fillMaxSize(),
content = firstQuestionContent
)
}
Spacer(modifier = Modifier.height(spaceMedium))
reusableMidContent()
Spacer(modifier = Modifier.height(spaceMedium))
mainContentTransition.AnimatedVisibility(
visible = { state -> state != AnimationState.StartGame },
modifier = Modifier
.fillMaxWidth()
.weight(1f),
// Make this animation from the opposite direction
enter = fadeIn() + slideInHorizontally(
initialOffsetX = { it }
),
exit = fadeOut() + slideOutHorizontally(
targetOffsetX = { it }
)
) {
Box(
modifier = Modifier.fillMaxSize(),
content = secondQuestionContent
)
}
attributionContent?.let { content ->
Spacer(modifier = Modifier.height(spaceMedium))
content()
}
}
} else {
Column(
modifier = Modifier
.padding(innerPadding + PaddingValues(spaceMedium))
.fillMaxSize()
) {
Row(
modifier = Modifier.weight(1f)
) {
mainContentTransition.AnimatedVisibility(
visible = { state -> state != AnimationState.StartGame },
modifier = Modifier
.fillMaxWidth()
.weight(1f),
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
) {
Box(
modifier = Modifier.fillMaxSize(),
content = firstQuestionContent
)
}
Spacer(modifier = Modifier.width(spaceMedium))
reusableMidContent()
Spacer(modifier = Modifier.width(spaceMedium))
mainContentTransition.AnimatedVisibility(
visible = { state -> state != AnimationState.StartGame },
modifier = Modifier
.fillMaxWidth()
.weight(1f),
enter = fadeIn() + slideInVertically(
initialOffsetY = { it }
),
exit = fadeOut() + slideOutVertically(
targetOffsetY = { it }
)
) {
Box(
modifier = Modifier.fillMaxSize(),
content = secondQuestionContent
)
}
}
attributionContent?.let { attribution ->
Spacer(modifier = Modifier.height(spaceMedium))
attribution()
}
}
}
}
}
@Composable
private fun DataSourceAttributionContent(
modifier: Modifier = Modifier,
text: String,
imageUrl: String? = null
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
)
if (imageUrl != null) {
Spacer(modifier = Modifier.width(MaterialTheme.spacing.small))
AsyncImage(
model = imageUrl,
contentDescription = "Logo of the data source",
modifier = Modifier
.size(24.dp)
.clip(MaterialTheme.shapes.extraSmall)
)
}
}
}
private const val ANIMATIONS_DELAY = 1000L
private const val NAV_TO_RESULTS_DELAY_MILLIS = 250L
@Composable
@PreviewScreenSizes
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalMaterial3WindowSizeClassApi::class,
ExperimentalAnimationApi::class
)
private fun ComparisonQuizScreenPreview() {
val question1 = ComparisonQuizItem(
title = "NewQuiz",
value = 3245.0,
imgUri = emptyJavaURI()
)
val question2 = ComparisonQuizItem(
title = "NewSocial",
value = 23445.0,
imgUri = emptyJavaURI()
)
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val screenWidth = configuration.screenWidthDp.dp
val windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(screenWidth, screenHeight))
val category = ComparisonQuizCategory(
name = "Social".toUiText(),
description = "Social media",
image = "",
id = "social",
questionDescription = ComparisonQuizCategory.QuestionDescription(
greater = "Which one is more popular?",
less = "Which one is less popular?"
),
dataSourceAttribution = ComparisonQuizCategory.DataSourceAttribution(
text = "Data from NewQuiz"
),
formatType = NumberFormatType.DEFAULT
)
NewQuizTheme {
Surface {
ComparisonQuizScreenImpl(
uiState = ComparisonQuizUiState(
currentQuestion = ComparisonQuizQuestion(
questions = question1 to question2,
categoryId = category.id,
comparisonMode = ComparisonMode.GREATER
),
gameDescription = "Which one is more popular?",
gameCategory = category,
userAvailable = true
),
windowSizeClass = windowSizeClass,
animationState = AnimationState.Normal,
isFromMaze = false,
onEvent = {}
)
}
}
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/ui/ComparisonQuizUiEvent.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.ui
import androidx.annotation.Keep
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItem
interface ComparisonQuizUiEvent {
@Keep
data class OnAnswerClick(
val item: ComparisonQuizItem
) : ComparisonQuizUiEvent
object ShowSkipQuestionDialog : ComparisonQuizUiEvent
object DismissSkipQuestionDialog : ComparisonQuizUiEvent
object SkipQuestion : ComparisonQuizUiEvent
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/ui/ComparisonQuizUiState.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.ui
import androidx.annotation.Keep
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizHelperValueState
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizQuestion
import com.infinitepower.newquiz.model.regional_preferences.RegionalPreferences
@Keep
data class ComparisonQuizUiState(
val currentQuestion: ComparisonQuizQuestion? = null,
val gameCategory: ComparisonQuizCategory? = null,
val comparisonMode: ComparisonMode? = null,
val gameDescription: String? = null,
val currentPosition: Int = 0,
val highestPosition: Int = 0,
val isGameOver: Boolean = false,
val isLastQuestionCorrect: Boolean = false,
val userAvailable: Boolean = false,
val userDiamonds: Int = -1,
val userDiamondsLoading: Boolean = false,
val skipCost: UInt = 0u,
val firstItemHelperValueState: ComparisonQuizHelperValueState = ComparisonQuizHelperValueState.HIDDEN,
val regionalPreferences: RegionalPreferences = RegionalPreferences()
)
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/ui/ComparisonQuizViewModel.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.ui
import android.util.Log
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.WorkManager
import com.infinitepower.newquiz.comparison_quiz.core.workers.ComparisonQuizEndGameWorker
import com.infinitepower.newquiz.comparison_quiz.navArgs
import com.infinitepower.newquiz.core.game.ComparisonQuizCore
import com.infinitepower.newquiz.core.ui.SnackbarController
import com.infinitepower.newquiz.core.user_services.InsufficientDiamondsException
import com.infinitepower.newquiz.core.user_services.UserService
import com.infinitepower.newquiz.data.worker.UpdateGlobalEventDataWorker
import com.infinitepower.newquiz.domain.repository.UserConfigRepository
import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository
import com.infinitepower.newquiz.domain.repository.home.RecentCategoriesRepository
import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository
import com.infinitepower.newquiz.model.global_event.GameEvent
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val TAG = "ComparisonQuizViewModel"
@HiltViewModel
class ComparisonQuizViewModel @Inject constructor(
private val comparisonQuizCore: ComparisonQuizCore,
savedStateHandle: SavedStateHandle,
comparisonQuizRepository: ComparisonQuizRepository,
private val workManager: WorkManager,
private val recentCategoriesRepository: RecentCategoriesRepository,
private val userService: UserService,
private val mazeQuizRepository: MazeQuizRepository,
private val userConfigRepository: UserConfigRepository
) : ViewModel() {
private val navArgs: ComparisonQuizScreenNavArg = savedStateHandle.navArgs()
private val _uiState = MutableStateFlow(ComparisonQuizUiState())
val uiState = combine(
_uiState,
comparisonQuizRepository.getHighestPositionFlow(categoryId = navArgs.categoryId),
comparisonQuizCore.quizDataFlow
) { uiState, highestPosition, quizData ->
val currentPosition = quizData.currentPosition
// Get the highest position between the current position and the highest position.
// The highest position is updated when the game is over.
val currentHighestPosition = maxOf(currentPosition, highestPosition)
uiState.copy(
currentQuestion = quizData.currentQuestion,
gameDescription = quizData.questionDescription,
currentPosition = currentPosition,
isGameOver = quizData.isGameOver,
isLastQuestionCorrect = quizData.isLastQuestionCorrect,
firstItemHelperValueState = quizData.firstItemHelperValueState,
highestPosition = currentHighestPosition
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = ComparisonQuizUiState()
)
init {
// Start game
viewModelScope.launch {
val category = comparisonQuizRepository.getCategoryById(navArgs.categoryId)
if (category == null) {
Log.e(TAG, "Category with id ${navArgs.categoryId} not found")
comparisonQuizCore.endGame()
SnackbarController.sendShortMessage("Category not found")
return@launch
}
val comparisonMode = navArgs.comparisonMode
// Update initial state with data that don't change during the game.
_uiState.update { currentState ->
currentState.copy(
gameCategory = category,
comparisonMode = comparisonMode,
skipCost = comparisonQuizCore.skipCost,
userAvailable = userService.userAvailable(),
regionalPreferences = userConfigRepository.getRegionalPreferences()
)
}
comparisonQuizCore.initializeGame(
initializationData = ComparisonQuizCore.InitializationData(
category = category,
comparisonMode = comparisonMode,
initialItems = navArgs.initialItems
)
)
launch {
recentCategoriesRepository.addComparisonCategory(category.id)
UpdateGlobalEventDataWorker.enqueueWork(
workManager = workManager,
GameEvent.ComparisonQuiz.PlayWithComparisonMode(comparisonMode),
GameEvent.ComparisonQuiz.PlayQuizWithCategory(category.id)
)
}
}
comparisonQuizCore
.quizDataFlow
.distinctUntilChangedBy { it.isGameOver }
.onEach { quizData ->
Log.d(TAG, "Current question: ${quizData.currentQuestion}")
if (quizData.isGameOver) {
Log.d(TAG, "Game over, with position ${quizData.currentPosition}")
if (navArgs.mazeItemId != null && quizData.isLastQuestionCorrect) {
mazeQuizRepository.completeMazeItem(navArgs.mazeItemId)
}
UpdateGlobalEventDataWorker.enqueueWork(
workManager = workManager,
GameEvent.ComparisonQuiz.PlayAndGetScore(quizData.currentPosition)
)
ComparisonQuizEndGameWorker.enqueueWork(
workManager = workManager,
categoryId = navArgs.categoryId,
comparisonMode = navArgs.comparisonMode,
endPosition = quizData.currentPosition,
skippedAnswers = quizData.skippedAnswers
)
}
}.launchIn(viewModelScope)
}
fun onEvent(event: ComparisonQuizUiEvent) {
when (event) {
is ComparisonQuizUiEvent.OnAnswerClick -> {
viewModelScope.launch {
comparisonQuizCore.onAnswerClicked(event.item)
}
}
is ComparisonQuizUiEvent.ShowSkipQuestionDialog -> getUserDiamonds()
is ComparisonQuizUiEvent.DismissSkipQuestionDialog -> {
_uiState.update { currentState ->
currentState.copy(
userDiamonds = -1,
userDiamondsLoading = false
)
}
}
is ComparisonQuizUiEvent.SkipQuestion -> {
viewModelScope.launch {
try {
comparisonQuizCore.skip()
} catch (e: InsufficientDiamondsException) {
e.printStackTrace()
SnackbarController.sendShortMessage("Insufficient diamonds")
}
}
}
}
}
private fun getUserDiamonds() = viewModelScope.launch {
_uiState.update { currentState ->
currentState.copy(userDiamondsLoading = true)
}
val userDiamonds = userService.getUserDiamonds()
_uiState.update { currentState ->
currentState.copy(
userDiamonds = userDiamonds.toInt(),
userDiamondsLoading = false
)
}
}
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/ui/components/ComparisonItem.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.ui.components
import android.net.Uri
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.decode.SvgDecoder
import com.infinitepower.newquiz.core.common.compose.preview.BooleanPreviewParameterProvider
import com.infinitepower.newquiz.core.theme.NewQuizTheme
import com.infinitepower.newquiz.core.theme.spacing
import com.infinitepower.newquiz.core.util.toAndroidUri
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizHelperValueState
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItem
@Composable
internal fun ComparisonItem(
modifier: Modifier = Modifier,
onClick: () -> Unit,
item: ComparisonQuizItem,
helperContentAlignment: Alignment,
helperValue: String,
helperValueState: ComparisonQuizHelperValueState = ComparisonQuizHelperValueState.HIDDEN
) {
ComparisonItem(
modifier = modifier,
title = item.title,
image = item.imgUri.toAndroidUri(),
helperValue = helperValue,
helperContentAlignment = helperContentAlignment,
helperValueState = helperValueState,
onClick = onClick
)
}
@Composable
internal fun ComparisonItem(
modifier: Modifier = Modifier,
onClick: () -> Unit,
title: String,
image: Uri,
helperValue: String,
helperContentAlignment: Alignment,
helperValueState: ComparisonQuizHelperValueState
) {
val spaceExtraSmall = MaterialTheme.spacing.extraSmall
val spaceMedium = MaterialTheme.spacing.medium
val helperColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)
val context = LocalContext.current
val imageLoader = ImageLoader
.Builder(context)
.components {
add(SvgDecoder.Factory())
}.build()
Surface(
modifier = modifier,
shape = MaterialTheme.shapes.large,
onClick = onClick,
tonalElevation = 8.dp
) {
Box(
modifier = Modifier.fillMaxSize()
) {
AsyncImage(
model = image,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
imageLoader = imageLoader
)
Surface(
modifier = Modifier
.fillMaxWidth()
.align(helperContentAlignment)
.padding(spaceMedium),
tonalElevation = 8.dp,
shape = MaterialTheme.shapes.large,
color = helperColor
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround,
modifier = Modifier.padding(spaceMedium)
) {
Text(
modifier = Modifier.weight(1f),
text = title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center
)
if (helperValueState == ComparisonQuizHelperValueState.NORMAL) {
Spacer(modifier = Modifier.width(spaceExtraSmall))
VerticalDivider(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.width(spaceExtraSmall))
AnimatedContent(
targetState = helperValue,
label = "helper value",
modifier = Modifier.weight(1f),
) { value ->
Text(
text = value,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
textAlign = TextAlign.Center
)
}
}
}
}
}
}
}
@Composable
@PreviewLightDark
private fun ComparisonQuizScreenPreview(
@PreviewParameter(BooleanPreviewParameterProvider::class) firstItem: Boolean
) {
val alignment = if (firstItem) Alignment.TopCenter else Alignment.BottomCenter
NewQuizTheme {
Surface {
ComparisonItem(
modifier = Modifier
.padding(16.dp)
.size(400.dp),
title = "NewQuizsssssssssssssssssssssssssssssssssss",
image = Uri.EMPTY,
helperValue = "12,345",
onClick = {},
helperContentAlignment = alignment,
helperValueState = ComparisonQuizHelperValueState.NORMAL
)
}
}
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/ui/components/ComparisonMidContent.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.infinitepower.newquiz.comparison_quiz.ui.AnimationState
import com.infinitepower.newquiz.core.common.compose.preview.BooleanPreviewParameterProvider
import com.infinitepower.newquiz.core.theme.NewQuizTheme
import com.infinitepower.newquiz.core.R as CoreR
@Composable
internal fun ComparisonMidContent(
modifier: Modifier = Modifier,
questionPosition: Int,
highestPosition: Int,
verticalContent: Boolean,
animationState: AnimationState
) {
ComparisonMidContainer(
modifier = modifier,
verticalContent = verticalContent,
currentPositionContent = {
Text(
text = stringResource(id = CoreR.string.position_n, questionPosition),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
},
highestPositionContent = {
Text(
text = stringResource(id = CoreR.string.highest_n, highestPosition),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
},
midContent = { MiddleCircle(animationState = animationState) }
)
}
@Composable
private fun ComparisonMidContainer(
modifier: Modifier = Modifier,
verticalContent: Boolean,
currentPositionContent: @Composable () -> Unit,
highestPositionContent: @Composable () -> Unit,
midContent: @Composable () -> Unit
) {
val movableCurrentPositionContent = remember(currentPositionContent) {
movableContentOf(currentPositionContent)
}
val movableHighestPositionContent = remember(highestPositionContent) {
movableContentOf(highestPositionContent)
}
val movableMidContent = remember(midContent) {
movableContentOf(midContent)
}
if (verticalContent) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
movableCurrentPositionContent()
movableMidContent()
movableHighestPositionContent()
}
} else {
Column(
modifier = modifier.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceEvenly,
horizontalAlignment = Alignment.CenterHorizontally
) {
movableCurrentPositionContent()
movableMidContent()
movableHighestPositionContent()
}
}
}
@Composable
@PreviewLightDark
private fun ComparisonMidContentPreview(
@PreviewParameter(BooleanPreviewParameterProvider::class) verticalContent: Boolean
) {
NewQuizTheme {
Surface {
ComparisonMidContent(
questionPosition = 1,
highestPosition = 10,
verticalContent = verticalContent,
modifier = Modifier.padding(16.dp),
animationState = AnimationState.Normal
)
}
}
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/ui/components/GameOverContent.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.airbnb.lottie.LottieProperty
import com.airbnb.lottie.SimpleColorFilter
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.rememberLottieComposition
import com.airbnb.lottie.compose.rememberLottieDynamicProperties
import com.airbnb.lottie.compose.rememberLottieDynamicProperty
import com.infinitepower.newquiz.core.R
import androidx.compose.ui.tooling.preview.PreviewLightDark
import com.infinitepower.newquiz.core.theme.NewQuizTheme
import com.infinitepower.newquiz.core.theme.spacing
import com.infinitepower.newquiz.core.R as CoreR
@Composable
internal fun GameOverContent(
modifier: Modifier = Modifier,
scorePosition: Int,
highestPosition: Int,
onBackClick: () -> Unit = {},
onPlayAgainClick: () -> Unit = {}
) {
val spaceMedium = MaterialTheme.spacing.medium
val spaceLarge = MaterialTheme.spacing.large
val trophySpec = LottieCompositionSpec.RawRes(R.raw.trophy2)
val trophyLottieComposition by rememberLottieComposition(spec = trophySpec)
val dynamicProperties = rememberLottieDynamicProperties(
rememberLottieDynamicProperty(
property = LottieProperty.COLOR_FILTER,
value = SimpleColorFilter(MaterialTheme.colorScheme.primary.toArgb()),
keyPath = arrayOf("**")
),
)
LazyColumn(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
contentPadding = PaddingValues(
vertical = MaterialTheme.spacing.extraLarge,
horizontal = spaceLarge
)
) {
item("headline") {
Text(
text = stringResource(id = CoreR.string.game_over),
style = MaterialTheme.typography.headlineLarge
)
Spacer(modifier = Modifier.height(spaceLarge))
}
item("animation_image") {
LottieAnimation(
composition = trophyLottieComposition,
modifier = Modifier.size(200.dp),
dynamicProperties = dynamicProperties
)
Spacer(modifier = Modifier.height(spaceLarge))
}
item("current_score") {
Text(
text = stringResource(id = CoreR.string.your_score).uppercase(),
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(spaceMedium))
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = spaceLarge)
){
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = scorePosition.toString(),
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(vertical = MaterialTheme.spacing.small)
)
}
}
Spacer(modifier = Modifier.height(spaceLarge))
}
item("highest_score") {
Text(
text = stringResource(id = CoreR.string.highest_score).uppercase(),
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(spaceMedium))
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = spaceLarge)
){
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = highestPosition.toString(),
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(vertical = MaterialTheme.spacing.small)
)
}
}
Spacer(modifier = Modifier.height(MaterialTheme.spacing.extraLarge))
}
item("buttons") {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = onBackClick,
modifier = Modifier.weight(1f)
) {
Text(text = stringResource(id = CoreR.string.back))
}
Spacer(modifier = Modifier.width(spaceMedium))
Button(
onClick = onPlayAgainClick,
modifier = Modifier.weight(1f)
) {
Text(text = stringResource(id = CoreR.string.play_again))
}
}
}
}
}
@Composable
@PreviewLightDark
private fun GameOverContentPreview() {
NewQuizTheme {
Surface {
GameOverContent(
scorePosition = 3,
highestPosition = 5
)
}
}
}
================================================
FILE: comparison-quiz/src/main/java/com/infinitepower/newquiz/comparison_quiz/ui/components/MiddleCircle.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.infinitepower.newquiz.comparison_quiz.ui.AnimationState
import com.infinitepower.newquiz.core.R
import com.infinitepower.newquiz.core.theme.CustomColor
import com.infinitepower.newquiz.core.theme.NewQuizTheme
import com.infinitepower.newquiz.core.theme.extendedColors
/**
* A circle with the text "or" in the middle.
* Used to separate the two options in the comparison quiz.
*
* When the question is answered, the circle is filled with the correct color
* with an animation.
*/
@Composable
internal fun MiddleCircle(
modifier: Modifier = Modifier,
animationState: AnimationState = AnimationState.Normal
) {
val animationTransition = updateTransition(
targetState = animationState,
label = "Middle Circle"
)
val correctQuestionColors = MaterialTheme.extendedColors.getColorsByKey(
key = CustomColor.Key.Green
)
val containerColor by animationTransition.animateColor(
transitionSpec = { tween(durationMillis = ANIMATION_DURATION_MILLIS) },
label = "Container Color"
) { state ->
when (state) {
AnimationState.StartGame -> MaterialTheme.colorScheme.surface
AnimationState.Normal -> MaterialTheme.colorScheme.tertiary
AnimationState.NextQuestion -> correctQuestionColors.color
}
}
val contentColor by animationTransition.animateColor(
transitionSpec = { tween(durationMillis = ANIMATION_DURATION_MILLIS) },
label = "Content Color"
) { state ->
when (state) {
AnimationState.StartGame -> MaterialTheme.colorScheme.onSurface
AnimationState.Normal -> MaterialTheme.colorScheme.onTertiary
AnimationState.NextQuestion -> correctQuestionColors.onColor
}
}
val tonalElevation by animationTransition.animateDp(
transitionSpec = { tween(durationMillis = ANIMATION_DURATION_MILLIS) },
label = "Tonal Elevation"
) { state ->
when (state) {
AnimationState.StartGame -> 0.dp
AnimationState.Normal, AnimationState.NextQuestion -> DEFAULT_TONAL_ELEVATION
}
}
MiddleCircle(
modifier = modifier,
animationTransition = animationTransition,
containerColor = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation
)
}
private const val ANIMATION_DURATION_MILLIS = 310
private val DEFAULT_TONAL_ELEVATION = 2.dp
@Composable
private fun MiddleCircle(
modifier: Modifier = Modifier,
animationTransition: Transition,
containerColor: Color = MaterialTheme.colorScheme.tertiary,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = DEFAULT_TONAL_ELEVATION
) {
Surface(
shape = CircleShape,
modifier = modifier.size(48.dp),
color = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
animationTransition.AnimatedContent { state ->
when (state) {
AnimationState.StartGame -> Unit
AnimationState.Normal -> {
Text(
text = stringResource(id = R.string.or).uppercase(),
style = MaterialTheme.typography.headlineSmall
)
}
AnimationState.NextQuestion -> {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
}
}
}
}
}
}
@Composable
@PreviewLightDark
private fun MiddleCirclePreview() {
NewQuizTheme {
Surface {
MiddleCircle(
modifier = Modifier.padding(16.dp),
animationState = AnimationState.Normal
)
}
}
}
================================================
FILE: comparison-quiz/src/test/java/com/infinitepower/newquiz/comparison_quiz/core/ComparisonQuizCoreImplTest.kt
================================================
package com.infinitepower.newquiz.comparison_quiz.core
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.infinitepower.newquiz.core.analytics.NoOpAnalyticsHelper
import com.infinitepower.newquiz.core.database.dao.GameResultDao
import com.infinitepower.newquiz.core.datastore.common.LocalUserCommon
import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager
import com.infinitepower.newquiz.core.datastore.manager.PreferencesDatastoreManager
import com.infinitepower.newquiz.core.game.ComparisonQuizCore
import com.infinitepower.newquiz.core.remote_config.RemoteConfig
import com.infinitepower.newquiz.core.remote_config.RemoteConfigValue
import com.infinitepower.newquiz.core.remote_config.get
import com.infinitepower.newquiz.core.testing.domain.FakeGameResultDao
import com.infinitepower.newquiz.core.testing.utils.mockAndroidLog
import com.infinitepower.newquiz.core.user_services.InsufficientDiamondsException
import com.infinitepower.newquiz.core.user_services.LocalUserServiceImpl
import com.infinitepower.newquiz.core.user_services.UserService
import com.infinitepower.newquiz.core.user_services.data.xp.ComparisonQuizXpGeneratorImpl
import com.infinitepower.newquiz.core.user_services.data.xp.MultiChoiceQuizXpGeneratorImpl
import com.infinitepower.newquiz.core.user_services.data.xp.WordleXpGeneratorImpl
import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository
import com.infinitepower.newquiz.model.NumberFormatType
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizHelperValueState
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItem
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizQuestion
import com.infinitepower.newquiz.model.toUiText
import io.mockk.coEvery
import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.io.TempDir
import java.io.File
import java.net.URI
/**
* Unit tests for [ComparisonQuizCoreImpl].
*/
internal class ComparisonQuizCoreImplTest {
private lateinit var comparisonQuizCoreImpl: ComparisonQuizCoreImpl
private val comparisonQuizRepository = mockk()
private val remoteConfig = mockk()
private lateinit var userService: UserService
@TempDir
lateinit var tmpDir: File
private lateinit var dataStoreManager: DataStoreManager
private lateinit var gameResultDao: GameResultDao
private val firstItemHelperValueState = ComparisonQuizHelperValueState.HIDDEN
@BeforeEach
fun setup() {
mockAndroidLog()
val testDataStore: DataStore = PreferenceDataStoreFactory.create(
produceFile = { File(tmpDir, "user.preferences_pb") }
)
dataStoreManager = PreferencesDatastoreManager(testDataStore)
gameResultDao = FakeGameResultDao()
userService = LocalUserServiceImpl(
dataStoreManager = dataStoreManager,
remoteConfig = remoteConfig,
gameResultDao = gameResultDao,
multiChoiceXpGenerator = MultiChoiceQuizXpGeneratorImpl(remoteConfig),
wordleXpGenerator = WordleXpGeneratorImpl(remoteConfig),
comparisonQuizXpGenerator = ComparisonQuizXpGeneratorImpl(remoteConfig)
)
comparisonQuizCoreImpl = ComparisonQuizCoreImpl(
comparisonQuizRepository = comparisonQuizRepository,
remoteConfig = remoteConfig,
analyticsHelper = NoOpAnalyticsHelper,
userService = userService
)
every {
remoteConfig.getString("comparison_quiz_first_item_helper_value")
} returns firstItemHelperValueState.name
}
@Test
fun `initializeGame should emit correct data`() = runTest {
val initialData = getInitializationData()
val uriMock = getUriMock()
val expectedQuestions = listOf(
ComparisonQuizItem(
title = "Question 1",
value = 1.0,
imgUri = uriMock
),
ComparisonQuizItem(
title = "Question 2",
value = 2.0,
imgUri = uriMock
),
ComparisonQuizItem(
title = "Question 3",
value = 3.0,
imgUri = uriMock
)
)
coEvery {
comparisonQuizRepository.getQuestions(category = initialData.category)
} returns expectedQuestions
comparisonQuizCoreImpl.initializeGame(initialData)
val quizData = comparisonQuizCoreImpl.quizDataFlow.first()
assertThat(quizData.comparisonMode).isEqualTo(initialData.comparisonMode)
assertThat(quizData.questions).contains(expectedQuestions[2])
assertThat(quizData.firstItemHelperValueState).isEqualTo(firstItemHelperValueState)
val expectedCurrentQuestion = ComparisonQuizQuestion(
questions = expectedQuestions[0] to expectedQuestions[1],
categoryId = "id",
comparisonMode = ComparisonMode.GREATER
)
// verify current question
assertThat(quizData.currentQuestion).isNotNull()
assertThat(quizData.currentQuestion).isEqualTo(expectedCurrentQuestion)
}
@Test
fun `initializeGame should end game when error in data request`() = runTest {
val initialData = getInitializationData()
coEvery {
comparisonQuizRepository.getQuestions(category = initialData.category)
} returns emptyList()
comparisonQuizCoreImpl.initializeGame(initialData)
comparisonQuizCoreImpl.quizDataFlow.test {
val quizData = awaitItem()
assertThat(quizData.isGameOver).isTrue()
assertThat(quizData.currentQuestion).isNull()
}
}
@Test
fun `onAnswerClicked should check the correct answer and move to the next question`() = runTest {
val initialData = getInitializationData(
comparisonMode = ComparisonMode.LESSER
)
val uriMock = getUriMock()
val expectedQuestions = listOf(
ComparisonQuizItem(
title = "Question 1",
value = 1.0,
imgUri = uriMock
),
ComparisonQuizItem(
title = "Question 2",
value = 2.0,
imgUri = uriMock
),
ComparisonQuizItem(
title = "Question 3",
value = 3.0,
imgUri = uriMock
)
)
coEvery {
comparisonQuizRepository.getQuestions(category = initialData.category)
} returns expectedQuestions
comparisonQuizCoreImpl.initializeGame(initialData)
// verify current question
val quizData = comparisonQuizCoreImpl.quizDataFlow.first()
val currentQuestion = quizData.currentQuestion
assertThat(quizData.comparisonMode).isEqualTo(initialData.comparisonMode)
// Check if the helper value is correct
assertThat(quizData.firstItemHelperValueState).isEqualTo(firstItemHelperValueState)
assertThat(quizData.isGameOver).isFalse()
assertThat(currentQuestion).isNotNull()
require(currentQuestion != null)
assertThat(quizData.questions).hasSize(1)
assertThat(quizData.questions).contains(expectedQuestions[2])
assertThat(currentQuestion.questions.first).isEqualTo(expectedQuestions[0])
assertThat(currentQuestion.questions.second).isEqualTo(expectedQuestions[1])
// verify answer
comparisonQuizCoreImpl.onAnswerClicked(currentQuestion.questions.first)
val newQuizData = comparisonQuizCoreImpl.quizDataFlow.first()
val newCurrentQuestion = newQuizData.currentQuestion
assertThat(newQuizData.isGameOver).isFalse()
assertThat(newCurrentQuestion).isNotNull()
require(newCurrentQuestion != null)
assertThat(newQuizData.questions).isEmpty()
assertThat(newCurrentQuestion.questions.first).isEqualTo(expectedQuestions[1])
assertThat(newCurrentQuestion.questions.second).isEqualTo(expectedQuestions[2])
// Check if the helper value is changed
assertThat(newQuizData.firstItemHelperValueState).isNotEqualTo(firstItemHelperValueState)
assertThat(newQuizData.firstItemHelperValueState).isEqualTo(ComparisonQuizHelperValueState.NORMAL)
}
// Test when the user gets the answer wrong
@Test
fun `onAnswerClicked should check the wrong answer and move to the next question`() = runTest {
val initialData = getInitializationData(
comparisonMode = ComparisonMode.LESSER
)
val uriMock = getUriMock()
val expectedQuestions = listOf(
ComparisonQuizItem(
title = "Question 1",
value = 1.0,
imgUri = uriMock
),
ComparisonQuizItem(
title = "Question 2",
value = 2.0,
imgUri = uriMock
),
ComparisonQuizItem(
title = "Question 3",
value = 3.0,
imgUri = uriMock
)
)
coEvery {
comparisonQuizRepository.getQuestions(category = initialData.category)
} returns expectedQuestions
// Loads the initial data and starts the game
// This will also set the current question
// The answer will be wrong
comparisonQuizCoreImpl.initializeGame(initialData)
// Get the current data and question
val quizData = comparisonQuizCoreImpl.quizDataFlow.first()
val currentQuestion = quizData.currentQuestion
// Check if comparison mode is correct
assertThat(quizData.comparisonMode).isEqualTo(initialData.comparisonMode)
// Check if the helper value is correct
assertThat(quizData.firstItemHelperValueState).isEqualTo(firstItemHelperValueState)
// Check if current question is not null
assertThat(currentQuestion).isNotNull()
require(currentQuestion != null)
assertThat(quizData.questions).hasSize(1)
assertThat(quizData.questions).contains(expectedQuestions[2])
assertThat(currentQuestion.questions.first).isEqualTo(expectedQuestions[0])
assertThat(currentQuestion.questions.second).isEqualTo(expectedQuestions[1])
// verify answer
comparisonQuizCoreImpl.onAnswerClicked(currentQuestion.questions.second)
val newQuizData = comparisonQuizCoreImpl.quizDataFlow.first()
val newCurrentQuestion = newQuizData.currentQuestion
assertThat(newCurrentQuestion).isNull()
assertThat(newQuizData.isGameOver).isTrue()
}
@Test
fun `onAnswerClicked should end the game when no more questions is remaining`() = runTest {
val initialData = getInitializationData()
val uriMock = getUriMock()
val expectedQuestions = listOf(
ComparisonQuizItem(
title = "Question 1",
value = 1.0,
imgUri = uriMock
),
ComparisonQuizItem(
title = "Question 2",
value = 2.0,
imgUri = uriMock
)
)
coEvery {
comparisonQuizRepository.getQuestions(category = initialData.category)
} returns expectedQuestions
comparisonQuizCoreImpl.initializeGame(initialData)
val quizData = comparisonQuizCoreImpl.quizDataFlow.first()
assertThat(quizData.questions).isEmpty()
val expectedCurrentQuestion = ComparisonQuizQuestion(
questions = expectedQuestions[0] to expectedQuestions[1],
categoryId = "id",
comparisonMode = ComparisonMode.GREATER
)
val currentQuestion = quizData.currentQuestion
// verify current question
assertThat(currentQuestion).isNotNull()
assertThat(currentQuestion).isEqualTo(expectedCurrentQuestion)
require(currentQuestion != null)
comparisonQuizCoreImpl.onAnswerClicked(currentQuestion.questions.second)
val newQuizData = comparisonQuizCoreImpl.quizDataFlow.first()
val newCurrentQuestion = newQuizData.currentQuestion
assertThat(newCurrentQuestion).isNull()
assertThat(newQuizData.isGameOver).isTrue()
}
@Test
fun `endGame() should end the game`() = runTest {
val initialData = getInitializationData()
val uriMock = getUriMock()
val expectedQuestions = listOf(
ComparisonQuizItem(
title = "Question 1",
value = 1.0,
imgUri = uriMock
),
ComparisonQuizItem(
title = "Question 2",
value = 2.0,
imgUri = uriMock
)
)
coEvery {
comparisonQuizRepository.getQuestions(category = initialData.category)
} returns expectedQuestions
comparisonQuizCoreImpl.initializeGame(initialData)
val quizData = comparisonQuizCoreImpl.quizDataFlow.first()
assertThat(quizData.questions).isEmpty()
val expectedCurrentQuestion = ComparisonQuizQuestion(
questions = expectedQuestions[0] to expectedQuestions[1],
categoryId = "id",
comparisonMode = ComparisonMode.GREATER
)
val currentQuestion = quizData.currentQuestion
// verify current question
assertThat(currentQuestion).isNotNull()
assertThat(currentQuestion).isEqualTo(expectedCurrentQuestion)
require(currentQuestion != null)
comparisonQuizCoreImpl.endGame()
val newQuizData = comparisonQuizCoreImpl.quizDataFlow.first()
val newCurrentQuestion = newQuizData.currentQuestion
assertThat(newCurrentQuestion).isNull()
assertThat(newQuizData.isGameOver).isTrue()
}
@Test
fun `skip() should deduct diamonds and update quiz data`() = runTest {
val skipCost = 1
val userDiamonds = 10
val initialData = getInitializationData()
every { remoteConfig.get(RemoteConfigValue.COMPARISON_QUIZ_SKIP_COST) } returns skipCost
every { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns userDiamonds
dataStoreManager.editPreference(LocalUserCommon.UserDiamonds(userDiamonds).key, userDiamonds)
val uriMock = getUriMock()
val expectedQuestions = listOf(
ComparisonQuizItem(
title = "Question 1",
value = 1.0,
imgUri = uriMock
),
ComparisonQuizItem(
title = "Question 2",
value = 2.0,
imgUri = uriMock
),
ComparisonQuizItem(
title = "Question 3",
value = 3.0,
imgUri = uriMock
)
)
coEvery {
comparisonQuizRepository.getQuestions(category = initialData.category)
} returns expectedQuestions
comparisonQuizCoreImpl.initializeGame(initialData)
// verify current question
val quizData = comparisonQuizCoreImpl.quizDataFlow.first()
val currentQuestion = quizData.currentQuestion
assertThat(quizData.comparisonMode).isEqualTo(initialData.comparisonMode)
// Check if the helper value is correct
assertThat(quizData.firstItemHelperValueState).isEqualTo(firstItemHelperValueState)
assertThat(quizData.isGameOver).isFalse()
assertThat(quizData.skippedAnswers).isEqualTo(0)
assertThat(currentQuestion).isNotNull()
require(currentQuestion != null)
comparisonQuizCoreImpl.skip()
verify(exactly = 1) { remoteConfig.getString("comparison_quiz_first_item_helper_value") }
verify { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) }
verify(exactly = 1) { remoteConfig.get(RemoteConfigValue.COMPARISON_QUIZ_SKIP_COST) }
confirmVerified(remoteConfig)
val newQuizData = comparisonQuizCoreImpl.quizDataFlow.first()
val newCurrentQuestion = newQuizData.currentQuestion
assertThat(newQuizData.isGameOver).isFalse()
assertThat(newQuizData.skippedAnswers).isEqualTo(1)
assertThat(newCurrentQuestion).isNotNull()
require(newCurrentQuestion != null)
assertThat(newQuizData.questions).isEmpty()
assertThat(newCurrentQuestion.questions.first).isEqualTo(expectedQuestions[1])
assertThat(newCurrentQuestion.questions.second).isEqualTo(expectedQuestions[2])
// Check if the helper value is changed
assertThat(newQuizData.firstItemHelperValueState).isNotEqualTo(firstItemHelperValueState)
assertThat(newQuizData.firstItemHelperValueState).isEqualTo(ComparisonQuizHelperValueState.NORMAL)
}
@Test
fun `skip should return when user doesn't have enough diamonds`() = runTest {
val skipCost = 10
val userDiamonds = 5
every { remoteConfig.get(RemoteConfigValue.COMPARISON_QUIZ_SKIP_COST) } returns skipCost
every { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns userDiamonds
dataStoreManager.editPreference(LocalUserCommon.UserDiamonds(userDiamonds).key, userDiamonds)
val initialQuizData = comparisonQuizCoreImpl.quizDataFlow.first()
// Assert exception is thrown
assertThrows {
comparisonQuizCoreImpl.skip()
}
// Verify that we called this once, one for checking if can skip
// We don't call it again because we don't have enough diamonds
verify(exactly = 1) { remoteConfig.get(RemoteConfigValue.COMPARISON_QUIZ_SKIP_COST) }
verify(exactly = 1) { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) }
confirmVerified(remoteConfig)
// Check if nothing is changed
assertThat(userService.getUserDiamonds()).isEqualTo(userDiamonds.toUInt())
assertThat(comparisonQuizCoreImpl.quizDataFlow.first()).isEqualTo(initialQuizData)
}
private fun getInitializationData(
comparisonMode: ComparisonMode = ComparisonMode.GREATER
) = ComparisonQuizCore.InitializationData(
category = ComparisonQuizCategory(
id = "id",
name = "title".toUiText(),
description = "description",
image = "imageUrl",
questionDescription = ComparisonQuizCategory.QuestionDescription(
greater = "greater",
less = "less"
),
formatType = NumberFormatType.DEFAULT,
helperValueSuffix = "helperValueSuffix",
dataSourceAttribution = ComparisonQuizCategory.DataSourceAttribution(
text = "text",
logo = "logo"
)
),
comparisonMode = comparisonMode
)
private fun getUriMock(): URI = URI("test/path")
}
================================================
FILE: compose_compiler_config.conf
================================================
// Consider LocalDateTime stable
java.time.LocalDateTime
// Consider my datalayer and all submodules stable
com.infinitepower.newquiz.model.**
com.infinitepower.newquiz.domain.repository.home.HomeCategories<*>
================================================
FILE: core/.gitignore
================================================
/build
================================================
FILE: core/analytics/.gitignore
================================================
/build
================================================
FILE: core/analytics/LOGGING_ANALYTICS.md
================================================
# Overview
This document describes the logging analytics events and parameters, collected by the application.
The events are sent anonymously to the Firebase Analytics. The user can opt out from the analytics in the settings screen.
These events are used to improve the application and to provide better user experience.
Firebase automatically collects some [events](https://support.google.com/firebase/answer/9234069?visit_id=638163118323661106-1839920987&rd=1).
# Core logging
## screen_view
This event logs the current user screen.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|----------------|--------|----------|---------------|-------------|
| screen_name | string | No | Home Screen | Screen name |
## level_up
This event logs the new level reached by the user.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|----------------|--------|----------|---------------|-------------------------|
| level | number | Yes | 10 | New level of the user |
| character | string | No | global | Where the user level up |
## earn_virtual_currency
This event logs the virtual currency earned by the user. This can be trigger when the user
reached a new level or when the user completed a quiz.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|-----------------------|--------|----------|---------------|-------------------------|
| virtual_currency_name | string | Yes | diamonds | Virtual currency name |
| value | number | Yes | 10 | Virtual currency value |
## spend_virtual_currency
This event logs the virtual currency spent by the user. This can be trigger when the user
buy a skips the question.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|-----------------------|--------|----------|---------------|-------------------------|
| virtual_currency_name | string | Yes | diamonds | Virtual currency name |
| value | number | Yes | 10 | Virtual currency value |
# Multi choice quiz
## multi_choice_game_start
This event logs the multi choice game quiz start.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|-----------------------------|--------|----------|---------------|-----------------------------|
| multi_choice_questions_size | number | Yes | 10 | Questions size |
| multi_choice_category | string | No | flag | Category for questions |
| multi_choice_difficulty | string | No | easy | Difficulty for questions |
| maze_item_id | string | No | 123456 | Item id from maze game mode |
## multi_choice_game_end
This event logs the multi choice game quiz end.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|------------------------------|--------|----------|---------------|-----------------------------|
| multi_choice_questions_size | number | Yes | 10 | Questions size |
| multi_choice_correct_answers | number | Yes | 5 | Correct answers size |
| maze_item_id | string | No | 123456 | Item id from maze game mode |
## multi_choice_category_clicked
This event logs when the user clicked on a multi choice quiz category.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|----------------|--------|----------|---------------|-----------------|
| id | string | Yes | flag | The category id |
## multi_choice_save_question
This event logs the user save a question from the multi choice quiz game.
## multi_choice_save_question
This event logs the user downloads multi choice quiz questions to save then.
## multi_choice_play_saved_questions
This event logs the user plays the saved multi choice quiz questions.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|-----------------------------|--------|----------|---------------|----------------|
| multi_choice_questions_size | number | Yes | 10 | Questions size |
# Wordle logging analytics
## wordle_game_start
This event logs the wordle game quiz start.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|--------------------|--------|----------|---------------|-----------------------------|
| wordle_word_length | number | Yes | 5 | Word length |
| wordle_max_rows | number | Yes | 10 | Quiz max rows |
| wordle_quiz_type | string | No | text | The type of the wordle quiz |
| wordle_day | string | No | 2019-01-01 | Wordle day mode: word date |
| maze_item_id | string | No | 123456 | Item id from maze game mode |
## wordle_game_end
This event logs the wordle game quiz end.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|-------------------------|--------|----------|---------------|-----------------------------|
| wordle_word_length | number | Yes | 5 | Word length |
| wordle_max_rows | number | Yes | 10 | Quiz max rows |
| wordle_last_row | number | Yes | 5 | Quiz last row position |
| wordle_last_row_correct | string | Yes | true | Is quiz last row correct |
| wordle_quiz_type | string | No | text | The type of the wordle quiz |
| wordle_day | string | No | 2019-01-01 | Wordle day mode: word date |
| maze_item_id | string | No | 123456 | Item id from maze game mode |
## daily_wordle_item_click
This event logs when the user click on a daily wordle item.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|--------------------|--------|----------|---------------|----------------------------|
| wordle_word_length | number | Yes | 5 | Word length |
| day | string | Yes | 2019-01-01 | Wordle day mode: word date |
## daily_wordle_item_complete
This event logs when the user complete a daily wordle item.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|-------------------------|--------|----------|---------------|----------------------------|
| wordle_word_length | number | Yes | 5 | Word length |
| wordle_last_row_correct | string | Yes | true | Is quiz last row correct |
| day | string | No | 2019-01-01 | Wordle day mode: word date |
# Maze
## create_maze
This event logs the maze game mode creation.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|----------------|--------|----------|---------------|---------------------|
| seed | number | No | 123456 | Maze random seed |
| item_size | number | No | 10 | Maze questions size |
| game_modes | string | No | wordle | Maze game modes |
## restart_maze
This event logs when the user restart the maze game mode.
## maze_item_click
This event logs when the user click on a maze item.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|----------------|--------|----------|---------------|-----------------|
| item_index | number | Yes | 23 | Maze item index |
## maze_item_played
This event logs when the user play and ends the game of the maze item.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|----------------|--------|----------|---------------|---------------------------------|
| correct | string | Yes | true | The user got the answer correct |
## maze_completed
This event logs when the user completes all the questions the maze game mode.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|----------------|--------|----------|---------------|---------------------------------|
| item_size | number | Yes | 23 | Number of questions in the maze |
# Comparison Quiz
## comparison_quiz_game_start
This event logs the comparison quiz game start.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|-----------------|--------|----------|--------------------|--------------------------------------|
| category | string | Yes | country_population | The category of the quiz |
| comparison_mode | string | Yes | higher | The comparison mode used in the quiz |
## comparison_quiz_game_end
This event logs the comparison quiz game end.
### Parameters
| Parameter name | Type | Required | Example value | Description |
|------------------|--------|----------|--------------------|--------------------------------------|
| category | string | No | country_population | The category of the quiz |
| comparison_mode | string | No | higher | The comparison mode used in the quiz |
| score | number | Yes | 10 | The score of the quiz |
================================================
FILE: core/analytics/README.md
================================================
# Analytics Module (:core:analytics)
The Analytics module provides a way to track user interactions with the application.
## Normal Flavor
The normal flavor of the Analytics module uses the [Firebase Analytics](https://firebase.google.com/docs/analytics) service to track user interactions.
## FOSS Flavor
The FOSS flavor of the Analytics module don't track any user interaction to the any third party services. Only logs the user interactions to the console.
================================================
FILE: core/analytics/build.gradle.kts
================================================
plugins {
alias(libs.plugins.newquiz.android.library.compose)
alias(libs.plugins.newquiz.android.hilt)
alias(libs.plugins.newquiz.detekt)
}
android {
namespace = "com.infinitepower.newquiz.core.analytics"
}
dependencies {
implementation(libs.androidx.compose.runtime)
normalImplementation(platform(libs.firebase.bom))
normalImplementation(libs.firebase.analytics)
normalImplementation(libs.firebase.crashlytics)
normalImplementation(libs.firebase.perf)
implementation(projects.model)
normalImplementation(projects.core.datastore)
}
================================================
FILE: core/analytics/consumer-rules.pro
================================================
================================================
FILE: core/analytics/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
================================================
FILE: core/analytics/src/foss/kotlin/com/infinitepower/newquiz/core/analytics/FossAnalyticsModule.kt
================================================
package com.infinitepower.newquiz.core.analytics
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
abstract class FossAnalyticsModule {
@Binds
abstract fun bindAnalyticsHelper(impl: LocalDebugAnalyticsHelper): AnalyticsHelper
}
================================================
FILE: core/analytics/src/main/AndroidManifest.xml
================================================
================================================
FILE: core/analytics/src/main/kotlin/com/infinitepower/newquiz/core/analytics/AnalyticsEvent.kt
================================================
package com.infinitepower.newquiz.core.analytics
import androidx.annotation.Keep
import com.infinitepower.newquiz.model.global_event.GameEvent
sealed class AnalyticsEvent(
val type: String,
val extras: Set> = emptySet()
) {
@Keep
data class Param(
val key: String,
val value: T?
)
/**
* Enum class for the different games that can be played.
*/
enum class Game {
MULTI_CHOICE_QUIZ,
WORDLE,
COMPARISON_QUIZ
}
// Core events
@Keep
data class CategoryClicked(
val game: Game,
val categoryId: String,
val otherData: Map = emptyMap()
) : AnalyticsEvent(
type = "category_clicked",
extras = setOf(
Param("game", game.name.lowercase()),
Param("id", categoryId)
) + otherData.map { Param(it.key, it.value) }
)
@Keep
data class LevelUp(val level: Int) : AnalyticsEvent(
type = "level_up",
extras = setOf(
Param("level", level),
Param("character", "global")
)
)
@Keep
data class EarnDiamonds(val earned: Int) : AnalyticsEvent(
type = "earn_virtual_currency",
extras = setOf(
Param("virtual_currency_name", "diamonds"),
Param("value", earned)
)
)
@Keep
data class SpendDiamonds(val amount: Int, val usedFor: String) : AnalyticsEvent(
type = "spend_virtual_currency",
extras = setOf(
Param("value", amount),
Param("virtual_currency_name", "diamonds"),
Param("item_name", usedFor)
)
)
// Multi choice quiz events
@Keep
data class MultiChoiceGameStart(
val questionsSize: Int,
val category: String,
val difficulty: String?,
val mazeItemId: Int? = null
) : AnalyticsEvent(
type = "multi_choice_game_start",
extras = setOf(
Param("questions_size", questionsSize),
Param("category", category),
Param("difficulty", difficulty),
Param("maze_item_id", mazeItemId)
)
)
@Keep
data class MultiChoiceGameEnd(
val questionsSize: Int,
val correctAnswers: Int,
val mazeItemId: Int? = null
) : AnalyticsEvent(
type = "multi_choice_game_end",
extras = setOf(
Param("questions_size", questionsSize),
Param("correct_answers", correctAnswers),
Param("maze_item_id", mazeItemId)
)
)
data object MultiChoiceSaveQuestion : AnalyticsEvent("multi_choice_save_question")
data object MultiChoiceDownloadQuestions : AnalyticsEvent("multi_choice_download_questions")
@Keep
data class MultiChoicePlaySavedQuestions(
val questionsSize: Int
) : AnalyticsEvent(
type = "multi_choice_play_saved_questions",
extras = setOf(
Param("questions_size", questionsSize)
)
)
// Wordle events
@Keep
data class WordleGameStart(
val wordLength: Int,
val maxRows: Int,
val category: String,
val mazeItemId: Int? = null
) : AnalyticsEvent(
type = "wordle_game_start",
extras = setOf(
Param("word_length", wordLength),
Param("max_rows", maxRows),
Param("category", category),
Param("maze_item_id", mazeItemId)
)
)
@Keep
data class WordleGameEnd(
val wordLength: Int,
val maxRows: Int,
val lastRow: Int,
val lastRowCorrect: Boolean,
val category: String,
val mazeItemId: Int? = null
) : AnalyticsEvent(
type = "wordle_game_end",
extras = setOf(
Param("word_length", wordLength),
Param("max_rows", maxRows),
Param("last_row", lastRow),
Param("last_row_correct", lastRowCorrect),
Param("category", category),
Param("maze_item_id", mazeItemId)
)
)
// Maze events
@Keep
data class CreateMaze(
val seed: Int,
val questionsSize: Int
) : AnalyticsEvent(
type = "create_maze",
extras = setOf(
Param("seed", seed),
Param("questions_size", questionsSize)
)
)
data object RestartMaze : AnalyticsEvent("restart_maze")
@Keep
data class MazeItemClick(val index: Int) : AnalyticsEvent(
type = "maze_item_click",
extras = setOf(
Param("index", index)
)
)
@Keep
data class MazeItemPlayed(val correct: Boolean) : AnalyticsEvent(
type = "maze_item_played",
extras = setOf(
Param("correct", correct)
)
)
@Keep
data class MazeCompleted(val questionSize: Int) : AnalyticsEvent(
type = "maze_completed",
extras = setOf(
Param("question_size", questionSize)
)
)
// Comparison Quiz
@Keep
data class ComparisonQuizGameStart(
val category: String,
val comparisonMode: String,
) : AnalyticsEvent(
type = "comparison_quiz_game_start",
extras = setOf(
Param("category", category),
Param("comparison_mode", comparisonMode)
)
)
@Keep
data class ComparisonQuizGameEnd(
val category: String?,
val comparisonMode: String?,
val score: Int
) : AnalyticsEvent(
type = "comparison_quiz_game_end",
extras = setOf(
Param("category", category),
Param("comparison_mode", comparisonMode),
Param("score", score)
)
)
// Daily challenge
@Keep
data class DailyChallengeItemClick(
val event: GameEvent
) : AnalyticsEvent(
type = "daily_challenge_item_click",
extras = setOf(
Param("event", event.toString())
)
)
@Keep
data class DailyChallengeItemClaim(
val event: GameEvent,
val steps: Int
) : AnalyticsEvent(
type = "daily_challenge_item_claim",
extras = setOf(
Param("event", event.toString()),
Param("steps", steps)
)
)
}
================================================
FILE: core/analytics/src/main/kotlin/com/infinitepower/newquiz/core/analytics/AnalyticsHelper.kt
================================================
package com.infinitepower.newquiz.core.analytics
import kotlinx.coroutines.flow.Flow
/**
* Interface for logging analytics events.
*/
interface AnalyticsHelper {
/**
* Logs the given [events].
*
* @param events the events to log.
* @see AnalyticsEvent
*/
fun logEvent(vararg events: AnalyticsEvent)
/**
* Sets the user property to the given value.
*
* @param userProperty the user property to set.
* @see UserProperty
*/
fun setUserProperty(userProperty: UserProperty)
fun setGeneralAnalyticsEnabled(enabled: Boolean)
fun setCrashlyticsEnabled(enabled: Boolean)
fun setPerformanceEnabled(enabled: Boolean)
val showDataAnalyticsConsentDialog: Flow
suspend fun updateDataConsent(agreed: Boolean)
}
================================================
FILE: core/analytics/src/main/kotlin/com/infinitepower/newquiz/core/analytics/LocalDebugAnalyticsHelper.kt
================================================
package com.infinitepower.newquiz.core.analytics
import android.util.Log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "LocalAnalyticsHelper"
@Singleton
class LocalDebugAnalyticsHelper @Inject constructor() : AnalyticsHelper {
override fun logEvent(vararg events: AnalyticsEvent) {
Log.d(TAG, "Received events: ${events.joinToString()}")
}
override fun setUserProperty(userProperty: UserProperty) {
Log.d(TAG, "Received user property: $userProperty")
}
override fun setGeneralAnalyticsEnabled(enabled: Boolean) {
Log.d(TAG, "enableGeneralAnalytics: $enabled")
}
override fun setCrashlyticsEnabled(enabled: Boolean) {
Log.d(TAG, "enableCrashlytics: $enabled")
}
override fun setPerformanceEnabled(enabled: Boolean) {
Log.d(TAG, "enablePerformanceMonitoring: $enabled")
}
// Because this is a local debug analytics helper, we don't want to show the dialog to the user
override val showDataAnalyticsConsentDialog: Flow = flowOf(false)
override suspend fun updateDataConsent(agreed: Boolean) {
Log.d(TAG, "updateDataConsent: $agreed")
}
}
================================================
FILE: core/analytics/src/main/kotlin/com/infinitepower/newquiz/core/analytics/NoOpAnalyticsHelper.kt
================================================
package com.infinitepower.newquiz.core.analytics
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
/**
* Implementation of [AnalyticsHelper] which does nothing. Useful for tests and previews.
*/
object NoOpAnalyticsHelper : AnalyticsHelper {
override fun logEvent(vararg events: AnalyticsEvent) = Unit
override fun setUserProperty(userProperty: UserProperty) = Unit
override fun setGeneralAnalyticsEnabled(enabled: Boolean) = Unit
override fun setCrashlyticsEnabled(enabled: Boolean) = Unit
override fun setPerformanceEnabled(enabled: Boolean) = Unit
override val showDataAnalyticsConsentDialog: Flow = emptyFlow()
override suspend fun updateDataConsent(agreed: Boolean) = Unit
}
================================================
FILE: core/analytics/src/main/kotlin/com/infinitepower/newquiz/core/analytics/UiHelpers.kt
================================================
package com.infinitepower.newquiz.core.analytics
import androidx.compose.runtime.staticCompositionLocalOf
/**
* A local composition that provides an [AnalyticsHelper] instance.
*/
val LocalAnalyticsHelper = staticCompositionLocalOf {
LocalDebugAnalyticsHelper()
}
================================================
FILE: core/analytics/src/main/kotlin/com/infinitepower/newquiz/core/analytics/UserProperty.kt
================================================
package com.infinitepower.newquiz.core.analytics
import androidx.annotation.Size
sealed class UserProperty(
@Size(min = 1L, max = 24L) val name: String,
@Size(max = 36L) val value: String?
) {
data class WordleLanguage(val lang: String) : UserProperty(
name = "wordle_lang",
value = lang
)
data class TranslatorModelDownloaded(val downloaded: Boolean) : UserProperty(
name = "translator_downloaded",
value = downloaded.toString()
)
}
================================================
FILE: core/analytics/src/normal/kotlin/com/infinitepower/newquiz/core/analytics/FirebaseAnalyticsHelper.kt
================================================
package com.infinitepower.newquiz.core.analytics
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.ParametersBuilder
import com.google.firebase.analytics.logEvent
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.perf.FirebasePerformance
import com.infinitepower.newquiz.core.datastore.common.DataAnalyticsCommon
import com.infinitepower.newquiz.core.datastore.di.DataAnalyticsDataStoreManager
import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager
import com.infinitepower.newquiz.model.DataAnalyticsConsentState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FirebaseAnalyticsHelper @Inject constructor(
private val firebaseAnalytics: FirebaseAnalytics,
private val firebaseCrashlytics: FirebaseCrashlytics,
private val firebasePerformance: FirebasePerformance,
@DataAnalyticsDataStoreManager private val dataAnalyticsDataStoreManager: DataStoreManager,
) : AnalyticsHelper {
private fun ParametersBuilder.param(
analyticsParam: AnalyticsEvent.Param<*>
) {
when (analyticsParam.value) {
null -> {}
is String -> param(analyticsParam.key, analyticsParam.value)
is Long -> param(analyticsParam.key, analyticsParam.value)
is Double -> param(analyticsParam.key, analyticsParam.value)
is Int -> param(analyticsParam.key, analyticsParam.value.toLong())
else -> param(analyticsParam.key, analyticsParam.value.toString())
}
}
override fun logEvent(vararg events: AnalyticsEvent) {
events.forEach { analyticsEvent ->
firebaseAnalytics.logEvent(analyticsEvent.type) {
analyticsEvent.extras.forEach { extra ->
param(extra)
}
}
}
}
override fun setUserProperty(userProperty: UserProperty) {
firebaseAnalytics.setUserProperty(userProperty.name, userProperty.value)
}
override fun setGeneralAnalyticsEnabled(enabled: Boolean) {
firebaseAnalytics.setAnalyticsCollectionEnabled(enabled)
}
override fun setCrashlyticsEnabled(enabled: Boolean) {
firebaseCrashlytics.setCrashlyticsCollectionEnabled(enabled)
}
override fun setPerformanceEnabled(enabled: Boolean) {
firebasePerformance.isPerformanceCollectionEnabled = enabled
}
override val showDataAnalyticsConsentDialog: Flow
get() = dataAnalyticsDataStoreManager
.getPreferenceFlow(DataAnalyticsCommon.DataAnalyticsConsent)
.map { consentStr ->
val consent = DataAnalyticsConsentState.valueOf(consentStr)
consent == DataAnalyticsConsentState.NONE
}
override suspend fun updateDataConsent(agreed: Boolean) {
val consentState = if (agreed) {
DataAnalyticsConsentState.AGREED
} else {
DataAnalyticsConsentState.DISAGREED
}
dataAnalyticsDataStoreManager.editPreference(
key = DataAnalyticsCommon.DataAnalyticsConsent.key,
newValue = consentState.name
)
// Update the parent data analytics settings
dataAnalyticsDataStoreManager.editPreference(
key = DataAnalyticsCommon.GloballyAnalyticsCollectionEnabled.key,
newValue = agreed
)
// Enable or disable the individual analytics settings,
// and update the datastore with the new values
setGeneralAnalyticsEnabled(agreed)
dataAnalyticsDataStoreManager.editPreference(
key = DataAnalyticsCommon.GeneralAnalyticsEnabled.key,
newValue = agreed
)
setCrashlyticsEnabled(agreed)
dataAnalyticsDataStoreManager.editPreference(
key = DataAnalyticsCommon.CrashlyticsEnabled.key,
newValue = agreed
)
setPerformanceEnabled(agreed)
dataAnalyticsDataStoreManager.editPreference(
key = DataAnalyticsCommon.PerformanceMonitoringEnabled.key,
newValue = agreed
)
}
}
================================================
FILE: core/analytics/src/normal/kotlin/com/infinitepower/newquiz/core/analytics/NormalAnalyticsModule.kt
================================================
package com.infinitepower.newquiz.core.analytics
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.ktx.analytics
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.ktx.Firebase
import com.google.firebase.perf.FirebasePerformance
import com.google.firebase.perf.ktx.performance
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class NormalAnalyticsModule {
@Binds
abstract fun bindAnalyticsHelper(impl: FirebaseAnalyticsHelper): AnalyticsHelper
companion object {
@Provides
@Singleton
fun provideFirebaseAnalytics(): FirebaseAnalytics = Firebase.analytics
@Provides
@Singleton
fun provideFirebaseCrashlytics(): FirebaseCrashlytics = Firebase.crashlytics
@Provides
@Singleton
fun provideFirebasePerformance(): FirebasePerformance = Firebase.performance
}
}
================================================
FILE: core/build.gradle.kts
================================================
plugins {
alias(libs.plugins.newquiz.android.library.compose)
alias(libs.plugins.newquiz.android.hilt)
alias(libs.plugins.newquiz.android.compose.destinations)
alias(libs.plugins.newquiz.kotlin.serialization)
}
android {
namespace = "com.infinitepower.newquiz.core"
lint {
disable += "MissingTranslation"
}
}
dependencies {
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.androidx.compose.ui.test)
debugApi(libs.androidx.tracing.ktx)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.constraintlayout.compose)
debugImplementation(libs.androidx.compose.ui.testManifest)
implementation(libs.hilt.navigationCompose)
implementation(libs.hilt.ext.work)
ksp(libs.hilt.ext.compiler)
implementation(libs.androidx.work.ktx)
implementation(libs.google.material)
normalImplementation(platform(libs.firebase.bom))
normalImplementation(libs.firebase.analytics)
implementation(libs.lottie.compose)
implementation(libs.kotlinx.datetime)
testImplementation(libs.kotlinx.coroutines.test)
implementation(libs.coil.kt.compose)
implementation(libs.coil.kt.svg)
//implementation("androidx.palette:palette-ktx:_")
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.serialization)
// Modules
implementation(projects.model)
testImplementation(projects.core.testing)
androidTestImplementation(projects.core.testing)
}
================================================
FILE: core/consumer-rules.pro
================================================
================================================
FILE: core/database/.gitignore
================================================
/build
================================================
FILE: core/database/build.gradle.kts
================================================
plugins {
alias(libs.plugins.newquiz.android.library)
alias(libs.plugins.newquiz.android.room)
alias(libs.plugins.newquiz.android.hilt)
alias(libs.plugins.newquiz.kotlin.serialization)
alias(libs.plugins.newquiz.detekt)
}
android {
namespace = "com.infinitepower.newquiz.core.database"
}
dependencies {
implementation(libs.kotlinx.datetime)
implementation(projects.model)
androidTestImplementation(projects.core.testing)
}
================================================
FILE: core/database/consumer-rules.pro
================================================
================================================
FILE: core/database/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
================================================
FILE: core/database/schemas/com.infinitepower.newquiz.core.database.AppDatabase/1.json
================================================
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "2b5755fa1bcc6adc57c9ab82bd83f2e3",
"entities": [
{
"tableName": "saved_multi_choice_questions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `description` TEXT NOT NULL, `image_url` TEXT, `answers` TEXT NOT NULL, `lang` TEXT NOT NULL, `category` TEXT NOT NULL, `correct_ans` INTEGER NOT NULL, `type` TEXT NOT NULL, `difficulty` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "answers",
"columnName": "answers",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lang",
"columnName": "lang",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "correctAns",
"columnName": "correct_ans",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "difficulty",
"columnName": "difficulty",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "wordle_daily_calendar",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` TEXT NOT NULL, `state` TEXT NOT NULL, `wordSize` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wordSize",
"columnName": "wordSize",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "mazeItems",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `difficulty` TEXT NOT NULL, `played` INTEGER NOT NULL, `type` TEXT NOT NULL, `mazeSeed` INTEGER NOT NULL, `maze_question_id` INTEGER, `maze_question_description` TEXT, `maze_question_image_url` TEXT, `maze_question_answers` TEXT, `maze_question_lang` TEXT, `maze_question_category` TEXT, `maze_question_correct_ans` INTEGER, `maze_question_type` TEXT, `maze_question_difficulty` TEXT, `maze_wordle_wordleWord` TEXT, `maze_wordle_wordleQuizType` TEXT, `maze_wordle_textHelper` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "difficulty",
"columnName": "difficulty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "played",
"columnName": "played",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mazeSeed",
"columnName": "mazeSeed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "multiChoiceQuestion.id",
"columnName": "maze_question_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.description",
"columnName": "maze_question_description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.imageUrl",
"columnName": "maze_question_image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.answers",
"columnName": "maze_question_answers",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.lang",
"columnName": "maze_question_lang",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.category",
"columnName": "maze_question_category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.correctAns",
"columnName": "maze_question_correct_ans",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.type",
"columnName": "maze_question_type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.difficulty",
"columnName": "maze_question_difficulty",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.wordleWord",
"columnName": "maze_wordle_wordleWord",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.wordleQuizType",
"columnName": "maze_wordle_wordleQuizType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.textHelper",
"columnName": "maze_wordle_textHelper",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"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, '2b5755fa1bcc6adc57c9ab82bd83f2e3')"
]
}
}
================================================
FILE: core/database/schemas/com.infinitepower.newquiz.core.database.AppDatabase/2.json
================================================
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "fcd9b57e8faa1a7b07c05b842276b5cf",
"entities": [
{
"tableName": "saved_multi_choice_questions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `description` TEXT NOT NULL, `image_url` TEXT, `answers` TEXT NOT NULL, `lang` TEXT NOT NULL, `category` TEXT NOT NULL, `correct_ans` INTEGER NOT NULL, `type` TEXT NOT NULL, `difficulty` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "answers",
"columnName": "answers",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lang",
"columnName": "lang",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "correctAns",
"columnName": "correct_ans",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "difficulty",
"columnName": "difficulty",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "wordle_daily_calendar",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` TEXT NOT NULL, `state` TEXT NOT NULL, `wordSize` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wordSize",
"columnName": "wordSize",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "mazeItems",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `difficulty` TEXT NOT NULL, `played` INTEGER NOT NULL, `type` TEXT NOT NULL, `mazeSeed` INTEGER NOT NULL, `maze_question_id` INTEGER, `maze_question_description` TEXT, `maze_question_image_url` TEXT, `maze_question_answers` TEXT, `maze_question_lang` TEXT, `maze_question_category` TEXT, `maze_question_correct_ans` INTEGER, `maze_question_type` TEXT, `maze_question_difficulty` TEXT, `maze_wordle_wordleWord` TEXT, `maze_wordle_wordleQuizType` TEXT, `maze_wordle_textHelper` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "difficulty",
"columnName": "difficulty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "played",
"columnName": "played",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mazeSeed",
"columnName": "mazeSeed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "multiChoiceQuestion.id",
"columnName": "maze_question_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.description",
"columnName": "maze_question_description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.imageUrl",
"columnName": "maze_question_image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.answers",
"columnName": "maze_question_answers",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.lang",
"columnName": "maze_question_lang",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.category",
"columnName": "maze_question_category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.correctAns",
"columnName": "maze_question_correct_ans",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.type",
"columnName": "maze_question_type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.difficulty",
"columnName": "maze_question_difficulty",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.wordleWord",
"columnName": "maze_wordle_wordleWord",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.wordleQuizType",
"columnName": "maze_wordle_wordleQuizType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.textHelper",
"columnName": "maze_wordle_textHelper",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "daily_challenge_tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `diamondsReward` INTEGER NOT NULL, `experienceReward` INTEGER NOT NULL, `isClaimed` INTEGER NOT NULL, `currentValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `type` TEXT NOT NULL, `startDate` INTEGER NOT NULL, `endDate` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "diamondsReward",
"columnName": "diamondsReward",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "experienceReward",
"columnName": "experienceReward",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isClaimed",
"columnName": "isClaimed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "currentValue",
"columnName": "currentValue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxValue",
"columnName": "maxValue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "startDate",
"columnName": "startDate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endDate",
"columnName": "endDate",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"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, 'fcd9b57e8faa1a7b07c05b842276b5cf')"
]
}
}
================================================
FILE: core/database/schemas/com.infinitepower.newquiz.core.database.AppDatabase/3.json
================================================
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "de2a8eef760db8b584b478e1043749db",
"entities": [
{
"tableName": "saved_multi_choice_questions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `description` TEXT NOT NULL, `image_url` TEXT, `answers` TEXT NOT NULL, `lang` TEXT NOT NULL, `category` TEXT NOT NULL, `correct_ans` INTEGER NOT NULL, `type` TEXT NOT NULL, `difficulty` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "answers",
"columnName": "answers",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lang",
"columnName": "lang",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "correctAns",
"columnName": "correct_ans",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "difficulty",
"columnName": "difficulty",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "mazeItems",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `difficulty` TEXT NOT NULL, `played` INTEGER NOT NULL, `type` TEXT NOT NULL, `mazeSeed` INTEGER NOT NULL, `maze_question_id` INTEGER, `maze_question_description` TEXT, `maze_question_image_url` TEXT, `maze_question_answers` TEXT, `maze_question_lang` TEXT, `maze_question_category` TEXT, `maze_question_correct_ans` INTEGER, `maze_question_type` TEXT, `maze_question_difficulty` TEXT, `maze_wordle_wordleWord` TEXT, `maze_wordle_wordleQuizType` TEXT, `maze_wordle_textHelper` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "difficulty",
"columnName": "difficulty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "played",
"columnName": "played",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mazeSeed",
"columnName": "mazeSeed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "multiChoiceQuestion.id",
"columnName": "maze_question_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.description",
"columnName": "maze_question_description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.imageUrl",
"columnName": "maze_question_image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.answers",
"columnName": "maze_question_answers",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.lang",
"columnName": "maze_question_lang",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.category",
"columnName": "maze_question_category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.correctAns",
"columnName": "maze_question_correct_ans",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.type",
"columnName": "maze_question_type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.difficulty",
"columnName": "maze_question_difficulty",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.wordleWord",
"columnName": "maze_wordle_wordleWord",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.wordleQuizType",
"columnName": "maze_wordle_wordleQuizType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.textHelper",
"columnName": "maze_wordle_textHelper",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "daily_challenge_tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `diamondsReward` INTEGER NOT NULL, `experienceReward` INTEGER NOT NULL, `isClaimed` INTEGER NOT NULL, `currentValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `type` TEXT NOT NULL, `startDate` INTEGER NOT NULL, `endDate` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "diamondsReward",
"columnName": "diamondsReward",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "experienceReward",
"columnName": "experienceReward",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isClaimed",
"columnName": "isClaimed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "currentValue",
"columnName": "currentValue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxValue",
"columnName": "maxValue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "startDate",
"columnName": "startDate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endDate",
"columnName": "endDate",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"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, 'de2a8eef760db8b584b478e1043749db')"
]
}
}
================================================
FILE: core/database/schemas/com.infinitepower.newquiz.core.database.AppDatabase/4.json
================================================
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "ca501a93f18f0b24e9772858b9dd5ee8",
"entities": [
{
"tableName": "saved_multi_choice_questions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `description` TEXT NOT NULL, `image_url` TEXT, `answers` TEXT NOT NULL, `lang` TEXT NOT NULL, `category` TEXT NOT NULL, `correct_ans` INTEGER NOT NULL, `type` TEXT NOT NULL, `difficulty` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "answers",
"columnName": "answers",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lang",
"columnName": "lang",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "correctAns",
"columnName": "correct_ans",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "difficulty",
"columnName": "difficulty",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "mazeItems",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `difficulty` TEXT NOT NULL, `played` INTEGER NOT NULL, `type` TEXT NOT NULL, `mazeSeed` INTEGER NOT NULL, `maze_question_id` INTEGER, `maze_question_description` TEXT, `maze_question_image_url` TEXT, `maze_question_answers` TEXT, `maze_question_lang` TEXT, `maze_question_category` TEXT, `maze_question_correct_ans` INTEGER, `maze_question_type` TEXT, `maze_question_difficulty` TEXT, `maze_wordle_wordleWord` TEXT, `maze_wordle_wordleQuizType` TEXT, `maze_wordle_textHelper` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "difficulty",
"columnName": "difficulty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "played",
"columnName": "played",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mazeSeed",
"columnName": "mazeSeed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "multiChoiceQuestion.id",
"columnName": "maze_question_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.description",
"columnName": "maze_question_description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.imageUrl",
"columnName": "maze_question_image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.answers",
"columnName": "maze_question_answers",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.lang",
"columnName": "maze_question_lang",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.category",
"columnName": "maze_question_category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.correctAns",
"columnName": "maze_question_correct_ans",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.type",
"columnName": "maze_question_type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.difficulty",
"columnName": "maze_question_difficulty",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.wordleWord",
"columnName": "maze_wordle_wordleWord",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.wordleQuizType",
"columnName": "maze_wordle_wordleQuizType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.textHelper",
"columnName": "maze_wordle_textHelper",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "daily_challenge_tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `diamondsReward` INTEGER NOT NULL, `experienceReward` INTEGER NOT NULL, `isClaimed` INTEGER NOT NULL, `currentValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `type` TEXT NOT NULL, `startDate` INTEGER NOT NULL, `endDate` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "diamondsReward",
"columnName": "diamondsReward",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "experienceReward",
"columnName": "experienceReward",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isClaimed",
"columnName": "isClaimed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "currentValue",
"columnName": "currentValue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxValue",
"columnName": "maxValue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "startDate",
"columnName": "startDate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endDate",
"columnName": "endDate",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "comparison_quiz_highest_position",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`categoryId` TEXT NOT NULL, `highestPosition` INTEGER NOT NULL, PRIMARY KEY(`categoryId`))",
"fields": [
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "highestPosition",
"columnName": "highestPosition",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"categoryId"
]
},
"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, 'ca501a93f18f0b24e9772858b9dd5ee8')"
]
}
}
================================================
FILE: core/database/schemas/com.infinitepower.newquiz.core.database.AppDatabase/5.json
================================================
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "4e64a492cda5cf6ef8b21c3c9599482a",
"entities": [
{
"tableName": "saved_multi_choice_questions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `description` TEXT NOT NULL, `image_url` TEXT, `answers` TEXT NOT NULL, `lang` TEXT NOT NULL, `category` TEXT NOT NULL, `correct_ans` INTEGER NOT NULL, `type` TEXT NOT NULL, `difficulty` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "answers",
"columnName": "answers",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lang",
"columnName": "lang",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "correctAns",
"columnName": "correct_ans",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "difficulty",
"columnName": "difficulty",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "mazeItems",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `difficulty` TEXT NOT NULL, `played` INTEGER NOT NULL, `type` TEXT NOT NULL, `mazeSeed` INTEGER NOT NULL, `maze_question_id` INTEGER, `maze_question_description` TEXT, `maze_question_image_url` TEXT, `maze_question_answers` TEXT, `maze_question_lang` TEXT, `maze_question_category` TEXT, `maze_question_correct_ans` INTEGER, `maze_question_type` TEXT, `maze_question_difficulty` TEXT, `maze_wordle_wordleWord` TEXT, `maze_wordle_wordleQuizType` TEXT, `maze_wordle_textHelper` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "difficulty",
"columnName": "difficulty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "played",
"columnName": "played",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mazeSeed",
"columnName": "mazeSeed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "multiChoiceQuestion.id",
"columnName": "maze_question_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.description",
"columnName": "maze_question_description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.imageUrl",
"columnName": "maze_question_image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.answers",
"columnName": "maze_question_answers",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.lang",
"columnName": "maze_question_lang",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.category",
"columnName": "maze_question_category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.correctAns",
"columnName": "maze_question_correct_ans",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.type",
"columnName": "maze_question_type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.difficulty",
"columnName": "maze_question_difficulty",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.wordleWord",
"columnName": "maze_wordle_wordleWord",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.wordleQuizType",
"columnName": "maze_wordle_wordleQuizType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.textHelper",
"columnName": "maze_wordle_textHelper",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "daily_challenge_tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `diamondsReward` INTEGER NOT NULL, `experienceReward` INTEGER NOT NULL, `isClaimed` INTEGER NOT NULL, `currentValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `type` TEXT NOT NULL, `startDate` INTEGER NOT NULL, `endDate` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "diamondsReward",
"columnName": "diamondsReward",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "experienceReward",
"columnName": "experienceReward",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isClaimed",
"columnName": "isClaimed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "currentValue",
"columnName": "currentValue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxValue",
"columnName": "maxValue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "startDate",
"columnName": "startDate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endDate",
"columnName": "endDate",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "comparison_quiz_highest_position",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`categoryId` TEXT NOT NULL, `highestPosition` INTEGER NOT NULL, PRIMARY KEY(`categoryId`))",
"fields": [
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "highestPosition",
"columnName": "highestPosition",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"categoryId"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "multi_choice_game_results",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`game_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `correct_answers` INTEGER NOT NULL, `skipped_questions` INTEGER NOT NULL, `question_count` INTEGER NOT NULL, `average_answer_time` REAL NOT NULL, `earned_xp` INTEGER NOT NULL, `played_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "gameId",
"columnName": "game_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "correctAnswers",
"columnName": "correct_answers",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "skippedQuestions",
"columnName": "skipped_questions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "questionCount",
"columnName": "question_count",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "averageAnswerTime",
"columnName": "average_answer_time",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "earnedXp",
"columnName": "earned_xp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playedAt",
"columnName": "played_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"game_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "wordle_game_results",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`game_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `earned_xp` INTEGER NOT NULL, `played_at` INTEGER NOT NULL, `word_length` INTEGER NOT NULL, `rows_used` INTEGER NOT NULL, `max_rows` INTEGER NOT NULL, `category_id` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "gameId",
"columnName": "game_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "earnedXp",
"columnName": "earned_xp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playedAt",
"columnName": "played_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "wordLength",
"columnName": "word_length",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rowsUsed",
"columnName": "rows_used",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxRows",
"columnName": "max_rows",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"game_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "comparison_quiz_game_results",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`game_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `earned_xp` INTEGER NOT NULL, `played_at` INTEGER NOT NULL, `category_id` TEXT NOT NULL, `comparison_mode` TEXT NOT NULL, `end_position` INTEGER NOT NULL, `highest_position` INTEGER NOT NULL, `skipped_answers` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "gameId",
"columnName": "game_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "earnedXp",
"columnName": "earned_xp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playedAt",
"columnName": "played_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "comparisonMode",
"columnName": "comparison_mode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "endPosition",
"columnName": "end_position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "highestPosition",
"columnName": "highest_position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "skippedAnswers",
"columnName": "skipped_answers",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"game_id"
]
},
"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, '4e64a492cda5cf6ef8b21c3c9599482a')"
]
}
}
================================================
FILE: core/database/schemas/com.infinitepower.newquiz.core.database.AppDatabase/6.json
================================================
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "3a23ffe1cbbd7ba6cecdcd8e2261c827",
"entities": [
{
"tableName": "saved_multi_choice_questions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `description` TEXT NOT NULL, `image_url` TEXT, `answers` TEXT NOT NULL, `lang` TEXT NOT NULL, `category` TEXT NOT NULL, `correct_ans` INTEGER NOT NULL, `type` TEXT NOT NULL, `difficulty` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "answers",
"columnName": "answers",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lang",
"columnName": "lang",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "correctAns",
"columnName": "correct_ans",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "difficulty",
"columnName": "difficulty",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "mazeItems",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `difficulty` TEXT NOT NULL, `played` INTEGER NOT NULL, `type` TEXT NOT NULL, `mazeSeed` INTEGER NOT NULL, `maze_question_id` INTEGER, `maze_question_description` TEXT, `maze_question_image_url` TEXT, `maze_question_answers` TEXT, `maze_question_lang` TEXT, `maze_question_category` TEXT, `maze_question_correct_ans` INTEGER, `maze_question_type` TEXT, `maze_question_difficulty` TEXT, `maze_wordle_wordleWord` TEXT, `maze_wordle_wordleQuizType` TEXT, `maze_wordle_textHelper` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "difficulty",
"columnName": "difficulty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "played",
"columnName": "played",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mazeSeed",
"columnName": "mazeSeed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "multiChoiceQuestion.id",
"columnName": "maze_question_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.description",
"columnName": "maze_question_description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.imageUrl",
"columnName": "maze_question_image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.answers",
"columnName": "maze_question_answers",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.lang",
"columnName": "maze_question_lang",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.category",
"columnName": "maze_question_category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.correctAns",
"columnName": "maze_question_correct_ans",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.type",
"columnName": "maze_question_type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.difficulty",
"columnName": "maze_question_difficulty",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.wordleWord",
"columnName": "maze_wordle_wordleWord",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.wordleQuizType",
"columnName": "maze_wordle_wordleQuizType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.textHelper",
"columnName": "maze_wordle_textHelper",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "daily_challenge_tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `diamondsReward` INTEGER NOT NULL, `experienceReward` INTEGER NOT NULL, `isClaimed` INTEGER NOT NULL, `currentValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `type` TEXT NOT NULL, `startDate` INTEGER NOT NULL, `endDate` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "diamondsReward",
"columnName": "diamondsReward",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "experienceReward",
"columnName": "experienceReward",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isClaimed",
"columnName": "isClaimed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "currentValue",
"columnName": "currentValue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxValue",
"columnName": "maxValue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "startDate",
"columnName": "startDate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endDate",
"columnName": "endDate",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "multi_choice_game_results",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`game_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `correct_answers` INTEGER NOT NULL, `skipped_questions` INTEGER NOT NULL, `question_count` INTEGER NOT NULL, `average_answer_time` REAL NOT NULL, `earned_xp` INTEGER NOT NULL, `played_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "gameId",
"columnName": "game_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "correctAnswers",
"columnName": "correct_answers",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "skippedQuestions",
"columnName": "skipped_questions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "questionCount",
"columnName": "question_count",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "averageAnswerTime",
"columnName": "average_answer_time",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "earnedXp",
"columnName": "earned_xp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playedAt",
"columnName": "played_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"game_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "wordle_game_results",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`game_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `earned_xp` INTEGER NOT NULL, `played_at` INTEGER NOT NULL, `word_length` INTEGER NOT NULL, `rows_used` INTEGER NOT NULL, `max_rows` INTEGER NOT NULL, `category_id` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "gameId",
"columnName": "game_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "earnedXp",
"columnName": "earned_xp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playedAt",
"columnName": "played_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "wordLength",
"columnName": "word_length",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rowsUsed",
"columnName": "rows_used",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxRows",
"columnName": "max_rows",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"game_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "comparison_quiz_game_results",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`game_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `earned_xp` INTEGER NOT NULL, `played_at` INTEGER NOT NULL, `category_id` TEXT NOT NULL, `comparison_mode` TEXT NOT NULL, `end_position` INTEGER NOT NULL, `skipped_answers` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "gameId",
"columnName": "game_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "earnedXp",
"columnName": "earned_xp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playedAt",
"columnName": "played_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "comparisonMode",
"columnName": "comparison_mode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "endPosition",
"columnName": "end_position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "skippedAnswers",
"columnName": "skipped_answers",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"game_id"
]
},
"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, '3a23ffe1cbbd7ba6cecdcd8e2261c827')"
]
}
}
================================================
FILE: core/database/schemas/com.infinitepower.newquiz.core.database.AppDatabase/7.json
================================================
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "d699dae423a5b36ad66302c323af52db",
"entities": [
{
"tableName": "saved_multi_choice_questions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `description` TEXT NOT NULL, `image_url` TEXT, `answers` TEXT NOT NULL, `lang` TEXT NOT NULL, `category` TEXT NOT NULL, `correct_ans` INTEGER NOT NULL, `type` TEXT NOT NULL, `difficulty` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "answers",
"columnName": "answers",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lang",
"columnName": "lang",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "correctAns",
"columnName": "correct_ans",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "difficulty",
"columnName": "difficulty",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "mazeItems",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `difficulty` TEXT NOT NULL, `played` INTEGER NOT NULL, `type` TEXT NOT NULL, `mazeSeed` INTEGER NOT NULL, `maze_question_id` INTEGER, `maze_question_description` TEXT, `maze_question_image_url` TEXT, `maze_question_answers` TEXT, `maze_question_lang` TEXT, `maze_question_category` TEXT, `maze_question_correct_ans` INTEGER, `maze_question_type` TEXT, `maze_question_difficulty` TEXT, `maze_wordle_wordleWord` TEXT, `maze_wordle_wordleQuizType` TEXT, `maze_wordle_textHelper` TEXT, `comparison_quiz_category` TEXT, `comparison_quiz_comparisonMode` TEXT, `comparison_quiz_first_question_title` TEXT, `comparison_quiz_first_question_value` REAL, `comparison_quiz_first_question_imgUrl` TEXT, `comparison_quiz_second_question_title` TEXT, `comparison_quiz_second_question_value` REAL, `comparison_quiz_second_question_imgUrl` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "difficulty",
"columnName": "difficulty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "played",
"columnName": "played",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mazeSeed",
"columnName": "mazeSeed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "multiChoiceQuestion.id",
"columnName": "maze_question_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.description",
"columnName": "maze_question_description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.imageUrl",
"columnName": "maze_question_image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.answers",
"columnName": "maze_question_answers",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.lang",
"columnName": "maze_question_lang",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.category",
"columnName": "maze_question_category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.correctAns",
"columnName": "maze_question_correct_ans",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.type",
"columnName": "maze_question_type",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "multiChoiceQuestion.difficulty",
"columnName": "maze_question_difficulty",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.wordleWord",
"columnName": "maze_wordle_wordleWord",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.wordleQuizType",
"columnName": "maze_wordle_wordleQuizType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "wordleItem.textHelper",
"columnName": "maze_wordle_textHelper",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "comparisonQuizQuestion.category",
"columnName": "comparison_quiz_category",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "comparisonQuizQuestion.comparisonMode",
"columnName": "comparison_quiz_comparisonMode",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "comparisonQuizQuestion.firstQuestion.title",
"columnName": "comparison_quiz_first_question_title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "comparisonQuizQuestion.firstQuestion.value",
"columnName": "comparison_quiz_first_question_value",
"affinity": "REAL",
"notNull": false
},
{
"fieldPath": "comparisonQuizQuestion.firstQuestion.imgUrl",
"columnName": "comparison_quiz_first_question_imgUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "comparisonQuizQuestion.secondQuestion.title",
"columnName": "comparison_quiz_second_question_title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "comparisonQuizQuestion.secondQuestion.value",
"columnName": "comparison_quiz_second_question_value",
"affinity": "REAL",
"notNull": false
},
{
"fieldPath": "comparisonQuizQuestion.secondQuestion.imgUrl",
"columnName": "comparison_quiz_second_question_imgUrl",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "daily_challenge_tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `diamondsReward` INTEGER NOT NULL, `experienceReward` INTEGER NOT NULL, `isClaimed` INTEGER NOT NULL, `currentValue` INTEGER NOT NULL, `maxValue` INTEGER NOT NULL, `type` TEXT NOT NULL, `startDate` INTEGER NOT NULL, `endDate` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "diamondsReward",
"columnName": "diamondsReward",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "experienceReward",
"columnName": "experienceReward",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isClaimed",
"columnName": "isClaimed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "currentValue",
"columnName": "currentValue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxValue",
"columnName": "maxValue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "startDate",
"columnName": "startDate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endDate",
"columnName": "endDate",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "multi_choice_game_results",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`game_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `correct_answers` INTEGER NOT NULL, `skipped_questions` INTEGER NOT NULL, `question_count` INTEGER NOT NULL, `average_answer_time` REAL NOT NULL, `earned_xp` INTEGER NOT NULL, `played_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "gameId",
"columnName": "game_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "correctAnswers",
"columnName": "correct_answers",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "skippedQuestions",
"columnName": "skipped_questions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "questionCount",
"columnName": "question_count",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "averageAnswerTime",
"columnName": "average_answer_time",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "earnedXp",
"columnName": "earned_xp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playedAt",
"columnName": "played_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"game_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "wordle_game_results",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`game_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `earned_xp` INTEGER NOT NULL, `played_at` INTEGER NOT NULL, `word_length` INTEGER NOT NULL, `rows_used` INTEGER NOT NULL, `max_rows` INTEGER NOT NULL, `category_id` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "gameId",
"columnName": "game_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "earnedXp",
"columnName": "earned_xp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playedAt",
"columnName": "played_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "wordLength",
"columnName": "word_length",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rowsUsed",
"columnName": "rows_used",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxRows",
"columnName": "max_rows",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"game_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "comparison_quiz_game_results",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`game_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `earned_xp` INTEGER NOT NULL, `played_at` INTEGER NOT NULL, `category_id` TEXT NOT NULL, `comparison_mode` TEXT NOT NULL, `end_position` INTEGER NOT NULL, `skipped_answers` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "gameId",
"columnName": "game_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "earnedXp",
"columnName": "earned_xp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "playedAt",
"columnName": "played_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "comparisonMode",
"columnName": "comparison_mode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "endPosition",
"columnName": "end_position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "skippedAnswers",
"columnName": "skipped_answers",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"game_id"
]
},
"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, 'd699dae423a5b36ad66302c323af52db')"
]
}
}
================================================
FILE: core/database/src/androidTest/AndroidManifest.xml
================================================
================================================
FILE: core/database/src/androidTest/kotlin/com/infinitepower/newquiz/core/database/dao/DailyChallengeDaoTest.kt
================================================
package com.infinitepower.newquiz.core.database.dao
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.infinitepower.newquiz.core.database.AppDatabase
import com.infinitepower.newquiz.core.database.model.DailyChallengeTaskEntity
import com.infinitepower.newquiz.core.testing.data.fake.FakeData
import com.infinitepower.newquiz.model.daily_challenge.DailyChallengeTask
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Clock
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
import kotlin.time.Duration.Companion.days
@RunWith(AndroidJUnit4::class)
internal class DailyChallengeDaoTest {
private lateinit var dailyChallengeDao: DailyChallengeDao
private lateinit var db: AppDatabase
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext()
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
dailyChallengeDao = db.dailyChallengeDao()
}
@After
@Throws(IOException::class)
fun closeDb() {
db.close()
}
@Test
fun getAllTasksFlow_returnsFlowOfAllTasks() = runTest {
val tasks = FakeData.generateTasks().map(DailyChallengeTask::toEntity)
dailyChallengeDao.insertAll(tasks.map { it.copy(id = 0) })
dailyChallengeDao.getAllTasksFlow().test {
val emittedTasks = awaitItem()
assertThat(emittedTasks).containsExactlyElementsIn(tasks)
}
}
@Test
fun getTasksForDateRange_returnsTasksWithinDateRange() = runTest {
val now = Clock.System.now()
val tasks = FakeData
.generateTasksWithOffset(instant = now)
.map(DailyChallengeTask::toEntity)
dailyChallengeDao.insertAll(tasks.map { it.copy(id = 0) })
val tasksForDateRange = dailyChallengeDao.getAvailableTasks(now.toEpochMilliseconds())
tasksForDateRange.forEach {
assertThat(it.startDate).isAtMost(now.toEpochMilliseconds())
assertThat(it.endDate).isAtLeast(now.toEpochMilliseconds())
}
}
@Test
fun tasksAreAvailable_returnsTrueIfTasksAreAvailable() = runTest {
val now = Clock.System.now()
val tasks = FakeData
.generateTasksWithOffset(instant = now)
.map(DailyChallengeTask::toEntity)
dailyChallengeDao.insertAll(tasks.map { it.copy(id = 0) })
val areTasksAvailable = dailyChallengeDao.tasksAreAvailable(now.toEpochMilliseconds())
assertThat(areTasksAvailable).isTrue()
}
@Test
fun tasksAreAvailable_returnsFalseIfTasksAreNotAvailable() = runTest {
val now = Clock.System.now()
val tasks = FakeData
.generateTasksWithOffset(instant = now)
.map(DailyChallengeTask::toEntity)
dailyChallengeDao.insertAll(tasks.map { it.copy(id = 0) })
val areTasksAvailable = dailyChallengeDao.tasksAreAvailable(
now.plus(10.days).toEpochMilliseconds()
)
assertThat(areTasksAvailable).isFalse()
}
}
private fun DailyChallengeTask.toEntity(): DailyChallengeTaskEntity = DailyChallengeTaskEntity(
id = id,
diamondsReward = diamondsReward.toInt(),
experienceReward = experienceReward.toInt(),
isClaimed = isClaimed,
currentValue = currentValue.toInt(),
maxValue = maxValue.toInt(),
type = event.key,
startDate = dateRange.start.toEpochMilliseconds(),
endDate = dateRange.endInclusive.toEpochMilliseconds()
)
================================================
FILE: core/database/src/main/AndroidManifest.xml
================================================
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/AppDatabase.kt
================================================
package com.infinitepower.newquiz.core.database
import android.annotation.SuppressLint
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.DeleteColumn
import androidx.room.DeleteTable
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import com.infinitepower.newquiz.core.database.dao.DailyChallengeDao
import com.infinitepower.newquiz.core.database.dao.GameResultDao
import com.infinitepower.newquiz.core.database.dao.MazeQuizDao
import com.infinitepower.newquiz.core.database.dao.SavedMultiChoiceQuestionsDao
import com.infinitepower.newquiz.core.database.model.DailyChallengeTaskEntity
import com.infinitepower.newquiz.core.database.model.MazeQuizItemEntity
import com.infinitepower.newquiz.core.database.model.MultiChoiceQuestionEntity
import com.infinitepower.newquiz.core.database.model.user.ComparisonQuizGameResultEntity
import com.infinitepower.newquiz.core.database.model.user.MultiChoiceGameResultEntity
import com.infinitepower.newquiz.core.database.model.user.WordleGameResultEntity
import com.infinitepower.newquiz.core.database.util.converters.ListConverter
import com.infinitepower.newquiz.core.database.util.converters.LocalDateConverter
import com.infinitepower.newquiz.core.database.util.converters.MathFormulaConverter
import com.infinitepower.newquiz.core.database.util.converters.QuestionDifficultyConverter
@SuppressLint("all")
@TypeConverters(
ListConverter::class,
LocalDateConverter::class,
QuestionDifficultyConverter::class,
MathFormulaConverter::class
)
@Database(
entities = [
MultiChoiceQuestionEntity::class,
MazeQuizItemEntity::class,
DailyChallengeTaskEntity::class,
MultiChoiceGameResultEntity::class,
WordleGameResultEntity::class,
ComparisonQuizGameResultEntity::class
],
version = 7,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(
from = 2,
to = 3,
spec = AppDatabase.RemoveDailyWordleTableMigration::class
),
AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5),
AutoMigration(
from = 5,
to = 6,
spec = AppDatabase.RemoveComparisonQuizHighestPositionTableMigration::class
),
AutoMigration(from = 6, to = 7)
]
)
abstract class AppDatabase : RoomDatabase() {
abstract fun savedQuestionsDao(): SavedMultiChoiceQuestionsDao
abstract fun mazeQuizDao(): MazeQuizDao
abstract fun dailyChallengeDao(): DailyChallengeDao
abstract fun gameResultDao(): GameResultDao
companion object {
internal const val DATABASE_NAME = "app-database"
}
@DeleteTable(tableName = "wordle_daily_calendar")
class RemoveDailyWordleTableMigration : AutoMigrationSpec
@DeleteColumn(
tableName = "comparison_quiz_game_results",
columnName = "highest_position"
)
@DeleteTable(tableName = "comparison_quiz_highest_position")
class RemoveComparisonQuizHighestPositionTableMigration : AutoMigrationSpec
}
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/dao/DailyChallengeDao.kt
================================================
package com.infinitepower.newquiz.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import com.infinitepower.newquiz.core.database.model.DailyChallengeTaskEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface DailyChallengeDao {
@Query("SELECT * FROM daily_challenge_tasks ORDER BY endDate DESC")
fun getAllTasksFlow(): Flow>
@Query("SELECT * FROM daily_challenge_tasks ORDER BY endDate DESC")
suspend fun getAllTasks(): List
/**
* Get all tasks that are available for the current date.
* A task is available if the [currentTime] date is between the start date and end date of the task.
*
* @param currentTime The current date in milliseconds.
*/
@Query(
"""
SELECT * FROM daily_challenge_tasks
WHERE startDate <= :currentTime AND endDate >= :currentTime
ORDER BY endDate DESC
"""
)
suspend fun getAvailableTasks(currentTime: Long): List
@Query(
"""
SELECT EXISTS(
SELECT 1
FROM daily_challenge_tasks
WHERE startDate <= :currentTime AND endDate >= :currentTime
)
"""
)
suspend fun tasksAreAvailable(currentTime: Long): Boolean
@Query("SELECT * FROM daily_challenge_tasks WHERE type = :type ORDER BY endDate DESC")
suspend fun getTaskByType(type: String): DailyChallengeTaskEntity?
@Insert
suspend fun insertAll(vararg tasks: DailyChallengeTaskEntity)
@Insert
suspend fun insertAll(tasks: List)
@Update
suspend fun update(vararg tasks: DailyChallengeTaskEntity)
@Update
suspend fun updateAll(tasks: List)
@Query("DELETE FROM daily_challenge_tasks")
suspend fun deleteAll()
}
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/dao/GameResultDao.kt
================================================
package com.infinitepower.newquiz.core.database.dao
import androidx.annotation.Keep
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.infinitepower.newquiz.core.database.model.user.ComparisonQuizGameResultEntity
import com.infinitepower.newquiz.core.database.model.user.MultiChoiceGameResultEntity
import com.infinitepower.newquiz.core.database.model.user.WordleGameResultEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface GameResultDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMultiChoiceResult(vararg result: MultiChoiceGameResultEntity)
@Query("SELECT * FROM multi_choice_game_results")
suspend fun getMultiChoiceResults(): List
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertWordleResult(vararg result: WordleGameResultEntity)
@Query("SELECT * FROM wordle_game_results")
suspend fun getWordleResults(): List
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertComparisonQuizResult(vararg result: ComparisonQuizGameResultEntity)
@Query("SELECT * FROM comparison_quiz_game_results")
suspend fun getComparisonQuizResults(): List
@Query("""
SELECT end_position
FROM comparison_quiz_game_results
WHERE category_id = :categoryId
ORDER BY end_position DESC
LIMIT 1
""")
suspend fun getComparisonQuizHighestPosition(
categoryId: String
): Int
@Query("""
SELECT end_position
FROM comparison_quiz_game_results
WHERE category_id = :categoryId
ORDER BY end_position DESC
LIMIT 1
""")
fun getComparisonQuizHighestPositionFlow(
categoryId: String
): Flow
@Keep
data class XpForPlayedAt(
@ColumnInfo(name = "earned_xp")
val earnedXp: Int,
@ColumnInfo(name = "played_at")
val playedAt: Long,
)
@Query("""
SELECT earned_xp, played_at FROM multi_choice_game_results
WHERE played_at BETWEEN :startDate AND :endDate
UNION ALL
SELECT earned_xp, played_at FROM wordle_game_results
WHERE played_at BETWEEN :startDate AND :endDate
UNION ALL
SELECT earned_xp, played_at FROM comparison_quiz_game_results
WHERE played_at BETWEEN :startDate AND :endDate
""")
suspend fun getXpForDateRange(
startDate: Long,
endDate: Long
): List
@Query("""
SELECT earned_xp, played_at FROM multi_choice_game_results
WHERE played_at BETWEEN :startDate AND :endDate
UNION ALL
SELECT earned_xp, played_at FROM wordle_game_results
WHERE played_at BETWEEN :startDate AND :endDate
UNION ALL
SELECT earned_xp, played_at FROM comparison_quiz_game_results
WHERE played_at BETWEEN :startDate AND :endDate
""")
fun getXpForDateRangeFlow(
startDate: Long,
endDate: Long
): Flow>
}
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/dao/MazeQuizDao.kt
================================================
package com.infinitepower.newquiz.core.database.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.infinitepower.newquiz.core.database.model.MazeQuizItemEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface MazeQuizDao {
@Query("SELECT * FROM mazeItems")
suspend fun getAllMazeItems(): List
@Query("SELECT * FROM mazeItems")
fun getAllMazeItemsFlow(): Flow>
@Query("SELECT * FROM mazeItems WHERE id = :id LIMIT 1")
suspend fun getMazeItemById(id: Int): MazeQuizItemEntity?
@Query("SELECT * FROM mazeItems WHERE played = 0 LIMIT 1")
suspend fun getFirstAvailableMazeItem(): MazeQuizItemEntity?
@Query("SELECT COUNT(id) FROM mazeItems")
suspend fun countAllItems(): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItems(vararg items: MazeQuizItemEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItems(items: List)
@Update(onConflict = OnConflictStrategy.REPLACE)
suspend fun updateItem(item: MazeQuizItemEntity)
@Delete
suspend fun removeItems(items: List)
@Query("DELETE FROM mazeItems")
suspend fun deleteAll()
}
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/dao/SavedMultiChoiceQuestionsDao.kt
================================================
package com.infinitepower.newquiz.core.database.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.infinitepower.newquiz.core.database.model.MultiChoiceQuestionEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface SavedMultiChoiceQuestionsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertQuestions(questions: List)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertQuestions(vararg questions: MultiChoiceQuestionEntity)
@Query("SELECT * FROM saved_multi_choice_questions")
fun getFlowQuestions(): Flow>
@Query("SELECT * FROM saved_multi_choice_questions ORDER BY description ASC")
fun getFlowQuestionsSortedByDescription(): Flow>
@Query("SELECT * FROM saved_multi_choice_questions ORDER BY category ASC")
fun getFlowQuestionsSortedByCategory(): Flow>
@Query("SELECT * FROM saved_multi_choice_questions")
suspend fun getQuestions(): List
@Query("SELECT count(*) FROM saved_multi_choice_questions")
fun getCount(): Flow
@Delete
suspend fun deleteAll(questions: List)
}
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/di/DaoModule.kt
================================================
package com.infinitepower.newquiz.core.database.di
import com.infinitepower.newquiz.core.database.AppDatabase
import com.infinitepower.newquiz.core.database.dao.DailyChallengeDao
import com.infinitepower.newquiz.core.database.dao.GameResultDao
import com.infinitepower.newquiz.core.database.dao.MazeQuizDao
import com.infinitepower.newquiz.core.database.dao.SavedMultiChoiceQuestionsDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
object DaoModule {
@Provides
fun provideSavedQuestionsDao(
appDatabase: AppDatabase
): SavedMultiChoiceQuestionsDao = appDatabase.savedQuestionsDao()
@Provides
fun provideMazeQuizDao(
appDatabase: AppDatabase
): MazeQuizDao = appDatabase.mazeQuizDao()
@Provides
fun provideDailyChallengeDao(
appDatabase: AppDatabase
): DailyChallengeDao = appDatabase.dailyChallengeDao()
@Provides
fun provideGameResultDao(
appDatabase: AppDatabase
): GameResultDao = appDatabase.gameResultDao()
}
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/di/DatabaseModule.kt
================================================
package com.infinitepower.newquiz.core.database.di
import android.content.Context
import androidx.room.Room
import com.infinitepower.newquiz.core.database.AppDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideAppDatabase(
@ApplicationContext applicationContext: Context
): AppDatabase = Room.databaseBuilder(
applicationContext,
AppDatabase::class.java,
AppDatabase.DATABASE_NAME
).build()
}
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/model/DailyChallengeTaskEntity.kt
================================================
package com.infinitepower.newquiz.core.database.model
import androidx.annotation.Keep
import androidx.room.Entity
import androidx.room.PrimaryKey
@Keep
@Entity(tableName = "daily_challenge_tasks")
data class DailyChallengeTaskEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val diamondsReward: Int,
val experienceReward: Int,
val isClaimed: Boolean,
val currentValue: Int,
val maxValue: Int,
val type: String,
val startDate: Long,
val endDate: Long
)
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/model/MazeQuizItemEntity.kt
================================================
package com.infinitepower.newquiz.core.database.model
import androidx.annotation.Keep
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.infinitepower.newquiz.model.GameMode
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItemEntity
import com.infinitepower.newquiz.model.question.QuestionDifficulty
import com.infinitepower.newquiz.model.wordle.WordleQuizType
@Keep
@Entity(tableName = "mazeItems")
data class MazeQuizItemEntity(
// Shared columns
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val difficulty: QuestionDifficulty,
val played: Boolean,
val type: GameMode,
val mazeSeed: Int,
// Multi choice quiz
@Embedded("maze_question_")
val multiChoiceQuestion: MultiChoiceQuestionEntity? = null,
// Wordle
@Embedded("maze_wordle_")
val wordleItem: WordleEntity? = null,
// Comparison quiz
@Embedded("comparison_quiz_")
val comparisonQuizQuestion: ComparisonQuizEntity? = null
) {
@Keep
data class WordleEntity(
val wordleWord: String,
val wordleQuizType: WordleQuizType,
val textHelper: String? = null
)
@Keep
data class ComparisonQuizEntity(
val category: String,
val comparisonMode: ComparisonMode,
@Embedded("first_question_")
val firstQuestion: ComparisonQuizItemEntity,
@Embedded("second_question_")
val secondQuestion: ComparisonQuizItemEntity
)
}
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/model/MultiChoiceQuestionEntity.kt
================================================
package com.infinitepower.newquiz.core.database.model
import androidx.annotation.Keep
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
/**
* Multi choice question entity, used to save items in database.
*/
@Keep
@Serializable
@Entity(tableName = "saved_multi_choice_questions")
data class MultiChoiceQuestionEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val description: String,
@ColumnInfo(name = "image_url") val imageUrl: String? = null,
val answers: List,
val lang: String,
val category: String,
@ColumnInfo(name = "correct_ans") val correctAns: Int,
val type: String,
val difficulty: String
) : java.io.Serializable {
override fun equals(other: Any?): Boolean {
if (other !is MultiChoiceQuestionEntity) return false
val idEquals = id == other.id
val descriptionEquals = description == other.description
val imageUrlEquals = imageUrl == other.imageUrl
val answersEquals = answers == other.answers
val langEquals = lang == other.lang
val categoryEquals = category == other.category
val correctAnsEquals = correctAns == other.correctAns
val typeEquals = type == other.type
val difficultyEquals = difficulty == other.difficulty
return idEquals
&& descriptionEquals
&& imageUrlEquals
&& answersEquals
&& langEquals
&& categoryEquals
&& correctAnsEquals
&& typeEquals
&& difficultyEquals
}
override fun hashCode(): Int {
var result = id
result = 31 * result + description.hashCode()
result = 31 * result + (imageUrl?.hashCode() ?: 0)
result = 31 * result + answers.hashCode()
result = 31 * result + lang.hashCode()
result = 31 * result + category.hashCode()
result = 31 * result + correctAns
result = 31 * result + type.hashCode()
result = 31 * result + difficulty.hashCode()
return result
}
}
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/model/user/BaseGameResultEntity.kt
================================================
package com.infinitepower.newquiz.core.database.model.user
interface BaseGameResultEntity {
val gameId: Int
val earnedXp: Int
val playedAt: Long
}
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/model/user/ComparisonQuizGameResultEntity.kt
================================================
package com.infinitepower.newquiz.core.database.model.user
import androidx.annotation.Keep
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Keep
@Entity(tableName = "comparison_quiz_game_results")
data class ComparisonQuizGameResultEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "game_id")
override val gameId: Int = 0,
@ColumnInfo(name = "earned_xp")
override val earnedXp: Int,
@ColumnInfo(name = "played_at")
override val playedAt: Long,
@ColumnInfo(name = "category_id")
val categoryId: String,
@ColumnInfo(name = "comparison_mode")
val comparisonMode: String,
@ColumnInfo(name = "end_position")
val endPosition: Int,
@ColumnInfo(name = "skipped_answers")
val skippedAnswers: Int
) : BaseGameResultEntity
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/model/user/MultiChoiceGameResultEntity.kt
================================================
package com.infinitepower.newquiz.core.database.model.user
import androidx.annotation.Keep
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Keep
@Entity(
tableName = "multi_choice_game_results"
)
data class MultiChoiceGameResultEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "game_id")
override val gameId: Int = 0,
@ColumnInfo(name = "correct_answers")
val correctAnswers: Int,
@ColumnInfo(name = "skipped_questions")
val skippedQuestions: Int,
@ColumnInfo(name = "question_count")
val questionCount: Int,
@ColumnInfo(name = "average_answer_time")
val averageAnswerTime: Double,
@ColumnInfo(name = "earned_xp")
override val earnedXp: Int,
@ColumnInfo(name = "played_at")
override val playedAt: Long,
) : BaseGameResultEntity
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/model/user/WordleGameResultEntity.kt
================================================
package com.infinitepower.newquiz.core.database.model.user
import androidx.annotation.Keep
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Keep
@Entity(
tableName = "wordle_game_results"
)
data class WordleGameResultEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "game_id")
override val gameId: Int = 0,
@ColumnInfo(name = "earned_xp")
override val earnedXp: Int,
@ColumnInfo(name = "played_at")
override val playedAt: Long,
@ColumnInfo(name = "word_length")
val wordLength: Int,
@ColumnInfo(name = "rows_used")
val rowsUsed: Int,
@ColumnInfo(name = "max_rows")
val maxRows: Int,
@ColumnInfo(name = "category_id")
val categoryId: String,
) : BaseGameResultEntity
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/util/converters/ListConverter.kt
================================================
package com.infinitepower.newquiz.core.database.util.converters
import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class ListConverter {
@TypeConverter
fun stringListToJson(value: List): String = Json.encodeToString(value)
@TypeConverter
fun jsonToStringList(value: String): List = Json.decodeFromString(value)
}
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/util/converters/LocalDateConverter.kt
================================================
package com.infinitepower.newquiz.core.database.util.converters
import androidx.room.TypeConverter
import kotlinx.datetime.LocalDate
class LocalDateConverter {
@TypeConverter
fun localDateToJson(value: LocalDate): String = value.toString()
@TypeConverter
fun stringToLocalDate(value: String): LocalDate = LocalDate.parse(value)
}
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/util/converters/MathFormulaConverter.kt
================================================
package com.infinitepower.newquiz.core.database.util.converters
import androidx.room.TypeConverter
import com.infinitepower.newquiz.model.math_quiz.MathFormula
class MathFormulaConverter {
@TypeConverter
fun mathFormulaToJson(value: MathFormula): String = value.fullFormula
@TypeConverter
fun stringToMathFormula(value: String): MathFormula = MathFormula.fromStringFullFormula(value)
}
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/util/converters/QuestionDifficultyConverter.kt
================================================
package com.infinitepower.newquiz.core.database.util.converters
import androidx.room.TypeConverter
import com.infinitepower.newquiz.model.question.QuestionDifficulty
class QuestionDifficultyConverter {
@TypeConverter
fun localDateToJson(value: QuestionDifficulty): String = value.id
@TypeConverter
fun stringToLocalDate(value: String): QuestionDifficulty = QuestionDifficulty.from(value)
}
================================================
FILE: core/database/src/main/kotlin/com/infinitepower/newquiz/core/database/util/mappers/MultiChoiceQuestionMapper.kt
================================================
package com.infinitepower.newquiz.core.database.util.mappers
import com.infinitepower.newquiz.core.database.model.MultiChoiceQuestionEntity
import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory
import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion
import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestionType
import com.infinitepower.newquiz.model.multi_choice_quiz.QuestionLanguage
import com.infinitepower.newquiz.model.question.QuestionDifficulty
import java.net.URI
fun MultiChoiceQuestion.toEntity(): MultiChoiceQuestionEntity {
return MultiChoiceQuestionEntity(
id = id,
description = description,
imageUrl = image?.toASCIIString(),
answers = answers,
lang = lang.name,
category = category.toString(),
correctAns = correctAns,
type = type.name,
difficulty = difficulty.toString()
)
}
fun MultiChoiceQuestionEntity.toModel(): MultiChoiceQuestion = MultiChoiceQuestion(
id = id,
description = description,
image = imageUrl?.let { URI.create(it) },
answers = answers,
lang = QuestionLanguage.EN,
category = MultiChoiceBaseCategory.fromId(category),
correctAns = correctAns,
type = MultiChoiceQuestionType.MULTIPLE,
difficulty = QuestionDifficulty.from(difficulty)
)
================================================
FILE: core/datastore/.gitignore
================================================
/build
================================================
FILE: core/datastore/build.gradle.kts
================================================
plugins {
alias(libs.plugins.newquiz.android.library)
alias(libs.plugins.newquiz.android.hilt)
alias(libs.plugins.newquiz.detekt)
}
android {
namespace = "com.infinitepower.newquiz.core.datastore"
}
dependencies {
api(libs.androidx.dataStore.preferences)
implementation(projects.model)
implementation(projects.core)
}
================================================
FILE: core/datastore/consumer-rules.pro
================================================
================================================
FILE: core/datastore/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
================================================
FILE: core/datastore/src/main/AndroidManifest.xml
================================================
================================================
FILE: core/datastore/src/main/kotlin/com/infinitepower/newquiz/core/datastore/PreferenceRequest.kt
================================================
package com.infinitepower.newquiz.core.datastore
import androidx.datastore.preferences.core.Preferences
open class PreferenceRequest(
val key: Preferences.Key,
val defaultValue: T
)
================================================
FILE: core/datastore/src/main/kotlin/com/infinitepower/newquiz/core/datastore/common/LocalUserCommon.kt
================================================
package com.infinitepower.newquiz.core.datastore.common
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.infinitepower.newquiz.core.datastore.PreferenceRequest
import java.util.UUID
val Context.localUserDataStore: DataStore by preferencesDataStore(name = "local_user")
object LocalUserCommon {
object UserUid : PreferenceRequest(
key = stringPreferencesKey("uid"),
defaultValue = UUID.randomUUID().toString()
)
object UserTotalXp : PreferenceRequest(
key = longPreferencesKey("totalXp"),
defaultValue = 0
)
data class UserDiamonds(
val initialDiamonds: Int = 0,
) : PreferenceRequest(
key = intPreferencesKey("diamonds"),
defaultValue = initialDiamonds
)
}
================================================
FILE: core/datastore/src/main/kotlin/com/infinitepower/newquiz/core/datastore/common/RecentCategoryDataStoreCommon.kt
================================================
package com.infinitepower.newquiz.core.datastore.common
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.infinitepower.newquiz.core.datastore.PreferenceRequest
val Context.recentCategoriesDataStore: DataStore by preferencesDataStore(name = "recent_categories")
object RecentCategoryDataStoreCommon {
object MultiChoice : PreferenceRequest>(stringSetPreferencesKey("multi_choice"), emptySet())
object Wordle : PreferenceRequest>(stringSetPreferencesKey("wordle"), emptySet())
object ComparisonQuiz : PreferenceRequest>(stringSetPreferencesKey("comparison_quiz"), emptySet())
}
================================================
FILE: core/datastore/src/main/kotlin/com/infinitepower/newquiz/core/datastore/common/SettingsCommon.kt
================================================
package com.infinitepower.newquiz.core.datastore.common
import android.content.Context
import androidx.annotation.Keep
import androidx.annotation.RawRes
import androidx.annotation.StringRes
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.infinitepower.newquiz.core.datastore.PreferenceRequest
import com.infinitepower.newquiz.model.category.ShowCategoryConnectionInfo
import com.infinitepower.newquiz.core.R as CoreR
import java.util.Locale
val Context.settingsDataStore: DataStore by preferencesDataStore(name = "settings")
object SettingsCommon {
object MultiChoiceQuizQuestionsSize : PreferenceRequest(
key = intPreferencesKey("quickQuizQuestionsSize"),
defaultValue = 5
)
object HideOnlineCategories : PreferenceRequest(
key = booleanPreferencesKey("hideOnlineCategories"),
defaultValue = false
)
@Keep
data class CategoryConnectionInfoBadge(
val default: ShowCategoryConnectionInfo = ShowCategoryConnectionInfo.NONE
) : PreferenceRequest(
key = stringPreferencesKey("categoryConnectionInfoBadge"),
defaultValue = default.name
)
object InfiniteWordleQuizLanguage : PreferenceRequest(
key = stringPreferencesKey("infiniteWordleQuizLanguage"),
defaultValue = getInfiniteWordleDefaultLang()
)
object WordleInfiniteRowsLimited : PreferenceRequest(
key = booleanPreferencesKey("wordleInfiniteRowsLimited"),
defaultValue = false
)
object WordleInfiniteRowsLimit : PreferenceRequest(
key = intPreferencesKey("wordleInfiniteRowsLimit"),
defaultValue = 6
)
object WordleHardMode : PreferenceRequest(
key = booleanPreferencesKey("wordleHardMode"),
defaultValue = false
)
object WordleColorBlindMode : PreferenceRequest(
key = booleanPreferencesKey("wordleColorBlindMode"),
defaultValue = false
)
object WordleLetterHints : PreferenceRequest(
key = booleanPreferencesKey("wordleLetterHints"),
defaultValue = false
)
object GlobalAnimationsEnabled : PreferenceRequest(
key = booleanPreferencesKey("animations_enabled"),
defaultValue = true
)
object WordleAnimationsEnabled : PreferenceRequest(
key = booleanPreferencesKey("wordle_animations_enabled"),
defaultValue = true
)
object MultiChoiceAnimationsEnabled : PreferenceRequest(
key = booleanPreferencesKey("multi_choice_animations_enabled"),
defaultValue = true
)
object MazeAutoScrollToCurrentItem : PreferenceRequest(
key = booleanPreferencesKey("mazeAutoScrollToCurrentItem"),
defaultValue = true
)
object TemperatureUnit : PreferenceRequest(
key = stringPreferencesKey("temperatureUnit"),
defaultValue = ""
)
object DistanceUnitType : PreferenceRequest(
key = stringPreferencesKey("distanceUnitType"),
defaultValue = ""
)
}
@Keep
data class SettingsWordleLang(
val key: String,
@StringRes val languageId: Int,
@RawRes val rawListId: Int
)
val textWordleSupportedLang = listOf(
SettingsWordleLang(
key = "en",
languageId = CoreR.string.english,
rawListId = CoreR.raw.wordle_list
),
SettingsWordleLang(
key = "pt",
languageId = CoreR.string.portuguese,
rawListId = CoreR.raw.wordle_list_pt
),
SettingsWordleLang(
key = "es",
languageId = CoreR.string.spanish,
rawListId = CoreR.raw.wordle_list_es
),
SettingsWordleLang(
key = "fr",
languageId = CoreR.string.french,
rawListId = CoreR.raw.wordle_list_fr
)
)
private fun getInfiniteWordleDefaultLang(): String {
val localeLanguage = Locale.getDefault().language
val langKeys = textWordleSupportedLang.map { it.key }
return if (localeLanguage in langKeys) localeLanguage else "en"
}
================================================
FILE: core/datastore/src/main/kotlin/com/infinitepower/newquiz/core/datastore/di/LocalUserDatastoreModule.kt
================================================
package com.infinitepower.newquiz.core.datastore.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.infinitepower.newquiz.core.datastore.common.localUserDataStore
import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager
import com.infinitepower.newquiz.core.datastore.manager.PreferencesDatastoreManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Qualifier
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object LocalUserDataStoreModule {
@Provides
@Singleton
@LocalUserDataStore
fun provideLocalUserDatastore(
@ApplicationContext context: Context
): DataStore = context.localUserDataStore
@Provides
@Singleton
@LocalUserDataStoreManager
fun provideLocalUserDataStoreManager(
@LocalUserDataStore dataStore: DataStore
): DataStoreManager = PreferencesDatastoreManager(dataStore)
}
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class LocalUserDataStore
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class LocalUserDataStoreManager
================================================
FILE: core/datastore/src/main/kotlin/com/infinitepower/newquiz/core/datastore/di/RecentCategoriesDatastoreModule.kt
================================================
package com.infinitepower.newquiz.core.datastore.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.infinitepower.newquiz.core.datastore.common.recentCategoriesDataStore
import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager
import com.infinitepower.newquiz.core.datastore.manager.PreferencesDatastoreManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Qualifier
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object RecentCategoriesDatastoreModule {
@Provides
@Singleton
@RecentCategoriesDataStore
fun provideRecentCategoriesDatastore(
@ApplicationContext context: Context
): DataStore = context.recentCategoriesDataStore
@Provides
@Singleton
@RecentCategoriesDataStoreManager
fun provideRecentCategoriesStoreManager(
@RecentCategoriesDataStore dataStore: DataStore
): DataStoreManager = PreferencesDatastoreManager(dataStore)
}
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class RecentCategoriesDataStore
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class RecentCategoriesDataStoreManager
================================================
FILE: core/datastore/src/main/kotlin/com/infinitepower/newquiz/core/datastore/di/SettingsDataStoreModule.kt
================================================
package com.infinitepower.newquiz.core.datastore.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.infinitepower.newquiz.core.datastore.common.settingsDataStore
import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager
import com.infinitepower.newquiz.core.datastore.manager.PreferencesDatastoreManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Qualifier
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SettingsDataStoreModule {
@Provides
@Singleton
@SettingsDataStore
fun provideSettingsDatastore(
@ApplicationContext context: Context
): DataStore = context.settingsDataStore
@Provides
@Singleton
@SettingsDataStoreManager
fun provideDataStoreManager(
@SettingsDataStore dataStore: DataStore
): DataStoreManager = PreferencesDatastoreManager(dataStore)
}
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class SettingsDataStore
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class SettingsDataStoreManager
================================================
FILE: core/datastore/src/main/kotlin/com/infinitepower/newquiz/core/datastore/manager/DataStoreManager.kt
================================================
package com.infinitepower.newquiz.core.datastore.manager
import androidx.datastore.preferences.core.Preferences
import com.infinitepower.newquiz.core.datastore.PreferenceRequest
import kotlinx.coroutines.flow.Flow
interface DataStoreManager {
val preferenceFlow: Flow
suspend fun getPreference(preferenceEntry: PreferenceRequest): T
fun getPreferenceFlow(request: PreferenceRequest): Flow
suspend fun editPreference(key: Preferences.Key, newValue: T)
suspend fun editPreferences(vararg prefs: Preferences.Pair<*>)
suspend fun clearPreferences()
}
================================================
FILE: core/datastore/src/main/kotlin/com/infinitepower/newquiz/core/datastore/manager/PreferencesDatastoreManager.kt
================================================
package com.infinitepower.newquiz.core.datastore.manager
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import com.infinitepower.newquiz.core.datastore.PreferenceRequest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
class PreferencesDatastoreManager(
private val dataStore: DataStore
) : DataStoreManager {
override val preferenceFlow = dataStore.data
override suspend fun getPreference(
preferenceEntry: PreferenceRequest
) = preferenceFlow
.firstOrNull()
?.get(preferenceEntry.key) ?: preferenceEntry.defaultValue
override fun getPreferenceFlow(request: PreferenceRequest) = preferenceFlow.map {
it[request.key] ?: request.defaultValue
}.distinctUntilChanged()
override suspend fun editPreference(key: Preferences.Key, newValue: T) {
dataStore.edit { preferences -> preferences[key] = newValue }
}
override suspend fun editPreferences(vararg prefs: Preferences.Pair<*>) {
dataStore.edit { preferences ->
preferences.putAll(*prefs)
}
}
override suspend fun clearPreferences() {
dataStore.edit { preferences -> preferences.clear() }
}
}
================================================
FILE: core/datastore/src/normal/kotlin/com/infinitepower/newquiz/core/datastore/common/DataAnalyticsCommon.kt
================================================
package com.infinitepower.newquiz.core.datastore.common
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.infinitepower.newquiz.core.datastore.PreferenceRequest
import com.infinitepower.newquiz.model.DataAnalyticsConsentState
val Context.dataAnalyticsDataStore: DataStore by preferencesDataStore(name = "data_analytics")
object DataAnalyticsCommon {
object DataAnalyticsConsent : PreferenceRequest(stringPreferencesKey("dataAnalyticsConsent"), DataAnalyticsConsentState.NONE.name)
object GloballyAnalyticsCollectionEnabled : PreferenceRequest(booleanPreferencesKey("dataAnalyticsEnabled"), false)
object GeneralAnalyticsEnabled : PreferenceRequest(booleanPreferencesKey("generalAnalyticsEnabled"), false)
object CrashlyticsEnabled : PreferenceRequest(booleanPreferencesKey("crashlyticsEnabled"), false)
object PerformanceMonitoringEnabled : PreferenceRequest(booleanPreferencesKey("performanceMonitoringEnabled"), false)
}
================================================
FILE: core/datastore/src/normal/kotlin/com/infinitepower/newquiz/core/datastore/common/TranslationCommon.kt
================================================
package com.infinitepower.newquiz.core.datastore.common
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.infinitepower.newquiz.core.datastore.PreferenceRequest
object TranslationCommon {
/**
* Returns whether the translation is enabled.
*/
object Enabled : PreferenceRequest(
key = booleanPreferencesKey("translationEnabled"),
defaultValue = false
)
/**
* Returns the target language for the app translation.
* When empty, there is no translation target language.
*/
object TargetLanguage : PreferenceRequest(
key = stringPreferencesKey("translationTargetLanguage"),
defaultValue = ""
)
/**
* Preference to store whether the user wants to download the translation model over wifi only.
*/
object RequireWifi : PreferenceRequest(
key = booleanPreferencesKey("translationRequireWifi"),
defaultValue = true
)
/**
* Preference to store whether the user wants to download the translation model only when charging.
*/
object RequireCharging : PreferenceRequest(
key = booleanPreferencesKey("translationRequireCharging"),
defaultValue = false
)
}
================================================
FILE: core/datastore/src/normal/kotlin/com/infinitepower/newquiz/core/datastore/di/DataAnalyticsDatastoreModule.kt
================================================
package com.infinitepower.newquiz.core.datastore.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.infinitepower.newquiz.core.datastore.common.dataAnalyticsDataStore
import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager
import com.infinitepower.newquiz.core.datastore.manager.PreferencesDatastoreManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Qualifier
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DataAnalyticsDatastoreModule {
@Provides
@Singleton
@DataAnalyticsDataStore
fun provideDataAnalyticsDatastore(
@ApplicationContext context: Context
): DataStore = context.dataAnalyticsDataStore
@Provides
@Singleton
@DataAnalyticsDataStoreManager
fun provideDataAnalyticsDataStoreManager(
@DataAnalyticsDataStore dataStore: DataStore
): DataStoreManager = PreferencesDatastoreManager(dataStore)
}
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class DataAnalyticsDataStore
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class DataAnalyticsDataStoreManager
================================================
FILE: core/datastore/src/test/kotlin/com/infinitepower/newquiz/core/datastore/manager/PreferencesDatastoreManagerTest.kt
================================================
package com.infinitepower.newquiz.core.datastore.manager
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.infinitepower.newquiz.core.datastore.PreferenceRequest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.io.TempDir
import java.io.File
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class PreferencesDatastoreManagerTest {
private lateinit var dataStoreManager: DataStoreManager
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher + Job())
@field:TempDir
lateinit var tempFile: File
private val testDataStore: DataStore = PreferenceDataStoreFactory.create(
scope = testScope,
produceFile = { tempFile.resolve("test.preferences_pb") }
)
@BeforeTest
fun setup() {
dataStoreManager = PreferencesDatastoreManager(testDataStore)
}
@AfterTest
fun tearDown() {
tempFile.delete()
}
@Test
fun `getPreference returns default value`() = runTest {
val key = stringPreferencesKey("key")
val defaultValue = "default"
val request = PreferenceRequest(key, defaultValue)
val result = dataStoreManager.getPreference(request)
assertThat(result).isEqualTo(defaultValue)
}
@Test
fun `getPreference returns new data when edited`() = runTest {
val key = stringPreferencesKey("key")
val defaultValue = "default"
val request = PreferenceRequest(key, defaultValue)
val newValue = "test"
dataStoreManager.editPreference(key, newValue)
val result = dataStoreManager.getPreference(request)
assertThat(result).isEqualTo(newValue)
}
@Test
fun `getPreferenceFlow returns correct value`() = runTest {
val key = stringPreferencesKey("key")
val defaultValue = "default"
val request = PreferenceRequest(key, defaultValue)
dataStoreManager.getPreferenceFlow(request).test {
assertThat(awaitItem()).isEqualTo(defaultValue)
dataStoreManager.editPreference(key, "test")
assertThat(awaitItem()).isEqualTo("test")
}
}
@Test
fun `test edit and clear multiple preferences`() = runTest {
val request1 = PreferenceRequest(
key = stringPreferencesKey("key1"),
defaultValue = "default1"
)
val request2 = PreferenceRequest(
key = stringPreferencesKey("key2"),
defaultValue = "default2"
)
dataStoreManager.editPreferences(
request1.key to "a",
request2.key to "b"
)
// Check that the preferences were set
assertThat(dataStoreManager.getPreference(request1)).isEqualTo("a")
assertThat(dataStoreManager.getPreference(request2)).isEqualTo("b")
dataStoreManager.clearPreferences()
// Check that the preferences were cleared
assertThat(dataStoreManager.getPreference(request1)).isEqualTo(request1.defaultValue)
assertThat(dataStoreManager.getPreference(request2)).isEqualTo(request2.defaultValue)
}
}
================================================
FILE: core/proguard-rules.pro
================================================
-dontwarn java.lang.invoke.StringConcatFactory
================================================
FILE: core/remote-config/.gitignore
================================================
/build
================================================
FILE: core/remote-config/README.md
================================================
# Remote Config Module (:core:remote_config)
================================================
FILE: core/remote-config/build.gradle.kts
================================================
plugins {
alias(libs.plugins.newquiz.android.library)
alias(libs.plugins.newquiz.android.hilt)
alias(libs.plugins.newquiz.kotlin.serialization)
alias(libs.plugins.newquiz.detekt)
}
android {
namespace = "com.infinitepower.newquiz.core.remote_config"
}
dependencies {
implementation(libs.androidx.startup.runtime)
normalImplementation(platform(libs.firebase.bom))
normalImplementation(libs.firebase.remoteConfig)
implementation(projects.model)
normalImplementation(projects.core)
}
================================================
FILE: core/remote-config/src/foss/kotlin/com/infinitepower/newquiz/core/remote_config/initializer/RemoteConfigInitializer.kt
================================================
package com.infinitepower.newquiz.core.remote_config.initializer
import android.content.Context
import android.util.Log
import androidx.startup.Initializer
import com.infinitepower.newquiz.core.remote_config.LocalDefaultsRemoteConfig
import com.infinitepower.newquiz.core.remote_config.RemoteConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class RemoteConfigInitializer : Initializer {
private companion object {
private const val TAG = "RemoteConfigInitializer"
}
@Provides
@Singleton
override fun create(@ApplicationContext context: Context): RemoteConfig {
Log.d(TAG, "Initializing local remote config")
val remoteConfig = LocalDefaultsRemoteConfig(context = context)
remoteConfig.initialize()
Log.d(TAG, "Local remote config initialized successfully")
return remoteConfig
}
override fun dependencies(): List>> {
return emptyList()
}
}
================================================
FILE: core/remote-config/src/main/AndroidManifest.xml
================================================
================================================
FILE: core/remote-config/src/main/kotlin/com/infinitepower/newquiz/core/remote_config/LocalDefaultsRemoteConfig.kt
================================================
package com.infinitepower.newquiz.core.remote_config
import android.content.Context
class NoValueForRemoteConfigKeyException(key: String) : IllegalArgumentException("No value for key $key")
class LocalDefaultsRemoteConfig (
private val context: Context
) : RemoteConfig {
private val remoteConfigValues = mutableMapOf()
override fun initialize(fetchInterval: Long) {
val parsedXmlValues = RemoteConfigXmlParser.parse(
context = context,
xmlResId = R.xml.remote_config_defaults
)
remoteConfigValues.clear()
remoteConfigValues.putAll(parsedXmlValues)
}
override fun getString(key: String): String {
return remoteConfigValues[key] ?: throw NoValueForRemoteConfigKeyException(key)
}
override fun getLong(key: String): Long {
return remoteConfigValues[key]?.toLong() ?: throw NoValueForRemoteConfigKeyException(key)
}
override fun getBoolean(key: String): Boolean {
return remoteConfigValues[key]?.toBoolean() ?: throw NoValueForRemoteConfigKeyException(key)
}
}
================================================
FILE: core/remote-config/src/main/kotlin/com/infinitepower/newquiz/core/remote_config/RemoteConfig.kt
================================================
package com.infinitepower.newquiz.core.remote_config
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
interface RemoteConfig {
fun initialize(
fetchInterval: Long = 3600L,
)
fun getString(key: String): String
fun getLong(key: String): Long
fun getInt(key: String): Int = getLong(key).toInt()
fun getBoolean(key: String): Boolean
}
/**
* Gets the value for the given [removeConfigValue] from the remote config.
* The type of the value is inferred from the type of the [removeConfigValue].
*
* Supported types:
* - [String]
* - [Long]
* - [Int]
* - [Boolean]
* - [Enum]
* - Custom Class (must be annotated with [Serializable] annotation)
*
* @param removeConfigValue the value to get from the remote config.
* @return the value for the given [removeConfigValue].
* @throws IllegalArgumentException if the type of the [removeConfigValue] is not supported.
*/
inline fun RemoteConfig.get(removeConfigValue: RemoteConfigValue): T {
return when (T::class) {
String::class -> getString(removeConfigValue.key) as T
Long::class -> getLong(removeConfigValue.key) as T
Int::class -> getInt(removeConfigValue.key) as T
Boolean::class -> getBoolean(removeConfigValue.key) as T
else -> {
// Check if the type is a custom class with serialization, if so, decode the serialized value.
// If the type is an enum with serialization, decode the enum value using the deserialization.
if (T::class.java.isAnnotationPresent(Serializable::class.java)) {
val serializedValue = getString(removeConfigValue.key)
return Json.decodeFromString(serializedValue)
} else if (T::class.java.isEnum) {
// If the type is an enum without serialization, decode the enum value using reflection.
val enumValue = getString(removeConfigValue.key)
return T::class.java.enumConstants
?.find { it.toString() == enumValue }
?: throw IllegalArgumentException("Invalid enum value: $enumValue")
}
throw IllegalArgumentException("Unsupported type ${T::class}")
}
}
}
================================================
FILE: core/remote-config/src/main/kotlin/com/infinitepower/newquiz/core/remote_config/RemoteConfigValue.kt
================================================
package com.infinitepower.newquiz.core.remote_config
import com.infinitepower.newquiz.model.category.ShowCategoryConnectionInfo
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizHelperValueState
import com.infinitepower.newquiz.model.question.QuestionDifficulty
@JvmInline
value class RemoteConfigValue(
val key: String
) {
companion object {
val MAZE_QUIZ_GENERATED_QUESTIONS = RemoteConfigValue("maze_quiz_generated_questions")
val FLAG_BASE_URL = RemoteConfigValue("flag_base_url")
val DEFAULT_SHOW_CATEGORY_CONNECTION_INFO = RemoteConfigValue(
key = "default_show_category_connection_info"
)
val DAILY_CHALLENGE_TASKS_TO_GENERATE = RemoteConfigValue("daily_challenge_tasks_to_generate")
val DAILY_CHALLENGE_ITEM_REWARD = RemoteConfigValue("daily_challenge_item_reward")
val COMPARISON_QUIZ_CATEGORIES = RemoteConfigValue("comparison_quiz_categories")
val COMPARISON_QUIZ_SKIP_COST = RemoteConfigValue("comparison_quiz_skip_cost")
val COMPARISON_QUIZ_FIRST_ITEM_HELPER_VALUE = RemoteConfigValue(
key = "comparison_quiz_first_item_helper_value"
)
val USER_INITIAL_DIAMONDS = RemoteConfigValue("user_initial_diamonds")
val COMPARISON_QUIZ_DEFAULT_XP_REWARD = RemoteConfigValue("comparison_quiz_default_xp_reward")
val WORDLE_DEFAULT_XP_REWARD = RemoteConfigValue("wordle_default_xp_reward")
val MULTICHOICE_QUIZ_DEFAULT_XP_REWARD = RemoteConfigValue("multichoice_quiz_default_xp_reward")
val ALL_LOGOS_QUIZ = RemoteConfigValue("all_logos_quiz")
val MULTICHOICE_QUICKQUIZ_DIFFICULTY = RemoteConfigValue("multichoice_quickquiz_difficulty")
val MATH_QUIZ_OPERATOR_SIZE = RemoteConfigValue("math_quiz_operator_size")
val MATH_QUIZ_DIFFICULTY = RemoteConfigValue("math_quiz_difficulty")
val NEW_LEVEL_DIAMONDS = RemoteConfigValue("new_level_diamonds")
val MULTICHOICE_SKIP_COST = RemoteConfigValue("multichoice_skip_cost")
}
}
================================================
FILE: core/remote-config/src/main/kotlin/com/infinitepower/newquiz/core/remote_config/RemoteConfigXmlParser.kt
================================================
package com.infinitepower.newquiz.core.remote_config
import android.content.Context
import android.content.res.XmlResourceParser
import android.util.Log
/**
* Parser for the defaults XML file.
*
* @author Miraziz Yusupov
*/
internal object RemoteConfigXmlParser {
private const val TAG = "RemoteConfigXmlParser"
private const val XML_TAG_ENTRY = "entry"
private const val XML_TAG_KEY = "key"
private const val XML_TAG_VALUE = "value"
@Suppress("NestedBlockDepth", "TooGenericExceptionCaught")
fun parse(
context: Context,
xmlResId: Int
): Map {
val defaultsMap = mutableMapOf()
try {
val resources = context.resources
if (resources == null) {
Log.e(
TAG,
"Could not find the resources of the current context while trying to set defaults from an XML."
)
return defaultsMap
}
val xmlParser = resources.getXml(xmlResId)
var curTag: String? = null
var key: String? = null
var value: String? = null
var eventType = xmlParser.eventType
while (eventType != XmlResourceParser.END_DOCUMENT) {
if (eventType == XmlResourceParser.START_TAG) {
curTag = xmlParser.name
} else if (eventType == XmlResourceParser.END_TAG) {
if (xmlParser.name == XML_TAG_ENTRY) {
if (key != null && value != null) {
defaultsMap[key] = value
} else {
Log.w(
TAG,
"An entry in the defaults XML has an invalid key and/or value tag."
)
}
key = null
value = null
}
curTag = null
} else if (eventType == XmlResourceParser.TEXT) {
if (curTag != null) {
when (curTag) {
XML_TAG_KEY -> key = xmlParser.text
XML_TAG_VALUE -> value = xmlParser.text
else -> Log.w(
TAG,
"Encountered an unexpected tag while parsing the defaults XML."
)
}
}
}
eventType = xmlParser.next()
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing remote config defaults XML", e)
}
return defaultsMap
}
}
================================================
FILE: core/remote-config/src/main/res/xml/remote_config_defaults.xml
================================================
flag_base_url
local
countries_and_capitals
[{"countryCode":"AF","capital":"Kabul","difficulty":"medium","countryName":"Afghanistan","continent":"Asia"},{"countryCode":"AL","capital":"Tirana","difficulty":"medium","countryName":"Albania","continent":"Europe"},{"countryCode":"DZ","capital":"Algiers","difficulty":"medium","countryName":"Algeria","continent":"Africa"},{"countryCode":"AD","capital":"Andorra la Vella","difficulty":"medium","countryName":"Andorra","continent":"Europe"},{"countryCode":"AO","capital":"Luanda","difficulty":"medium","countryName":"Angola","continent":"Africa"},{"countryCode":"AG","capital":"St. John's","difficulty":"hard","countryName":"Antigua and Barbuda","continent":"North America"},{"countryCode":"AR","capital":"Buenos Aires","difficulty":"medium","countryName":"Argentina","continent":"South America"},{"countryCode":"AM","capital":"Yerevan","difficulty":"medium","countryName":"Armenia","continent":"Asia"},{"countryCode":"AU","capital":"Canberra","difficulty":"medium","countryName":"Australia","continent":"Oceania"},{"countryCode":"AT","capital":"Vienna","difficulty":"easy","countryName":"Austria","continent":"Europe"},{"countryCode":"AZ","capital":"Baku","difficulty":"medium","countryName":"Azerbaijan","continent":"Asia"},{"countryCode":"BS","capital":"Nassau","difficulty":"medium","countryName":"Bahamas","continent":"North America"},{"countryCode":"BH","capital":"Manama","difficulty":"medium","countryName":"Bahrain","continent":"Asia"},{"countryCode":"BD","capital":"Dhaka","difficulty":"medium","countryName":"Bangladesh","continent":"Asia"},{"countryCode":"BB","capital":"Bridgetown","difficulty":"hard","countryName":"Barbados","continent":"North America"},{"countryCode":"BY","capital":"Minsk","difficulty":"medium","countryName":"Belarus","continent":"Europe"},{"countryCode":"BE","capital":"Brussels","difficulty":"easy","countryName":"Belgium","continent":"Europe"},{"countryCode":"BZ","capital":"Belmopan","difficulty":"medium","countryName":"Belize","continent":"North America"},{"countryCode":"BJ","capital":"Porto-Novo","difficulty":"medium","countryName":"Benin","continent":"Africa"},{"countryCode":"BT","capital":"Thimphu","difficulty":"medium","countryName":"Bhutan","continent":"Asia"},{"countryCode":"BO","capital":"La Paz","difficulty":"hard","countryName":"Bolivia","continent":"South America"},{"countryCode":"BA","capital":"Sarajevo","difficulty":"medium","countryName":"Bosnia and Herzegovina","continent":"Europe"},{"countryCode":"BW","capital":"Gaborone","difficulty":"medium","countryName":"Botswana","continent":"Africa"},{"countryCode":"BR","capital":"Brasília","difficulty":"medium","countryName":"Brazil","continent":"South America"},{"countryCode":"BN","capital":"Bandar Seri Begawan","difficulty":"hard","countryName":"Brunei","continent":"Asia"},{"countryCode":"BG","capital":"Sofia","difficulty":"easy","countryName":"Bulgaria","continent":"Europe"},{"countryCode":"BF","capital":"Ouagadougou","difficulty":"medium","countryName":"Burkina Faso","continent":"Africa"},{"countryCode":"BI","capital":"Bujumbura","difficulty":"hard","countryName":"Burundi","continent":"Africa"},{"countryCode":"KH","capital":"Phnom Penh","difficulty":"medium","countryName":"Cambodia","continent":"Asia"},{"countryCode":"CM","capital":"Yaoundé","difficulty":"medium","countryName":"Cameroon","continent":"Africa"},{"countryCode":"CA","capital":"Ottawa","difficulty":"easy","countryName":"Canada","continent":"North America"},{"countryCode":"CV","capital":"Praia","difficulty":"medium","countryName":"Cape Verde","continent":"Africa"},{"countryCode":"CF","capital":"Bangui","difficulty":"medium","countryName":"Central African Republic","continent":"Africa"},{"countryCode":"TD","capital":"N'Djamena","difficulty":"medium","countryName":"Chad","continent":"Africa"},{"countryCode":"CL","capital":"Santiago","difficulty":"medium","countryName":"Chile","continent":"South America"},{"countryCode":"CN","capital":"Beijing","difficulty":"medium","countryName":"China","continent":"Asia"},{"countryCode":"CO","capital":"Bogotá","difficulty":"medium","countryName":"Colombia","continent":"South America"},{"countryCode":"KM","capital":"Moroni","difficulty":"medium","countryName":"Comoros","continent":"Africa"},{"countryCode":"CD","capital":"Kinshasa","difficulty":"medium","countryName":"Democratic Republic of the Congo","continent":"Africa"},{"countryCode":"CG","capital":"Brazzaville","difficulty":"medium","countryName":"Republic of the Congo","continent":"Africa"},{"countryCode":"CR","capital":"San José","difficulty":"medium","countryName":"Costa Rica","continent":"North America"},{"countryCode":"CI","capital":"Yamoussoukro","difficulty":"medium","countryName":"Côte d'Ivoire","continent":"Africa"},{"countryCode":"HR","capital":"Zagreb","difficulty":"medium","countryName":"Croatia","continent":"Europe"},{"countryCode":"CU","capital":"Havana","difficulty":"medium","countryName":"Cuba","continent":"North America"},{"countryCode":"CY","capital":"Nicosia","difficulty":"medium","countryName":"Cyprus","continent":"Asia"},{"countryCode":"CZ","capital":"Prague","difficulty":"medium","countryName":"Czech Republic","continent":"Europe"},{"countryCode":"DK","capital":"Copenhagen","difficulty":"easy","countryName":"Denmark","continent":"Europe"},{"countryCode":"DJ","capital":"Djibouti","difficulty":"hard","countryName":"Djibouti","continent":"Africa"},{"countryCode":"DM","capital":"Roseau","difficulty":"medium","countryName":"Dominica","continent":"North America"},{"countryCode":"DO","capital":"Santo Domingo","difficulty":"medium","countryName":"Dominican Republic","continent":"North America"},{"countryCode":"EC","capital":"Quito","difficulty":"medium","countryName":"Ecuador","continent":"South America"},{"countryCode":"EG","capital":"Cairo","difficulty":"medium","countryName":"Egypt","continent":"Africa"},{"countryCode":"SV","capital":"San Salvador","difficulty":"medium","countryName":"El Salvador","continent":"North America"},{"countryCode":"GQ","capital":"Malabo","difficulty":"medium","countryName":"Equatorial Guinea","continent":"Africa"},{"countryCode":"ER","capital":"Asmara","difficulty":"hard","countryName":"Eritrea","continent":"Africa"},{"countryCode":"EE","capital":"Tallinn","difficulty":"medium","countryName":"Estonia","continent":"Europe"},{"countryCode":"ET","capital":"Addis Ababa","difficulty":"hard","countryName":"Ethiopia","continent":"Africa"},{"countryCode":"FJ","capital":"Suva","difficulty":"medium","countryName":"Fiji","continent":"Oceania"},{"countryCode":"FI","capital":"Helsinki","difficulty":"medium","countryName":"Finland","continent":"Europe"},{"countryCode":"FR","capital":"Paris","difficulty":"medium","countryName":"France","continent":"Europe"},{"countryCode":"GA","capital":"Libreville","difficulty":"medium","countryName":"Gabon","continent":"Africa"},{"countryCode":"GM","capital":"Banjul","difficulty":"medium","countryName":"Gambia","continent":"Africa"},{"countryCode":"GE","capital":"Tbilisi","difficulty":"medium","countryName":"Georgia","continent":"Asia"},{"countryCode":"DE","capital":"Berlin","difficulty":"medium","countryName":"Germany","continent":"Europe"},{"countryCode":"GH","capital":"Accra","difficulty":"medium","countryName":"Ghana","continent":"Africa"},{"countryCode":"GR","capital":"Athens","difficulty":"medium","countryName":"Greece","continent":"Europe"},{"countryCode":"GD","capital":"St. George's","difficulty":"medium","countryName":"Grenada","continent":"North America"},{"countryCode":"GT","capital":"Guatemala City","difficulty":"medium","countryName":"Guatemala","continent":"North America"},{"countryCode":"GN","capital":"Conakry","difficulty":"medium","countryName":"Guinea","continent":"Africa"},{"countryCode":"GW","capital":"Bissau","difficulty":"medium","countryName":"Guinea-Bissau","continent":"Africa"},{"countryCode":"GY","capital":"Georgetown","difficulty":"medium","countryName":"Guyana","continent":"South America"},{"countryCode":"HT","capital":"Port-au-Prince","difficulty":"medium","countryName":"Haiti","continent":"North America"},{"countryCode":"HN","capital":"Tegucigalpa","difficulty":"medium","countryName":"Honduras","continent":"North America"},{"countryCode":"HU","capital":"Budapest","difficulty":"medium","countryName":"Hungary","continent":"Europe"},{"countryCode":"IS","capital":"Reykjavik","difficulty":"medium","countryName":"Iceland","continent":"Europe"},{"countryCode":"IN","capital":"New Delhi","difficulty":"medium","countryName":"India","continent":"Asia"},{"countryCode":"ID","capital":"Jakarta","difficulty":"medium","countryName":"Indonesia","continent":"Asia"},{"countryCode":"IR","capital":"Tehran","difficulty":"hard","countryName":"Iran","continent":"Asia"},{"countryCode":"IQ","capital":"Baghdad","difficulty":"hard","countryName":"Iraq","continent":"Asia"},{"countryCode":"IE","capital":"Dublin","difficulty":"medium","countryName":"Ireland","continent":"Europe"},{"countryCode":"IL","capital":"Jerusalem","difficulty":"medium","countryName":"Israel","continent":"Asia"},{"countryCode":"IT","capital":"Rome","difficulty":"medium","countryName":"Italy","continent":"Europe"},{"countryCode":"JM","capital":"Kingston","difficulty":"medium","countryName":"Jamaica","continent":"North America"},{"countryCode":"JP","capital":"Tokyo","difficulty":"medium","countryName":"Japan","continent":"Asia"},{"countryCode":"JO","capital":"Amman","difficulty":"medium","countryName":"Jordan","continent":"Asia"},{"countryCode":"KZ","capital":"Nur-Sultan","difficulty":"medium","countryName":"Kazakhstan","continent":"Asia"},{"countryCode":"KE","capital":"Nairobi","difficulty":"medium","countryName":"Kenya","continent":"Africa"},{"countryCode":"KI","capital":"Tarawa","difficulty":"medium","countryName":"Kiribati","continent":"Oceania"},{"countryCode":"KP","capital":"Pyongyang","difficulty":"hard","countryName":"North Korea","continent":"Asia"},{"countryCode":"KR","capital":"Seoul","difficulty":"medium","countryName":"South Korea","continent":"Asia"},{"countryCode":"KW","capital":"Kuwait City","difficulty":"medium","countryName":"Kuwait","continent":"Asia"},{"countryCode":"KG","capital":"Bishkek","difficulty":"medium","countryName":"Kyrgyzstan","continent":"Asia"},{"countryCode":"LA","capital":"Vientiane","difficulty":"hard","countryName":"Laos","continent":"Asia"},{"countryCode":"LV","capital":"Riga","difficulty":"medium","countryName":"Latvia","continent":"Europe"},{"countryCode":"LB","capital":"Beirut","difficulty":"medium","countryName":"Lebanon","continent":"Asia"},{"countryCode":"LS","capital":"Maseru","difficulty":"medium","countryName":"Lesotho","continent":"Africa"},{"countryCode":"LR","capital":"Monrovia","difficulty":"medium","countryName":"Liberia","continent":"Africa"},{"countryCode":"LY","capital":"Tripoli","difficulty":"hard","countryName":"Libya","continent":"Africa"},{"countryCode":"LI","capital":"Vaduz","difficulty":"medium","countryName":"Liechtenstein","continent":"Europe"},{"countryCode":"LT","capital":"Vilnius","difficulty":"medium","countryName":"Lithuania","continent":"Europe"},{"countryCode":"LU","capital":"Luxembourg City","difficulty":"medium","countryName":"Luxembourg","continent":"Europe"},{"countryCode":"MG","capital":"Antananarivo","difficulty":"medium","countryName":"Madagascar","continent":"Africa"},{"countryCode":"MW","capital":"Lilongwe","difficulty":"medium","countryName":"Malawi","continent":"Africa"},{"countryCode":"MY","capital":"Kuala Lumpur","difficulty":"medium","countryName":"Malaysia","continent":"Asia"},{"countryCode":"MV","capital":"Male","difficulty":"medium","countryName":"Maldives","continent":"Asia"},{"countryCode":"ML","capital":"Bamako","difficulty":"medium","countryName":"Mali","continent":"Africa"},{"countryCode":"MT","capital":"Valletta","difficulty":"medium","countryName":"Malta","continent":"Europe"},{"countryCode":"MH","capital":"Majuro","difficulty":"medium","countryName":"Marshall Islands","continent":"Oceania"},{"countryCode":"MR","capital":"Nouakchott","difficulty":"medium","countryName":"Mauritania","continent":"Africa"},{"countryCode":"MU","capital":"Port Louis","difficulty":"medium","countryName":"Mauritius","continent":"Africa"},{"countryCode":"MX","capital":"Mexico City","difficulty":"medium","countryName":"Mexico","continent":"North America"},{"countryCode":"FM","capital":"Palikir","difficulty":"medium","countryName":"Micronesia","continent":"Oceania"},{"countryCode":"MD","capital":"Chisinau","difficulty":"medium","countryName":"Moldova","continent":"Europe"},{"countryCode":"MC","capital":"Monaco","difficulty":"medium","countryName":"Monaco","continent":"Europe"},{"countryCode":"MN","capital":"Ulaanbaatar","difficulty":"medium","countryName":"Mongolia","continent":"Asia"},{"countryCode":"ME","capital":"Podgorica","difficulty":"medium","countryName":"Montenegro","continent":"Europe"},{"countryCode":"MA","capital":"Rabat","difficulty":"medium","countryName":"Morocco","continent":"Africa"},{"countryCode":"MZ","capital":"Maputo","difficulty":"medium","countryName":"Mozambique","continent":"Africa"},{"countryCode":"MM","capital":"Naypyidaw","difficulty":"medium","countryName":"Myanmar (Burma)","continent":"Asia"},{"countryCode":"NA","capital":"Windhoek","difficulty":"medium","countryName":"Namibia","continent":"Africa"},{"countryCode":"NR","capital":"Yaren","difficulty":"hard","countryName":"Nauru","continent":"Oceania"},{"countryCode":"NP","capital":"Kathmandu","difficulty":"medium","countryName":"Nepal","continent":"Asia"},{"countryCode":"NL","capital":"Amsterdam","difficulty":"medium","countryName":"Netherlands","continent":"Europe"},{"countryCode":"NZ","capital":"Wellington","difficulty":"medium","countryName":"New Zealand","continent":"Oceania"},{"countryCode":"NI","capital":"Managua","difficulty":"medium","countryName":"Nicaragua","continent":"North America"},{"countryCode":"NE","capital":"Niamey","difficulty":"medium","countryName":"Niger","continent":"Africa"},{"countryCode":"NG","capital":"Abuja","difficulty":"medium","countryName":"Nigeria","continent":"Africa"},{"countryCode":"KP","capital":"Pyongyang","difficulty":"hard","countryName":"North Korea","continent":"Asia"},{"countryCode":"MK","capital":"Skopje","difficulty":"medium","countryName":"North Macedonia","continent":"Europe"},{"countryCode":"NO","capital":"Oslo","difficulty":"medium","countryName":"Norway","continent":"Europe"},{"countryCode":"OM","capital":"Muscat","difficulty":"medium","countryName":"Oman","continent":"Asia"},{"countryCode":"PK","capital":"Islamabad","difficulty":"medium","countryName":"Pakistan","continent":"Asia"},{"countryCode":"PW","capital":"Ngerulmud","difficulty":"hard","countryName":"Palau","continent":"Oceania"},{"countryCode":"PA","capital":"Panama City","difficulty":"medium","countryName":"Panama","continent":"North America"},{"countryCode":"PG","capital":"Port Moresby","difficulty":"medium","countryName":"Papua New Guinea","continent":"Oceania"},{"countryCode":"PY","capital":"Asunción","difficulty":"medium","countryName":"Paraguay","continent":"South America"},{"countryCode":"PE","capital":"Lima","difficulty":"medium","countryName":"Peru","continent":"South America"},{"countryCode":"PH","capital":"Manila","difficulty":"medium","countryName":"Philippines","continent":"Asia"},{"countryCode":"PL","capital":"Warsaw","difficulty":"medium","countryName":"Poland","continent":"Europe"},{"countryCode":"PT","capital":"Lisbon","difficulty":"medium","countryName":"Portugal","continent":"Europe"},{"countryCode":"QA","capital":"Doha","difficulty":"medium","countryName":"Qatar","continent":"Asia"},{"countryCode":"RO","capital":"Bucharest","difficulty":"medium","countryName":"Romania","continent":"Europe"},{"countryCode":"RU","capital":"Moscow","difficulty":"hard","countryName":"Russia","continent":"Europe"},{"countryCode":"RW","capital":"Kigali","difficulty":"medium","countryName":"Rwanda","continent":"Africa"},{"countryCode":"KN","capital":"Basseterre","difficulty":"medium","countryName":"Saint Kitts and Nevis","continent":"North America"},{"countryCode":"LC","capital":"Castries","difficulty":"medium","countryName":"Saint Lucia","continent":"North America"},{"countryCode":"VC","capital":"Kingstown","difficulty":"medium","countryName":"Saint Vincent and the Grenadines","continent":"North America"},{"countryCode":"WS","capital":"Apia","difficulty":"medium","countryName":"Samoa","continent":"Oceania"},{"countryCode":"SM","capital":"San Marino","difficulty":"hard","countryName":"San Marino","continent":"Europe"},{"countryCode":"ST","capital":"São Tomé","difficulty":"medium","countryName":"Sao Tome and Principe","continent":"Africa"},{"countryCode":"SA","capital":"Riyadh","difficulty":"medium","countryName":"Saudi Arabia","continent":"Asia"},{"countryCode":"SN","capital":"Dakar","difficulty":"medium","countryName":"Senegal","continent":"Africa"},{"countryCode":"RS","capital":"Belgrade","difficulty":"medium","countryName":"Serbia","continent":"Europe"},{"countryCode":"SC","capital":"Victoria","difficulty":"medium","countryName":"Seychelles","continent":"Africa"},{"countryCode":"SL","capital":"Freetown","difficulty":"medium","countryName":"Sierra Leone","continent":"Africa"},{"countryCode":"SG","capital":"Singapore","difficulty":"medium","countryName":"Singapore","continent":"Asia"},{"countryCode":"SK","capital":"Bratislava","difficulty":"medium","countryName":"Slovakia","continent":"Europe"},{"countryCode":"SI","capital":"Ljubljana","difficulty":"medium","countryName":"Slovenia","continent":"Europe"},{"countryCode":"SB","capital":"Honiara","difficulty":"medium","countryName":"Solomon Islands","continent":"Oceania"},{"countryCode":"SO","capital":"Mogadishu","difficulty":"medium","countryName":"Somalia","continent":"Africa"},{"countryCode":"ZA","capital":"Pretoria","difficulty":"medium","countryName":"South Africa","continent":"Africa"},{"countryCode":"KR","capital":"Seoul","difficulty":"medium","countryName":"South Korea","continent":"Asia"},{"countryCode":"SS","capital":"Juba","difficulty":"medium","countryName":"South Sudan","continent":"Africa"},{"countryCode":"ES","capital":"Madrid","difficulty":"medium","countryName":"Spain","continent":"Europe"},{"countryCode":"LK","capital":"Sri Jayawardenepura Kotte","difficulty":"medium","countryName":"Sri Lanka","continent":"Asia"},{"countryCode":"SD","capital":"Khartoum","difficulty":"medium","countryName":"Sudan","continent":"Africa"},{"countryCode":"SR","capital":"Paramaribo","difficulty":"medium","countryName":"Suriname","continent":"South America"},{"countryCode":"SZ","capital":"Mbabane","difficulty":"medium","countryName":"Eswatini","continent":"Africa"},{"countryCode":"SE","capital":"Stockholm","difficulty":"medium","countryName":"Sweden","continent":"Europe"},{"countryCode":"CH","capital":"Bern","difficulty":"medium","countryName":"Switzerland","continent":"Europe"},{"countryCode":"SY","capital":"Damascus","difficulty":"medium","countryName":"Syria","continent":"Asia"},{"countryCode":"TW","capital":"Taipei","difficulty":"medium","countryName":"Taiwan","continent":"Asia"},{"countryCode":"TJ","capital":"Dushanbe","difficulty":"medium","countryName":"Tajikistan","continent":"Asia"},{"countryCode":"TZ","capital":"Dodoma","difficulty":"medium","countryName":"Tanzania","continent":"Africa"},{"countryCode":"TH","capital":"Bangkok","difficulty":"medium","countryName":"Thailand","continent":"Asia"},{"countryCode":"TL","capital":"Dili","difficulty":"medium","countryName":"Timor-Leste","continent":"Asia"},{"countryCode":"TG","capital":"Lomé","difficulty":"medium","countryName":"Togo","continent":"Africa"},{"countryCode":"TO","capital":"Nuku'alofa","difficulty":"medium","countryName":"Tonga","continent":"Oceania"},{"countryCode":"TT","capital":"Port of Spain","difficulty":"medium","countryName":"Trinidad and Tobago","continent":"North America"},{"countryCode":"TN","capital":"Tunis","difficulty":"medium","countryName":"Tunisia","continent":"Africa"},{"countryCode":"TR","capital":"Ankara","difficulty":"medium","countryName":"Turkey","continent":"Asia"},{"countryCode":"TM","capital":"Ashgabat","difficulty":"medium","countryName":"Turkmenistan","continent":"Asia"},{"countryCode":"TV","capital":"Funafuti","difficulty":"medium","countryName":"Tuvalu","continent":"Oceania"},{"countryCode":"UG","capital":"Kampala","difficulty":"medium","countryName":"Uganda","continent":"Africa"},{"countryCode":"UA","capital":"Kyiv","difficulty":"medium","countryName":"Ukraine","continent":"Europe"},{"countryCode":"AE","capital":"Abu Dhabi","difficulty":"medium","countryName":"United Arab Emirates","continent":"Asia"},{"countryCode":"GB","capital":"London","difficulty":"medium","countryName":"United Kingdom","continent":"Europe"},{"countryCode":"US","capital":"Washington D.C.","difficulty":"medium","countryName":"United States of America","continent":"North America"},{"countryCode":"UY","capital":"Montevideo","difficulty":"medium","countryName":"Uruguay","continent":"South America"},{"countryCode":"UZ","capital":"Tashkent","difficulty":"medium","countryName":"Uzbekistan","continent":"Asia"},{"countryCode":"VU","capital":"Port Vila","difficulty":"medium","countryName":"Vanuatu","continent":"Oceania"},{"countryCode":"VE","capital":"Caracas","difficulty":"medium","countryName":"Venezuela","continent":"South America"},{"countryCode":"VN","capital":"Hanoi","difficulty":"medium","countryName":"Vietnam","continent":"Asia"},{"countryCode":"EH","capital":"El Aaiún","difficulty":"hard","countryName":"Western Sahara","continent":"Africa"},{"countryCode":"YE","capital":"Sana'a","difficulty":"medium","countryName":"Yemen","continent":"Asia"},{"countryCode":"ZM","capital":"Lusaka","difficulty":"medium","countryName":"Zambia","continent":"Africa"},{"countryCode":"ZW","capital":"Harare","difficulty":"medium","countryName":"Zimbabwe","continent":"Africa"}]
maze_quiz_generated_questions
50
default_show_category_connection_info
NONE
daily_challenge_tasks_to_generate
5
daily_challenge_item_reward
1
comparison_quiz_categories
[{"id":"country-population","name":"Country Population","description":"Compare population of each country","image":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/Illustrations%2Fworld_population_illustration.png?alt=media&token=db98d82c-7bbb-41bc-94a8-16771e0699aa","requireInternetConnection":"false","generateQuestionsLocally":"true","questionDescription":{"greater":"Which country has more population?","less":"Which country has less population?"},"formatType":"default","dataSourceAttribution":{"text":"Data from RESTCountries"}},{"id":"country-area","name":"Country Area","description":"Compare the area of each country (km²)","image":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/Illustrations%2Fflags_illustration.png?alt=media&token=ec6b2820-1d26-4352-9c54-201bd387ae94","requireInternetConnection":"false","generateQuestionsLocally":"true","questionDescription":{"greater":"Which country has more area?","less":"Which country has less area?"},"formatType":"distance","helperValueSuffix":"square_kilometer","dataSourceAttribution":{"text":"Data from RESTCountries"}},{"id":"movie-popularity","name":"Movie Popularity","description":"Compare the populatiry of movies.","image":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/Illustrations%2Fmovie_popularity_illustration.png?alt=media&token=9e54b03f-391a-4cd9-9a04-e0a8f29d0b9f","questionDescription":{"greater":"Which movie is more popular?","less":"Which movie is less popular?"},"formatType":"default","dataSourceAttribution":{"text":"Powered by TMDB"}},{"id":"movie-release-date","name":"Movie Release Date","description":"Compare the release date of movies.","image":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/Illustrations%2Fmovie_popularity_illustration.png?alt=media&token=9e54b03f-391a-4cd9-9a04-e0a8f29d0b9f","questionDescription":{"greater":"Which movie is more recent?","less":"Which movie is less recent?"},"formatType":"date","dataSourceAttribution":{"text":"Powered by TMDB"}},{"id":"club-trophies","name":"Club Trophies","description":"Compare trophies of clubs.","image":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/comparison-quiz%2Fclub-trophies.jpg?alt=media&token=532f1a0b-ce22-4fd2-9d02-6ff7e480a08d","questionDescription":{"greater":"Which club has more trophies?","less":"Which club has less trophies?"},"formatType":"default"},{"id":"club-foundation-date","name":"Club Foundation Date","description":"Compare foundation date of clubs.","image":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/comparison-quiz%2Fstadium-field.jpg?alt=media&token=bd9b32b6-0cd3-4a4b-bed5-c3ace9a7734c","questionDescription":{"greater":"Which club is more recent?","less":"Which club is older?"},"formatType":"date"},{"id":"club-stadium-capacity","name":"Stadium Capacity","description":"Compare the capacity of stadiums.","image":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/comparison-quiz%2Fstadium-inside.jpg?alt=media&token=82843706-2fab-4f12-811a-1f556a0c0674","questionDescription":{"greater":"Which stadium has more capacity?","less":"Which stadium has less capacity?"},"formatType":"default"},{"id":"club-stadium-opened-date","name":"Stadium Opening Date","description":"Compare the opening date of stadiums.","image":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/comparison-quiz%2Fstadium-inside2.jpg?alt=media&token=38771c7d-1a4b-47a9-bb89-f49f2fc3ad95","questionDescription":{"greater":"Which stadium opened most recently?","less":"Which stadium opened first?"},"formatType":"date"}]
comparison_quiz_skip_cost
1
comparison_quiz_first_item_helper_value
HIDDEN
user_initial_diamonds
10
new_level_diamonds
10
comparison_quiz_default_xp_reward
5
wordle_default_xp_reward
50
multichoice_quiz_default_xp_reward
{"easy":10,"medium":15,"hard":20}
all_logos_quiz
[{"description":"What is the name of this game?","name":"New Quiz","img_url":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/LogoNewQuiz.png?alt=media&token=239af120-55a6-480d-beea-279da9133e45","incorrect_answers":["New Social","Mega Quiz","My Quiz"],"difficulty":"easy"},{"description":"What is the name of this social app?","name":"Instagram","img_url":"https://play-lh.googleusercontent.com/LM9vBt64KdRxLFRPMpNM6OvnGTGoUFSXYV-w-cGVeUxhgFWkCsfsPSJ5GYh7x9qKqw=s300","incorrect_answers":["Facebook","Whats App","Snap Chat"],"difficulty":"easy"},{"description":"What is the name of this social app?","name":"Discord","img_url":"https://play-lh.googleusercontent.com/0oO5sAneb9lJP6l8c6DH4aj6f85qNpplQVHmPmbbBxAukDnlO7DarDW0b-kEIHa8SQ=s300","incorrect_answers":["Facebook","Whats App","Instagram"],"difficulty":"easy"},{"description":"What is the name of this social app?","name":"Facebook","img_url":"https://play-lh.googleusercontent.com/ccWDU4A7fX1R24v-vvT480ySh26AYp97g1VrIB_FIdjRcuQB2JP2WdY7h_wVVAeSpg=s300","incorrect_answers":["Instagram","Whats App","Face Time"],"difficulty":"easy"},{"description":"What is the name of this company?","name":"Google","img_url":"https://kgo.googleusercontent.com/profile_vrt_raw_bytes_1587515358_10512.png","incorrect_answers":["Meta","Apple","Microsoft"],"difficulty":"easy"},{"description":"What is the name of this company?","name":"Starbucks","img_url":"https://static.dicionariodesimbolos.com.br/upload/d1/42/logo-da-starbucks-significado-historia-e-evolucao-4_xl.jpeg","incorrect_answers":["Starcoffee","McDonald's","Startasks"],"difficulty":"easy"},{"description":"What is the name of this fast food company?","name":"McDonald's","img_url":"https://upload.wikimedia.org/wikipedia/commons/thumb/3/36/McDonald%27s_Golden_Arches.svg/1200px-McDonald%27s_Golden_Arches.svg.png","incorrect_answers":["Burger King","MyDonald's","McBurger"],"difficulty":"easy"},{"description":"What is the name of this company?","name":"IKEA","img_url":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/LogoQuiz%2Fikea_clear.png?alt=media&token=74728d10-7e33-4e8c-8ab9-c79a6abbad8a","incorrect_answers":["IKEO","IREA","IKER"],"difficulty":"medium"},{"description":"What is the name of this company?","name":"LEGO","img_url":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/LogoQuiz%2Flego_logo.png?alt=media&token=d255065b-355b-4e66-9a06-3eaf691e3589","incorrect_answers":["FEGA","LEGA","LEMA"],"difficulty":"medium"},{"description":"What is the name of this social app?","name":"Tiktok","img_url":"https://www.edigitalagency.com.au/wp-content/uploads/TikTok-icon-glyph.png","incorrect_answers":["Toktik","Tiktik","Toktok"],"difficulty":"easy"},{"description":"What is the name of this company?","name":"Netflix","img_url":"https://1000logos.net/wp-content/uploads/2017/05/Netflix-Logo-2006.png","incorrect_answers":["Myflix","Newflix","Netflis"],"difficulty":"easy"},{"description":"What is the name of this payments company?","name":"Paypal","img_url":"https://www.paypalobjects.com/webstatic/icon/pp258.png","incorrect_answers":["Palpay","Payme","Wepay"],"difficulty":"easy"},{"description":"What is the name of this car company?","name":"Tesla","img_url":"https://upload.wikimedia.org/wikipedia/commons/thumb/b/bb/Tesla_T_symbol.svg/800px-Tesla_T_symbol.svg.png","incorrect_answers":["Tata Motors","Volvo","Mercedes"],"difficulty":"medium"},{"description":"What is the name of this financial services company?","name":"Visa","img_url":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/LogoQuiz%2FVISA-logo.png?alt=media&token=557f36f2-2f46-4a33-bc0d-df44974611f1","incorrect_answers":["Vita","Vila","Vis"],"difficulty":"easy"},{"description":"What is the name of this car company?","name":"Ferrari","img_url":"https://www.escolacasa.com/wp-content/uploads/2021/03/Emblema_Ferrari.jpg","incorrect_answers":["Audi","Bugatti","Mercedes"],"difficulty":"medium"},{"description":"What is the name of this company?","name":"Amazon","img_url":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/LogoQuiz%2Famazon.webp?alt=media&token=24912534-9aff-4967-b685-cab95c33f1be","incorrect_answers":["Alazon","Amazob","Abazos"],"difficulty":"easy"},{"description":"What is the name of this company?","name":"Nike","img_url":"https://lh3.googleusercontent.com/FK8EcHV1SJGHeTUJCsUhCQl0hmQu-QbC4wG6bM59S0v-rLv-jQl16YC3LQ4x-ZpPwS1cUs_4Idap57kYgcTCOQFB","incorrect_answers":["Mike","Mile","Nile"],"difficulty":"easy"},{"description":"What is the name of this company?","name":"Adidas","img_url":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/LogoQuiz%2Fadidas.png?alt=media&token=6ab85092-f19f-43d7-b56b-49add8e74969","incorrect_answers":["Abibas","Ardidas","Amigas"],"difficulty":"easy"},{"description":"What is the name of this company?","name":"Dell","img_url":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/LogoQuiz%2Fdell.png?alt=media&token=e080737a-446c-42a8-8b7a-5c65cb0b0453","incorrect_answers":["Bell","Sell","Dele"],"difficulty":"hard"},{"description":"What is the name of this company?","name":"Levis","img_url":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/LogoQuiz%2Flevis.png?alt=media&token=6c878b7b-9096-47f2-aba9-1fe19896b4dd","incorrect_answers":["Legis","Lebis","Lives"],"difficulty":"hard"},{"description":"What is the name of this company?","name":"Coca-Cola","img_url":"https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/LogoQuiz%2FCoca-Cola-Logo.png?alt=media&token=1fdec8d8-71c8-4fbb-b548-6a3916959040","incorrect_answers":["Cola-Coca","Cola-Cola","Coca-Coca"],"difficulty":"hard"}]
multichoice_quickquiz_difficulty
random
multichoice_skip_cost
1
math_quiz_operator_size
1
math_quiz_difficulty
easy
================================================
FILE: core/remote-config/src/normal/kotlin/com/infinitepower/newquiz/core/remote_config/FirebaseRemoteConfigImpl.kt
================================================
package com.infinitepower.newquiz.core.remote_config
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.remoteConfigSettings
class FirebaseRemoteConfigImpl(
private val firebaseRemoteConfig: FirebaseRemoteConfig
) : RemoteConfig {
override fun initialize(fetchInterval: Long) {
val remoteConfigSettings = remoteConfigSettings {
minimumFetchIntervalInSeconds = fetchInterval
}
firebaseRemoteConfig.setConfigSettingsAsync(remoteConfigSettings)
firebaseRemoteConfig.setDefaultsAsync(R.xml.remote_config_defaults)
firebaseRemoteConfig.fetchAndActivate()
}
override fun getString(key: String): String = firebaseRemoteConfig.getString(key)
override fun getLong(key: String): Long = firebaseRemoteConfig.getLong(key)
override fun getBoolean(key: String): Boolean = firebaseRemoteConfig.getBoolean(key)
}
================================================
FILE: core/remote-config/src/normal/kotlin/com/infinitepower/newquiz/core/remote_config/initializer/RemoteConfigInitializer.kt
================================================
package com.infinitepower.newquiz.core.remote_config.initializer
import android.content.Context
import android.util.Log
import androidx.startup.Initializer
import com.google.firebase.Firebase
import com.google.firebase.app
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.infinitepower.newquiz.core.initializer.CoreFirebaseInitializer
import com.infinitepower.newquiz.core.remote_config.FirebaseRemoteConfigImpl
import com.infinitepower.newquiz.core.remote_config.RemoteConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class RemoteConfigInitializer : Initializer {
private companion object {
private const val TAG = "RemoteConfigInitializer"
}
@Provides
@Singleton
override fun create(@ApplicationContext context: Context): RemoteConfig {
val firebaseApp = Firebase.app
val firebaseRemoteConfig = FirebaseRemoteConfig.getInstance(firebaseApp)
Log.d(TAG, "Initializing Firebase Remote Config...")
val remoteConfig = FirebaseRemoteConfigImpl(firebaseRemoteConfig = firebaseRemoteConfig)
remoteConfig.initialize()
Log.d(TAG, "Firebase Remote Config initialized successfully")
return remoteConfig
}
override fun dependencies(): List>> {
return listOf(CoreFirebaseInitializer::class.java)
}
}
================================================
FILE: core/remote-config/src/test/kotlin/com/infinitepower/newquiz/core/remote_config/LocalRemoteConfig.kt
================================================
package com.infinitepower.newquiz.core.remote_config
/**
* A [RemoteConfig] implementation that uses a map of remote config values.
* Only used for testing.
*/
internal class LocalRemoteConfig(
private val remoteConfigValues: Map
) : RemoteConfig {
override fun initialize(fetchInterval: Long) {
// Do nothing
}
override fun getString(key: String): String {
return remoteConfigValues[key] ?: throw NoValueForRemoteConfigKeyException(key)
}
override fun getLong(key: String): Long {
return remoteConfigValues[key]?.toLong() ?: throw NoValueForRemoteConfigKeyException(key)
}
override fun getBoolean(key: String): Boolean {
return remoteConfigValues[key]?.toBoolean() ?: throw NoValueForRemoteConfigKeyException(key)
}
}
================================================
FILE: core/remote-config/src/test/kotlin/com/infinitepower/newquiz/core/remote_config/RemoteConfigTest.kt
================================================
package com.infinitepower.newquiz.core.remote_config
import com.google.common.truth.Truth.assertThat
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.assertThrows
import kotlin.test.BeforeTest
import kotlin.test.Test
internal class RemoteConfigTest {
private lateinit var remoteConfig: RemoteConfig
private val testValue1 = RemoteConfigValue("test_value_1")
@Serializable
private data class TestDataClass(
val value1: Int,
val value2: String
)
private val testData2 = TestDataClass(1, "test")
private val testValue2 = RemoteConfigValue("test_value_2")
private enum class TestEnum { A, B, C }
private val testData3 = TestEnum.A
private val testValue3 = RemoteConfigValue("test_value_3")
@Serializable
private enum class TestEnum2 {
@SerialName("a") A,
@SerialName("b") B,
@SerialName("c") C
}
private val testData4 = TestEnum2.A
private val testValue4 = RemoteConfigValue("test_value_4")
private data class TestDataClass2(val name: String)
private val testValue5 = RemoteConfigValue("test_value_5")
@BeforeTest
fun setUp() {
println(Json.encodeToString(testData4))
remoteConfig = LocalRemoteConfig(
remoteConfigValues = mapOf(
testValue1.key to "1",
testValue2.key to Json.encodeToString(testData2),
testValue3.key to testData3.name,
testValue4.key to Json.encodeToString(testData4),
testValue5.key to """{ "name": "test" }"""
)
)
remoteConfig.initialize()
}
@Test
fun `test get value`() {
// Test for Int primitive type
val value = remoteConfig.get(testValue1)
assertThat(value).isEqualTo(1)
// Test for custom class with serialization
val value2 = remoteConfig.get(testValue2)
assertThat(value2).isEqualTo(testData2)
// When the enum class does not have serialization, it should be decoded using reflection
val value3 = remoteConfig.get(testValue3)
assertThat(value3).isEqualTo(testData3)
// When enum class has serialization, it should be decoded using the deserialization method
val value4 = remoteConfig.get(testValue4)
assertThat(value4).isEqualTo(testData4)
// When the class is not supported, an exception should be thrown
val exception = assertThrows {
remoteConfig.get(testValue5)
}
assertThat(exception.message).isEqualTo("Unsupported type ${TestDataClass2::class}")
}
}
================================================
FILE: core/src/androidTest/AndroidManifest.xml
================================================
================================================
FILE: core/src/androidTest/java/com/infinitepower/newquiz/core/ui/components/RemainingTimeComponentTest.kt
================================================
package com.infinitepower.newquiz.core.ui.components
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertRangeInfoEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.infinitepower.newquiz.model.RemainingTime
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Duration.Companion.seconds
@SmallTest
@RunWith(AndroidJUnit4::class)
class RemainingTimeComponentTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testInitialState() {
val maxTime = 30.seconds
val remainingTime = RemainingTime(30.seconds)
// Render the RemainingTimeComponent
composeTestRule.setContent {
RemainingTimeComponent(
remainingTime = remainingTime,
maxTime = maxTime
)
}
// Assert that the progress indicator is shown
composeTestRule
.onNodeWithTag(RemainingTimeComponentTestTags.PROGRESS_INDICATOR)
.assertIsDisplayed()
.assertRangeInfoEquals(
ProgressBarRangeInfo(
current = 1f,
range = 0f..1f
)
)
// Assert that the remaining time text is displayed without animation
composeTestRule
.onNodeWithText("30")
.assertIsDisplayed()
}
@Test
fun testWarningState() {
val maxTime = 30.seconds
val remainingTime = RemainingTime(5.seconds)
// Render the RemainingTimeComponent
composeTestRule.setContent {
RemainingTimeComponent(
remainingTime = remainingTime,
maxTime = maxTime
)
}
// Assert that the progress indicator is shown
composeTestRule
.onNodeWithTag(RemainingTimeComponentTestTags.PROGRESS_INDICATOR)
.assertIsDisplayed()
.assertRangeInfoEquals(
ProgressBarRangeInfo(
current = remainingTime.getRemainingPercent(maxTime).toFloat(),
range = 0f..1f
)
)
// Assert that the remaining time text is displayed without animation
composeTestRule
.onNodeWithText("5")
.assertIsDisplayed()
}
}
================================================
FILE: core/src/androidTest/java/com/infinitepower/newquiz/core/ui/components/category/CategoryComponentTest.kt
================================================
package com.infinitepower.newquiz.core.ui.components.category
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.infinitepower.newquiz.core.testing.utils.setTestContent
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@SmallTest
@RunWith(AndroidJUnit4::class)
internal class CategoryComponentTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun categoryComponent_titleAndImageDisplayed() {
val title = "Test Title"
val imageUrl = "https://testimage.com/image.jpg"
composeTestRule.setTestContent {
CategoryComponent(
modifier = Modifier.testTag("CategoryComponent"),
title = title,
imageUrl = imageUrl,
enabled = true
)
}
composeTestRule
.onNodeWithTag("CategoryComponent")
.assertIsDisplayed()
.assertIsEnabled()
.assertHasClickAction()
composeTestRule
.onNodeWithContentDescription("Image category of $title")
.assertIsDisplayed()
composeTestRule
.onNodeWithText(title)
.assertIsDisplayed()
}
@Test
fun categoryComponent_onClick() {
var clicked = false
val title = "Test Title"
val imageUrl = "https://testimage.com/image.jpg"
composeTestRule.setTestContent {
CategoryComponent(
modifier = Modifier.testTag("CategoryComponent"),
title = title,
imageUrl = imageUrl,
enabled = true,
onClick = { clicked = true }
)
}
composeTestRule
.onNodeWithTag("CategoryComponent")
.assertHasClickAction()
.performClick()
assert(clicked)
}
@Test
fun categoryComponent_disabled() {
val title = "Test Title"
val imageUrl = "https://testimage.com/image.jpg"
composeTestRule.setTestContent {
CategoryComponent(
modifier = Modifier.testTag("CategoryComponent"),
title = title,
imageUrl = imageUrl,
enabled = false
)
}
composeTestRule
.onNodeWithTag("CategoryComponent")
.assertIsDisplayed()
.assertIsNotEnabled()
}
}
================================================
FILE: core/src/androidTest/java/com/infinitepower/newquiz/core/ui/components/category/CategoryConnectionInfoBadgeTest.kt
================================================
package com.infinitepower.newquiz.core.ui.components.category
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.infinitepower.newquiz.core.testing.utils.setTestContent
import org.junit.Rule
import org.junit.runner.RunWith
import kotlin.test.Test
/**
* Tests for [CategoryConnectionInfoBadge] component.
*/
@SmallTest
@RunWith(AndroidJUnit4::class)
internal class CategoryConnectionInfoBadgeTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun test_requireConnection_and_noShowTextByDefault() {
composeTestRule.setTestContent {
CategoryConnectionInfoBadge(
modifier = Modifier.testTag(COMPONENT_TAG),
requireConnection = true,
showTextByDefault = false
)
}
composeTestRule
.onNodeWithText("Requires internet connection")
.assertDoesNotExist()
composeTestRule
.onNodeWithContentDescription("Requires internet connection")
.assertIsDisplayed()
.performClick() // Expand the badge to show the text
composeTestRule
.onNodeWithText("Requires internet connection")
.assertIsDisplayed()
.performClick() // Collapse the badge to hide the text
.assertDoesNotExist()
composeTestRule
.onNodeWithContentDescription("Requires internet connection")
.assertIsDisplayed()
}
@Test
fun test_requireConnection_and_showTextByDefault() {
composeTestRule.setTestContent {
CategoryConnectionInfoBadge(
modifier = Modifier.testTag(COMPONENT_TAG),
requireConnection = true,
showTextByDefault = true
)
}
composeTestRule
.onNodeWithContentDescription("Requires internet connection")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Requires internet connection")
.assertIsDisplayed()
.performClick() // Collapse the badge to hide the text
.assertDoesNotExist()
composeTestRule
.onNodeWithContentDescription("Requires internet connection")
.assertIsDisplayed()
}
companion object {
private const val COMPONENT_TAG = "offline_category_badge"
}
}
================================================
FILE: core/src/androidTest/java/com/infinitepower/newquiz/core/ui/components/icon/button/BackIconButtonTest.kt
================================================
package com.infinitepower.newquiz.core.ui.components.icon.button
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.infinitepower.newquiz.core.testing.clearExistingImages
import com.infinitepower.newquiz.core.testing.utils.setTestContent
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@SmallTest
@RunWith(AndroidJUnit4::class)
internal class BackIconButtonTest {
@get:Rule
val composeTestRule = createComposeRule()
companion object {
private const val BUTTON_GOLDEN_NAME = "back_button"
@JvmStatic
@BeforeClass
fun clearExistingImagesBeforeStart() {
clearExistingImages(BUTTON_GOLDEN_NAME)
}
}
@Test
fun backIconButton_shouldRenderCorrectly() {
var clicked = false
composeTestRule.setTestContent {
BackIconButton(
onClick = { clicked = true },
modifier = Modifier.testTag("BackButton")
)
}
// Assert that an IconButton and an Icon are rendered with the correct attributes
composeTestRule
.onNodeWithTag("BackButton")
.assertExists()
.assertIsDisplayed()
.assertHasClickAction()
.performClick()
assert(clicked)
}
/*
@Test
fun test_backIconButton_screenshot() {
composeTestRule.setContent {
Surface {
BackIconButton(
onClick = {},
modifier = Modifier.testTag("BackButton")
)
}
}
composeTestRule
.onNodeWithTag("BackButton")
.assertMatchesGolden(BUTTON_GOLDEN_NAME, "buttons")
}
*/
}
================================================
FILE: core/src/androidTest/java/com/infinitepower/newquiz/core/ui/components/skip_question/SkipQuestionDialogTest.kt
================================================
package com.infinitepower.newquiz.core.ui.components.skip_question
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.google.common.truth.Truth.assertThat
import com.infinitepower.newquiz.core.testing.utils.setTestContent
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Tests for [SkipQuestionDialog].
*/
@SmallTest
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalMaterial3Api::class)
internal class SkipQuestionDialogTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun skipQuestionDialog_CanSkip_ShowAlertDialog() {
val userDiamonds = 10
val skipCost = 5
var skipClickCalled = false
var dismissClickCalled = false
composeTestRule.setTestContent {
SkipQuestionDialog(
userDiamonds = userDiamonds,
skipCost = skipCost,
onSkipClick = { skipClickCalled = true },
onDismissClick = { dismissClickCalled = true }
)
}
assertThat(skipClickCalled).isFalse()
assertThat(dismissClickCalled).isFalse()
composeTestRule.onNodeWithText("Skip question?").assertIsDisplayed()
composeTestRule.onNodeWithText("You have $userDiamonds diamonds, do you want to use $skipCost diamonds to skip this question?").assertIsDisplayed()
composeTestRule.onNodeWithText("Close").assertIsDisplayed()
composeTestRule
.onNodeWithText("Skip")
.assertIsDisplayed()
.performClick()
assertThat(skipClickCalled).isTrue()
assertThat(dismissClickCalled).isTrue()
skipClickCalled = false
dismissClickCalled = false
composeTestRule.onNodeWithText("Close").performClick()
assertThat(dismissClickCalled).isTrue()
assertThat(skipClickCalled).isFalse()
}
@Test
fun skipQuestionDialog_CannotSkip_ShowNoDiamondsDialog() {
val userDiamonds = 3
val skipCost = 5
var skipClickCalled = false
var dismissClickCalled = false
composeTestRule.setTestContent {
SkipQuestionDialog(
userDiamonds = userDiamonds,
skipCost = skipCost,
onSkipClick = { skipClickCalled = true },
onDismissClick = { dismissClickCalled = true }
)
}
composeTestRule.onNodeWithText("Skip question?").assertDoesNotExist()
composeTestRule.onNodeWithText("No diamonds").assertIsDisplayed()
composeTestRule.onNodeWithText("You don't have diamonds to skip this question!").assertIsDisplayed()
composeTestRule.onNodeWithText("Skip").assertDoesNotExist()
composeTestRule
.onNodeWithText("Close")
.assertIsDisplayed()
.performClick()
assertThat(dismissClickCalled).isTrue()
assertThat(skipClickCalled).isFalse()
}
}
================================================
FILE: core/src/main/AndroidManifest.xml
================================================
================================================
FILE: core/src/main/java/com/infinitepower/newquiz/core/NumberFormatter.kt
================================================
package com.infinitepower.newquiz.core
import android.icu.util.LocaleData
import android.icu.util.ULocale
import android.os.Build
import androidx.core.text.util.LocalePreferences
import com.infinitepower.newquiz.model.NumberFormatType
import com.infinitepower.newquiz.model.regional_preferences.DistanceUnitType
import com.infinitepower.newquiz.model.regional_preferences.RegionalPreferences
import com.infinitepower.newquiz.model.regional_preferences.TemperatureUnit
import java.math.RoundingMode
import java.text.DateFormat
import java.text.NumberFormat
import java.util.Locale
import kotlin.math.pow
sealed class NumberFormatter(
val formatType: NumberFormatType
) {
abstract fun formatValueToString(
value: Number,
helperValueSuffix: String? = null,
regionalPreferences: RegionalPreferences = RegionalPreferences(),
): String
companion object {
fun from(formatType: NumberFormatType): NumberFormatter {
return when (formatType) {
NumberFormatType.DEFAULT -> Default
NumberFormatType.DATE -> Date
NumberFormatType.TIME -> Time
NumberFormatType.DATETIME -> DateTime
NumberFormatType.PERCENTAGE -> Percentage
NumberFormatType.TEMPERATURE -> Temperature
NumberFormatType.DISTANCE -> Distance
}
}
/**
* Formats the [value] to a string with the [helperValueSuffix] if it's not null.
*/
private fun valueWithSuffix(
value: String,
helperValueSuffix: String? = null
): String = if (helperValueSuffix != null) "$value $helperValueSuffix" else value
private fun formatNumberToString(
value: Number,
helperValueSuffix: String?,
locale: Locale
): String {
val numberFormat = NumberFormat.getNumberInstance(locale)
val numberFormatted = numberFormat.format(value)
return valueWithSuffix(numberFormatted, helperValueSuffix)
}
}
override fun toString(): String = formatType.name.lowercase()
/**
* The format type of the quiz is a number.
*/
object Default : NumberFormatter(formatType = NumberFormatType.DEFAULT) {
/**
* Formats the double (number) [value] to a string with the [helperValueSuffix] if it's not null.
*/
override fun formatValueToString(
value: Number,
helperValueSuffix: String?,
regionalPreferences: RegionalPreferences
): String = formatNumberToString(
value = value,
helperValueSuffix = helperValueSuffix,
locale = regionalPreferences.locale
)
}
object Date : NumberFormatter(formatType = NumberFormatType.DATE) {
/**
* Formats the [value] to a string with the [helperValueSuffix] if it's not null.
* The [value] is a timestamp in milliseconds.
*/
override fun formatValueToString(
value: Number,
helperValueSuffix: String?,
regionalPreferences: RegionalPreferences
): String {
val dateFormat =
DateFormat.getDateInstance(DateFormat.MEDIUM, regionalPreferences.locale)
val dateFormatted = dateFormat.format(value.toLong())
return valueWithSuffix(dateFormatted, helperValueSuffix)
}
}
object Time : NumberFormatter(formatType = NumberFormatType.TIME) {
/**
* Formats the [value] to a string with the [helperValueSuffix] if it's not null.
* The [value] is a timestamp in milliseconds.
*/
override fun formatValueToString(
value: Number,
helperValueSuffix: String?,
regionalPreferences: RegionalPreferences
): String {
val timeFormat =
DateFormat.getTimeInstance(DateFormat.SHORT, regionalPreferences.locale)
val timeFormatted = timeFormat.format(value.toLong())
return valueWithSuffix(timeFormatted, helperValueSuffix)
}
}
object DateTime : NumberFormatter(formatType = NumberFormatType.DATETIME) {
/**
* Formats the [value] to a string with the [helperValueSuffix] if it's not null.
* The [value] is a timestamp in milliseconds.
*/
override fun formatValueToString(
value: Number,
helperValueSuffix: String?,
regionalPreferences: RegionalPreferences
): String {
val dateTimeFormat =
DateFormat.getDateTimeInstance(
DateFormat.MEDIUM,
DateFormat.SHORT,
regionalPreferences.locale
)
val dateTimeFormatted = dateTimeFormat.format(value.toLong())
return valueWithSuffix(dateTimeFormatted, helperValueSuffix)
}
}
object Percentage : NumberFormatter(
formatType = NumberFormatType.PERCENTAGE
) {
/**
* Formats the [value] to a string with the [helperValueSuffix] if it's not null.
* The [value] is a percentage in double.
*/
override fun formatValueToString(
value: Number,
helperValueSuffix: String?,
regionalPreferences: RegionalPreferences
): String {
val numberFormat = NumberFormat.getPercentInstance(regionalPreferences.locale)
val percentageFormatted = numberFormat.format(value.toDouble())
return valueWithSuffix(percentageFormatted, helperValueSuffix)
}
}
object Temperature : NumberFormatter(
formatType = NumberFormatType.TEMPERATURE
) {
/**
* Formats the [value] to a string, the [helperValueSuffix] is the [LocalePreferences.TemperatureUnit] from the [regionalPreferences].
*
* @param value The value to format.
* @param helperValueSuffix The [LocalePreferences.TemperatureUnit] key.
*/
override fun formatValueToString(
value: Number,
helperValueSuffix: String?,
regionalPreferences: RegionalPreferences,
): String {
checkNotNull(helperValueSuffix) { "The helperValueSuffix cannot be null for the temperature format type." }
val valueTemperatureUnit = TemperatureUnit.fromKey(helperValueSuffix)
// If the user has configured a temperature unit, use it instead of the locale
val convertTemperatureUnitKey = regionalPreferences.temperatureUnit?.key
?: LocalePreferences.getTemperatureUnit(regionalPreferences.locale)
// If the value temperature unit is the same as the convert temperature unit, it's not necessary to convert
// the value, just return the value with the suffix
if (valueTemperatureUnit.key == convertTemperatureUnitKey) {
return valueWithSuffix(value.toString(), valueTemperatureUnit.value)
}
val convertTemperatureUnit = regionalPreferences.temperatureUnit
?: TemperatureUnit.fromKey(convertTemperatureUnitKey)
val convertedValue = valueTemperatureUnit.convert(
to = convertTemperatureUnit,
value = value.toDouble()
)
if (convertedValue.isNaN()) {
return valueWithSuffix("NaN", convertTemperatureUnit.value)
}
val valueStr = convertedValue.toBigDecimal()
.setScale(2, RoundingMode.HALF_EVEN)
.stripTrailingZeros()
.toPlainString()
return valueWithSuffix(valueStr, convertTemperatureUnit.value)
}
}
object Distance : NumberFormatter(formatType = NumberFormatType.DISTANCE) {
enum class DistanceUnit(
val key: String,
val value: String,
val type: DistanceUnitType,
) {
METER(
key = "meter",
value = "m",
type = DistanceUnitType.METRIC
),
KILOMETER(
key = "kilometer",
value = "km",
type = DistanceUnitType.METRIC
),
SQUARE_KILOMETER(
key = "square_kilometer",
value = "km²",
type = DistanceUnitType.METRIC
),
FOOT(
key = "foot",
value = "ft",
type = DistanceUnitType.IMPERIAL
),
MILE(
key = "mile",
value = "mi",
type = DistanceUnitType.IMPERIAL
),
SQUARE_MILE(
key = "square_mile",
value = "mi²",
type = DistanceUnitType.IMPERIAL
);
companion object {
fun fromKey(key: String): DistanceUnit = entries
.firstOrNull { it.key == key || it.value == key }
?: throw IllegalArgumentException("Unknown distance unit: $key")
private const val FOOT_TO_METER_MULTIPLIER = 0.3048
private const val MILE_TO_KILOMETER_MULTIPLIER = 1.609344
private const val METER_TO_FOOT_MULTIPLIER = 3.2808399
private const val KILOMETER_TO_MILE_MULTIPLIER = 0.621371192
fun convert(
value: Double,
from: DistanceUnit,
to: DistanceUnitType
): Pair {
if (from.type == to) return Pair(value, from)
return when (to) {
// Convert to metric
DistanceUnitType.METRIC -> {
when (from) {
FOOT -> Pair(value * FOOT_TO_METER_MULTIPLIER, METER)
MILE -> Pair(value * MILE_TO_KILOMETER_MULTIPLIER, KILOMETER)
SQUARE_MILE -> Pair(
value * MILE_TO_KILOMETER_MULTIPLIER.pow(2),
SQUARE_KILOMETER
)
METER, KILOMETER, SQUARE_KILOMETER -> Pair(
value,
from
) // Already metric
}
}
// Convert to imperial
DistanceUnitType.IMPERIAL -> {
when (from) {
METER -> Pair(value * METER_TO_FOOT_MULTIPLIER, FOOT)
KILOMETER -> Pair(value * KILOMETER_TO_MILE_MULTIPLIER, MILE)
SQUARE_KILOMETER -> Pair(
value * KILOMETER_TO_MILE_MULTIPLIER.pow(2),
SQUARE_MILE
)
FOOT, MILE, SQUARE_MILE -> Pair(value, from) // Already imperial
}
}
}
}
}
override fun toString(): String = key
}
private fun Locale.getDistanceUnitType(): DistanceUnitType {
return if (isMetric()) {
DistanceUnitType.METRIC
} else {
DistanceUnitType.IMPERIAL
}
}
private fun Locale.isMetric(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val uLocale = ULocale.forLocale(this)
val measurementSystem = LocaleData.getMeasurementSystem(uLocale)
measurementSystem == LocaleData.MeasurementSystem.SI
} else {
isMetricLower()
}
}
private fun Locale.isMetricLower(): Boolean {
return when (country.uppercase(this)) {
"US", "LR", "MM" -> false
else -> true
}
}
override fun formatValueToString(
value: Number,
helperValueSuffix: String?,
regionalPreferences: RegionalPreferences
): String {
checkNotNull(helperValueSuffix) { "The helperValueSuffix cannot be null for the distance format type." }
val valueDistanceUnit = DistanceUnit.fromKey(helperValueSuffix)
// If the user has configured a distance unit, use it instead of the locale
val convertDistanceUnitType =
regionalPreferences.distanceUnitType
?: regionalPreferences.locale.getDistanceUnitType()
// If the value distance unit type is the same as the user distance unit type, return the value with the suffix
if (valueDistanceUnit.type == convertDistanceUnitType) {
return formatNumberToString(
value = value,
helperValueSuffix = valueDistanceUnit.value,
locale = regionalPreferences.locale
)
}
val (convertedValue, convertedUnit) = DistanceUnit.convert(
from = valueDistanceUnit,
to = convertDistanceUnitType,
value = value.toDouble()
)
if (convertedValue.isNaN()) {
return valueWithSuffix("NaN", convertedUnit.value)
}
val valueRounded = convertedValue.toBigDecimal()
.setScale(2, RoundingMode.HALF_EVEN)
.stripTrailingZeros()
.toDouble()
return formatNumberToString(
// Remove the decimals from the converted value
value = valueRounded,
helperValueSuffix = convertedUnit.value,
locale = regionalPreferences.locale
)
}
}
}
================================================
FILE: core/src/main/java/com/infinitepower/newquiz/core/common/BaseApiUrls.kt
================================================
package com.infinitepower.newquiz.core.common
object BaseApiUrls {
const val NEWQUIZ = "https://newquiz-app.vercel.app"
}
================================================
FILE: core/src/main/java/com/infinitepower/newquiz/core/common/Common.kt
================================================
package com.infinitepower.newquiz.core.common
const val DEFAULT_USER_PHOTO = "https://firebasestorage.googleapis.com/v0/b/newsocial-app.appspot.com/o/Default%2Fuser_default_image.png?alt=media&token=69389847-7158-42aa-aab5-fc6a5499d3c7"
================================================
FILE: core/src/main/java/com/infinitepower/newquiz/core/common/compose/preview/BooleanPreviewParameterProvider.kt
================================================
package com.infinitepower.newquiz.core.common.compose.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class BooleanPreviewParameterProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(true, false)
}
================================================
FILE: core/src/main/java/com/infinitepower/newquiz/core/common/database/DatabaseCommon.kt
================================================
package com.infinitepower.newquiz.core.common.database
object DatabaseCommon {
object UserCollection {
const val NAME = "users"
const val FIELD_DIAMONDS = "data.diamonds"
const val FIELD_TOTAL_XP = "data.totalXp"
const val FIELD_LAST_QUIZ_TIMES = "data.multiChoiceQuizData.lastQuizTimes"
const val FIELD_TOTAL_QUESTIONS_PLAYED = "data.multiChoiceQuizData.totalQuestionsPlayed"
const val FIELD_CORRECT_ANSWERS = "data.multiChoiceQuizData.totalCorrectAnswers"
const val FIELD_WORDLE_WORDS_PLAYED = "data.wordleData.wordsPlayed"
const val FIELD_WORDLE_WORDS_CORRECT = "data.wordleData.wordsCorrect"
}
}
================================================
FILE: core/src/main/java/com/infinitepower/newquiz/core/compose/preferences/LocalPreferenceEnabledStatus.kt
================================================
package com.infinitepower.newquiz.core.compose.preferences
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.structuralEqualityPolicy
val LocalPreferenceEnabledStatus: ProvidableCompositionLocal =
compositionLocalOf(structuralEqualityPolicy()) { true }
================================================
FILE: core/src/main/java/com/infinitepower/newquiz/core/di/KtorModule.kt
================================================
package com.infinitepower.newquiz.core.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object KtorModule {
@Provides
@Singleton
fun providerKtorClient(): HttpClient = HttpClient(OkHttp)
}
================================================
FILE: core/src/main/java/com/infinitepower/newquiz/core/di/NetworkStatusModule.kt
================================================
package com.infinitepower.newquiz.core.di
import com.infinitepower.newquiz.core.network.NetworkStatusTracker
import com.infinitepower.newquiz.core.network.NetworkStatusTrackerImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
abstract class NetworkStatusModule {
@Binds
abstract fun bindNetworkStatusTracker(
networkStatusTrackerImpl: NetworkStatusTrackerImpl
): NetworkStatusTracker
}
================================================
FILE: core/src/main/java/com/infinitepower/newquiz/core/game/ComparisonQuizCore.kt
================================================
package com.infinitepower.newquiz.core.game
import androidx.annotation.Keep
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizHelperValueState
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItem
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItemEntity
import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizQuestion
/**
* Represents the core functionality of a comparison quiz.
*/
interface ComparisonQuizCore :
GameCore, SkipGame {
/**
* Handles the click event on an answer in the comparison quiz.
*
* @param answer The selected answer.
*/
fun onAnswerClicked(answer: ComparisonQuizItem)
/**
* Represents the data structure for a comparison quiz used in the [ComparisonQuizCore].
*
* @property questions The list of comparison quiz items.
* @property questionDescription The description of the quiz question.
* @property currentQuestion The current question in the quiz.
* @property comparisonMode The comparison mode for the quiz.
* @property currentPosition The current position in the quiz.
* @property isGameOver A flag indicating if the game is over.
* @property firstItemHelperValueState The state of the first item helper value.
* @property skippedAnswers The number of skipped answers.
*/
@Keep
data class QuizData(
val category: ComparisonQuizCategory? = null,
val questions: List = emptyList(),
val questionDescription: String? = null,
val currentQuestion: ComparisonQuizQuestion? = null,
val comparisonMode: ComparisonMode = ComparisonMode.GREATER,
val currentPosition: Int = 0,
val isGameOver: Boolean = false,
val isLastQuestionCorrect: Boolean = false,
val firstItemHelperValueState: ComparisonQuizHelperValueState = ComparisonQuizHelperValueState.HIDDEN,
val skippedAnswers: Int = 0
) {
/**
* Returns the next question and updates the current question.
*
* If the current question is null, it means it is a new game and the function will
* retrieve the first two questions from the questions list and remove them from the list.
*
* If the current question is not null, it means it is not a new game and the function will
* retrieve the first question from the questions list and remove it from the list.
*
* @return The next question.
* @throws GameOverException If the questions list is empty.
* @throws GameOverException If the questions list has less than two items.
*/
fun getNextQuestion(skipped: Boolean = false): QuizData {
requireNotNull(category) { "Category cannot be null" }
if (questions.isEmpty()) {
throw GameOverException("Questions list is empty")
}
val newQuestions = questions.toMutableList()
// Checks if is new game, if so, gets the two first questions
// And then removes from the questions list
val newCurrentQuestion = if (currentQuestion == null) {
// Check if there is at least two questions
if (newQuestions.size < 2) {
throw GameOverException("Questions list has less than two items")
}
val firstQuestion = newQuestions.first()
val secondQuestion = newQuestions[1]
// Remove the two questions
newQuestions.removeAt(0)
newQuestions.removeAt(0)
ComparisonQuizQuestion(
questions = firstQuestion to secondQuestion,
categoryId = category.id,
comparisonMode = comparisonMode
)
} else {
// If is not a new game, gets the first question from the questions list
// And then removes from the questions list
val firstQuestion = newQuestions.first()
newQuestions.removeAt(0)
currentQuestion.nextQuestion(firstQuestion)
}
val isNewGame = currentQuestion == null
return copy(
questions = newQuestions,
currentQuestion = newCurrentQuestion,
currentPosition = currentPosition + 1,
// If it is a new game, then it should show the current helper value
// If it is next question, then it should show the helper value
firstItemHelperValueState = if (isNewGame) {
firstItemHelperValueState
} else {
ComparisonQuizHelperValueState.NORMAL
},
// If the question was skipped, then it should increment the skipped questions
skippedAnswers = if (skipped) {
this.skippedAnswers + 1
} else {
this.skippedAnswers
}
)
}
}
/**
* Represents the initial data for the [ComparisonQuizCore].
*
* @property category The category of the comparison quiz.
* @property comparisonMode The comparison mode for the quiz.
*/
@Keep
data class InitializationData(
val category: ComparisonQuizCategory,
val comparisonMode: ComparisonMode,
val initialItems: List = emptyList()
)
}
================================================
FILE: core/src/main/java/com/infinitepower/newquiz/core/game/GameCore.kt
================================================
package com.infinitepower.newquiz.core.game
import kotlinx.coroutines.flow.StateFlow
class GameOverException(
override val message: String
) : Exception(message)
/**
* Represents the core functionality of a game, providing the basic structure and operations.
*
* @param QuizData The data type representing the quiz-specific information.
* @param InitializationData The data type representing the initialization parameters for the game.
*/
sealed interface GameCore {
/**
* A [StateFlow] that emits the current quiz data.
*/
val quizDataFlow: StateFlow
/**
* Initializes and starts the game with the given initial data.
* This method should be called only once.
*
* @param initializationData The initial data required to set up the game.
*
*/
suspend fun initializeGame(initializationData: InitializationData)
/**
* Starts the game.
*/
fun startGame()
/**
* Ends the game.
*/
fun endGame()
}
================================================
FILE: core/src/main/java/com/infinitepower/newquiz/core/game/SkipGame.kt
================================================
package com.infinitepower.newquiz.core.game
interface SkipGame {
val skipCost: UInt
suspend fun getUserDiamonds(): UInt
suspend fun skip()
}
================================================
FILE: core/src/main/java/com/infinitepower/newquiz/core/math/evaluator/Expressions.kt
================================================
package com.infinitepower.newquiz.core.math.evaluator
import com.infinitepower.newquiz.core.math.evaluator.internal.Function
import com.infinitepower.newquiz.core.math.evaluator.internal.Evaluator
import com.infinitepower.newquiz.core.math.evaluator.internal.Expr
import com.infinitepower.newquiz.core.math.evaluator.internal.Parser
import com.infinitepower.newquiz.core.math.evaluator.internal.Scanner
import com.infinitepower.newquiz.core.math.evaluator.internal.Token
import java.math.BigDecimal
import java.math.MathContext
import java.math.RoundingMode
import kotlin.math.log
import kotlin.math.log10
import kotlin.math.sqrt
class ExpressionException(message: String) : RuntimeException(message)
class Expressions {
private val evaluator = Evaluator()
init {
define("π", Math.PI)
define("e", Math.E)
evaluator.addFunction("ln", object : Function() {
override fun call(arguments: List): BigDecimal {
if (arguments.size != 1)
throw ExpressionException("ln requires one argument")
return log(arguments.first().toDouble(), Math.E).toBigDecimal()
}
})
evaluator.addFunction("log", object : Function() {
override fun call(arguments: List): BigDecimal {
if (arguments.size != 1)
throw ExpressionException("log requires one argument")
return log10(arguments.first().toDouble()).toBigDecimal()
}
})
evaluator.addFunction("√", object : Function() {
override fun call(arguments: List): BigDecimal {
if (arguments.size != 1)
throw ExpressionException("square root requires one argument")
return sqrt(arguments.first().toDouble()).toBigDecimal()
}
})
}
val precision: Int
get() = evaluator.mathContext.precision
val roundingMode: RoundingMode
get() = evaluator.mathContext.roundingMode
fun setPrecision(precision: Int): Expressions {
evaluator.mathContext = MathContext(precision, roundingMode)
return this
}
fun setRoundingMode(roundingMode: RoundingMode): Expressions {
evaluator.mathContext = MathContext(precision, roundingMode)
return this
}
fun define(name: String, value: Long): Expressions {
define(name, value.toString())
return this
}
fun define(name: String, value: Double): Expressions {
define(name, value.toString())
return this
}
fun define(name: String, value: BigDecimal): Expressions {
define(name, value.toPlainString())
return this
}
fun define(name: String, expression: String): Expressions {
val expr = parse(expression)
evaluator.define(name, expr)
return this
}
fun addFunction(name: String, function: Function): Expressions {
evaluator.addFunction(name, function)
return this
}
fun addFunction(name: String, func: (List) -> BigDecimal): Expressions {
evaluator.addFunction(name, object : Function() {
override fun call(arguments: List): BigDecimal {
return func(arguments)
}
})
return this
}
fun eval(expression: String): BigDecimal {
return evaluator.eval(parse(expression))
}
/**
* eval an expression then round it with {@link Evaluator#mathContext} and call toEngineeringString
* if error will return message from Throwable
* @param expression String
* @return String
*/
fun evalToString(expression: String): String {
return try {
evaluator
.eval(parse(expression))
.round(evaluator.mathContext)
.stripTrailingZeros()
.toEngineeringString()
} catch (e: Throwable) {
e.cause?.message ?: e.message ?: "unknown error"
}
}
private fun parse(expression: String): Expr {
return parse(scan(expression))
}
private fun parse(tokens: List): Expr {
return Parser(tokens).parse()
}
private fun scan(expression: String): List {
return Scanner(expression, evaluator.mathContext).scanTokens()
}
}
================================================
FILE: core/src/main/java/com/infinitepower/newquiz/core/math/evaluator/internal/Evaluator.kt
================================================
package com.infinitepower.newquiz.core.math.evaluator.internal
import com.infinitepower.newquiz.core.math.evaluator.ExpressionException
import java.math.BigDecimal
import java.math.MathContext
import com.infinitepower.newquiz.core.math.evaluator.internal.TokenType.*
import java.math.RoundingMode
import kotlin.math.pow
internal class Evaluator : ExprVisitor {
internal var mathContext: MathContext = MathContext.DECIMAL64
private val variables: LinkedHashMap = linkedMapOf()
private val functions: MutableMap = mutableMapOf()
private fun define(name: String, value: BigDecimal) {
variables += name to value
}
fun define(name: String, expr: Expr): Evaluator {
define(name.lowercase(), eval(expr))
return this
}
fun addFunction(name: String, function: Function): Evaluator {
functions += name.lowercase() to function
return this
}
fun eval(expr: Expr): BigDecimal {
return expr.accept(this)
}
override fun visitAssignExpr(expr: AssignExpr): BigDecimal {
val value = eval(expr.value)
define(expr.name.lexeme, value)
return value
}
override fun visitLogicalExpr(expr: LogicalExpr): BigDecimal {
val left = expr.left
val right = expr.right
return when (expr.operator.type) {
BAR_BAR -> left or right
AMP_AMP -> left and right
else -> throw ExpressionException(
"Invalid logical operator '${expr.operator.lexeme}'"
)
}
}
override fun visitBinaryExpr(expr: BinaryExpr): BigDecimal {
val left = eval(expr.left)
val right = eval(expr.right)
return when (expr.operator.type) {
PLUS -> left + right
MINUS -> left - right
STAR -> left * right
SLASH -> left.divide(right, mathContext)
MODULO -> left.remainder(right, mathContext)
EXPONENT -> left pow right
EQUAL_EQUAL -> (left == right).toBigDecimal()
NOT_EQUAL -> (left != right).toBigDecimal()
GREATER -> (left > right).toBigDecimal()
GREATER_EQUAL -> (left >= right).toBigDecimal()
LESS -> (left < right).toBigDecimal()
LESS_EQUAL -> (left <= right).toBigDecimal()
else -> throw ExpressionException("Invalid binary operator '${expr.operator.lexeme}'")
}
}
private fun Int.factorial() = (2..this).fold(1, Int::times)
override fun visitUnaryExpr(expr: UnaryExpr): BigDecimal {
val right = eval(expr.right)
return when (expr.operator.type) {
MINUS -> {
right.negate()
}
else -> throw ExpressionException("Invalid unary operator")
}
}
override fun visitLeftExpr(expr: LeftExpr): BigDecimal {
val left = eval(expr.left)
return when (expr.operator.type) {
FACTORIAL -> left.intValueExact().factorial().toBigDecimal()
else -> throw ExpressionException("Invalid left operator")
}
}
override fun visitCallExpr(expr: CallExpr): BigDecimal {
val name = expr.name
val function =
functions[name.lowercase()] ?: throw ExpressionException("Undefined function '$name'")
return function.call(expr.arguments.map { eval(it) })
}
override fun visitLiteralExpr(expr: LiteralExpr): BigDecimal {
return expr.value
}
override fun visitVariableExpr(expr: VariableExpr): BigDecimal {
val name = expr.name.lexeme
return variables[name.lowercase()]
?: throw ExpressionException("Undefined variable '$name'")
}
override fun visitGroupingExpr(expr: GroupingExpr): BigDecimal {
return eval(expr.expression)
}
private infix fun Expr.or(right: Expr): BigDecimal {
val left = eval(this)
// short-circuit if left is truthy
if (left.isTruthy()) return BigDecimal.ONE
return eval(right).isTruthy().toBigDecimal()
}
private infix fun Expr.and(right: Expr): BigDecimal {
val left = eval(this)
// short-circuit if left is falsey
if (!left.isTruthy()) return BigDecimal.ZERO
return eval(right).isTruthy().toBigDecimal()
}
private fun BigDecimal.isTruthy(): Boolean {
return this != BigDecimal.ZERO
}
private fun Boolean.toBigDecimal(): BigDecimal {
return if (this) BigDecimal.ONE else BigDecimal.ZERO
}
private infix fun BigDecimal.pow(n: BigDecimal): BigDecimal {
var right = n
val signOfRight = right.signum()
right = right.multiply(signOfRight.toBigDecimal())
val remainderOfRight = right.remainder(BigDecimal.ONE)
val n2IntPart = right.subtract(remainderOfRight)
val intPow = pow(n2IntPart.intValueExact(), mathContext)
val doublePow = BigDecimal(toDouble().pow(remainderOfRight.toDouble()))
var result = intPow.multiply(doublePow, mathContext)
if (signOfRight == -1) result = BigDecimal
.ONE.divide(result, mathContext.precision, RoundingMode.HALF_UP)
return result
}
}
================================================
FILE: core/src/main/java/com/infinitepower/newquiz/core/math/evaluator/internal/Expr.kt
================================================
package com.infinitepower.newquiz.core.math.evaluator.internal
import java.math.BigDecimal
internal sealed class Expr {
abstract fun accept(visitor: ExprVisitor): R
}
internal class AssignExpr(
val name: Token,
val value: Expr
) : Expr() {
override fun accept(visitor: ExprVisitor): R {
return visitor.visitAssignExpr(this)
}
}
internal class LogicalExpr(
val left: Expr,
val operator: Token,
val right: Expr
) : Expr() {
override fun accept(visitor: ExprVisitor): R {
return visitor.visitLogicalExpr(this)
}
}
internal class BinaryExpr(
val left: Expr,
val operator: Token,
val right: Expr
) : Expr() {
override fun accept(visitor: ExprVisitor): R {
return visitor.visitBinaryExpr(this)
}
}
internal class UnaryExpr(
val operator: Token,
val right: Expr
) : Expr() {
override fun accept(visitor: ExprVisitor): R {
return visitor.visitUnaryExpr(this)
}
}
internal class LeftExpr(
val operator: Token,
val left: Expr
) : Expr() {
override fun accept(visitor: ExprVisitor): R {
return visitor.visitLeftExpr(this)
}
}
internal class CallExpr(
val name: String,
val arguments: List
) : Expr() {
override fun accept(visitor: ExprVisitor): R {
return visitor.visitCallExpr(this)
}
}
internal class LiteralExpr(val value: BigDecimal) : Expr() {
override fun accept(visitor: ExprVisitor): R {
return visitor.visitLiteralExpr(this)
}
}
internal class VariableExpr(val name: Token) : Expr() {
override fun accept(visitor: ExprVisitor): R {
return visitor.visitVariableExpr(this)
}
}
internal class GroupingExpr(val expression: Expr) : Expr() {
override fun accept(visitor: ExprVisitor): R {
return visitor.visitGroupingExpr(this)
}
}
internal interface ExprVisitor