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 [![Version](https://img.shields.io/badge/Version-1.6.2-blueviolet)](https://github.com/joaomanaia/newquiz/releases/tag/1.6.2) [![Android CI](https://github.com/joaomanaia/newquiz/actions/workflows/android.yml/badge.svg?branch=main)](https://github.com/joaomanaia/newquiz/actions/workflows/android.yml) [![GitHub license](https://img.shields.io/badge/license-Apache%20License%202.0-blue.svg?style=flat)](https://www.apache.org/licenses/LICENSE-2.0) [![Translation status](https://hosted.weblate.org/widgets/newquiz/-/android-strings/svg-badge.svg)](https://hosted.weblate.org/engage/newquiz) Available at amazon appstore Do you like to challenge your knowledge? So NewQuiz is the ideal game for you. ![NewQuiz purple light](pictures/NewQuiz-Promotion-purple-light.png) New quiz is optimized to material you, the theme of new quiz will adapt to your background. ![NewQuiz green night](pictures/NewQuiz-Promotion-green-night.png) # 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. [![Translation status](https://hosted.weblate.org/widgets/newquiz/-/android-strings/horizontal-auto.svg)](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. ![NewQuiz purple light](../pictures/comparison_quiz.jpg) ================================================ 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 { fun visitAssignExpr(expr: AssignExpr): R fun visitLogicalExpr(expr: LogicalExpr): R fun visitBinaryExpr(expr: BinaryExpr): R fun visitUnaryExpr(expr: UnaryExpr): R fun visitLeftExpr(expr: LeftExpr): R fun visitCallExpr(expr: CallExpr): R fun visitLiteralExpr(expr: LiteralExpr): R fun visitVariableExpr(expr: VariableExpr): R fun visitGroupingExpr(expr: GroupingExpr): R } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/math/evaluator/internal/Function.kt ================================================ package com.infinitepower.newquiz.core.math.evaluator.internal import java.math.BigDecimal abstract class Function { abstract fun call(arguments: List): BigDecimal } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/math/evaluator/internal/Parser.kt ================================================ package com.infinitepower.newquiz.core.math.evaluator.internal import com.infinitepower.newquiz.core.math.evaluator.ExpressionException import com.infinitepower.newquiz.core.math.evaluator.internal.TokenType.* import java.math.BigDecimal internal class Parser(private val tokens: List) { private var current = 0 fun parse(): Expr { val expr = expression() if (!isAtEnd()) { throw ExpressionException("Expected end of expression, found '${peek().lexeme}'") } return expr } private fun expression(): Expr { return assignment() } private fun assignment(): Expr { val expr = or() if (match(ASSIGN)) { val value = assignment() if (expr is VariableExpr) { val name = expr.name return AssignExpr(name, value) } else { throw ExpressionException("Invalid assignment target") } } return expr } private fun or(): Expr { var expr = and() while (match(BAR_BAR)) { val operator = previous() val right = and() expr = LogicalExpr(expr, operator, right) } return expr } private fun and(): Expr { var expr = equality() while (match(AMP_AMP)) { val operator = previous() val right = equality() expr = LogicalExpr(expr, operator, right) } return expr } private fun equality(): Expr { var left = comparison() while (match(EQUAL_EQUAL, NOT_EQUAL)) { val operator = previous() val right = comparison() left = BinaryExpr(left, operator, right) } return left } private fun comparison(): Expr { var left = addition() while (match(GREATER, GREATER_EQUAL, LESS, LESS_EQUAL)) { val operator = previous() val right = addition() left = BinaryExpr(left, operator, right) } return left } private fun addition(): Expr { var left = multiplication() while (match(PLUS, MINUS)) { val operator = previous() val right = multiplication() left = BinaryExpr(left, operator, right) } return left } private fun multiplication(): Expr { var left = unary() while (match(STAR, SLASH, MODULO)) { val operator = previous() val right = unary() left = BinaryExpr(left, operator, right) } return left } private fun unary(): Expr { if (match(MINUS)) { val operator = previous() val right = unary() return UnaryExpr(operator, right) } return factorial() } private fun factorial(): Expr { val left = exponent() if (match(FACTORIAL)) { val operator = previous() return LeftExpr(operator, left) } return left } private fun exponent(): Expr { var left = call() if (match(EXPONENT)) { val operator = previous() val right = unary() left = BinaryExpr(left, operator, right) } return left } private fun call(): Expr { if (matchTwo(IDENTIFIER, LEFT_PAREN)) { val (name, _) = previousTwo() val arguments = mutableListOf() if (!check(RIGHT_PAREN)) { do { arguments += expression() } while (match(COMMA)) } consume(RIGHT_PAREN, "Expected ')' after function arguments") return CallExpr(name.lexeme, arguments) } return primary() } private fun primary(): Expr { if (match(NUMBER)) { return LiteralExpr(previous().literal as BigDecimal) } if (match(IDENTIFIER)) { return VariableExpr(previous()) } if (match(LEFT_PAREN)) { val expr = expression() consume(RIGHT_PAREN, "Expected ')' after '${previous().lexeme}'.") return GroupingExpr(expr) } throw ExpressionException("Expected expression after '${previous().lexeme}'.") } private fun match(vararg types: TokenType): Boolean { for (type in types) { if (check(type)) { advance() return true } } return false } private fun matchTwo(first: TokenType, second: TokenType): Boolean { val start = current if (match(first) && match(second)) { return true } current = start return false } private fun check(tokenType: TokenType): Boolean { return if (isAtEnd()) { false } else { peek().type === tokenType } } private fun consume(type: TokenType, message: String): Token { if (check(type)) return advance() throw ExpressionException(message) } private fun advance(): Token { if (!isAtEnd()) current++ return previous() } private fun isAtEnd() = peek().type == EOF private fun peek() = tokens[current] private fun previous() = tokens[current - 1] private fun previousTwo() = Pair(tokens[current - 2], tokens[current - 1]) } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/math/evaluator/internal/Scanner.kt ================================================ package com.infinitepower.newquiz.core.math.evaluator.internal import com.infinitepower.newquiz.core.math.evaluator.ExpressionException import com.infinitepower.newquiz.core.math.evaluator.internal.TokenType.* import java.math.MathContext private fun invalidToken(c: Char) { throw ExpressionException("Invalid token '$c'") } internal class Scanner( private val source: String, private val mathContext: MathContext ) { private val tokens: MutableList = mutableListOf() private var start = 0 private var current = 0 fun scanTokens(): List { while (!isAtEnd()) scanToken() tokens.add(Token(EOF, "", null)) return tokens } private fun isAtEnd() = current >= source.length private fun scanToken() { start = current when (val c = advance()) { ' ', '\r', '\t' -> { // Ignore whitespace. } '+' -> addToken(PLUS) '-' -> addToken(MINUS) '*' -> addToken(STAR) '/' -> addToken(SLASH) '%' -> addToken(MODULO) '^' -> addToken(EXPONENT) '=' -> if (match('=')) addToken(EQUAL_EQUAL) else addToken(ASSIGN) '!' -> if (match('=')) addToken(NOT_EQUAL) else addToken(FACTORIAL) '>' -> if (match('=')) addToken(GREATER_EQUAL) else addToken(GREATER) '<' -> if (match('=')) addToken(LESS_EQUAL) else addToken(LESS) '|' -> if (match('|')) addToken(BAR_BAR) else invalidToken(c) '&' -> if (match('&')) addToken(AMP_AMP) else invalidToken(c) ',' -> addToken(COMMA) '(' -> addToken(LEFT_PAREN) ')' -> addToken(RIGHT_PAREN) else -> { when { c.isDigit() -> number() c.isAlpha() || c.isOtherIdentifiers() -> identifier() else -> invalidToken(c) } } } } private fun isDigit( char: Char, previousChar: Char = '\u0000', nextChar: Char = '\u0000' ): Boolean { return char.isDigit() || when (char) { '.' -> true 'e', 'E' -> previousChar.isDigit() && (nextChar.isDigit() || nextChar == '+' || nextChar == '-') '+', '-' -> (previousChar == 'e' || previousChar == 'E') && nextChar.isDigit() else -> false } } private fun number() { while (peek().isDigit()) advance() if (isDigit(peek(), peekPrevious(), peekNext())) { advance() while (isDigit(peek(), peekPrevious(), peekNext())) advance() } val value = source .substring(start, current) .toBigDecimal(mathContext) addToken(NUMBER, value) } private fun identifier() { while (peek().isAlphaNumeric()) advance() addToken(IDENTIFIER) } private fun advance() = source[current++] private fun peek(): Char = if (isAtEnd()) '\u0000' else source[current] private fun peekPrevious(): Char = if (current > 0) source[current - 1] else '\u0000' private fun peekNext(): Char { return if (current + 1 >= source.length) { '\u0000' } else { source[current + 1] } } private fun match(expected: Char): Boolean { if (isAtEnd()) return false if (source[current] != expected) return false current++ return true } private fun addToken(type: TokenType) = addToken(type, null) private fun addToken(type: TokenType, literal: Any?) { val text = source.substring(start, current) tokens.add(Token(type, text, literal)) } private fun Char.isAlphaNumeric() = isAlpha() || isDigit() || isOtherIdentifiers() private fun Char.isAlpha() = this in 'a'..'z' || this in 'A'..'Z' || this == '_' private fun Char.isOtherIdentifiers() = this == 'π' || this == '√' || this == '!' private fun Char.isDigit() = this == '.' || this in '0'..'9' } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/math/evaluator/internal/Token.kt ================================================ package com.infinitepower.newquiz.core.math.evaluator.internal internal class Token( val type: TokenType, val lexeme: String, val literal: Any?, ) { override fun toString() = "$type $lexeme $literal" } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/math/evaluator/internal/TokenType.kt ================================================ package com.infinitepower.newquiz.core.math.evaluator.internal internal enum class TokenType { // Basic operators PLUS, MINUS, STAR, SLASH, MODULO, EXPONENT, ASSIGN, FACTORIAL, // Logical operators EQUAL_EQUAL, NOT_EQUAL, GREATER, GREATER_EQUAL, LESS, LESS_EQUAL, BAR_BAR, AMP_AMP, // Other COMMA, // Parentheses LEFT_PAREN, RIGHT_PAREN, // Literals NUMBER, IDENTIFIER, EOF } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/navigation/MazeNavigator.kt ================================================ package com.infinitepower.newquiz.core.navigation import com.infinitepower.newquiz.model.maze.MazeQuiz interface MazeNavigator { fun navigateToGame(item: MazeQuiz.MazeItem) fun navigateToMazeResults(itemId: Int) } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/network/NetworkStatusTracker.kt ================================================ package com.infinitepower.newquiz.core.network import kotlinx.coroutines.flow.Flow interface NetworkStatusTracker { val isOnline: Flow fun isCurrentlyConnected(): Boolean } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/network/NetworkStatusTrackerImpl.kt ================================================ package com.infinitepower.newquiz.core.network import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.Build import android.util.Log import androidx.core.content.getSystemService import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.conflate import javax.inject.Inject import javax.inject.Singleton @Singleton class NetworkStatusTrackerImpl @Inject constructor( @ApplicationContext private val context: Context ) : NetworkStatusTracker { override val isOnline: Flow = callbackFlow { val connectivityManager = context.getSystemService() // Close the flow if connectivityManager is null if (connectivityManager == null) { trySend(false) close() return@callbackFlow } /** * The callback's methods are invoked on changes to *any* network matching the [NetworkRequest], * not just the active network. So we can simply track the presence (or absence) of such [Network]. */ val callback = object : ConnectivityManager.NetworkCallback() { private val networks = mutableSetOf() override fun onAvailable(network: Network) { networks += network trySend(true) } override fun onLost(network: Network) { networks -= network // If there are no networks, we're offline trySend(networks.isNotEmpty()) } } val request = NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .build() Log.d("NetworkStatusTracker", "Registering network callback") connectivityManager.registerNetworkCallback(request, callback) /** * Sends the latest connectivity status to the underlying channel. */ trySend(connectivityManager.isCurrentlyConnected()) /** * When the flow is closed, unregisters the callback. */ awaitClose { Log.d("NetworkStatusTracker", "Unregistering network callback") connectivityManager.unregisterNetworkCallback(callback) } }.conflate() @Suppress("DEPRECATION") private fun ConnectivityManager.isCurrentlyConnected() = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> { activeNetwork ?.let(::getNetworkCapabilities) ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } else -> activeNetworkInfo?.isConnected } ?: false override fun isCurrentlyConnected(): Boolean { val connectivityManager = context.getSystemService() ?: return false return connectivityManager.isCurrentlyConnected() } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/theme/ExtendedColor.kt ================================================ package com.infinitepower.newquiz.core.theme import androidx.annotation.Keep import androidx.annotation.Size import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import com.google.android.material.color.ColorRoles import com.google.android.material.color.MaterialColors @Keep @Immutable data class CustomColor( val key: Key, val originalColor: Color, val harmonize: Boolean = true, val roles: ColorRoles = unspecifiedColorRoles ) { enum class Key { Blue, Green, Yellow, Red } @Keep @Immutable data class ColorRoles( val color: Color, val onColor: Color, val colorContainer: Color, val onColorContainer: Color ) } private val unspecifiedColorRoles = CustomColor.ColorRoles( color = Color.Unspecified, onColor = Color.Unspecified, colorContainer = Color.Unspecified, onColorContainer = Color.Unspecified, ) private fun ColorRoles.toColorRoles(): CustomColor.ColorRoles = CustomColor.ColorRoles( color = Color(this.accent), onColor = Color(this.onAccent), colorContainer = Color(this.accentContainer), onColorContainer = Color(this.onAccentContainer), ) @Keep @Immutable data class ExtendedColors( val colors: List ) { fun getColorsByKey(key: CustomColor.Key): CustomColor.ColorRoles { return colors.find { color -> color.key == key }?.roles ?: error("No color found for key $key") } fun getColorByKey(key: CustomColor.Key): Color = getColorsByKey(key).color fun getOnColorByKey(key: CustomColor.Key): Color = getColorsByKey(key).onColor fun getColorContainerByKey(key: CustomColor.Key): Color = getColorsByKey(key).colorContainer fun getOnColorContainerByKey(key: CustomColor.Key): Color = getColorsByKey(key).onColorContainer } private val initializeExtend = ExtendedColors( listOf( CustomColor( key = CustomColor.Key.Green, originalColor = Color(red = .3f, green = .6f, blue = .3f), ), CustomColor( key = CustomColor.Key.Yellow, originalColor = Color.Yellow, ), CustomColor( key = CustomColor.Key.Red, originalColor = Color.Red, ), CustomColor( key = CustomColor.Key.Blue, originalColor = Color.Blue, ), ) ) val LocalExtendedColors = staticCompositionLocalOf { initializeExtend } val MaterialTheme.extendedColors: ExtendedColors @Composable @ReadOnlyComposable get() = LocalExtendedColors.current internal fun setupCustomColors( colorScheme: ColorScheme, isLight: Boolean ): ExtendedColors { val colors = initializeExtend.colors.map { customColor -> val shouldHarmonize = customColor.harmonize // Harmonize the color if needed, if not, use the original color to get the roles val color = if (shouldHarmonize) { MaterialColors.harmonize( customColor.originalColor.toArgb(), colorScheme.primary.toArgb() ) } else { customColor.originalColor.toArgb() } val roles = MaterialColors.getColorRoles(color, isLight) customColor.copy(roles = roles.toColorRoles()) } return ExtendedColors(colors) } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/theme/LocalAnimationsEnabled.kt ================================================ package com.infinitepower.newquiz.core.theme import androidx.annotation.Keep import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.compositionLocalOf @Keep data class AnimationsEnabled( val global: Boolean = true, val wordle: Boolean = true, val multiChoice: Boolean = true ) val LocalAnimationsEnabled = compositionLocalOf { AnimationsEnabled() } val MaterialTheme.animationsEnabled: AnimationsEnabled @Composable @ReadOnlyComposable get() = LocalAnimationsEnabled.current ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/theme/Spacing.kt ================================================ package com.infinitepower.newquiz.core.theme import androidx.annotation.Keep import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @Keep data class Spacing( /** 8.dp **/ val default: Dp = 8.dp, /** 4.dp **/ val extraSmall: Dp = 4.dp, /** 8.dp **/ val small: Dp = 8.dp, /** 16.dp **/ val medium: Dp = 16.dp, /** 32.dp **/ val large: Dp = 32.dp, /** 64.dp **/ val extraLarge: Dp = 64.dp, ) val LocalSpacing = staticCompositionLocalOf { Spacing() } val MaterialTheme.spacing: Spacing @Composable @ReadOnlyComposable get() = LocalSpacing.current ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/theme/Theme.kt ================================================ package com.infinitepower.newquiz.core.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import com.google.android.material.color.MaterialColors private val LightThemeColors = lightColorScheme() private val DarkThemeColors = darkColorScheme() private fun setupErrorColors( colorScheme: ColorScheme, isLight: Boolean ): ColorScheme { val harmonizedError = MaterialColors.harmonize(colorScheme.error.toArgb(), colorScheme.primary.toArgb()) val roles = MaterialColors.getColorRoles(harmonizedError, isLight) //returns a colorScheme with newly harmonized error colors return colorScheme.copy( error = Color(roles.accent), onError = Color(roles.onAccent), errorContainer = Color(roles.accentContainer), onErrorContainer = Color(roles.onAccentContainer) ) } @Composable fun NewQuizTheme( darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, lightColorScheme: ColorScheme = LightThemeColors, darkColorScheme: ColorScheme = DarkThemeColors, animationsEnabled: AnimationsEnabled = AnimationsEnabled(), content: @Composable () -> Unit ) { val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } darkTheme -> darkColorScheme else -> lightColorScheme } val colorsWithHarmonizedError = setupErrorColors(colorScheme, !darkTheme) val extendedColors = setupCustomColors(colorScheme, !darkTheme) CompositionLocalProvider( LocalSpacing provides Spacing(), LocalExtendedColors provides extendedColors, LocalAnimationsEnabled provides animationsEnabled ) { MaterialTheme( colorScheme = colorsWithHarmonizedError, content = content, typography = AppTypography ) } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/theme/Type.kt ================================================ package com.infinitepower.newquiz.core.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp val Roboto = FontFamily.Default val AppTypography = Typography( displayLarge = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.W400, fontSize = 57.sp, lineHeight = 64.sp, letterSpacing = (-0.25).sp, ), displayMedium = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.W400, fontSize = 45.sp, lineHeight = 52.sp, letterSpacing = 0.sp, ), displaySmall = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.W400, fontSize = 36.sp, lineHeight = 44.sp, letterSpacing = 0.sp, ), headlineLarge = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.W400, fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.sp, ), headlineMedium = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.W400, fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.sp, ), headlineSmall = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.W400, fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.sp, ), titleLarge = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.W400, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.sp, ), titleMedium = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.1.sp, ), titleSmall = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp, ), labelLarge = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp, ), bodyLarge = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.W400, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp, ), bodyMedium = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.W400, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.25.sp, ), bodySmall = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.W400, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp, ), labelMedium = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp, ), labelSmall = TextStyle( fontFamily = Roboto, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp, ), ) ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/DisabledEmphasisWrappers.kt ================================================ package com.infinitepower.newquiz.core.ui import androidx.compose.foundation.layout.Box import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha const val DISABLED_ALPHA = 0.38f /** * Wraps the content with [CompositionLocalProvider] that overrides [LocalContentColor] with * an alpha of [DISABLED_ALPHA] if [enabled] is false. Otherwise, the content is not wrapped and is displayed normally. */ @Composable fun DisabledLocalContentEmphasis( enabled: Boolean, content: @Composable () -> Unit ) { if (enabled) { content() } else { CompositionLocalProvider( LocalContentColor provides LocalContentColor.current.copy(alpha = DISABLED_ALPHA), content = content ) } } @Composable fun DisabledContentEmphasis( enabled: Boolean, content: @Composable () -> Unit ) { if (enabled) { content() } else { Box(modifier = Modifier.alpha(DISABLED_ALPHA)) { content() } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/ObserveAsEvents.kt ================================================ package com.infinitepower.newquiz.core.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext @Composable fun ObserveAsEvents( flow: Flow, vararg keys: Any?, onEvent: (T) -> Unit ) { val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner.lifecycle, *keys, flow) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { withContext(Dispatchers.Main.immediate) { flow.collect(onEvent) } } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/SnackbarController.kt ================================================ package com.infinitepower.newquiz.core.ui import androidx.compose.material3.SnackbarDuration import com.infinitepower.newquiz.model.UiText import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow object SnackbarController { private val _events = Channel() val events = _events.receiveAsFlow() suspend fun sendEvent(event: SnackbarEvent) { _events.send(event) } suspend fun sendShortMessage(message: UiText) { sendEvent(event = SnackbarEvent(message)) } /** * Show a short message to the user. */ suspend fun sendShortMessage(message: String) { sendEvent(event = SnackbarEvent(UiText.DynamicString(message))) } } data class SnackbarEvent( val message: UiText, val action: SnackbarAction? = null, val withDismissAction: Boolean = false, val duration: SnackbarDuration = if (action == null) SnackbarDuration.Short else SnackbarDuration.Indefinite ) data class SnackbarAction( val name: UiText, val action: () -> Unit ) ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/components/AppNameWithLogo.kt ================================================ package com.infinitepower.newquiz.core.ui.components import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState 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.size import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox 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.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp 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.util.rememberAppVersion import com.infinitepower.newquiz.core.R as CoreR @Composable fun AppNameWithLogo( modifier: Modifier = Modifier ) { val appVersion = rememberAppVersion() AppNameWithLogo( modifier = modifier, logoContent = { AppLogo() }, appNameWithVersion = { AppNameWithVersion( appName = stringResource(id = CoreR.string.app_name), appVersion = appVersion ) } ) } @Composable internal fun AppNameWithLogo( modifier: Modifier = Modifier, logoContent: @Composable () -> Unit, appNameWithVersion: @Composable () -> Unit ) { Column( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { logoContent() Spacer(modifier = Modifier.height(MaterialTheme.spacing.large)) appNameWithVersion() } } @Composable private fun AppNameWithVersion( modifier: Modifier = Modifier, appName: String, appVersion: String ) { BadgedBox( badge = { Badge { Text(text = appVersion) } }, modifier = modifier ) { Text( text = appName, style = MaterialTheme.typography.headlineMedium ) } } @Composable private fun AppLogo( modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.primary, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, onClick: () -> Unit = {} ) { val isPressed by interactionSource.collectIsPressedAsState() val animatedCurve by animateFloatAsState( targetValue = if (isPressed) 0f else 0.05f, animationSpec = tween(durationMillis = 500), label = "Curve" ) val roundedPolygonShape = RoundedPolygonShape( sides = 12, curve = animatedCurve.toDouble() ) Surface( modifier = modifier.size(240.dp), color = color, shape = roundedPolygonShape, interactionSource = interactionSource, onClick = onClick ) { Icon( painter = painterResource(id = CoreR.drawable.logo_monochromatic), contentDescription = stringResource(id = CoreR.string.app_name), modifier = Modifier.size(90.dp) ) } } @Composable @PreviewLightDark private fun AppNameWithLogoPreview() { NewQuizTheme { Surface { AppNameWithLogo( modifier = Modifier.padding(16.dp), logoContent = { AppLogo() }, appNameWithVersion = { AppNameWithVersion( appName = stringResource(id = CoreR.string.app_name), appVersion = "1.0.0" ) } ) } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/components/RemainingTimeComponent.kt ================================================ package com.infinitepower.newquiz.core.ui.components import androidx.annotation.VisibleForTesting import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.tooling.preview.PreviewLightDark import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.model.RemainingTime import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds /** * Component that displays the remaining time of a quiz. * * @param modifier Modifier to be applied to the component. * @param remainingTime The remaining time to be displayed. * @param maxTime The maximum time of the quiz. * @param warningTime The time at which the text starts blinking. * @param showProgressIndicator Whether to show the progress indicator or not. */ @Composable fun RemainingTimeComponent( modifier: Modifier = Modifier, remainingTime: RemainingTime, maxTime: Duration, warningTime: Duration = 10.seconds, showProgressIndicator: Boolean = true, animationsEnabled: Boolean = true ) { val animatedProgressValue by animateFloatAsState( targetValue = remainingTime.getRemainingPercent(maxTime).toFloat(), animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, label = "Animated progress value" ) // If the remaining time is less than the warning time, the text starts animating. val isWarningTime by remember(remainingTime) { derivedStateOf { animationsEnabled && remainingTime.value <= warningTime } } val progressColor by animateColorAsState( targetValue = if (isWarningTime) { MaterialTheme.colorScheme.error } else { MaterialTheme.colorScheme.primary }, label = "Animated progress color" ) val trackProgressColor by animateColorAsState( targetValue = if (isWarningTime) { MaterialTheme.colorScheme.error.copy(alpha = 0.2f) } else { MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp) }, label = "Animated track progress color" ) Box( contentAlignment = Alignment.Center, modifier = modifier ) { if (showProgressIndicator) { CircularProgressIndicator( modifier = Modifier .size(75.dp) .testTag(RemainingTimeComponentTestTags.PROGRESS_INDICATOR), progress = { animatedProgressValue }, color = progressColor, trackColor = trackProgressColor, strokeCap = StrokeCap.Round, ) } if (isWarningTime) { AnimatedRemainingTimeText(remainingTime = remainingTime) } else { RemainingTimeText(remainingTime = remainingTime.toMinuteSecondFormatted()) } } } /** * Component that displays the text of the remaining time of a quiz. * * @param modifier Modifier to be applied to the component. */ @Composable private fun RemainingTimeText( modifier: Modifier = Modifier, remainingTime: String, style: TextStyle = MaterialTheme.typography.titleMedium, color: Color = LocalContentColor.current ) { Text( text = remainingTime, textAlign = TextAlign.Center, style = style, modifier = modifier, color = color ) } @Composable private fun AnimatedRemainingTimeText( modifier: Modifier = Modifier, remainingTime: RemainingTime ) { val remainingTimeInSeconds by remember(remainingTime) { derivedStateOf { remainingTime.value.inWholeSeconds } } RemainingTimeText( modifier = modifier, remainingTime = remainingTimeInSeconds.toString(), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.error ) /* Crash problem? AnimatedContent( modifier = modifier, targetState = remainingTimeInSeconds, transitionSpec = { // Compare the incoming number with the previous number. if (remainingTimeInSeconds > initialState) { // If the target number is larger, it slides up and fades in // while the initial (smaller) number slides up and fades out. slideInVertically { height -> height } + fadeIn() togetherWith slideOutVertically { height -> -height } + fadeOut() } else { // If the target number is smaller, it slides down and fades in // while the initial number slides down and fades out. slideInVertically { height -> -height } + fadeIn() togetherWith slideOutVertically { height -> height } + fadeOut() }.using( // Disable clipping since the faded slide-in/out should // be displayed out of bounds. SizeTransform(clip = false) ) }, label = "Pulsating remaining time text" ) { targetRemainingTime -> RemainingTimeText( remainingTime = targetRemainingTime.toString(), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.error ) } */ } @VisibleForTesting internal object RemainingTimeComponentTestTags { const val PROGRESS_INDICATOR = "PROGRESS_INDICATOR" } @Composable @PreviewLightDark private fun RemainingTimeComponentPreview() { NewQuizTheme { Surface { RemainingTimeComponent( modifier = Modifier.padding(16.dp), remainingTime = RemainingTime(5.seconds), maxTime = 30.seconds ) } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/components/RoundedPolygonShape.kt ================================================ package com.infinitepower.newquiz.core.ui.components import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import kotlin.math.PI import kotlin.math.cos import kotlin.math.min import kotlin.math.sin /** * Shape describing star with rounded corners * * Note: The shape draws within the minimum of provided width and height so can't be used to create stretched shape. * * @param sides number of sides. * @param curve a double value between 0.0 - 1.0 for modifying star curve. * @param rotation value between 0 - 360 * @param iterations a value between 0 - 360 that determines the quality of star shape. * @author pz64 */ class RoundedPolygonShape( private val sides: Int, private val curve: Double = 0.09, private val rotation: Float = 0f, private val iterations: Int = 360 ) : Shape { private companion object { const val TWO_PI = 2 * PI } private val steps = (TWO_PI) / min(iterations, 360) private val rotationDegree = (PI / 180) * rotation override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline = Outline.Generic(Path().apply { val r = min(size.height, size.width) * 0.4 * mapRange(1.0, 0.0, 0.5, 1.0, curve) val xCenter = size.width * .5f val yCenter = size.height * .5f moveTo(xCenter, yCenter) var t = 0.0 while (t <= TWO_PI) { val x = r * (cos(t - rotationDegree) * (1 + curve * cos(sides * t))) val y = r * (sin(t - rotationDegree) * (1 + curve * cos(sides * t))) lineTo((x + xCenter).toFloat(), (y + yCenter).toFloat()) t += steps } val x = r * (cos(t - rotationDegree) * (1 + curve * cos(sides * t))) val y = r * (sin(t - rotationDegree) * (1 + curve * cos(sides * t))) lineTo((x + xCenter).toFloat(), (y + yCenter).toFloat()) }) private fun mapRange(a: Double, b: Double, c: Double, d: Double, x: Double): Double { return (x - a) / (b - a) * (d - c) + c } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/components/category/CategoryBadge.kt ================================================ package com.infinitepower.newquiz.core.ui.components.category import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material.icons.rounded.WifiOff import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.R import com.infinitepower.newquiz.core.common.compose.preview.BooleanPreviewParameterProvider import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing /** * A badge that indicates that the category requires an internet connection or not. * When clicked, it shows a text that explains its meaning. * * @param modifier The modifier to be applied to the component. * @param requireConnection Whether the category requires an internet connection or not. * @param showTextByDefault Whether the text should be shown by default. */ @Composable internal fun CategoryConnectionInfoBadge( modifier: Modifier = Modifier, requireConnection: Boolean = true, showTextByDefault: Boolean = false, ) { val (showText, setShowText) = remember { mutableStateOf(showTextByDefault) } val description = stringResource( id = if (requireConnection) { R.string.require_internet_connection } else { R.string.dont_require_internet_connection } ) CategoryBadge( modifier = modifier, onClick = { setShowText(!showText) } ) { Row( modifier = Modifier .padding(MaterialTheme.spacing.default) .semantics( mergeDescendants = true, ) { contentDescription = description }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.default) ) { Icon( imageVector = if (requireConnection) { Icons.Rounded.Wifi } else { Icons.Rounded.WifiOff }, contentDescription = null ) AnimatedVisibility( visible = showText, label = "Connection info text" ) { Text( text = description, style = MaterialTheme.typography.bodySmall ) } } } } /** * A badge that indicates that the category is checked. */ @Composable internal fun CategoryCheckedBadge( modifier: Modifier = Modifier, onClick: () -> Unit ) { CategoryBadge( modifier = modifier, color = MaterialTheme.colorScheme.primary, onClick = onClick ) { Icon( imageVector = Icons.Rounded.Check, contentDescription = null, modifier = Modifier.padding(MaterialTheme.spacing.default) ) } } @Composable private fun CategoryBadge( modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.tertiaryContainer, shape: Shape = MaterialTheme.shapes.medium, onClick: () -> Unit = {}, badgeContent: @Composable () -> Unit ) { Surface( modifier = modifier, color = color, shape = shape, onClick = onClick, content = badgeContent ) } @Composable @PreviewLightDark private fun CategoryConnectionInfoBadgePreview( @PreviewParameter(BooleanPreviewParameterProvider::class) requireConnection: Boolean ) { NewQuizTheme { Surface { CategoryConnectionInfoBadge( modifier = Modifier.padding(16.dp), requireConnection = requireConnection ) } } } @Composable @PreviewLightDark private fun CategoryCheckedBadgePreview() { NewQuizTheme { Surface { CategoryCheckedBadge( modifier = Modifier.padding(16.dp), onClick = {} ) } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/components/category/CategoryComponent.kt ================================================ package com.infinitepower.newquiz.core.ui.components.category import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.infinitepower.newquiz.core.R import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.DISABLED_ALPHA import com.infinitepower.newquiz.model.category.ShowCategoryConnectionInfo @Composable fun CategoryComponent( modifier: Modifier = Modifier, title: String, imageUrl: Any, requireInternetConnection: Boolean = false, showConnectionInfo: ShowCategoryConnectionInfo = ShowCategoryConnectionInfo.NONE, checked: Boolean = false, enabled: Boolean = true, clickEnabled: Boolean = enabled, textStyle: TextStyle = MaterialTheme.typography.headlineLarge, shape: Shape = MaterialTheme.shapes.large, onClick: () -> Unit = {}, onCheckClick: () -> Unit = {} ) { val containerOverlayColor = if (isSystemInDarkTheme()) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.primary }.copy(alpha = if (checked) 0.6f else 0.5f) val textColor = Color.White.copy( alpha = if (enabled) 1f else DISABLED_ALPHA ) Surface( modifier = modifier.height(120.dp), shape = shape, onClick = onClick, enabled = enabled && clickEnabled, border = if (checked) { BorderStroke(2.dp, MaterialTheme.colorScheme.primary) } else { null } ) { AsyncImage( model = imageUrl, contentDescription = stringResource(id = R.string.image_category_of_s, title), modifier = Modifier .fillMaxSize() .clip(MaterialTheme.shapes.medium) .alpha(if (enabled) 1f else DISABLED_ALPHA), contentScale = ContentScale.Crop ) Box( modifier = Modifier .fillMaxSize() .background(containerOverlayColor), contentAlignment = Alignment.Center ) { Text( text = title, style = textStyle, color = textColor, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = MaterialTheme.spacing.large) ) Row( modifier = Modifier .padding(MaterialTheme.spacing.default) .align(Alignment.TopEnd), ) { if (showConnectionInfo.shouldShowBadge(requireInternetConnection)) { CategoryConnectionInfoBadge( requireConnection = requireInternetConnection ) } AnimatedVisibility( visible = checked, label = "Checked badge" ) { CategoryCheckedBadge( onClick = onCheckClick ) } } } } } @Composable @PreviewLightDark private fun CategoryPreview() { NewQuizTheme { Surface { CategoryComponent( title = "Title", imageUrl = "", modifier = Modifier .padding(16.dp) .fillMaxWidth(), requireInternetConnection = true, showConnectionInfo = ShowCategoryConnectionInfo.BOTH, checked = true ) } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/components/icon/button/BackIconButton.kt ================================================ package com.infinitepower.newquiz.core.ui.components.icon.button import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.infinitepower.newquiz.core.R @Composable fun BackIconButton( modifier: Modifier = Modifier, onClick: () -> Unit ) { IconButton( onClick = onClick, modifier = modifier ) { Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(id = R.string.back) ) } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/components/skip_question/SkipButton.kt ================================================ package com.infinitepower.newquiz.core.ui.components.skip_question import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.SkipNext import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.infinitepower.newquiz.core.R @Composable fun SkipIconButton( modifier: Modifier = Modifier, onClick: () -> Unit ) { IconButton( modifier = modifier, onClick = onClick ) { Icon( imageVector = Icons.Rounded.SkipNext, contentDescription = stringResource(id = R.string.skip) ) } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/components/skip_question/SkipQuestionDialog.kt ================================================ package com.infinitepower.newquiz.core.ui.components.skip_question import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.material3.AlertDialog import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import com.infinitepower.newquiz.core.R import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.R as CoreR @Composable @ExperimentalMaterial3Api fun SkipQuestionDialog( userDiamonds: Int, skipCost: Int, loading: Boolean = false, onSkipClick: () -> Unit, onDismissClick: () -> Unit ) { if (loading) { LoadingDialog() } else if (userDiamonds >= 0) { // If user diamonds is negative, it means that the user has already skipped the question or // the user didn't request to skip the question yet. SkipQuestionDialog( userDiamonds = userDiamonds, skipCost = skipCost, onSkipClick = onSkipClick, onDismissClick = onDismissClick ) } } @Composable private fun SkipQuestionDialog( userDiamonds: Int, skipCost: Int, onSkipClick: () -> Unit, onDismissClick: () -> Unit ) { val canSkip = remember(userDiamonds, skipCost) { userDiamonds >= skipCost } if (canSkip) { AlertDialog( onDismissRequest = onDismissClick, title = { Text(text = stringResource(id = CoreR.string.skip_question_q)) }, text = { Text( text = pluralStringResource( id = CoreR.plurals.you_have_n_diamonds_skip_question_q, count = userDiamonds, formatArgs = arrayOf(userDiamonds, skipCost) ) ) }, confirmButton = { TextButton( onClick = { onSkipClick() onDismissClick() } ) { Text(text = stringResource(id = CoreR.string.skip)) } }, dismissButton = { TextButton(onClick = onDismissClick) { Text(text = stringResource(id = CoreR.string.close)) } } ) } else { NoDiamondsDialog(onDismissClick = onDismissClick) } } @Composable private fun NoDiamondsDialog( onDismissClick: () -> Unit ) { AlertDialog( onDismissRequest = onDismissClick, title = { Text(text = stringResource(id = R.string.no_diamonds)) }, text = { Text( text = stringResource(id = R.string.no_diamonds_to_skip_question_description) ) }, confirmButton = { TextButton(onClick = onDismissClick) { Text(text = stringResource(id = R.string.close)) } } ) } @Composable @ExperimentalMaterial3Api private fun LoadingDialog() { BasicAlertDialog( onDismissRequest = {}, properties = DialogProperties( dismissOnBackPress = false, dismissOnClickOutside = false ) ) { Surface( modifier = Modifier .height(200.dp) .fillMaxWidth(), shape = MaterialTheme.shapes.medium, tonalElevation = 6.dp ) { Column( modifier = Modifier .fillMaxSize() .padding(MaterialTheme.spacing.medium), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = "Loading your diamonds...", style = MaterialTheme.typography.headlineMedium, textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(MaterialTheme.spacing.large)) CircularProgressIndicator() } } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/home/ExpandCategoriesButton.kt ================================================ package com.infinitepower.newquiz.core.ui.home import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.graphicsLayer import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import com.infinitepower.newquiz.core.R import com.infinitepower.newquiz.core.common.compose.preview.BooleanPreviewParameterProvider import com.infinitepower.newquiz.core.theme.NewQuizTheme @Composable internal fun ExpandCategoriesButton( modifier: Modifier = Modifier, seeAllCategories: Boolean, onSeeAllCategoriesClick: () -> Unit ) { val transition = updateTransition( targetState = seeAllCategories, label = "Expand Categories Button" ) val seeAllText = if (seeAllCategories) { stringResource(id = R.string.see_less_categories) } else { stringResource(id = R.string.see_all_categories) } // Rotates the icon when the button is pressed val rotation by transition.animateFloat( label = "Icon Rotation", ) { state -> if (state) 180f else 0f } Box( modifier = modifier, contentAlignment = Alignment.Center ) { TextButton(onClick = onSeeAllCategoriesClick) { Icon( imageVector = Icons.Rounded.ExpandMore, contentDescription = seeAllText, modifier = Modifier .size(ButtonDefaults.IconSize) .graphicsLayer { rotationZ = rotation } ) Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) Text( text = seeAllText, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.animateContentSize() ) } } } @Composable @PreviewLightDark private fun ExpandCategoriesButtonPreview( @PreviewParameter(BooleanPreviewParameterProvider::class) seeAllCategories: Boolean ) { NewQuizTheme { Surface { ExpandCategoriesButton( seeAllCategories = seeAllCategories, onSeeAllCategoriesClick = {} ) } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/home/HomeCategoriesItems.kt ================================================ package com.infinitepower.newquiz.core.ui.home import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.R import com.infinitepower.newquiz.core.ui.components.category.CategoryComponent import com.infinitepower.newquiz.core.util.asString import com.infinitepower.newquiz.model.BaseCategory import com.infinitepower.newquiz.model.category.ShowCategoryConnectionInfo fun LazyListScope.homeCategoriesItems( contentPadding: PaddingValues = PaddingValues(), seeAllCategories: Boolean, recentCategories: List, otherCategories: List, isInternetAvailable: Boolean, showConnectionInfo: ShowCategoryConnectionInfo, onCategoryClick: (T) -> Unit, onSeeAllCategoriesClick: () -> Unit, ) { if (recentCategories.isEmpty() && otherCategories.isEmpty()) { item { Box( modifier = Modifier.fillParentMaxWidth(), contentAlignment = Alignment.Center ) { Text( text = stringResource(id = R.string.no_categories_available), style = MaterialTheme.typography.bodyMedium ) } } } items( items = recentCategories, key = { category -> "recent_category_${category.id}" } ) { category -> CategoryComponent( modifier = Modifier .fillParentMaxWidth() .height(120.dp) .padding(contentPadding), title = category.name.asString(), imageUrl = category.image, onClick = { onCategoryClick(category) }, enabled = isInternetAvailable || !category.requireInternetConnection, requireInternetConnection = category.requireInternetConnection, showConnectionInfo = showConnectionInfo ) } if (recentCategories.isNotEmpty() && otherCategories.isNotEmpty()) { item( key = "see_all_categories_button", ) { ExpandCategoriesButton( modifier = Modifier.fillParentMaxWidth(), seeAllCategories = seeAllCategories, onSeeAllCategoriesClick = onSeeAllCategoriesClick ) } } if (seeAllCategories) { items( items = otherCategories, key = { category -> category.id } ) { category -> CategoryComponent( modifier = Modifier .fillParentMaxWidth() .height(120.dp) .padding(contentPadding), title = category.name.asString(), imageUrl = category.image, onClick = { onCategoryClick(category) }, enabled = isInternetAvailable || !category.requireInternetConnection, requireInternetConnection = category.requireInternetConnection, showConnectionInfo = showConnectionInfo ) } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/home/HomeLazyColumn.kt ================================================ package com.infinitepower.newquiz.core.ui.home import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.infinitepower.newquiz.core.theme.spacing @Composable fun HomeLazyColumn( modifier: Modifier = Modifier, verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(MaterialTheme.spacing.medium), contentPadding: PaddingValues = PaddingValues( top = MaterialTheme.spacing.medium, start = MaterialTheme.spacing.medium, end = MaterialTheme.spacing.medium, bottom = MaterialTheme.spacing.large, ), content: LazyListScope.() -> Unit ) { LazyColumn( modifier = modifier.fillMaxSize(), verticalArrangement = verticalArrangement, contentPadding = contentPadding, content = content ) } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/home_card/HomeListContent.kt ================================================ package com.infinitepower.newquiz.core.ui.home_card import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.home_card.components.HomeCardItemContent import com.infinitepower.newquiz.core.ui.home_card.model.HomeCardItem @Composable @ExperimentalMaterial3Api fun HomeListContent( items: List ) { val spaceMedium = MaterialTheme.spacing.medium LazyColumn( contentPadding = PaddingValues(spaceMedium), verticalArrangement = Arrangement.spacedBy(spaceMedium) ) { items( items = items, key = { it.getId() }, ) { item -> HomeCardItemContent( modifier = Modifier.fillParentMaxWidth(), item = item ) } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/home_card/components/HomeCardIcon.kt ================================================ package com.infinitepower.newquiz.core.ui.home_card.components import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.rememberLottieComposition import com.infinitepower.newquiz.core.ui.home_card.model.CardIcon @Composable internal fun HomeCardIcon( modifier: Modifier = Modifier, icon: CardIcon, contentDescription: String ) { when (icon) { is CardIcon.Icon -> { Icon( imageVector = icon.vector, contentDescription = contentDescription, modifier = modifier ) } is CardIcon.Lottie -> { val composition by rememberLottieComposition(spec = icon.spec) LottieAnimation( composition = composition, modifier = modifier, iterations = LottieConstants.IterateForever, ) } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/home_card/components/HomeCardItemContent.kt ================================================ package com.infinitepower.newquiz.core.ui.home_card.components import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.infinitepower.newquiz.core.ui.home_card.model.HomeCardItem @Composable @ExperimentalMaterial3Api fun HomeCardItemContent( modifier: Modifier = Modifier, item: HomeCardItem, ) { when (item) { is HomeCardItem.GroupTitle -> { HomeGroupTitle( modifier = modifier, data = item ) } is HomeCardItem.LargeCard -> { HomeLargeCard( modifier = modifier, data = item ) } is HomeCardItem.MediumCard -> { HomeMediumCard( modifier = modifier, data = item ) } is HomeCardItem.HorizontalItems<*> -> { HomeHorizontalItems( modifier = modifier, item = item, itemContent = item.itemContent ) } is HomeCardItem.CustomItem -> item.content() } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/home_card/components/HomeGroupTitle.kt ================================================ package com.infinitepower.newquiz.core.ui.home_card.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.res.stringResource import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.home_card.model.HomeCardItem @Composable fun HomeGroupTitle( modifier: Modifier = Modifier, data: HomeCardItem.GroupTitle ) { HomeGroupTitle( modifier = modifier, title = stringResource(id = data.title) ) } @Composable fun HomeGroupTitle( modifier: Modifier = Modifier, title: String ) { Surface( modifier = modifier ) { Text( text = title, style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(vertical = MaterialTheme.spacing.medium) ) } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/home_card/components/HomeHorizontalItems.kt ================================================ package com.infinitepower.newquiz.core.ui.home_card.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.home_card.model.HomeCardItem @Composable @ExperimentalMaterial3Api internal fun HomeHorizontalItems( modifier: Modifier = Modifier, item: HomeCardItem.HorizontalItems, itemContent: @Composable (item: T) -> Unit ) { LazyRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium), modifier = modifier ) { items(items = item.items) { item -> itemContent(item) } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/home_card/components/HomeLargeCard.kt ================================================ package com.infinitepower.newquiz.core.ui.home_card.components 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.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.Card import androidx.compose.material3.CardColors import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api 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.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha 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.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.rememberLottieComposition 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.core.ui.DISABLED_ALPHA import com.infinitepower.newquiz.core.ui.home_card.model.CardIcon import com.infinitepower.newquiz.core.ui.home_card.model.HomeCardItem @Composable @ExperimentalMaterial3Api fun HomeLargeCard( modifier: Modifier = Modifier, data: HomeCardItem.LargeCard ) { val spaceMedium = MaterialTheme.spacing.medium val title = stringResource(id = data.title) val cardColors = if (data.backgroundPrimary) { getPrimaryCardColors() } else { CardDefaults.cardColors() } Card( onClick = data.onClick, enabled = data.enabled, modifier = modifier, colors = cardColors ) { Column( modifier = Modifier.padding(spaceMedium), ) { Text( text = title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(spaceMedium)) Row( modifier = Modifier.align(Alignment.End), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spaceMedium) ) { when (data.icon) { is CardIcon.Icon -> { Icon( imageVector = data.icon.vector, contentDescription = title, modifier = Modifier.size(100.dp) ) } is CardIcon.Lottie -> { val composition by rememberLottieComposition(spec = data.icon.spec) LottieAnimation( composition = composition, modifier = Modifier .size(100.dp) .alpha(if (data.enabled) 1f else DISABLED_ALPHA), iterations = LottieConstants.IterateForever, isPlaying = data.enabled ) } } } } } } @Composable private fun getPrimaryCardColors(): CardColors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = DISABLED_ALPHA), disabledContentColor = MaterialTheme.colorScheme.onPrimary, ) @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class) private fun HomeLargeCardPreview( @PreviewParameter(BooleanPreviewParameterProvider::class) enabled: Boolean ) { NewQuizTheme { Surface { HomeLargeCard( data = HomeCardItem.LargeCard( title = R.string.quick_quiz, icon = CardIcon.Icon(Icons.Rounded.Check), onClick = {}, enabled = enabled ), modifier = Modifier.padding(16.dp) ) } } } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class) private fun PrimaryHomeLargeCardPreview( @PreviewParameter(BooleanPreviewParameterProvider::class) enabled: Boolean ) { NewQuizTheme { Surface { HomeLargeCard( data = HomeCardItem.LargeCard( title = R.string.quick_quiz, icon = CardIcon.Icon(Icons.Rounded.Check), onClick = {}, enabled = enabled, backgroundPrimary = true ), modifier = Modifier.padding(16.dp) ) } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/home_card/components/HomeMediumCard.kt ================================================ package com.infinitepower.newquiz.core.ui.home_card.components import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.R import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.home_card.model.CardIcon import com.infinitepower.newquiz.core.ui.home_card.model.HomeCardItem @Composable fun HomeMediumCard( modifier: Modifier = Modifier, data: HomeCardItem.MediumCard ) { val spaceMedium = MaterialTheme.spacing.medium val title = stringResource(id = data.title) val isInspectionMode = LocalInspectionMode.current Card( onClick = data.onClick, enabled = data.enabled || isInspectionMode, modifier = modifier ) { Row( modifier = Modifier.padding(spaceMedium), verticalAlignment = Alignment.CenterVertically ) { HomeCardIcon( icon = data.icon, contentDescription = title, modifier = Modifier .size(75.dp) .padding(MaterialTheme.spacing.small), ) Spacer(modifier = Modifier.width(spaceMedium)) Column { Text( text = title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.fillMaxWidth() ) if (data.description != null) { Spacer(modifier = Modifier.height(MaterialTheme.spacing.small)) Text( text = data.description, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.fillMaxWidth() ) } } } } } @Composable @PreviewLightDark private fun HomeMediumCardPreview() { NewQuizTheme { Surface { HomeMediumCard( data = HomeCardItem.MediumCard( title = R.string.quick_quiz, description = "10 Questions", icon = CardIcon.Icon(Icons.Rounded.Check), onClick = {}, ), modifier = Modifier.padding(16.dp) ) } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/home_card/components/PlayRandomQuizCard.kt ================================================ package com.infinitepower.newquiz.core.ui.home_card.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults 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.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.theme.NewQuizTheme @Composable fun PlayRandomQuizCard( modifier: Modifier = Modifier, title: String, buttonTitle: String, containerMainColor: Color = MaterialTheme.colorScheme.primary, enabled: Boolean = true, onClick: () -> Unit ) { val backgroundColor = Brush.horizontalGradient( colors = listOf( containerMainColor, containerMainColor.copy(alpha = 0.8f) ) ) val textColor = if (enabled) { MaterialTheme.colorScheme.surface } else { MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) } val cardShape = MaterialTheme.shapes.large Box( modifier = modifier.clickable( enabled = enabled, onClick = onClick, role = Role.Button ).background( brush = backgroundColor, shape = cardShape, alpha = if (enabled) 1f else 0.12f ), ) { Column( modifier = Modifier .fillMaxWidth() .padding( horizontal = 24.dp, vertical = 24.dp ), verticalArrangement = Arrangement.spacedBy(24.dp) ) { Text( text = title, color = textColor, modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.titleMedium ) Button( onClick = onClick, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.onSurface ), enabled = enabled, ) { Text(text = buttonTitle) } } } } @Composable @PreviewLightDark private fun MultiChoiceCategoriesPreview() { NewQuizTheme { Surface { PlayRandomQuizCard( modifier = Modifier .fillMaxWidth() .padding(16.dp), title = "PLay a quiz with random categories", buttonTitle = "Random Quiz", onClick = {}, enabled = false ) } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/home_card/model/HomeCardItem.kt ================================================ package com.infinitepower.newquiz.core.ui.home_card.model import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector import com.airbnb.lottie.compose.LottieCompositionSpec sealed class HomeCardItem { fun getId(): Int = this.hashCode() data class GroupTitle( @StringRes val title: Int ) : HomeCardItem() data class LargeCard( @StringRes val title: Int, val icon: CardIcon, val enabled: Boolean = true, val backgroundPrimary: Boolean = false, val onClick: () -> Unit ) : HomeCardItem() data class MediumCard( @StringRes val title: Int, val description: String? = null, val icon: CardIcon, val enabled: Boolean = true, val onClick: () -> Unit ) : HomeCardItem() data class HorizontalItems ( val items: List, val itemContent: @Composable (item: @UnsafeVariance T) -> Unit ) : HomeCardItem() data class CustomItem( val content: @Composable () -> Unit ) : HomeCardItem() } sealed class CardIcon { data class Icon(val vector: ImageVector) : CardIcon() data class Lottie(val spec: LottieCompositionSpec) : CardIcon() } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/icons/TrophyIcon.kt ================================================ package com.infinitepower.newquiz.core.ui.icons import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp public val Trophy: ImageVector get() { if (_Trophy != null) { return _Trophy!! } _Trophy = ImageVector.Builder( name = "Trophy", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 960f, viewportHeight = 960f ).apply { path( fill = SolidColor(Color.Black), fillAlpha = 1.0f, stroke = null, strokeAlpha = 1.0f, strokeLineWidth = 1.0f, strokeLineCap = StrokeCap.Butt, strokeLineJoin = StrokeJoin.Miter, strokeLineMiter = 1.0f, pathFillType = PathFillType.NonZero ) { moveTo(440f, 760f) verticalLineToRelative(-124f) quadToRelative(-49f, -11f, -87.5f, -41.5f) reflectiveQuadTo(296f, 518f) quadToRelative(-75f, -9f, -125.5f, -65.5f) reflectiveQuadTo(120f, 320f) verticalLineToRelative(-40f) quadToRelative(0f, -33f, 23.5f, -56.5f) reflectiveQuadTo(200f, 200f) horizontalLineToRelative(80f) quadToRelative(0f, -33f, 23.5f, -56.5f) reflectiveQuadTo(360f, 120f) horizontalLineToRelative(240f) quadToRelative(33f, 0f, 56.5f, 23.5f) reflectiveQuadTo(680f, 200f) horizontalLineToRelative(80f) quadToRelative(33f, 0f, 56.5f, 23.5f) reflectiveQuadTo(840f, 280f) verticalLineToRelative(40f) quadToRelative(0f, 76f, -50.5f, 132.5f) reflectiveQuadTo(664f, 518f) quadToRelative(-18f, 46f, -56.5f, 76.5f) reflectiveQuadTo(520f, 636f) verticalLineToRelative(124f) horizontalLineToRelative(120f) quadToRelative(17f, 0f, 28.5f, 11.5f) reflectiveQuadTo(680f, 800f) reflectiveQuadToRelative(-11.5f, 28.5f) reflectiveQuadTo(640f, 840f) horizontalLineTo(320f) quadToRelative(-17f, 0f, -28.5f, -11.5f) reflectiveQuadTo(280f, 800f) reflectiveQuadToRelative(11.5f, -28.5f) reflectiveQuadTo(320f, 760f) close() moveTo(280f, 432f) verticalLineToRelative(-152f) horizontalLineToRelative(-80f) verticalLineToRelative(40f) quadToRelative(0f, 38f, 22f, 68.5f) reflectiveQuadToRelative(58f, 43.5f) moveToRelative(200f, 128f) quadToRelative(50f, 0f, 85f, -35f) reflectiveQuadToRelative(35f, -85f) verticalLineToRelative(-240f) horizontalLineTo(360f) verticalLineToRelative(240f) quadToRelative(0f, 50f, 35f, 85f) reflectiveQuadToRelative(85f, 35f) moveToRelative(200f, -128f) quadToRelative(36f, -13f, 58f, -43.5f) reflectiveQuadToRelative(22f, -68.5f) verticalLineToRelative(-40f) horizontalLineToRelative(-80f) close() moveToRelative(-200f, -52f) } }.build() return _Trophy!! } private var _Trophy: ImageVector? = null ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/ui/text/CompactDecimalText.kt ================================================ package com.infinitepower.newquiz.core.ui.text import android.icu.text.CompactDecimalFormat import android.os.Build import androidx.compose.foundation.layout.padding import androidx.compose.material3.Surface import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.theme.NewQuizTheme import java.util.Locale import kotlin.math.ln import kotlin.math.pow @Composable fun CompactDecimalText( modifier: Modifier = Modifier, value: Int, style: TextStyle = LocalTextStyle.current ) { val text = remember(value) { getCompactDecimalText(value) } Text( modifier = modifier, text = text, style = style ) } private fun getCompactDecimalText(value: Int): String { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val formatter = CompactDecimalFormat.getInstance( Locale.getDefault(), CompactDecimalFormat.CompactStyle.SHORT ) formatter.format(value) } else { value.compactFormat() } } private fun Int.compactFormat(): String { if (this < 1000) return toString() val exp = (ln(this.toDouble()) / ln(1000.0)).toInt() return String.format("%.1f %c", this / 1000.0.pow(exp.toDouble()), "kMGTPE"[exp - 1]) } @Composable @PreviewLightDark private fun CompactDecimalTextPreview() { NewQuizTheme { Surface { CompactDecimalText( modifier = Modifier.padding(16.dp), value = 1234 ) } } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/util/ComposeUtils.kt ================================================ package com.infinitepower.newquiz.core.util import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.ui.unit.LayoutDirection operator fun PaddingValues.plus(other: PaddingValues): PaddingValues = PaddingValues( start = this.calculateStartPadding(LayoutDirection.Ltr) + other.calculateStartPadding(LayoutDirection.Ltr), top = this.calculateTopPadding() + other.calculateTopPadding(), end = this.calculateEndPadding(LayoutDirection.Ltr) + other.calculateEndPadding(LayoutDirection.Ltr), bottom = this.calculateBottomPadding() + other.calculateBottomPadding(), ) ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/util/PackageUtils.kt ================================================ package com.infinitepower.newquiz.core.util import android.content.pm.PackageManager import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @Composable fun rememberAppVersion(): String { val context = LocalContext.current return remember { val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.packageManager.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(0)) } else { @Suppress("DEPRECATION") context.packageManager.getPackageInfo(context.packageName, 0) } info.versionName ?: "Unknown" } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/util/UiTextUtils.kt ================================================ package com.infinitepower.newquiz.core.util import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import com.infinitepower.newquiz.model.UiText @Composable @ReadOnlyComposable fun UiText.asString(): String { val argsFormatted = formatArgs(*args) return when (this) { is UiText.DynamicString -> value.format(*argsFormatted) is UiText.StringResource -> stringResource(resId, *argsFormatted) is UiText.PluralStringResource -> pluralStringResource(id = resId, count = quantity, *argsFormatted) } } fun UiText.asString(context: Context): String { val argsFormatted = context.formatArgs(*args) return when (this) { is UiText.DynamicString -> value.format(*argsFormatted) is UiText.StringResource -> context.getString(resId, *argsFormatted) is UiText.PluralStringResource -> context.resources.getQuantityString(resId, quantity, *argsFormatted) } } @Composable @ReadOnlyComposable private fun formatArgs( vararg args: Any ): Array = args.map { arg -> when (arg) { is UiText -> arg.asString() else -> arg } }.toTypedArray() private fun Context.formatArgs( vararg args: Any ): Array = args.map { arg -> when (arg) { is UiText -> arg.asString(this) else -> arg } }.toTypedArray() ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/util/UriUtils.kt ================================================ package com.infinitepower.newquiz.core.util import android.net.Uri import java.net.URI fun URI.toAndroidUri(): Uri = Uri.parse(this.toString()) fun Uri.toJavaURI(): URI = URI.create(this.toString()) fun emptyJavaURI(): URI = URI.create("") ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/util/android/DrawableUtils.kt ================================================ package com.infinitepower.newquiz.core.util.android /* /** * Get the dominant color of the image asynchronously */ fun Drawable.getDominantColor( onFinish: (Color) -> Unit ) { val bitmap = (this as BitmapDrawable).bitmap.copy(Bitmap.Config.ARGB_8888, true) Palette.from(bitmap).generate { palette -> palette?.vibrantSwatch?.rgb?.let { colorValue -> onFinish(Color(colorValue)) } } } */ ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/util/android/resources/ResourcesUtil.kt ================================================ package com.infinitepower.newquiz.core.util.android.resources import android.content.res.Resources import androidx.annotation.RawRes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json suspend inline fun Resources.readRawJson(@RawRes rawResId: Int): T { return withContext(Dispatchers.IO) { val resStr = openRawResource(rawResId).use { it.readBytes().decodeToString() } Json.decodeFromString(resStr) } } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/util/collections/Collections.kt ================================================ package com.infinitepower.newquiz.core.util.collections inline fun Iterable.indexOfFirstOrNull(predicate: (T) -> Boolean) = indexOfFirst(predicate).takeIf { it >= 0 } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/util/kotlin/BooleanUtils.kt ================================================ package com.infinitepower.newquiz.core.util.kotlin fun Boolean.toInt(): Int = if (this) 1 else 0 fun Boolean.toLong(): Long = if (this) 1L else 0L fun Boolean.toULong(): ULong = if (this) 1uL else 0uL ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/util/kotlin/CollectionsUtils.kt ================================================ package com.infinitepower.newquiz.core.util.kotlin import kotlin.random.Random /** * Returns the sum of all elements in the collection. */ @JvmName("sumOfIntRange") fun Iterable.sum(): IntRange { if (count() == 0) return 0..0 return reduce { acc, intRange -> (acc.first + intRange.first)..(acc.last + intRange.last) } } infix fun ClosedFloatingPointRange.increaseEndBy( other: Float ): ClosedFloatingPointRange { return endInclusive..(endInclusive + other) } suspend fun generateRandomUniqueItems( itemCount: Int, generator: suspend () -> T, maxIterations: Int = Int.MAX_VALUE, exclusions: List = emptyList() ): Iterable { val items = HashSet() var iterations = 0 while (items.size < itemCount && iterations < maxIterations) { val generatedItem = generator() // Generate new item if (generatedItem in exclusions) continue // Checks if generated item is not in items items.add(generatedItem) iterations++ } return items } /** * Generates a list of [answerCount] number of incorrect answers for a given [correctSolution] integer. * @param answerCount The number of incorrect answers to generate. * @param correctSolution The correct solution to the question. * @param fromRange The minimum range of values from which to generate incorrect answers. Default value is 10. * @param toRange The maximum range of values from which to generate incorrect answers. Default value is 10. * @param random The random number generator used to generate the incorrect answers. */ suspend fun generateIncorrectNumberAnswers( answerCount: Int, correctSolution: Int, fromRange: Int = 10, toRange: Int = 10, random: Random = Random ): List = generateRandomUniqueItems( itemCount = answerCount, exclusions = listOf(correctSolution), generator = { random.nextInt(correctSolution - fromRange, correctSolution + toRange) } ).toList() ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/util/kotlin/Math.kt ================================================ package com.infinitepower.newquiz.core.util.kotlin import kotlin.math.pow import kotlin.math.roundToInt fun Double.roundToUInt(): UInt = roundToInt().toUInt() infix fun UInt.pow(n: Int): UInt = toDouble().pow(n).toUInt() infix operator fun ULong.div(other: Float): Float = toLong() / other infix operator fun ULong.div(other: Double): Double = toLong() / other ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/util/kotlin/NumberUtils.kt ================================================ package com.infinitepower.newquiz.core.util.kotlin fun Int.toDoubleDigit(): String = String.format("%02d", this) operator fun UInt.times(multiplierFactor: Float): UInt { return this.toInt().times(multiplierFactor).toUInt() } operator fun UIntRange.times(multiplierFactor: Float): UIntRange { return this.first * multiplierFactor..this.last * multiplierFactor } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/util/kotlin/SetUtils.kt ================================================ package com.infinitepower.newquiz.core.util.kotlin fun Set.removeFirst(): Set { return this - first() } fun Set.removeLast(): Set { return this - last() } ================================================ FILE: core/src/main/java/com/infinitepower/newquiz/core/util/model/QuestionDifficultyUtil.kt ================================================ package com.infinitepower.newquiz.core.util.model import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.question.QuestionDifficulty import com.infinitepower.newquiz.core.R as CoreR fun QuestionDifficulty.getText(): UiText = when (this) { is QuestionDifficulty.Easy -> UiText.StringResource(CoreR.string.easy) is QuestionDifficulty.Medium -> UiText.StringResource(CoreR.string.medium) is QuestionDifficulty.Hard -> UiText.StringResource(CoreR.string.hard) } ================================================ FILE: core/src/main/res/drawable/github_logo.xml ================================================ ================================================ FILE: core/src/main/res/drawable/logo_monochromatic.xml ================================================ ================================================ FILE: core/src/main/res/drawable/round_android_24.xml ================================================ ================================================ FILE: core/src/main/res/drawable/round_flag_circle_24.xml ================================================ ================================================ FILE: core/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: core/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: core/src/main/res/raw/trophy2.json ================================================ {"v":"5.8.1","fr":30,"ip":0,"op":71,"w":500,"h":500,"nm":"Trophy","ddd":0,"assets":[{"id":"comp_0","nm":"Pre-comp 3","fr":30,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Pre-comp 2","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[391.176,345.588,0],"ix":2,"l":2},"a":{"a":0,"k":[50,48.5,0],"ix":1,"l":2},"s":{"a":0,"k":[30,30,100],"ix":6,"l":2}},"ao":0,"w":100,"h":97,"ip":2,"op":17,"st":2,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"Pre-comp 2","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[344.118,294.118,0],"ix":2,"l":2},"a":{"a":0,"k":[50,48.5,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":100,"h":97,"ip":1,"op":16,"st":1,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Pre-comp 2","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[151.471,317.647,0],"ix":2,"l":2},"a":{"a":0,"k":[50,48.5,0],"ix":1,"l":2},"s":{"a":0,"k":[30,30,100],"ix":6,"l":2}},"ao":0,"w":100,"h":97,"ip":7,"op":22,"st":7,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Pre-comp 2","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[104.412,266.176,0],"ix":2,"l":2},"a":{"a":0,"k":[50,48.5,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":100,"h":97,"ip":6,"op":21,"st":6,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"Pre-comp 2","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[342.647,145.588,0],"ix":2,"l":2},"a":{"a":0,"k":[50,48.5,0],"ix":1,"l":2},"s":{"a":0,"k":[30,30,100],"ix":6,"l":2}},"ao":0,"w":100,"h":97,"ip":4,"op":19,"st":4,"bm":0},{"ddd":0,"ind":6,"ty":0,"nm":"Pre-comp 2","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[295.588,94.118,0],"ix":2,"l":2},"a":{"a":0,"k":[50,48.5,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":100,"h":97,"ip":3,"op":18,"st":3,"bm":0},{"ddd":0,"ind":7,"ty":0,"nm":"Pre-comp 2","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[133.824,122.059,0],"ix":2,"l":2},"a":{"a":0,"k":[50,48.5,0],"ix":1,"l":2},"s":{"a":0,"k":[30,30,100],"ix":6,"l":2}},"ao":0,"w":100,"h":97,"ip":1,"op":16,"st":1,"bm":0},{"ddd":0,"ind":8,"ty":0,"nm":"Pre-comp 2","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[179.412,82.353,0],"ix":2,"l":2},"a":{"a":0,"k":[50,48.5,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":100,"h":97,"ip":0,"op":15,"st":0,"bm":0}]},{"id":"comp_1","nm":"Pre-comp 2","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 12","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-90,"ix":10},"p":{"a":0,"k":[50.5,47,0],"ix":2,"l":2},"a":{"a":0,"k":[-142.5,-154,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-142.5,-154],[-101.5,-154]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":4,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[0]},{"t":14,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":15,"st":-11,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 11","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":180,"ix":10},"p":{"a":0,"k":[50.5,47,0],"ix":2,"l":2},"a":{"a":0,"k":[-142.5,-154,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-142.5,-154],[-101.5,-154]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":4,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[0]},{"t":14,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":15,"st":-11,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 10","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":90,"ix":10},"p":{"a":0,"k":[50.5,47,0],"ix":2,"l":2},"a":{"a":0,"k":[-142.5,-154,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-142.5,-154],[-101.5,-154]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":4,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[0]},{"t":14,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":15,"st":-11,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 9","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50.5,47,0],"ix":2,"l":2},"a":{"a":0,"k":[-142.5,-154,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-142.5,-154],[-101.5,-154]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":4,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[0]},{"t":14,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":15,"st":-11,"bm":0}]},{"id":"comp_2","nm":"Pre-comp 1","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 10","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,0],[178,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[60]},{"t":14,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[60]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 11","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":30,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,0],[178,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[60]},{"t":14,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[60]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 12","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":60,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,0],[178,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[60]},{"t":14,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[60]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 13","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":90,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,0],[178,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[60]},{"t":14,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[60]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Shape Layer 14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":120,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,0],[178,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[60]},{"t":14,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[60]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Shape Layer 15","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":150,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,0],[178,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[60]},{"t":14,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[60]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Shape Layer 16","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":180,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,0],[178,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[60]},{"t":14,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[60]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Shape Layer 17","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":210,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,0],[178,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[60]},{"t":14,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[60]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Shape Layer 18","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":240,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,0],[178,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[60]},{"t":14,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[60]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"Shape Layer 19","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":270,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,0],[178,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":4,"s":[60]},{"t":14,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[60]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"Shape Layer 21","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":300,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,0],[178,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":3,"s":[60]},{"t":13,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[60]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"Shape Layer 20","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":330,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,0],[178,0]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.705882352941,0.247058838489,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":3,"s":[60]},{"t":13,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[60]},{"t":10,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":300,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Pre-comp 3","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[-100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":39,"op":61,"st":39,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"Pre-comp 3","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":24,"op":46,"st":24,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Cup 3","parent":14,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0.371,-98.838,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.8,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[{"i":[[-11.815,0],[0,0],[1.176,-11.756],[0,0],[5.492,54.916],[0,0]],"o":[[0,0],[11.815,0],[0,0],[-5.492,54.916],[0,0],[-1.176,-11.756]],"v":[[-49.8,-128.285],[49.3,-128.285],[70.626,-106.958],[62.096,-21.652],[-62.596,-21.652],[-71.126,-106.958]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.2,"y":0},"t":14,"s":[{"i":[[0,6.785],[0,0],[0,-11.667],[0,0],[0,55.777],[0,0]],"o":[[0,0],[0,8.035],[0,0],[0,54.652],[0,0],[0,-12.042]],"v":[[-0.25,-128.285],[-0.25,-128.285],[-0.25,-106.958],[-0.25,-21.652],[-0.25,-21.652],[-0.25,-106.958]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":18,"s":[{"i":[[-11.815,0],[0,0],[1.176,-11.756],[0,0],[5.492,54.916],[0,0]],"o":[[0,0],[11.815,0],[0,0],[-5.492,54.916],[0,0],[-1.176,-11.756]],"v":[[-49.8,-128.285],[49.3,-128.285],[70.626,-106.958],[62.096,-21.652],[-62.596,-21.652],[-71.126,-106.958]],"c":true}]},{"i":{"x":1,"y":1},"o":{"x":0.333,"y":0},"t":24,"s":[{"i":[[-11.815,0],[0,0],[1.176,-11.756],[0,0],[5.492,54.916],[0,0]],"o":[[0,0],[11.815,0],[0,0],[-5.492,54.916],[0,0],[-1.176,-11.756]],"v":[[-49.8,-128.285],[49.3,-128.285],[70.626,-106.958],[62.096,-21.652],[-62.596,-21.652],[-71.126,-106.958]],"c":true}]},{"i":{"x":0.223,"y":1},"o":{"x":0.2,"y":0},"t":31,"s":[{"i":[[0,6.785],[0,0],[0,-11.667],[0,0],[0,55.777],[0,0]],"o":[[0,0],[0,8.035],[0,0],[0,54.652],[0,0],[0,-12.042]],"v":[[-0.25,-128.285],[-0.25,-128.285],[-0.25,-106.958],[-0.25,-21.652],[-0.25,-21.652],[-0.25,-106.958]],"c":true}]},{"t":50,"s":[{"i":[[-11.815,0],[0,0],[1.176,-11.756],[0,0],[5.492,54.916],[0,0]],"o":[[0,0],[11.815,0],[0,0],[-5.492,54.916],[0,0],[-1.176,-11.756]],"v":[[-49.8,-128.285],[49.3,-128.285],[70.626,-106.958],[62.096,-21.652],[-62.596,-21.652],[-71.126,-106.958]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882370472,0.247058823705,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Cup","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":31,"op":310,"st":10,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 7","tt":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"t":24,"s":[{"i":[[3.191,-0.395],[2.304,-0.927],[2.095,-1.709],[1.788,-1.877],[0.908,-1.912],[-1.334,-6.312],[-2.779,-4.188],[-3.401,-3.602],[-3.548,-3.297],[-2.312,-2.352],[-2.506,-2.506],[-2.476,-2.535],[-0.232,-1.997],[0.723,-0.831],[0.267,-1.304],[-2.88,-0.857],[1.3,9.712],[4.203,4.76],[9.453,16.328],[-0.295,3.28],[-3.343,1.249],[-4.023,-0.951],[-1.8,-0.768],[-8.286,2.069],[-0.398,3.182],[3.129,3.445],[1.614,1.176],[1.189,0.657],[2.306,0.956],[2.086,0.582]],"o":[[-4.689,0.581],[-2.304,0.927],[-1.938,1.582],[-1.788,1.877],[-3.116,6.566],[1.334,6.312],[2.849,4.294],[3.401,3.602],[2.244,2.084],[2.312,2.352],[2.864,2.864],[2.476,2.535],[0.16,1.372],[-0.723,0.831],[-1.339,6.557],[13.183,3.921],[-1.018,-7.607],[-12.335,-13.97],[-1.509,-2.606],[0.413,-4.602],[3.955,-1.477],[2.275,0.538],[8.878,3.789],[3.458,-0.863],[0.467,-3.729],[-1.703,-1.875],[-1.654,-1.205],[-1.861,-1.028],[-2.371,-0.983],[-7.691,-2.147]],"v":[[-93,-111],[-102.945,-108.846],[-109,-105],[-114.773,-99.748],[-119,-94],[-120.921,-74.216],[-114,-58],[-104.525,-46.252],[-94,-36],[-87.197,-29.316],[-80,-22],[-71.526,-13.849],[-67,-7],[-68.18,-3.949],[-70,-1],[-64,12],[-47,-11],[-59,-30],[-99,-72],[-102,-81],[-94,-91],[-82,-91],[-75,-89],[-55,-79],[-48,-87],[-53,-98],[-58,-101],[-62,-105],[-69,-107],[-75,-110]],"c":true}],"h":1},{"t":25,"s":[{"i":[[6.363,-1.468],[2.979,-2.095],[1.84,-2.639],[0.408,-1.067],[0.408,-1.35],[0.465,-0.387],[0.082,-0.263],[-3.965,-7.542],[-4.029,-4.555],[-0.766,-0.479],[-0.438,-0.523],[-0.104,-0.568],[-0.27,-0.353],[-0.859,-0.529],[-0.842,-0.709],[-4.878,-5.799],[-0.092,-0.71],[0.419,-2.677],[-6.464,-0.238],[-1.53,2.112],[6.189,7.171],[5.82,5.82],[5.515,7.127],[-5.296,5.528],[-9.204,-2.345],[-3.834,-2.514],[-5.231,0.751],[-0.822,3.258],[5.25,2.566],[1.551,0.608]],"o":[[-4.243,0.979],[-2.98,2.096],[-0.962,1.38],[-0.408,1.067],[-0.057,0.189],[-0.465,0.387],[-2.889,9.276],[3.965,7.542],[0.497,0.561],[0.766,0.479],[0.313,0.374],[0.104,0.568],[1.053,1.378],[1.068,0.657],[6.494,5.469],[1.271,1.511],[0.356,2.738],[-1.191,7.598],[4.588,0.169],[8.605,-11.877],[-4.677,-5.419],[-6.151,-6.151],[-4.119,-5.322],[4.622,-4.825],[3.701,0.943],[4.525,2.967],[3.142,-0.451],[2.691,-10.661],[-1.826,-0.892],[-7.754,-3.037]],"v":[[-95,-111],[-105.802,-106.245],[-113,-99],[-114.915,-95.478],[-116,-92],[-116.981,-91.056],[-118,-90],[-114.689,-64.459],[-101,-46],[-98.956,-44.471],[-97,-43],[-96.468,-41.485],[-96,-40],[-92,-37],[-90,-35],[-71,-17],[-65,-8],[-68,-1],[-59,12],[-49,6],[-55,-29],[-72,-45],[-91,-66],[-97,-87],[-77,-91],[-66,-85],[-54,-79],[-46,-86],[-62,-106],[-67,-109]],"c":true}],"h":1},{"t":26,"s":[{"i":[[1.111,-0.113],[2.585,-1.009],[1.674,-1.559],[0.573,-0.084],[0.356,-0.336],[0.475,-0.926],[0.564,-0.79],[0.36,-0.281],[0.285,-0.493],[0.871,-2.718],[0.063,-2.618],[-3.733,-5.588],[-2.015,-2.521],[-0.872,-1.07],[-1.274,-1.411],[-7.648,-7.648],[-0.543,-5.007],[0.55,-2.542],[-2.362,-2.153],[-1.562,7.645],[2.913,4.566],[2.86,3.478],[9.12,11.123],[-1.11,7.282],[-2.157,0.726],[-4.835,-3.467],[-4.962,0.362],[-0.24,6.416],[7.573,3.176],[2.407,0.544]],"o":[[-3.987,0.405],[-2.585,1.009],[-0.352,0.328],[-0.573,0.084],[-0.536,0.507],[-0.475,0.926],[-0.273,0.382],[-0.36,0.281],[-1.452,2.508],[-0.871,2.718],[-0.253,10.508],[1.754,2.625],[0.961,1.203],[1.009,1.238],[6.895,7.635],[5.268,5.268],[0.253,2.337],[-0.918,4.241],[8.175,7.452],[1.806,-8.842],[-2.806,-4.398],[-8.695,-10.574],[-5.23,-6.378],[0.863,-5.666],[7.845,-2.641],[3.765,2.699],[4.671,-0.341],[0.327,-8.737],[-3.3,-1.384],[-5.338,-1.207]],"v":[[-85,-112],[-94.735,-109.865],[-101,-106],[-102.498,-105.506],[-104,-105],[-105.479,-102.712],[-107,-100],[-107.991,-99.083],[-109,-98],[-112.542,-90.083],[-114,-82],[-106,-58],[-100,-51],[-98,-47],[-94,-44],[-75,-23],[-63,-8],[-65,-1],[-63,9],[-43,-1],[-48,-23],[-58,-35],[-84,-62],[-94,-83],[-86,-92],[-64,-86],[-52,-79],[-43,-89],[-63,-108],[-72,-112]],"c":true}],"h":1},{"t":27,"s":[{"i":[[2.317,-0.535],[3.86,-4.04],[1.242,-4.613],[-2.12,-5.938],[-2.373,-3.37],[-0.492,-0.639],[-0.459,-0.632],[-0.8,-1.421],[-0.923,-1.114],[-0.951,-0.655],[-0.472,-0.507],[-0.081,-0.566],[-0.34,-0.374],[-1.019,-0.973],[-0.936,-1.02],[-0.997,-1.18],[-0.954,-1.127],[-0.458,-4.534],[0.591,-1.92],[-7.875,-0.144],[-0.943,9.517],[4.79,6.294],[7.791,10.593],[-2.16,8.125],[-1.994,0.531],[-4.781,-3.242],[-0.608,-0.61],[-0.949,-0.628],[-0.752,8.522],[12.133,3.114]],"o":[[-5.317,1.227],[-3.86,4.04],[-1.851,6.879],[2.12,5.938],[0.571,0.811],[0.492,0.639],[0.875,1.203],[0.8,1.421],[0.723,0.873],[0.951,0.655],[0.337,0.361],[0.081,0.566],[0.99,1.09],[1.018,0.973],[1.058,1.152],[0.997,1.18],[3.481,4.111],[0.271,2.68],[-2.255,7.329],[7.212,0.132],[1.112,-11.222],[-8.19,-10.762],[-4.476,-6.085],[0.814,-3.063],[5.149,-1.372],[0.641,0.434],[1.172,1.175],[7.025,4.649],[0.85,-9.635],[-5.404,-1.387]],"v":[[-82,-111],[-96.057,-102.54],[-104,-89],[-102.668,-69.368],[-95,-55],[-93.416,-52.866],[-92,-51],[-89.536,-46.934],[-87,-43],[-84.312,-40.725],[-82,-39],[-81.502,-37.509],[-81,-36],[-77.959,-32.947],[-75,-30],[-71.923,-26.481],[-69,-23],[-58,-8],[-60,-2],[-51,12],[-38,-5],[-48,-30],[-76,-62],[-84,-85],[-77,-92],[-60,-87],[-57,-85],[-55,-81],[-38,-88],[-65,-111]],"c":true}],"h":1},{"t":28,"s":[{"i":[[9.692,-1.405],[1.755,-0.653],[1.212,-0.989],[0.612,-0.799],[1.064,-1.588],[0.956,-1.834],[0.829,-2.651],[-1.471,-5.599],[-2.124,-4.18],[-2.534,-3.99],[-2.617,-3.613],[-1.064,-1.14],[-0.856,-1.216],[-1.667,-2.958],[-0.212,-2.155],[0.684,-2.633],[-1.441,-2.449],[-1.846,-1.035],[-3.412,0.49],[-1.764,4.555],[0.994,6.213],[1.271,2.573],[1.579,2.614],[5.214,6.995],[2.346,5.732],[-9.454,1.216],[-1.712,-1.097],[-10.861,3.613],[-0.352,2.926],[4.889,4.64]],"o":[[-2.767,0.401],[-1.755,0.653],[-1.489,1.215],[-0.612,0.799],[-1.348,2.013],[-0.956,1.834],[-2.008,6.423],[1.471,5.599],[2.249,4.426],[2.534,3.99],[0.888,1.226],[1.064,1.14],[2.12,3.012],[1.667,2.958],[0.297,3.021],[-0.684,2.633],[0.206,0.35],[1.846,1.035],[3.939,-0.566],[1.764,-4.555],[-0.335,-2.095],[-1.271,-2.573],[-3.799,-6.286],[-5.214,-6.995],[-3.248,-7.936],[1.752,-0.225],[5.75,3.685],[2.926,-0.973],[0.526,-4.371],[-6.865,-6.516]],"v":[[-63,-111],[-69.666,-109.441],[-74,-107],[-76.82,-104.28],[-79,-101],[-82.389,-95.478],[-85,-89],[-85.099,-70.817],[-79,-56],[-71.776,-43.39],[-64,-32],[-60.976,-28.493],[-58,-25],[-52.068,-15.857],[-49,-8],[-50.358,0.429],[-50,8],[-46.904,10.63],[-39,12],[-30.301,3.736],[-29,-13],[-31.567,-20.11],[-36,-28],[-50.589,-48.416],[-63,-68],[-59,-92],[-53,-89],[-34,-79],[-28,-87],[-36,-101]],"c":true}],"h":1},{"t":29,"s":[{"i":[[-2.683,7.317],[-0.869,-1.25],[-0.8,-0.658],[-1.031,-0.204],[-1.563,0.111],[-1.735,1.264],[-0.45,1.828],[3.728,4.807],[2.323,0.76],[3.865,-2.557],[1.585,-3.719],[-0.715,-8.537],[-2.363,-6.29],[-1.877,-4.04],[-1.844,-4.454],[-1.186,-3.102],[-0.134,-2.887],[0.496,-1.449],[0.053,-1.394],[-1.552,-2.05],[-2.93,-0.213],[-1.523,0.346],[-0.883,0.689],[-0.327,1.358],[-0.442,1.593],[1.215,6.113],[1.85,4.387],[0.684,1.501],[0.533,1.264],[2.41,7.866]],"o":[[1.239,1.98],[0.869,1.25],[0.8,0.658],[1.031,0.204],[1.61,-0.114],[1.735,-1.264],[1.234,-5.011],[-3.728,-4.807],[-5.956,-1.948],[-3.865,2.557],[-3.224,7.564],[0.715,8.537],[1.648,4.387],[1.877,4.04],[1.12,2.706],[1.186,3.102],[0.038,0.811],[-0.496,1.449],[-0.136,3.585],[1.552,2.05],[1.025,0.075],[1.523,-0.346],[1.249,-0.974],[0.327,-1.358],[1.71,-6.16],[-1.215,-6.113],[-0.73,-1.733],[-0.684,-1.501],[-3.044,-7.221],[-2.41,-7.866]],"v":[[-33,-87],[-29.914,-82.19],[-27.486,-79.364],[-24.816,-78.105],[-21,-78],[-15.63,-80.215],[-12,-85],[-17.332,-100.689],[-28,-110],[-42.778,-108.25],[-51,-98],[-54.191,-73.044],[-49,-50],[-43.647,-37.55],[-38,-25],[-34.26,-16.136],[-32,-7],[-32.932,-3.437],[-34,1],[-31.799,9.529],[-25,13],[-20.893,12.572],[-17,11],[-14.895,7.464],[-14,3],[-13.83,-15.829],[-19,-32],[-21.148,-36.851],[-23,-41],[-32.295,-63.928]],"c":true}],"h":1},{"t":30,"s":[{"i":[[3.333,-0.976],[1.133,-1.074],[0.545,-1.572],[0.264,-2.087],[0.291,-2.619],[-0.238,-6.751],[-0.732,-6.624],[-0.753,-6.186],[-0.301,-5.438],[0.011,-2.415],[-0.042,-2.238],[-0.282,-1.704],[-0.709,-0.814],[-1.027,-0.436],[-1.371,-0.226],[-1.409,0.198],[-1.139,0.835],[0.122,7.953],[0.958,8.662],[0.318,3.74],[0.375,3.844],[0.571,4.011],[-0.7,2.69],[-0.723,0.668],[-0.748,0.937],[-0.514,0.926],[-0.22,1.386],[0.663,2.516],[0.717,1.6],[2.195,1.193]],"o":[[-2.029,0.594],[-1.133,1.074],[-0.545,1.572],[-0.264,2.087],[-0.729,6.568],[0.238,6.751],[0.732,6.624],[0.753,6.186],[0.124,2.236],[-0.011,2.415],[0.042,2.238],[0.282,1.704],[0.377,0.433],[1.027,0.436],[1.371,0.226],[1.409,-0.198],[3.447,-2.528],[-0.122,-7.953],[-0.353,-3.197],[-0.318,-3.74],[-0.416,-4.266],[-0.571,-4.011],[0.361,-1.39],[0.723,-0.668],[0.726,-0.91],[0.514,-0.926],[0.37,-2.338],[-0.663,-2.516],[-1.885,-4.206],[-2.195,-1.193]],"v":[[-11,-109],[-15.667,-106.502],[-18.108,-102.537],[-19.245,-97.054],[-20,-90],[-20.619,-69.944],[-19.046,-49.805],[-16.7,-30.513],[-15,-13],[-14.877,-5.936],[-14.877,1.132],[-14.438,7.133],[-13,11],[-10.818,12.357],[-7.145,13.403],[-2.899,13.497],[1,12],[5.304,-4.9],[3,-31],[2.016,-41.514],[1,-53],[-0.837,-65.682],[-1,-76],[0.711,-78.84],[3,-81],[4.88,-83.643],[6,-87],[5.316,-94.554],[3,-101],[-2.914,-108.886]],"c":true}],"h":1},{"t":31,"s":[{"i":[[3.441,-0.647],[1.283,-0.953],[0.833,-1.422],[0.508,-1.758],[0.31,-1.96],[-0.087,-1.457],[-0.408,-1.039],[-0.617,-0.938],[-0.714,-1.153],[-0.141,-1.047],[0.131,-1.095],[0.206,-1.125],[0.086,-1.137],[0.415,-3.836],[0.451,-3.58],[0.436,-3.579],[0.369,-3.834],[0.163,-4.005],[-0.565,-3.399],[-1.82,-1.944],[-3.604,0.359],[-1.357,1.89],[-0.311,2.89],[0.076,3.305],[-0.195,3.135],[-0.864,6.178],[-0.881,7.07],[-0.327,7.107],[0.798,6.288],[2.455,2.453]],"o":[[-1.859,0.35],[-1.283,0.953],[-0.833,1.422],[-0.508,1.758],[-0.346,2.191],[0.087,1.457],[0.408,1.039],[0.617,0.938],[0.608,0.981],[0.141,1.047],[-0.131,1.095],[-0.206,1.125],[-0.328,4.346],[-0.415,3.836],[-0.451,3.58],[-0.436,3.579],[-0.363,3.764],[-0.163,4.005],[0.565,3.399],[1.82,1.944],[3.06,-0.305],[1.357,-1.89],[0.311,-2.89],[-0.076,-3.305],[0.275,-4.432],[0.864,-6.178],[0.881,-7.07],[0.327,-7.107],[-0.741,-5.836],[-2.455,-2.453]],"v":[[11,-109],[6.318,-107.013],[3.176,-103.416],[1.196,-98.612],[0,-93],[-0.361,-87.608],[0.409,-83.943],[1.975,-81.057],[4,-78],[5.074,-74.953],[5.041,-71.735],[4.487,-68.399],[4,-65],[2.874,-52.791],[1.563,-41.731],[0.22,-31.056],[-1,-20],[-1.921,-8.134],[-1.45,3.184],[1.995,11.41],[10,14],[16.461,10.561],[18.798,3.245],[18.986,-6.194],[19,-16],[20.851,-32.129],[23.61,-52.216],[25.564,-73.694],[25,-94],[20.025,-106.362]],"c":true}],"h":1},{"t":32,"s":[{"i":[[10.012,-0.857],[0.663,-0.066],[0.696,-0.133],[0.685,-0.25],[0.629,-0.419],[1.161,-1.785],[0.997,-2.24],[0.588,-2.086],[-0.068,-1.321],[-1.507,-1.219],[-0.877,-0.316],[-1.658,-0.093],[-0.11,-0.173],[0.768,-3.552],[0.803,-3.414],[1.144,-4.168],[1.192,-4.1],[1.234,-4.969],[-0.056,-4.277],[-2.293,-3.72],[-5.767,1.698],[-1.257,0.847],[-0.419,0.742],[0.306,2.867],[-0.16,2.647],[-0.757,2.689],[-0.84,3.026],[-1.59,5.162],[-1.1,4.811],[1.605,11.555]],"o":[[-0.585,0.05],[-0.663,0.066],[-0.696,0.133],[-0.685,0.25],[-1.079,0.718],[-1.161,1.785],[-0.997,2.24],[-0.588,2.086],[0.093,1.806],[1.507,1.219],[1.258,0.453],[1.658,0.093],[0.876,1.379],[-0.768,3.552],[-1.23,5.23],[-1.144,4.168],[-1.119,3.847],[-1.234,4.969],[0.062,4.817],[2.293,3.72],[-0.445,0.131],[1.257,-0.847],[1.063,-1.884],[-0.306,-2.867],[0.134,-2.219],[0.757,-2.689],[1.538,-5.541],[1.59,-5.162],[2.433,-10.639],[-1.605,-11.555]],"v":[[27,-109],[25.116,-108.839],[23.066,-108.553],[20.982,-107.991],[19,-107],[15.579,-103.093],[12.28,-96.903],[9.841,-90.262],[9,-85],[11.912,-80.383],[16,-78],[20.861,-77.29],[24,-77],[23.76,-69.026],[21,-58],[17.471,-44.153],[14,-32],[10.119,-18.323],[8,-4],[11.222,9.887],[23,14],[24.852,12.655],[28,10],[28.678,2.572],[28,-6],[29.471,-13.395],[32,-22],[36.828,-38.047],[41,-53],[43.334,-89.622]],"c":true}],"h":1},{"t":33,"s":[{"i":[[-1.692,-0.766],[1.471,-6.219],[2.553,-5.943],[1.166,-2.647],[1.155,-2.664],[1.283,-2.686],[0.627,-2.395],[-0.895,-5.6],[-2.8,-1.331],[-2.452,0.856],[-0.736,1.221],[0.438,2.954],[-0.248,2.689],[-1.182,2.599],[-0.96,2.214],[-0.473,1.359],[-0.575,1.331],[-0.379,0.478],[-0.227,0.489],[-0.098,0.91],[-0.285,0.66],[-1.26,2.459],[-0.88,2.66],[-0.699,3.206],[-0.248,3.282],[3.214,7.139],[7.793,-0.124],[1.212,-0.836],[-16.354,-1.393],[-1.724,3.235]],"o":[[1.434,6.183],[-1.471,6.218],[-1.18,2.747],[-1.166,2.647],[-1.24,2.86],[-1.283,2.686],[-1.379,5.271],[0.895,5.6],[3.456,1.643],[2.452,-0.856],[1.506,-2.497],[-0.438,-2.954],[0.312,-3.39],[1.182,-2.599],[0.535,-1.233],[0.473,-1.359],[0.258,-0.599],[0.379,-0.478],[0.325,-0.701],[0.098,-0.91],[1.079,-2.504],[1.26,-2.459],[0.958,-2.895],[0.698,-3.206],[0.647,-8.57],[-3.214,-7.139],[-2.471,0.04],[-7.887,5.438],[7.4,0.63],[0.183,-0.343]],"v":[[38,-85],[37.49,-66.32],[31,-48],[27.481,-39.938],[24,-32],[20.04,-23.651],[17,-16],[16.366,1.455],[22,13],[31.04,13.648],[36,10],[36.944,1.644],[36,-7],[38.514,-15.882],[42,-23],[43.47,-26.927],[45,-31],[46.023,-32.582],[47,-34],[47.53,-36.53],[48,-39],[51.649,-46.383],[55,-54],[57.532,-63.209],[59,-73],[55.33,-98.02],[39,-110],[28,-106],[26,-77],[36,-83]],"c":true}],"h":1},{"t":34,"s":[{"i":[[14.095,-1.272],[0.937,-0.322],[0.989,-0.507],[0.677,-0.062],[0.614,-0.406],[1.107,-1.421],[0.922,-1.224],[0.408,-0.14],[0.201,-0.216],[0.756,-2.55],[-0.329,-1.4],[-2.06,-1.454],[-3.378,0.441],[-2.23,3.005],[-1.843,-1.444],[-0.405,-1.932],[-0.025,-1.995],[0.392,-1.914],[0.456,-1.6],[2.132,-3.599],[2.472,-4.571],[0.616,-1.544],[0.771,-1.406],[0.656,-1.147],[0.679,-1.473],[-14.013,-1.683],[-0.906,4.713],[-0.286,3.517],[-3.246,5.959],[-1.098,19.932]],"o":[[-2.264,0.204],[-0.937,0.322],[-0.678,0.347],[-0.677,0.062],[-1.651,1.092],[-1.107,1.421],[-0.169,0.224],[-0.407,0.14],[-1.305,1.401],[-0.756,2.55],[0.382,1.624],[2.06,1.454],[3.235,-0.423],[2.23,-3.005],[0.353,0.276],[0.405,1.932],[0.022,1.745],[-0.392,1.914],[-2.002,7.031],[-2.132,3.6],[-0.767,1.416],[-0.616,1.544],[-0.631,1.151],[-0.757,1.324],[-5.008,10.858],[6.28,0.754],[0.78,-4.06],[0.405,-4.988],[8.39,-15.404],[1.187,-21.552]],"v":[[47,-109],[42.544,-108.226],[40,-107],[37.952,-106.544],[36,-106],[31.953,-102.099],[29,-98],[28.025,-97.494],[27,-97],[23.774,-90.499],[23,-84],[26.753,-78.951],[35,-77],[43.044,-83.9],[49,-88],[50.246,-84.288],[51,-78],[50.358,-72.392],[49,-67],[42.853,-52.155],[36,-41],[34.003,-36.492],[32,-32],[29,-29],[27,-24],[32,14],[44,6],[42,-6],[49,-23],[71,-75]],"c":true}],"h":1},{"t":35,"s":[{"i":[[8.043,-0.886],[1.416,-0.578],[2.278,-1.352],[1.236,-0.627],[0.831,-0.763],[0.311,-0.547],[0.347,-0.438],[0.627,-2.982],[-3.135,-2.767],[-3.115,2.252],[-2.844,2.136],[-1.146,-0.796],[-0.7,-3.858],[2.241,-5.103],[2.163,-3.381],[0.336,-0.745],[0.319,-0.495],[4.168,-7.075],[0.392,-6.585],[-1.84,-4.18],[-4.893,0],[-1.309,1.377],[-0.485,1.491],[0.519,2.006],[-0.269,2.498],[-1.51,2.797],[-1.745,2.835],[-3.084,4.604],[-2.081,10.716],[5.726,6.344]],"o":[[-3.056,0.337],[-1.416,0.578],[-1.225,0.727],[-1.236,0.627],[-0.408,0.374],[-0.311,0.547],[-2.626,3.307],[-0.627,2.981],[4.694,4.144],[3.115,-2.252],[2.716,-2.041],[1.146,0.796],[1.126,6.206],[-2.241,5.103],[-0.337,0.526],[-0.336,0.745],[-3.936,6.116],[-4.168,7.075],[-0.255,4.279],[1.841,4.18],[3.279,0],[1.309,-1.377],[0.806,-2.478],[-0.519,-2.006],[0.213,-1.978],[1.511,-2.797],[3.564,-5.791],[5.859,-8.748],[2.529,-13.025],[-5.149,-5.706]],"v":[[54,-109],[47.916,-107.762],[43,-105],[39.204,-103.026],[36,-101],[34.955,-99.548],[34,-98],[28.68,-88.595],[32,-80],[43.387,-78.79],[52,-87],[57.513,-88.924],[60,-82],[57.466,-64.882],[50,-52],[48.986,-49.976],[48,-48],[34.842,-27.852],[27,-7],[29.139,6.709],[39,14],[45.596,11.618],[48,7],[47.903,0.515],[47,-6],[49.851,-13.357],[55,-22],[67,-40],[80,-70],[72,-102]],"c":true}],"h":1},{"t":36,"s":[{"i":[[6.95,-0.74],[2.905,-1.098],[1.338,-1.282],[2.561,-3.354],[-1.377,-4.54],[-1.524,-1.099],[-2.451,-0.109],[-1.063,0.784],[-0.925,0.989],[-2.672,1.205],[-0.427,0.231],[-1.115,-0.377],[-0.221,-2.138],[2.655,-4.292],[0.852,-1.278],[0.353,-0.47],[0.554,-0.76],[0.376,-0.501],[0.552,-0.759],[1.638,-2.261],[2.119,-8.747],[-11.571,0.349],[0.102,-0.212],[-0.6,5.533],[-3.865,5.614],[-3.36,5.017],[-2.959,7.022],[-0.68,2.933],[8.858,3.993],[0.432,0.229]],"o":[[-3.041,0.324],[-2.905,1.098],[-2.132,2.042],[-2.561,3.354],[0.454,1.496],[1.524,1.099],[2.821,0.126],[1.064,-0.784],[2.484,-2.655],[0.357,-0.161],[1.608,-0.869],[1.776,0.601],[0.807,7.818],[-0.965,1.561],[-0.298,0.447],[-0.636,0.848],[-0.344,0.472],[-0.635,0.846],[-1.758,2.419],[-5.704,7.876],[-2.125,8.771],[5.64,-0.17],[2.416,-5.024],[0.401,-3.692],[4.493,-6.525],[4.477,-6.685],[1.175,-2.788],[3.523,-15.198],[-0.36,-0.162],[-3.543,-1.875]],"v":[[60,-109],[50.723,-106.719],[44,-103],[35.868,-94.873],[33,-83],[36.002,-78.96],[42,-77],[47.422,-78.164],[50,-81],[56,-86],[57,-88],[64,-90],[69,-80],[60,-57],[58,-52],[56,-51],[55,-48],[53,-47],[52,-44],[46,-37],[32,-11],[44,14],[53,9],[51,-6],[60,-21],[73,-39],[84,-59],[88,-68],[76,-105],[75,-107]],"c":true}],"h":1},{"t":37,"s":[{"i":[[6.233,-0.744],[3.673,-1.795],[2.536,-2.251],[1.463,-2.357],[-0.21,-2.367],[-1.895,-1.574],[-2.184,-0.066],[-3.996,3.091],[-3.467,-1.045],[-0.259,-4.087],[3.554,-5.557],[3.616,-4.531],[1.087,-12.139],[-9.903,1.954],[-0.682,3.684],[-0.71,4.488],[-0.964,1.734],[-0.939,1.446],[-0.345,0.461],[-0.554,0.76],[-0.376,0.501],[-0.596,0.775],[-1.455,2.193],[-0.623,0.94],[-0.623,0.94],[-0.506,0.737],[-0.962,1.445],[-0.981,1.538],[0.282,9.538],[7.628,4.07]],"o":[[-4.773,0.57],[-3.673,1.795],[-1.358,1.206],[-1.463,2.357],[0.234,2.637],[1.896,1.574],[6.258,0.189],[4.147,-3.208],[2.255,0.68],[0.397,6.272],[-3.899,6.097],[-9.421,11.805],[-1.145,12.792],[3.806,-0.751],[0.803,-4.341],[0.086,-0.546],[0.913,-1.643],[0.285,-0.438],[0.636,-0.848],[0.344,-0.472],[0.653,-0.87],[2.166,-2.819],[1.103,-1.662],[1.103,-1.662],[0.595,-0.897],[0.809,-1.177],[0.873,-1.312],[4.282,-6.717],[-0.374,-12.63],[-4.306,-2.297]],"v":[[65,-109],[52.323,-105.261],[43,-99],[38.324,-93.371],[36,-86],[39.537,-79.572],[46,-77],[57,-84],[70,-90],[76,-79],[68,-59],[56,-43],[35,-8],[50,14],[57,6],[55,-7],[58,-11],[60,-16],[62,-17],[63,-20],[65,-21],[66,-24],[74,-33],[77,-37],[80,-41],[82,-43],[84,-48],[87,-52],[96,-79],[81,-107]],"c":true}],"h":1},{"t":38,"s":[{"i":[[1.647,-0.211],[3.205,-1.278],[3.453,-2.54],[2.196,-2.853],[-1.45,-4.627],[-1.536,-1.121],[-2.416,-0.112],[-2.608,2.496],[-2.576,1.201],[-2.905,-1.487],[-0.312,-4.948],[1.855,-3.591],[1.77,-2.606],[2.135,-2.77],[2.131,-2.486],[4.115,-5.865],[0,-7.076],[-9.381,0.969],[0.945,4.244],[-0.331,2.895],[-2.557,3.213],[-1.725,2.071],[-2.393,3.051],[-0.908,1.137],[-1.532,2.219],[-0.672,1.075],[-1.198,3.937],[3.55,7.75],[6.098,3.388],[1.056,0.329]],"o":[[-2.727,0.349],[-3.205,1.278],[-2.665,1.96],[-2.196,2.853],[0.439,1.403],[1.536,1.121],[3.993,0.185],[2.608,-2.496],[4.069,-1.897],[2.905,1.487],[0.191,3.028],[-1.855,3.591],[-2.331,3.432],[-2.135,2.77],[-5.541,6.463],[-4.115,5.865],[0,10.245],[8.784,-0.907],[-0.459,-2.063],[0.63,-5.51],[1.77,-2.224],[3.326,-3.993],[0.909,-1.159],[1.718,-2.153],[0.596,-0.863],[2.908,-4.652],[3.053,-10.033],[-3.703,-8.083],[-0.968,-0.538],[-4.187,-1.303]],"v":[[69,-109],[60.044,-106.643],[50,-101],[41.914,-94.001],[40,-83],[43.017,-79.032],[49,-77],[58.562,-81.461],[66,-88],[76.817,-88.634],[82,-79],[78.971,-68.684],[73,-59],[66.35,-49.791],[60,-42],[44.844,-23.959],[38,-5],[52,14],[60,1],[58,-6],[68,-20],[73,-27],[82,-37],[84,-41],[90,-47],[92,-51],[100,-66],[98,-93],[86,-106],[82,-109]],"c":true}],"h":1},{"t":39,"s":[{"i":[[6.89,-0.815],[3.45,-1.257],[3.629,-2.669],[2.123,-2.867],[-1.331,-4.445],[-1.321,-1.204],[-2.45,-0.294],[-2.437,1.703],[-1.607,-13.824],[3.698,-5.29],[0.365,-0.487],[0.611,-0.78],[0.417,-0.448],[0.686,-0.803],[2.043,-2.273],[2.527,-10.638],[-10.055,1.039],[-0.351,0.734],[-0.86,5.47],[-1.778,2.471],[-3.623,4.033],[-1.799,1.999],[-1.727,2.054],[-0.83,1.103],[-0.403,0.433],[-0.679,0.936],[-0.711,1.062],[0.53,11.664],[4.486,4.612],[1.94,1.026]],"o":[[-2.573,0.304],[-3.45,1.257],[-2.84,2.088],[-2.123,2.867],[0.267,0.889],[1.321,1.204],[6.346,0.762],[8.352,-5.838],[0.651,5.598],[-0.322,0.461],[-0.659,0.878],[-0.361,0.46],[-0.697,0.749],[-2.476,2.9],[-10.39,11.564],[-3.517,14.807],[4.645,-0.48],[2.721,-5.69],[0.214,-1.358],[3.702,-5.144],[2.269,-2.525],[1.844,-2.05],[0.988,-1.175],[0.337,-0.448],[0.745,-0.8],[0.829,-1.142],[4.953,-7.395],[-0.454,-9.995],[-2.485,-2.555],[-5.035,-2.663]],"v":[[73,-109],[63.792,-106.773],[53,-101],[44.872,-93.767],[43,-83],[45.362,-79.554],[51,-77],[64,-84],[87,-80],[79,-60],[77,-59],[76,-56],[74,-55],[73,-52],[66,-45],[42,-12],[55,14],[63,9],[61,-7],[66,-14],[77,-27],[83,-34],[89,-40],[91,-44],[93,-45],[94,-48],[97,-51],[107,-80],[97,-101],[91,-107]],"c":true}],"h":1},{"t":40,"s":[{"i":[[4.56,-0.479],[2.024,-0.262],[1.95,-0.748],[0.607,-0.565],[0.799,-0.444],[1.906,-1.337],[1.585,-2.101],[0.779,-1.504],[-0.362,-2.331],[-4.248,-0.316],[-3.926,2.486],[-1.573,-12.837],[4.023,-5.229],[6.173,-6.924],[2.693,-4.652],[0.019,-5.058],[-8.53,1.272],[-0.963,3.04],[0.206,2.491],[-0.396,3.016],[-1.573,2.228],[-3.55,3.963],[-1.701,1.835],[-3.405,4.389],[-0.712,0.971],[-1.534,2.568],[-1.537,4.72],[-0.22,2.206],[6.333,4.201],[0.665,0.404]],"o":[[-1.954,0.206],[-2.024,0.262],[-0.772,0.296],[-0.607,0.565],[-2.79,1.549],[-1.906,1.337],[-1.247,1.653],[-0.779,1.504],[0.802,5.157],[6.652,0.495],[9.988,-6.325],[0.794,6.479],[-5.783,7.516],[-4.146,4.65],[-2.608,4.505],[-0.039,10.342],[5.007,-0.747],[0.615,-1.941],[-0.203,-2.447],[0.19,-1.452],[3.567,-5.052],[2.286,-2.552],[3.581,-3.864],[0.608,-0.783],[2.013,-2.745],[2.329,-3.899],[0.731,-2.245],[1.388,-13.905],[-0.823,-0.546],[-5.657,-3.436]],"v":[[77,-109],[70.996,-108.407],[65,-107],[63.02,-105.611],[61,-104],[54.097,-99.914],[49,-95],[45.793,-90.508],[45,-85],[54,-77],[67,-85],[91,-80],[82,-59],[60,-35],[48,-20],[43,-5],[58,14],[65,7],[66,2],[63,-6],[68,-13],[79,-26],[85,-33],[97,-45],[99,-49],[104,-56],[109,-67],[111,-74],[99,-104],[97,-106]],"c":true}],"h":1},{"t":41,"s":[{"i":[[5.891,-0.619],[7.054,-4.41],[-0.705,-7.194],[-5.031,-0.092],[-2.824,1.828],[-0.657,0.394],[-2.013,0.636],[-2.125,-0.552],[-0.418,-5.349],[2.56,-3.348],[0.67,-0.82],[2.544,-2.78],[1.253,-1.437],[1.207,-1.257],[0,-14.229],[-8.933,0.442],[-0.698,1.155],[0.579,3.606],[-0.339,2.536],[-0.446,0.479],[-0.686,0.877],[-0.919,0.987],[-0.682,0.766],[-5.785,6.618],[-0.808,0.985],[-0.432,0.464],[-0.687,0.872],[-1.654,2.521],[-1.364,6.519],[8.91,4.888]],"o":[[-6.357,0.668],[-5.62,3.514],[0.498,5.084],[5.931,0.109],[0.694,-0.449],[1.764,-1.058],[2.338,-0.739],[2.344,0.608],[0.506,6.467],[-0.612,0.801],[-2.183,2.67],[-1.231,1.345],[-1.246,1.429],[-12.272,12.774],[0,8.527],[2.795,-0.138],[0.685,-1.133],[-0.352,-2.197],[0.226,-1.696],[0.726,-0.779],[1.372,-1.753],[0.681,-0.732],[7.305,-8.207],[0.724,-0.829],[0.388,-0.473],[0.724,-0.777],[1.893,-2.404],[3.54,-5.395],[3.454,-16.502],[-6.286,-3.448]],"v":[[80,-109],[59,-102],[47,-86],[57,-77],[68,-84],[70,-85],[77,-89],[87,-90],[95,-79],[86,-61],[85,-58],[77,-51],[74,-46],[70,-42],[45,-5],[59,14],[67,10],[68,1],[65,-6],[69,-11],[70,-14],[75,-18],[76,-21],[96,-40],[98,-44],[100,-45],[101,-48],[107,-55],[114,-71],[99,-106]],"c":true}],"h":1},{"t":42,"s":[{"i":[[5.581,-0.632],[7.165,-4.053],[-1.142,-7.166],[-1.553,-1.381],[-2.421,-0.18],[-1.8,1.565],[-1.521,0.984],[-3.539,1.312],[-2.811,-0.562],[-1.738,-1.872],[-0.263,-3.155],[2.61,-4.713],[9.48,-10.878],[1.233,-1.504],[1.8,-3.22],[0.368,-5.272],[-1.843,-2.869],[-6.806,1.534],[-0.796,2.513],[0.246,2.439],[-0.489,3.636],[-0.368,0.49],[-0.662,0.792],[-3.438,3.791],[-5.613,8.462],[-1.561,2.724],[-1.17,6.131],[7.022,5.403],[0.481,0.217],[0.411,0.238]],"o":[[-5.48,0.621],[-7.166,4.053],[0.366,2.296],[1.553,1.381],[3.279,0.244],[1.8,-1.565],[2.031,-1.314],[3.54,-1.312],[1.785,0.357],[1.738,1.872],[0.371,4.452],[-8.341,15.06],[-1.324,1.52],[-2.438,2.974],[-1.597,2.857],[-0.327,4.68],[1.338,2.084],[3.671,-0.828],[0.639,-2.018],[-0.224,-2.216],[0.192,-1.43],[0.677,-0.903],[3.676,-4.395],[7.576,-8.354],[1.656,-2.497],[2.724,-4.753],[2.594,-13.588],[-0.41,-0.315],[-0.349,-0.157],[-5.679,-3.284]],"v":[[82,-109],[60.534,-101.908],[49,-85],[51.959,-79.413],[58,-77],[65.318,-79.579],[70,-84],[78.915,-88.407],[89,-90],[94.642,-86.599],[98,-79],[94,-68],[61,-32],[58,-27],[51,-19],[47,-7],[50,8],[63,14],[69,7],[70,2],[67,-6],[71,-11],[72,-14],[85,-27],[108,-52],[112,-59],[117,-72],[106,-104],[104,-104],[103,-106]],"c":true}],"h":1},{"t":43,"s":[{"i":[[4.927,-0.558],[3.542,-1.123],[3.644,-2.146],[2.59,-2.62],[-0.351,-3.759],[-1.728,-1.527],[-1.838,-0.203],[-1.693,1.27],[-1.334,1.242],[-1.264,0.661],[-0.923,0.407],[-1.021,0.334],[-1.016,0.265],[-2.77,-1.549],[-1.102,-4.061],[2.077,-3.671],[2.572,-3.066],[2.696,-2.589],[1.977,-1.977],[2.091,-10.911],[-10.779,1.307],[-1.057,1.787],[-1.051,5.198],[-2.459,2.941],[-0.93,1.141],[-3.209,2.975],[-2.158,2.158],[-2.093,2.308],[-2.789,7.123],[11.877,6.689]],"o":[[-3.189,0.361],[-3.542,1.123],[-2.991,1.761],[-2.59,2.62],[0.251,2.69],[1.728,1.527],[2.895,0.32],[1.693,-1.27],[1.02,-0.95],[1.264,-0.661],[0.9,-0.398],[1.021,-0.334],[4.816,-1.257],[2.77,1.549],[0.882,3.25],[-2.077,3.671],[-2.239,2.668],[-2.696,2.589],[-9.81,9.81],[-2.071,10.81],[1.08,-0.131],[3.745,-6.328],[0.488,-2.412],[0.944,-1.129],[2.981,-3.657],[2.545,-2.359],[2.037,-2.037],[5.832,-6.43],[6.585,-16.816],[-5.88,-3.312]],"v":[[84,-109],[73.841,-106.839],[63,-102],[53.993,-95.498],[50,-86],[53.309,-79.635],[59,-77],[65.671,-78.829],[70,-83],[73.573,-85.407],[77,-87],[79.913,-88.099],[83,-89],[94.285,-88.489],[100,-80],[97.591,-69.362],[90,-59],[82.303,-50.981],[75,-44],[49,-10],[63,14],[70,10],[69,-7],[75,-14],[77,-18],[87,-28],[95,-34],[101,-41],[118,-64],[105,-106]],"c":true}],"h":1},{"t":44,"s":[{"i":[[5.115,-0.566],[0.943,-0.154],[1.191,-0.267],[0.914,-0.327],[1.32,-0.463],[1.941,-0.604],[1.595,-1.01],[0.14,-0.424],[0.226,-0.169],[0.793,-0.671],[0.484,-0.704],[-9.232,-0.443],[-2.197,1.257],[-5.522,-1.014],[-0.547,-6.508],[2.724,-4.107],[3.049,-2.882],[3.51,-5.292],[1.328,-2.294],[0.363,-1.894],[-1.376,-2.868],[-5.947,0.179],[-0.63,1.987],[-1.123,6.386],[-1.55,1.785],[-9.427,10.506],[-1.063,1.345],[-1.763,2.75],[0.099,8.822],[7.131,4.016]],"o":[[-1.038,0.115],[-0.943,0.154],[-1.026,0.231],[-0.914,0.327],[-1.639,0.575],[-1.941,0.604],[-0.215,0.136],[-0.14,0.424],[-0.907,0.68],[-1.156,0.978],[-5.414,7.879],[4.964,0.238],[4.374,-2.503],[3.374,0.62],[0.679,8.082],[-3.097,4.669],[-5.604,5.298],[-2.105,3.173],[-1.447,2.499],[-1.606,8.373],[2.312,4.819],[6.404,-0.193],[2.077,-6.55],[0.135,-0.768],[10.333,-11.897],[0.89,-0.991],[2.309,-2.921],[3.774,-5.887],[-0.135,-11.968],[-6.041,-3.402]],"v":[[86,-109],[83.114,-108.614],[80,-108],[77.22,-107.174],[74,-106],[68.467,-104.326],[63,-102],[62.508,-101.025],[62,-100],[59,-99],[55,-95],[61,-77],[75,-85],[93,-90],[103,-79],[91,-58],[78,-44],[61,-28],[54,-19],[50,-11],[52,6],[63,14],[72,7],[70,-7],[75,-14],[107,-45],[109,-49],[115,-56],[123,-79],[107,-106]],"c":true}],"h":1},{"t":45,"s":[{"i":[[0.261,-0.024],[8.017,-4.31],[-2.441,-8.274],[-1.328,-1.18],[-2.397,-0.288],[-2.528,1.81],[-1.946,1.114],[-3.131,1.036],[-3.078,-0.516],[-1.994,-1.612],[-0.453,-3.289],[2.299,-3.506],[2.393,-2.681],[2.722,-2.635],[2.206,-2.206],[2.769,-2.663],[2.362,-2.786],[2.18,-3.757],[0.046,-4.341],[-2.666,-3.677],[-4.385,0.463],[1.056,5.63],[-0.76,4],[-2.669,2.987],[-6.78,6.435],[-3.89,11.92],[3.755,5.161],[2.881,1.299],[0.428,0.231],[7.572,0.345]],"o":[[-5.371,0.485],[-8.018,4.31],[0.293,0.992],[1.328,1.18],[2.942,0.353],[2.528,-1.81],[2.397,-1.371],[3.131,-1.036],[1.571,0.263],[1.994,1.612],[0.59,4.296],[-2.299,3.506],[-2.904,3.254],[-2.722,2.635],[-2.564,2.564],[-2.769,2.663],[-2.232,2.632],[-2.181,3.757],[-0.05,4.755],[2.666,3.677],[7.589,-0.801],[-0.441,-2.352],[0.115,-0.608],[6.683,-7.478],[10.619,-10.079],[3.595,-11.016],[-2.432,-3.343],[-0.358,-0.161],[-4.269,-2.305],[-0.985,-0.045]],"v":[[88,-109],[64.641,-101.842],[53,-83],[55.422,-79.472],[61,-77],[69.247,-79.9],[76,-85],[84.489,-88.916],[94,-90],[99.839,-87.269],[104,-80],[100.738,-68.289],[93,-59],[84.476,-50.214],[77,-43],[68.849,-35.167],[61,-27],[53.861,-17.282],[50,-5],[54.174,8.414],[65,14],[73,1],[71,-7],[77,-15],[99,-36],[123,-69],[118,-97],[110,-104],[109,-106],[90,-110]],"c":true}],"h":1},{"t":46,"s":[{"i":[[0.261,-0.024],[3.609,-1.108],[3.998,-2.245],[-0.756,-8.148],[-4.586,-0.172],[-3.519,2.276],[-3.334,0.953],[-4.742,-2.039],[-0.147,-6.015],[1.516,-2.355],[5.212,-4.928],[2.644,-2.52],[2.302,-2.807],[1.252,-1.683],[0.027,-7.326],[-9.201,1.116],[1.156,7.206],[-0.386,2.527],[-2.006,2.278],[-4.659,4.126],[-1.031,0.939],[-4.081,4.977],[-0.432,0.464],[-0.685,0.891],[-1.167,2.216],[0.124,6.681],[1.678,2.899],[2.982,2.713],[0.823,0.444],[6.03,0.275]],"o":[[-3.566,0.322],[-3.609,1.108],[-6.202,3.483],[0.531,5.729],[7.273,0.272],[2.833,-1.832],[3.97,-1.135],[1.682,0.723],[0.086,3.512],[-5.448,8.46],[-2.964,2.802],[-3.241,3.089],[-1.422,1.734],[-3.514,4.726],[-0.035,9.359],[6.251,-0.758],[-0.396,-2.471],[0.249,-1.627],[4.982,-5.658],[1.278,-1.132],[5.173,-4.711],[0.388,-0.473],[0.73,-0.785],[2.117,-2.754],[2.635,-5.004],[-0.117,-6.315],[-2.072,-3.579],[-2.029,-1.847],[-4.47,-2.413],[-0.985,-0.045]],"v":[[89,-109],[78.324,-106.942],[67,-102],[53,-86],[63,-77],[73,-83],[84,-88],[99,-89],[106,-78],[101,-67],[78,-42],[69,-35],[62,-26],[58,-22],[51,-5],[66,14],[74,1],[71,-6],[77,-13],[93,-29],[97,-32],[112,-49],[114,-50],[115,-53],[121,-60],[126,-79],[121,-94],[115,-101],[110,-106],[91,-110]],"c":true}],"h":1},{"t":47,"s":[{"i":[[0.256,-0.022],[2.254,-0.585],[3.206,-1.332],[1.791,-0.633],[1.243,-0.803],[0.459,-0.344],[0.796,-0.69],[0.578,-2.536],[-5.412,-0.37],[-5.389,2.961],[-5.612,-7.141],[4.373,-4.9],[5.997,-4.909],[3.55,-3.74],[1.212,-6.251],[-3.531,-3.523],[-4.555,1.079],[-0.436,0.887],[-0.723,4.885],[-1.207,1.819],[-1.929,1.28],[-0.83,0.751],[-7.632,9.27],[-2.211,5.316],[-0.595,2.568],[2.654,4.923],[0.466,0.486],[3.364,1.516],[0.428,0.231],[8.55,0.39]],"o":[[-3.78,0.33],[-2.254,0.585],[-1.595,0.663],[-1.791,0.633],[-0.437,0.283],[-0.915,0.687],[-2.477,2.147],[-1.81,7.935],[5.485,0.375],[9.229,-5.071],[7.221,9.187],[-7.227,8.097],[-4.679,3.831],[-5.316,5.6],[-1.874,9.669],[2.82,2.814],[1.501,-0.355],[3.521,-7.157],[0.248,-1.678],[1.85,-2.789],[1.033,-0.685],[9.235,-8.355],[4.474,-5.434],[0.69,-1.658],[2.05,-8.853],[-0.787,-1.461],[-2.878,-3.004],[-0.358,-0.161],[-4.426,-2.389],[-0.981,-0.045]],"v":[[90,-109],[81.569,-107.752],[74,-105],[68.736,-103.105],[64,-101],[63,-99],[60,-98],[54,-90],[63,-77],[78,-85],[104,-85],[96,-60],[77,-41],[65,-29],[52,-11],[57,10],[68,14],[74,9],[72,-6],[79,-15],[86,-22],[88,-24],[112,-48],[124,-64],[126,-71],[123,-92],[121,-96],[112,-104],[111,-106],[92,-110]],"c":true}],"h":1},{"t":48,"s":[{"i":[[5.707,-0.548],[3.607,-1.102],[4.005,-2.249],[2.819,-2.546],[-0.378,-4.074],[-1.662,-1.648],[-2.151,-0.238],[-1.707,1.271],[-1.321,1.23],[-4.305,1.431],[-2.995,-0.388],[-2.217,-1.571],[-0.439,-4.495],[2.592,-3.463],[2.102,-2.328],[3.975,-3.638],[4.112,-3.924],[2.697,-3.058],[1.5,-3.18],[0.57,-2.285],[-0.267,-2.771],[-10.363,1.257],[1.29,7.424],[-0.455,2.952],[-2.67,2.951],[-4.807,4.372],[-3.133,3.133],[-1.813,14.684],[3.516,5.047],[5.309,2.786]],"o":[[-3.566,0.342],[-3.607,1.102],[-3.101,1.742],[-2.819,2.546],[0.201,2.169],[1.662,1.648],[2.852,0.316],[1.707,-1.271],[1.786,-1.663],[4.305,-1.431],[1.695,0.22],[2.216,1.571],[0.471,4.82],[-2.592,3.463],[-4.989,5.524],[-3.975,3.638],[-2.714,2.59],[-2.697,3.058],[-0.985,2.088],[-0.571,2.285],[0.413,4.288],[6.161,-0.747],[-0.406,-2.339],[0.384,-2.489],[5.442,-6.015],[2.741,-2.493],[10.566,-10.566],[1.415,-11.462],[-3.734,-5.361],[-5.857,-3.073]],"v":[[90,-109],[79.329,-106.93],[68,-102],[58.391,-95.749],[54,-86],[57.038,-80.051],[63,-77],[69.648,-78.841],[74,-83],[84.094,-88.039],[96,-90],[102.442,-87.706],[107,-79],[102.93,-66.631],[95,-58],[81.841,-44.8],[70,-34],[61.59,-25.442],[55,-16],[52.561,-9.512],[52,-2],[67,14],[75,1],[72,-6],[79,-14],[94,-29],[102,-37],[127,-73],[121,-96],[111,-106]],"c":true}],"h":1},{"t":49,"s":[{"i":[[3.726,-0.269],[3.866,-1.088],[4.144,-2.403],[2.823,-2.612],[-0.276,-3.822],[-1.586,-1.63],[-2.499,-0.282],[-1.548,1.293],[-1.59,1.192],[-0.564,0.12],[-0.354,0.225],[-4.842,0.875],[-3.172,-2.701],[-0.118,-3.707],[1.76,-2.908],[6.757,-6.211],[3.75,-10.485],[-0.208,-3.713],[-1.091,-1.383],[2.067,12.881],[-0.361,2.289],[-1.78,2.037],[-2.428,2.305],[-7.457,6.16],[-3.098,4.195],[3.88,12.637],[0.571,0.761],[0.508,0.74],[2.927,1.694],[0.556,0.251]],"o":[[-3.391,0.245],[-3.866,1.088],[-2.984,1.73],[-2.823,2.612],[0.159,2.197],[1.586,1.63],[3.218,0.363],[1.548,-1.293],[0.392,-0.294],[0.564,-0.12],[3.46,-2.202],[4.842,-0.875],[2.825,2.405],[0.108,3.373],[-5.399,8.92],[-11.244,10.335],[-2.003,5.601],[0.162,2.889],[6.774,8.581],[-0.434,-2.706],[0.207,-1.314],[2.331,-2.667],[8.916,-8.463],[4.571,-3.776],[5.652,-7.653],[-0.646,-2.104],[-0.616,-0.821],[-3.524,-5.128],[-0.784,-0.454],[-4.8,-2.162]],"v":[[91,-109],[80.065,-107.118],[68,-102],[58.555,-95.569],[54,-86],[56.744,-80.063],[63,-77],[69.721,-78.833],[74,-83],[75.529,-83.551],[77,-84],[90.216,-89.177],[103,-87],[108,-78],[104,-68],[82,-45],[54,-13],[52,-3],[56,9],[75,1],[72,-6],[78,-13],[85,-20],[107,-41],[118,-54],[127,-87],[123,-93],[122,-96],[112,-105],[110,-107]],"c":true}],"h":1},{"t":50,"s":[{"i":[[10.014,-0.756],[0.334,0.01],[0.332,-0.013],[7.34,-4.098],[-0.047,-5.375],[-1.561,-1.849],[-2.735,-0.328],[-1.553,1.304],[-1.559,1.17],[-0.564,0.12],[-0.354,0.225],[-4.846,0.868],[-3.148,-2.679],[-0.605,-0.782],[-0.453,-0.912],[2.39,-3.712],[5.022,-4.916],[1.428,-1.092],[0.716,-0.65],[4.753,-5.254],[0.052,-8.184],[-9.639,1.902],[0.967,6.027],[-0.365,2.287],[-0.108,0.144],[-0.519,0.745],[-3.935,3.656],[-4.823,4.433],[-3.771,12.303],[9.393,5.781]],"o":[[-0.331,0.025],[-0.334,-0.01],[-5.69,0.232],[-7.339,4.098],[0.02,2.276],[1.561,1.849],[3.227,0.388],[1.553,-1.304],[0.392,-0.294],[0.564,-0.12],[3.47,-2.209],[4.846,-0.868],[1.127,0.96],[0.605,0.782],[2.799,5.636],[-4.487,6.967],[-2.004,1.962],[-0.743,0.568],[-5.103,4.635],[-4.541,5.021],[-0.059,9.36],[6.182,-1.22],[-0.434,-2.705],[0.005,-0.029],[0.621,-0.828],[3.433,-4.925],[6.122,-5.688],[10.354,-9.517],[4.825,-15.744],[-6.583,-4.051]],"v":[[91,-109],[90.001,-108.991],[89,-109],[67.197,-101.857],[54,-87],[56.464,-80.539],[63,-77],[69.751,-78.832],[74,-83],[75.529,-83.551],[77,-84],[90.242,-89.166],[103,-87],[105.506,-84.464],[107,-82],[103,-67],[88,-50],[82,-44],[79,-43],[63,-27],[52,-5],[68,14],[75,1],[72,-6],[74,-7],[75,-10],[86,-21],[101,-35],[126,-68],[114,-104]],"c":true}],"h":1},{"t":51,"s":[{"i":[[10.014,-0.756],[0.334,0.01],[0.332,-0.013],[7.385,-4.171],[-0.434,-6.076],[-1.652,-1.692],[-2.25,-0.27],[-3.046,2.238],[-3.59,1.436],[-3.744,0.083],[-2.191,-1.865],[-0.881,-1.501],[-0.057,-1.79],[1.061,-1.93],[0.82,-1.273],[2.519,-2.714],[2.61,-2.555],[1.071,-1.118],[0.714,-0.546],[0.716,-0.65],[4.921,-5.441],[0,-8.425],[-9.948,1.963],[0.967,6.027],[-0.361,2.289],[-1.893,2.135],[-2.46,2.285],[-4.891,4.496],[-3.77,12.301],[9.393,5.781]],"o":[[-0.331,0.025],[-0.334,-0.01],[-5.894,0.24],[-7.385,4.171],[0.141,1.96],[1.652,1.692],[3.227,0.388],[3.046,-2.238],[3.834,-1.534],[3.744,-0.083],[1.418,1.208],[0.881,1.501],[0.064,2.005],[-1.061,1.93],[-2.312,3.591],[-2.519,2.714],[-1.002,0.981],[-1.071,1.118],[-0.743,0.568],[-5.167,4.692],[-4.504,4.98],[0,9.142],[6.182,-1.22],[-0.434,-2.706],[0.105,-0.668],[2.217,-2.501],[6.163,-5.726],[10.351,-9.515],[4.825,-15.744],[-6.583,-4.051]],"v":[[91,-109],[90.001,-108.991],[89,-109],[66.754,-101.877],[54,-86],[56.918,-80.233],[63,-77],[72.227,-80.632],[82,-87],[93.732,-89.55],[103,-87],[106.521,-82.937],[108,-78],[106.163,-71.951],[103,-67],[95.723,-57.723],[88,-50],[84.784,-46.674],[82,-44],[79,-43],[63,-27],[52,-5],[68,14],[75,1],[72,-6],[78,-14],[86,-21],[101,-35],[126,-68],[114,-104]],"c":true}],"h":1},{"t":52,"s":[{"i":[[12.38,-0.935],[0.334,0.01],[0.332,-0.013],[7.395,-4.231],[-0.401,-5.84],[-1.589,-1.631],[-2.495,-0.281],[-1.556,1.296],[-1.569,1.177],[-0.708,0.451],[-6.344,-5.401],[-0.118,-3.707],[1.553,-2.412],[2.109,-2.261],[3.479,-3.032],[1.015,-0.925],[4.884,-5.399],[0.457,-0.597],[0.675,-0.881],[0.965,-1.73],[-4.653,-6.739],[-6.717,1.325],[0.967,6.027],[-0.361,2.289],[-1.893,2.135],[-2.46,2.285],[-4.813,4.442],[-3.763,12.278],[3.547,5.784],[3.366,3.062]],"o":[[-0.331,0.025],[-0.334,-0.01],[-5.822,0.237],[-7.395,4.231],[0.151,2.196],[1.589,1.631],[3.209,0.362],[1.556,-1.296],[0.784,-0.588],[6.92,-4.404],[2.825,2.405],[0.127,3.97],[-1.794,2.785],[-3.802,4.076],[-1.461,1.273],[-5.345,4.868],[-0.563,0.622],[-0.636,0.832],[-1.303,1.701],[-4.265,7.65],[2.215,3.208],[6.182,-1.22],[-0.434,-2.706],[0.105,-0.668],[2.217,-2.501],[6.121,-5.687],[10.358,-9.559],[2.752,-8.978],[-2.654,-4.329],[-7.422,-6.753]],"v":[[91,-109],[90.001,-108.991],[89,-109],[66.832,-101.702],[54,-86],[56.742,-80.064],[63,-77],[69.73,-78.846],[74,-83],[77,-84],[103,-87],[108,-78],[103,-67],[96,-58],[84,-46],[79,-43],[63,-27],[61,-25],[60,-22],[56,-18],[56,8],[68,14],[75,1],[72,-6],[78,-14],[86,-21],[101,-35],[126,-68],[124,-93],[117,-101]],"c":true}],"h":1},{"t":53,"s":[{"i":[[12.304,-0.929],[0.334,0.01],[0.332,-0.013],[7.404,-4.223],[-0.424,-5.87],[-1.586,-1.63],[-2.499,-0.282],[-1.556,1.296],[-1.569,1.177],[-0.564,0.12],[-0.354,0.225],[-4.842,0.875],[-3.172,-2.701],[-0.882,-1.486],[-0.059,-1.853],[1.066,-1.956],[0.799,-1.242],[3.42,-3.683],[2.926,-2.55],[1.015,-0.925],[4.644,-5.08],[-8.281,-11.995],[-6.717,1.325],[0.967,6.027],[-0.361,2.289],[-1.837,2.073],[-2.369,2.222],[-4.859,4.485],[13.718,22.37],[3.366,3.062]],"o":[[-0.331,0.025],[-0.334,-0.01],[-5.806,0.237],[-7.404,4.223],[0.159,2.197],[1.586,1.63],[3.209,0.362],[1.556,-1.296],[0.392,-0.294],[0.564,-0.12],[3.46,-2.202],[4.842,-0.875],[1.412,1.203],[0.882,1.486],[0.061,1.934],[-1.066,1.956],[-2.394,3.718],[-3.42,3.683],[-1.461,1.273],[-5.408,4.924],[-7.65,8.367],[2.215,3.208],[6.182,-1.22],[-0.434,-2.706],[0.097,-0.617],[2.18,-2.459],[6.391,-5.993],[13.758,-12.697],[-2.654,-4.329],[-7.482,-6.808]],"v":[[91,-109],[90.001,-108.991],[89,-109],[66.828,-101.724],[54,-86],[56.744,-80.063],[63,-77],[69.73,-78.846],[74,-83],[75.529,-83.551],[77,-84],[90.216,-89.177],[103,-87],[106.515,-82.988],[108,-78],[106.146,-71.981],[103,-67],[93.899,-55.624],[84,-46],[79,-43],[63,-27],[56,8],[68,14],[75,1],[72,-6],[78,-14],[86,-21],[101,-35],[124,-93],[117,-101]],"c":true}],"h":1},{"t":54,"s":[{"i":[[12.304,-0.929],[0.334,0.01],[0.332,-0.013],[7.404,-4.223],[-0.424,-5.87],[-1.586,-1.63],[-2.499,-0.282],[-1.556,1.296],[-1.569,1.177],[-0.564,0.12],[-0.354,0.225],[-4.842,0.875],[-3.172,-2.701],[-0.882,-1.486],[-0.059,-1.853],[1.066,-1.956],[0.799,-1.242],[3.42,-3.683],[2.926,-2.55],[1.015,-0.925],[4.644,-5.08],[-8.281,-11.995],[-6.717,1.325],[0.967,6.027],[-0.361,2.289],[-1.837,2.073],[-2.369,2.222],[-4.843,4.499],[13.699,22.339],[3.366,3.062]],"o":[[-0.331,0.025],[-0.334,-0.01],[-5.806,0.237],[-7.404,4.223],[0.159,2.197],[1.586,1.63],[3.209,0.362],[1.556,-1.296],[0.392,-0.294],[0.564,-0.12],[3.46,-2.202],[4.842,-0.875],[1.412,1.203],[0.882,1.486],[0.061,1.934],[-1.066,1.956],[-2.394,3.718],[-3.42,3.683],[-1.461,1.273],[-5.408,4.924],[-7.65,8.367],[2.215,3.208],[6.182,-1.22],[-0.434,-2.706],[0.097,-0.617],[2.18,-2.459],[6.39,-5.993],[13.777,-12.8],[-2.654,-4.329],[-7.482,-6.808]],"v":[[91,-109],[90.001,-108.991],[89,-109],[66.828,-101.724],[54,-86],[56.744,-80.063],[63,-77],[69.73,-78.846],[74,-83],[75.529,-83.551],[77,-84],[90.216,-89.177],[103,-87],[106.515,-82.988],[108,-78],[106.146,-71.981],[103,-67],[93.899,-55.624],[84,-46],[79,-43],[63,-27],[56,8],[68,14],[75,1],[72,-6],[78,-14],[86,-21],[101,-35],[124,-93],[117,-101]],"c":true}],"h":1},{"t":55,"s":[{"i":[[12.3,-0.929],[0.334,0.01],[0.332,-0.013],[7.404,-4.223],[-0.424,-5.87],[-1.586,-1.63],[-2.499,-0.282],[-1.556,1.296],[-1.569,1.177],[-0.564,0.12],[-0.354,0.225],[-6.344,-5.401],[-0.118,-3.707],[1.809,-2.989],[6.743,-6.198],[3.75,-10.485],[-0.208,-3.713],[-1.091,-1.383],[2.067,12.881],[-0.361,2.289],[-1.831,2.095],[-2.428,2.305],[-3.88,3.668],[-1.006,0.754],[-0.796,0.79],[-2.045,2.458],[-2.43,5.656],[-0.59,2.441],[2.858,4.937],[2.61,2.375]],"o":[[-0.331,0.025],[-0.334,-0.01],[-5.806,0.237],[-7.404,4.223],[0.159,2.197],[1.586,1.63],[3.209,0.362],[1.556,-1.296],[0.392,-0.294],[0.564,-0.12],[6.92,-4.404],[2.825,2.405],[0.107,3.347],[-5.399,8.921],[-11.244,10.335],[-2.003,5.601],[0.162,2.889],[6.774,8.581],[-0.434,-2.706],[0.213,-1.349],[2.331,-2.667],[5.725,-5.434],[1.796,-1.698],[0.952,-0.714],[2.279,-2.261],[4.543,-5.459],[0.729,-1.698],[2.288,-9.461],[-1.972,-3.406],[-7.468,-6.795]],"v":[[91,-109],[90.001,-108.991],[89,-109],[66.828,-101.724],[54,-86],[56.744,-80.063],[63,-77],[69.73,-78.846],[74,-83],[75.529,-83.551],[77,-84],[103,-87],[108,-78],[104,-68],[82,-45],[54,-13],[52,-3],[56,9],[75,1],[72,-6],[78,-13],[85,-20],[99,-34],[104,-39],[107,-40],[113,-48],[125,-64],[127,-71],[123,-94],[117,-101]],"c":true}],"h":1},{"t":56,"s":[{"i":[[12.291,-0.928],[0.334,0.01],[0.332,-0.013],[7.404,-4.223],[-0.424,-5.87],[-1.585,-1.63],[-2.5,-0.282],[-1.556,1.296],[-1.569,1.177],[-0.564,0.12],[-0.354,0.225],[-4.842,0.875],[-3.172,-2.701],[-0.118,-3.707],[1.809,-2.989],[6.743,-6.198],[3.75,-10.485],[-0.208,-3.713],[-1.091,-1.383],[2.067,12.881],[-0.361,2.289],[-1.78,2.037],[-2.428,2.305],[-5.434,5.059],[-0.68,0.664],[-2.148,2.482],[-1.904,2.579],[0.202,10.903],[1.709,2.951],[2.701,2.457]],"o":[[-0.331,0.025],[-0.334,-0.01],[-5.806,0.237],[-7.404,4.223],[0.159,2.197],[1.585,1.63],[3.209,0.362],[1.556,-1.296],[0.392,-0.294],[0.564,-0.12],[3.46,-2.202],[4.842,-0.875],[2.825,2.405],[0.107,3.347],[-5.399,8.921],[-11.244,10.335],[-2.003,5.601],[0.162,2.889],[6.774,8.581],[-0.434,-2.706],[0.207,-1.314],[2.331,-2.667],[7.595,-7.21],[0.69,-0.642],[2.343,-2.289],[2.151,-2.486],[4.975,-6.737],[-0.118,-6.386],[-1.971,-3.404],[-7.448,-6.777]],"v":[[91,-109],[90.001,-108.991],[89,-109],[66.828,-101.724],[54,-86],[56.744,-80.063],[63,-77],[69.73,-78.846],[74,-83],[75.529,-83.551],[77,-84],[90.216,-89.177],[103,-87],[108,-78],[104,-68],[82,-45],[54,-13],[52,-3],[56,9],[75,1],[72,-6],[78,-13],[85,-20],[103,-38],[106,-39],[112,-47],[118,-54],[128,-79],[123,-94],[117,-101]],"c":true}],"h":1},{"t":57,"s":[{"i":[[12.291,-0.928],[0.334,0.01],[0.332,-0.013],[7.406,-4.223],[-0.432,-5.871],[-1.584,-1.63],[-2.502,-0.282],[-1.556,1.296],[-1.569,1.177],[-0.564,0.12],[-0.354,0.225],[-4.842,0.875],[-3.172,-2.701],[-0.882,-1.486],[-0.059,-1.853],[0.787,-1.708],[0.904,-1.495],[6.663,-6.125],[3.75,-10.485],[-0.208,-3.713],[-1.091,-1.383],[2.067,12.881],[-0.361,2.289],[-1.78,2.037],[-2.428,2.305],[-7.457,6.16],[-0.666,0.747],[0.293,15.788],[1.709,2.951],[2.701,2.457]],"o":[[-0.331,0.025],[-0.334,-0.01],[-5.806,0.237],[-7.406,4.223],[0.162,2.198],[1.584,1.63],[3.209,0.362],[1.556,-1.296],[0.392,-0.294],[0.564,-0.12],[3.46,-2.202],[4.842,-0.875],[1.412,1.203],[0.882,1.486],[0.053,1.673],[-0.787,1.708],[-5.368,8.869],[-11.244,10.335],[-2.003,5.601],[0.162,2.889],[6.774,8.581],[-0.434,-2.706],[0.207,-1.314],[2.331,-2.667],[8.916,-8.463],[0.788,-0.651],[8.101,-9.083],[-0.118,-6.386],[-1.971,-3.404],[-7.448,-6.777]],"v":[[91,-109],[90.001,-108.991],[89,-109],[66.821,-101.725],[54,-86],[56.745,-80.063],[63,-77],[69.73,-78.846],[74,-83],[75.529,-83.551],[77,-84],[90.216,-89.177],[103,-87],[106.515,-82.988],[108,-78],[106.719,-72.866],[104,-68],[82,-45],[54,-13],[52,-3],[56,9],[75,1],[72,-6],[78,-13],[85,-20],[107,-41],[109,-43],[128,-79],[123,-94],[117,-101]],"c":true}],"h":1},{"t":58,"s":[{"i":[[12.291,-0.928],[0.334,0.01],[0.332,-0.013],[7.406,-4.223],[-0.432,-5.871],[-1.584,-1.63],[-2.502,-0.282],[-1.556,1.296],[-1.569,1.177],[-0.564,0.12],[-0.354,0.225],[-4.842,0.875],[-3.172,-2.701],[-0.882,-1.486],[-0.059,-1.853],[0.787,-1.708],[0.904,-1.495],[6.663,-6.125],[3.75,-10.485],[-0.208,-3.713],[-1.091,-1.383],[2.067,12.881],[-0.361,2.289],[-1.78,2.037],[-2.428,2.305],[-7.457,6.16],[-0.666,0.747],[0.293,15.788],[1.709,2.951],[2.701,2.457]],"o":[[-0.331,0.025],[-0.334,-0.01],[-5.806,0.237],[-7.406,4.223],[0.162,2.198],[1.584,1.63],[3.209,0.362],[1.556,-1.296],[0.392,-0.294],[0.564,-0.12],[3.46,-2.202],[4.842,-0.875],[1.412,1.203],[0.882,1.486],[0.053,1.673],[-0.787,1.708],[-5.368,8.869],[-11.244,10.335],[-2.003,5.601],[0.162,2.889],[6.774,8.581],[-0.434,-2.706],[0.207,-1.314],[2.331,-2.667],[8.916,-8.463],[0.788,-0.651],[8.101,-9.083],[-0.118,-6.386],[-1.971,-3.404],[-7.448,-6.777]],"v":[[91,-109],[90.001,-108.991],[89,-109],[66.821,-101.725],[54,-86],[56.745,-80.063],[63,-77],[69.73,-78.846],[74,-83],[75.529,-83.551],[77,-84],[90.216,-89.177],[103,-87],[106.515,-82.988],[108,-78],[106.719,-72.866],[104,-68],[82,-45],[54,-13],[52,-3],[56,9],[75,1],[72,-6],[78,-13],[85,-20],[107,-41],[109,-43],[128,-79],[123,-94],[117,-101]],"c":true}],"h":1},{"t":59,"s":[{"i":[[0.233,-0.018],[0.334,0.01],[0.332,-0.013],[7.34,-4.074],[-0.087,-5.471],[-1.568,-1.799],[-2.689,-0.303],[-1.556,1.296],[-1.569,1.177],[-0.564,0.12],[-0.354,0.225],[-4.842,0.875],[-3.172,-2.701],[-0.118,-3.707],[1.665,-2.75],[6.591,-6.058],[3.695,-10.333],[-0.208,-3.713],[-1.136,-1.439],[2.07,12.899],[-0.361,2.289],[-2.204,2.522],[-2.383,2.262],[-7.197,5.946],[-3.098,4.195],[0.194,10.463],[2.607,2.721],[3.203,1.444],[0.428,0.231],[6.327,0.288]],"o":[[-0.331,0.025],[-0.334,-0.01],[-5.729,0.234],[-7.339,4.074],[0.04,2.501],[1.568,1.799],[3.209,0.362],[1.556,-1.296],[0.392,-0.294],[0.564,-0.12],[3.46,-2.202],[4.842,-0.875],[2.825,2.405],[0.102,3.205],[-5.468,9.033],[-11.226,10.319],[-2.003,5.601],[0.162,2.892],[6.756,8.557],[-0.434,-2.706],[0.214,-1.36],[2.256,-2.581],[8.329,-7.906],[4.571,-3.776],[4.943,-6.693],[-0.111,-5.958],[-2.958,-3.088],[-0.358,-0.161],[-4.365,-2.357],[-0.963,-0.044]],"v":[[91,-109],[90.001,-108.991],[89,-109],[67.138,-101.928],[54,-87],[56.513,-80.352],[63,-77],[69.73,-78.846],[74,-83],[75.529,-83.551],[77,-84],[90.216,-89.177],[103,-87],[108,-78],[104,-68],[82,-45],[54,-13],[52,-3],[56,9],[75,1],[72,-6],[78,-13],[85,-20],[107,-41],[118,-54],[128,-79],[122,-96],[113,-104],[112,-106],[93,-110]],"c":true}],"h":1},{"t":60,"s":[{"i":[[12.291,-0.928],[0.334,0.01],[0.332,-0.013],[7.34,-4.074],[-0.087,-5.471],[-1.568,-1.799],[-2.689,-0.303],[-1.556,1.296],[-1.569,1.177],[-0.564,0.12],[-0.354,0.225],[-4.842,0.875],[-3.172,-2.701],[-0.882,-1.486],[-0.059,-1.853],[0.805,-1.756],[0.833,-1.375],[6.591,-6.058],[3.695,-10.333],[-0.208,-3.713],[-1.136,-1.439],[2.07,12.899],[-0.361,2.289],[-1.78,2.037],[-2.428,2.305],[-7.457,6.16],[-0.666,0.747],[0.293,15.788],[1.709,2.951],[2.701,2.457]],"o":[[-0.331,0.025],[-0.334,-0.01],[-5.729,0.234],[-7.339,4.074],[0.04,2.501],[1.568,1.799],[3.209,0.362],[1.556,-1.296],[0.392,-0.294],[0.564,-0.12],[3.46,-2.202],[4.842,-0.875],[1.412,1.203],[0.882,1.486],[0.051,1.602],[-0.805,1.756],[-5.468,9.033],[-11.226,10.319],[-2.003,5.601],[0.162,2.892],[6.756,8.557],[-0.434,-2.706],[0.207,-1.314],[2.331,-2.667],[8.916,-8.463],[0.788,-0.651],[8.101,-9.083],[-0.118,-6.386],[-1.971,-3.404],[-7.448,-6.777]],"v":[[91,-109],[90.001,-108.991],[89,-109],[67.138,-101.928],[54,-87],[56.513,-80.352],[63,-77],[69.73,-78.846],[74,-83],[75.529,-83.551],[77,-84],[90.216,-89.177],[103,-87],[106.515,-82.988],[108,-78],[106.663,-72.829],[104,-68],[82,-45],[54,-13],[52,-3],[56,9],[75,1],[72,-6],[78,-13],[85,-20],[107,-41],[109,-43],[128,-79],[123,-94],[117,-101]],"c":true}],"h":1}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.525490196078,0.270588235294,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":31,"op":300,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Shape Layer 4","parent":15,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0.016,54.049,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[{"i":[[0,0],[11.928,-26.533],[-4,-20],[1.5,-2]],"o":[[-4.5,-7],[-12.25,27.25],[0.88,4.401],[-1.5,2]],"v":[[-64.5,-87],[-116.25,-85.75],[-62.5,-7],[-65.5,4]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":18,"s":[{"i":[[0,0],[-11.928,-26.533],[4,-20],[-1.5,-2]],"o":[[4.5,-7],[12.25,27.25],[-0.88,4.401],[1.5,2]],"v":[[64.42,-87],[116.17,-85.75],[62.42,-7],[65.42,4]],"c":false}]},{"i":{"x":0,"y":1},"o":{"x":0.333,"y":0},"t":24,"s":[{"i":[[0,0],[11.928,-26.533],[-4,-20],[1.5,-2]],"o":[[-4.5,-7],[-12.25,27.25],[0.88,4.401],[-1.5,2]],"v":[[-64.5,-87],[-116.25,-85.75],[-62.5,-7],[-65.5,4]],"c":false}]},{"t":50,"s":[{"i":[[0,0],[-11.928,-26.533],[4,-20],[-1.5,-2]],"o":[[4.5,-7],[12.25,27.25],[-0.88,4.401],[1.5,2]],"v":[[64.42,-87],[116.17,-85.75],[62.42,-7],[65.42,4]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.525490196078,0.270588235294,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":20,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[43.313,-47.836],"ix":2},"a":{"a":0,"k":[43.313,-47.836],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":24,"op":31,"st":10,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Shape Layer 1","parent":15,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0.016,54.049,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[{"i":[[0,0],[11.928,-26.533],[-4,-20],[1.5,-2]],"o":[[-4.5,-7],[-12.25,27.25],[0.88,4.401],[-1.5,2]],"v":[[-64.5,-87],[-116.25,-85.75],[-62.5,-7],[-65.5,4]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":18,"s":[{"i":[[0,0],[-11.928,-26.533],[4,-20],[-1.5,-2]],"o":[[4.5,-7],[12.25,27.25],[-0.88,4.401],[1.5,2]],"v":[[64.42,-87],[116.17,-85.75],[62.42,-7],[65.42,4]],"c":false}]},{"i":{"x":0,"y":1},"o":{"x":0.333,"y":0},"t":24,"s":[{"i":[[0,0],[11.928,-26.533],[-4,-20],[1.5,-2]],"o":[[-4.5,-7],[-12.25,27.25],[0.88,4.401],[-1.5,2]],"v":[[-64.5,-87],[-116.25,-85.75],[-62.5,-7],[-65.5,4]],"c":false}]},{"t":50,"s":[{"i":[[0,0],[-11.928,-26.533],[4,-20],[-1.5,-2]],"o":[[4.5,-7],[12.25,27.25],[-0.88,4.401],[1.5,2]],"v":[[64.42,-87],[116.17,-85.75],[62.42,-7],[65.42,4]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.525490196078,0.270588235294,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":20,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[43.313,-47.836],"ix":2},"a":{"a":0,"k":[43.313,-47.836],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":18,"st":10,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Shape Layer 5","parent":15,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0.016,54.049,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[-100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[{"i":[[0,0],[11.928,-26.533],[-4,-20],[1.5,-2]],"o":[[-4.5,-7],[-12.25,27.25],[0.88,4.401],[-1.5,2]],"v":[[-64.5,-87],[-116.25,-85.75],[-62.5,-7],[-65.5,4]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":18,"s":[{"i":[[0,0],[-11.928,-26.533],[4,-20],[-1.5,-2]],"o":[[4.5,-7],[12.25,27.25],[-0.88,4.401],[1.5,2]],"v":[[64.42,-87],[116.17,-85.75],[62.42,-7],[65.42,4]],"c":false}]},{"i":{"x":0,"y":1},"o":{"x":0.333,"y":0},"t":24,"s":[{"i":[[0,0],[11.928,-26.533],[-4,-20],[1.5,-2]],"o":[[-4.5,-7],[-12.25,27.25],[0.88,4.401],[-1.5,2]],"v":[[-64.5,-87],[-116.25,-85.75],[-62.5,-7],[-65.5,4]],"c":false}]},{"t":50,"s":[{"i":[[0,0],[-11.928,-26.533],[4,-20],[-1.5,-2]],"o":[[4.5,-7],[12.25,27.25],[-0.88,4.401],[1.5,2]],"v":[[64.42,-87],[116.17,-85.75],[62.42,-7],[65.42,4]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.525490196078,0.270588235294,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":20,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[-78.173,-47.836],"ix":2},"a":{"a":0,"k":[-78.173,-47.836],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":18,"op":24,"st":10,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"Cup 2","parent":15,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-11.815,0],[0,0],[1.176,-11.756],[0,0],[5.492,54.916],[0,0]],"o":[[0,0],[11.815,0],[0,0],[-5.492,54.916],[0,0],[-1.176,-11.756]],"v":[[-49.55,-73.91],[49.55,-73.91],[70.876,-52.583],[62.346,32.723],[-62.346,32.723],[-70.876,-52.583]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882370472,0.247058823705,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Cup","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":310,"st":10,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"Star 4 :M","parent":15,"tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[-225,-6.953,0],"to":[75,0,0],"ti":[-75,0,0]},{"t":50,"s":[225,-6.953,0]}],"ix":2,"l":2},"a":{"a":0,"k":[24.984,188.998,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[5.278,-3.874],[6.547,-0.032],[5.316,3.822],[2.054,6.217],[-1.993,6.237],[-5.278,3.874],[-6.547,0.032],[-5.316,-3.822],[-2.054,-6.217],[1.993,-6.237]],"o":[[-5.278,3.874],[-6.547,0.032],[-5.316,-3.822],[-2.054,-6.217],[1.993,-6.237],[5.278,-3.874],[6.547,-0.033],[5.316,3.822],[2.054,6.217],[-1.993,6.237]],"v":[[19.304,28.834],[0.146,23.68],[-18.962,29.022],[-19.98,9.209],[-30.965,-7.313],[-12.436,-14.404],[-0.118,-29.957],[12.352,-14.526],[30.95,-7.617],[20.128,9.011]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Star","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[249.984,188.998],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Star","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[-200.016,188.998],"ix":2},"a":{"a":0,"k":[249.984,188.998],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Star 4","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[5.278,-3.874],[6.547,-0.032],[5.316,3.822],[2.054,6.217],[-1.993,6.237],[-5.278,3.874],[-6.547,0.032],[-5.316,-3.822],[-2.054,-6.217],[1.993,-6.237]],"o":[[-5.278,3.874],[-6.547,0.032],[-5.316,-3.822],[-2.054,-6.217],[1.993,-6.237],[5.278,-3.874],[6.547,-0.033],[5.316,3.822],[2.054,6.217],[-1.993,6.237]],"v":[[19.304,28.834],[0.146,23.68],[-18.962,29.022],[-19.98,9.209],[-30.965,-7.313],[-12.436,-14.404],[-0.118,-29.957],[12.352,-14.526],[30.95,-7.617],[20.128,9.011]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Star","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[249.984,188.998],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Star","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[-50.016,188.998],"ix":2},"a":{"a":0,"k":[249.984,188.998],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Star 3","np":1,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[5.278,-3.874],[6.547,-0.032],[5.316,3.822],[2.054,6.217],[-1.993,6.237],[-5.278,3.874],[-6.547,0.032],[-5.316,-3.822],[-2.054,-6.217],[1.993,-6.237]],"o":[[-5.278,3.874],[-6.547,0.032],[-5.316,-3.822],[-2.054,-6.217],[1.993,-6.237],[5.278,-3.874],[6.547,-0.033],[5.316,3.822],[2.054,6.217],[-1.993,6.237]],"v":[[19.304,28.834],[0.146,23.68],[-18.962,29.022],[-19.98,9.209],[-30.965,-7.313],[-12.436,-14.404],[-0.118,-29.957],[12.352,-14.526],[30.95,-7.617],[20.128,9.011]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Star","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[249.984,188.998],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Star","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[99.984,188.998],"ix":2},"a":{"a":0,"k":[249.984,188.998],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Star 2","np":1,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[5.278,-3.874],[6.547,-0.032],[5.316,3.822],[2.054,6.217],[-1.993,6.237],[-5.278,3.874],[-6.547,0.032],[-5.316,-3.822],[-2.054,-6.217],[1.993,-6.237]],"o":[[-5.278,3.874],[-6.547,0.032],[-5.316,-3.822],[-2.054,-6.217],[1.993,-6.237],[5.278,-3.874],[6.547,-0.033],[5.316,3.822],[2.054,6.217],[-1.993,6.237]],"v":[[19.304,28.834],[0.146,23.68],[-18.962,29.022],[-19.98,9.209],[-30.965,-7.313],[-12.436,-14.404],[-0.118,-29.957],[12.352,-14.526],[30.95,-7.617],[20.128,9.011]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Star","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[249.984,188.998],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Star","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[249.984,188.998],"ix":2},"a":{"a":0,"k":[249.984,188.998],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Star","np":1,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":310,"st":10,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"Black Stand 2","parent":14,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-24.605,0],[0,0],[18.303,0]],"o":[[-18.303,0],[0,0],[24.605,0],[0,0]],"v":[[-42.653,-29.114],[-53.962,29.114],[53.962,29.114],[42.653,-29.114]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.349019616842,0.345098048449,0.43137255311,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Black Stand","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":310,"st":10,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"White Stand 4 :M","parent":14,"tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[-225,-1.544,0],"to":[75,0,0],"ti":[-75,0,0]},{"t":50,"s":[225,-1.544,0]}],"ix":2,"l":2},"a":{"a":0,"k":[24.984,347.302,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-4.323,0],[0,0],[-1.582,-4.024],[0,0],[4.323,0],[0,0],[-1.582,4.024],[0,0]],"o":[[0,0],[4.323,0],[0,0],[1.582,4.024],[0,0],[-4.323,0],[0,0],[1.582,-4.024]],"v":[[-25.949,-12.268],[25.998,-12.268],[33.803,-4.464],[37.313,4.464],[31.758,12.268],[-32.174,12.268],[-37.263,4.464],[-33.753,-4.464]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"White Stand","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[249.984,347.302],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"White Stand","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[-200.016,347.302],"ix":2},"a":{"a":0,"k":[249.984,347.302],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"White Stand 4","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-4.323,0],[0,0],[-1.582,-4.024],[0,0],[4.323,0],[0,0],[-1.582,4.024],[0,0]],"o":[[0,0],[4.323,0],[0,0],[1.582,4.024],[0,0],[-4.323,0],[0,0],[1.582,-4.024]],"v":[[-25.949,-12.268],[25.998,-12.268],[33.803,-4.464],[37.313,4.464],[31.758,12.268],[-32.174,12.268],[-37.263,4.464],[-33.753,-4.464]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"White Stand","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[249.984,347.302],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"White Stand","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[-50.016,347.302],"ix":2},"a":{"a":0,"k":[249.984,347.302],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"White Stand 3","np":1,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-4.323,0],[0,0],[-1.582,-4.024],[0,0],[4.323,0],[0,0],[-1.582,4.024],[0,0]],"o":[[0,0],[4.323,0],[0,0],[1.582,4.024],[0,0],[-4.323,0],[0,0],[1.582,-4.024]],"v":[[-25.949,-12.268],[25.998,-12.268],[33.803,-4.464],[37.313,4.464],[31.758,12.268],[-32.174,12.268],[-37.263,4.464],[-33.753,-4.464]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"White Stand","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[249.984,347.302],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"White Stand","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[99.984,347.302],"ix":2},"a":{"a":0,"k":[249.984,347.302],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"White Stand 2","np":1,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-4.323,0],[0,0],[-1.582,-4.024],[0,0],[4.323,0],[0,0],[-1.582,4.024],[0,0]],"o":[[0,0],[4.323,0],[0,0],[1.582,4.024],[0,0],[-4.323,0],[0,0],[1.582,-4.024]],"v":[[-25.949,-12.268],[25.998,-12.268],[33.803,-4.464],[37.313,4.464],[31.758,12.268],[-32.174,12.268],[-37.263,4.464],[-33.753,-4.464]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"White Stand","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[249.984,347.302],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"White Stand","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tr","p":{"a":0,"k":[249.984,347.302],"ix":2},"a":{"a":0,"k":[249.984,347.302],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"White Stand","np":1,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":310,"st":10,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"Black Stand","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"k":[{"s":[90],"t":2,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[88.052],"t":3,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[83.09],"t":4,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[75.985],"t":5,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[67.277],"t":6,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[57.336],"t":7,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[46.447],"t":8,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[34.86],"t":9,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[10.836],"t":11,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0],"t":12,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-6.514],"t":13,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-10.253],"t":14,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-11.772],"t":15,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-11.657],"t":16,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-10.457],"t":17,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-8.646],"t":18,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-6.599],"t":19,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-4.592],"t":20,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-2.804],"t":21,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-1.336],"t":22,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.223],"t":23,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.544],"t":24,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[1.006],"t":25,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[1.219],"t":26,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[1.245],"t":27,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[1.142],"t":28,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.963],"t":29,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.75],"t":30,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.535],"t":31,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.34],"t":32,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.176],"t":33,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.049],"t":34,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.04],"t":35,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.097],"t":36,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.125],"t":37,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.132],"t":38,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.124],"t":39,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.107],"t":40,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.085],"t":41,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.062],"t":42,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.041],"t":43,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.023],"t":44,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.008],"t":45,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.002],"t":46,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.009],"t":47,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.013],"t":48,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.014],"t":49,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.013],"t":50,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.012],"t":51,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.01],"t":52,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.007],"t":53,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.005],"t":54,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.003],"t":55,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0.001],"t":56,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0],"t":57,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.001],"t":58,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.001],"t":59,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.001],"t":60,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.001],"t":61,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.001],"t":62,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.001],"t":63,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[-0.001],"t":65,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0],"t":66,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0],"t":67,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0],"t":68,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"s":[0],"t":69,"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}}]},"p":{"k":[{"s":[138.235,254.547,0],"t":0,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[143.584,250.368,0],"t":1,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[157.812,240.556,0],"t":2,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[179.791,229.215,0],"t":3,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[209.087,221.759,0],"t":4,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[243.189,225.873,0],"t":5,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[274.404,246.799,0],"t":6,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[294.84,281.274,0],"t":7,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[299.502,322.507,0],"t":8,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[282.589,360.014,0],"t":9,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[249.984,377.959,0],"t":10,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[228.111,384.013,0],"t":11,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[215.555,387.488,0],"t":12,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[210.454,388.9,0],"t":13,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[210.841,388.792,0],"t":14,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[214.869,387.678,0],"t":15,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[220.951,385.994,0],"t":16,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[227.823,384.092,0],"t":17,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[234.564,382.227,0],"t":18,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[240.567,380.565,0],"t":19,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[245.498,379.201,0],"t":20,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[249.235,378.166,0],"t":21,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[251.813,377.453,0],"t":22,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[253.364,377.023,0],"t":23,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[254.079,376.826,0],"t":24,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[254.164,376.802,0],"t":25,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[253.818,376.898,0],"t":26,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[253.217,377.064,0],"t":27,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[252.503,377.262,0],"t":28,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[251.782,377.461,0],"t":29,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[251.126,377.643,0],"t":30,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[250.576,377.795,0],"t":31,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[250.15,377.913,0],"t":32,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[249.849,377.996,0],"t":33,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[249.66,378.049,0],"t":34,"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"s":[249.909,377.98,0],"t":42,"i":{"x":1,"y":1},"o":{"x":0,"y":0}}],"l":2},"a":{"a":0,"k":[0,29.114,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[0,0,100]},{"t":10,"s":[100,100,100]}],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"Elastic Controller","np":5,"mn":"Pseudo/MDS Elastic Controller","ix":1,"en":1,"ef":[{"ty":0,"nm":"Amplitude","mn":"Pseudo/MDS Elastic Controller-0001","ix":1,"v":{"a":0,"k":20,"ix":1}},{"ty":0,"nm":"Frequency","mn":"Pseudo/MDS Elastic Controller-0002","ix":2,"v":{"a":0,"k":40,"ix":2}},{"ty":0,"nm":"Decay","mn":"Pseudo/MDS Elastic Controller-0003","ix":3,"v":{"a":0,"k":60,"ix":3}}]},{"ty":5,"nm":"Elastic Controller 2","np":5,"mn":"Pseudo/MDS Elastic Controller","ix":2,"en":1,"ef":[{"ty":0,"nm":"Amplitude","mn":"Pseudo/MDS Elastic Controller-0001","ix":1,"v":{"a":0,"k":20,"ix":1}},{"ty":0,"nm":"Frequency","mn":"Pseudo/MDS Elastic Controller-0002","ix":2,"v":{"a":0,"k":40,"ix":2}},{"ty":0,"nm":"Decay","mn":"Pseudo/MDS Elastic Controller-0003","ix":3,"v":{"a":0,"k":60,"ix":3}}]}],"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-24.605,0],[0,0],[18.303,0]],"o":[[-18.303,0],[0,0],[24.605,0],[0,0]],"v":[[-42.653,-29.114],[-53.962,29.114],[53.962,29.114],[42.653,-29.114]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.349019616842,0.345098048449,0.43137255311,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Black Stand","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":310,"st":10,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"Cup","parent":14,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,-152.895,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-11.815,0],[0,0],[1.176,-11.756],[0,0],[5.492,54.916],[0,0]],"o":[[0,0],[11.815,0],[0,0],[-5.492,54.916],[0,0],[-1.176,-11.756]],"v":[[-49.55,-73.91],[49.55,-73.91],[70.876,-52.583],[62.346,32.723],[-62.346,32.723],[-70.876,-52.583]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.705882370472,0.247058823705,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Cup","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":310,"st":10,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"Stand","parent":14,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,-56.636,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[19.235,36.65],[0,0],[-15.853,-38.082],[0,0]],"o":[[0,0],[-20.405,35.342],[0,0],[17.561,-38.659]],"v":[[-33.841,-56.55],[33.841,-56.55],[25.31,56.55],[-25.31,56.55]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.525490224361,0.270588248968,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Stand","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":310,"st":10,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"Shape Layer 3","parent":15,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0.016,54.049,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[{"i":[[0,0],[11.928,-26.533],[-4,-20],[1.5,-2]],"o":[[-4.5,-7],[-12.25,27.25],[0.88,4.401],[-1.5,2]],"v":[[-64.5,-87],[-116.25,-85.75],[-62.5,-7],[-65.5,4]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":18,"s":[{"i":[[0,0],[-11.928,-26.533],[4,-20],[-1.5,-2]],"o":[[4.5,-7],[12.25,27.25],[-0.88,4.401],[1.5,2]],"v":[[64.42,-87],[116.17,-85.75],[62.42,-7],[65.42,4]],"c":false}]},{"i":{"x":0,"y":1},"o":{"x":0.333,"y":0},"t":24,"s":[{"i":[[0,0],[11.928,-26.533],[-4,-20],[1.5,-2]],"o":[[-4.5,-7],[-12.25,27.25],[0.88,4.401],[-1.5,2]],"v":[[-64.5,-87],[-116.25,-85.75],[-62.5,-7],[-65.5,4]],"c":false}]},{"t":50,"s":[{"i":[[0,0],[-11.928,-26.533],[4,-20],[-1.5,-2]],"o":[[4.5,-7],[12.25,27.25],[-0.88,4.401],[1.5,2]],"v":[[64.42,-87],[116.17,-85.75],[62.42,-7],[65.42,4]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.525490196078,0.270588235294,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":20,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[43.313,-47.836],"ix":2},"a":{"a":0,"k":[43.313,-47.836],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":18,"op":24,"st":10,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"Shape Layer 6","parent":15,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0.016,54.049,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[-100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[{"i":[[0,0],[11.928,-26.533],[-4,-20],[1.5,-2]],"o":[[-4.5,-7],[-12.25,27.25],[0.88,4.401],[-1.5,2]],"v":[[-64.5,-87],[-116.25,-85.75],[-62.5,-7],[-65.5,4]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":18,"s":[{"i":[[0,0],[-11.928,-26.533],[4,-20],[-1.5,-2]],"o":[[4.5,-7],[12.25,27.25],[-0.88,4.401],[1.5,2]],"v":[[64.42,-87],[116.17,-85.75],[62.42,-7],[65.42,4]],"c":false}]},{"i":{"x":0,"y":1},"o":{"x":0.333,"y":0},"t":24,"s":[{"i":[[0,0],[11.928,-26.533],[-4,-20],[1.5,-2]],"o":[[-4.5,-7],[-12.25,27.25],[0.88,4.401],[-1.5,2]],"v":[[-64.5,-87],[-116.25,-85.75],[-62.5,-7],[-65.5,4]],"c":false}]},{"t":50,"s":[{"i":[[0,0],[-11.928,-26.533],[4,-20],[-1.5,-2]],"o":[[4.5,-7],[12.25,27.25],[-0.88,4.401],[1.5,2]],"v":[[64.42,-87],[116.17,-85.75],[62.42,-7],[65.42,4]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.525490196078,0.270588235294,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":20,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[-78.173,-47.836],"ix":2},"a":{"a":0,"k":[-78.173,-47.836],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":24,"op":310,"st":10,"bm":0},{"ddd":0,"ind":19,"ty":4,"nm":"Shape Layer 2","parent":15,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0.016,54.049,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[-100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[{"i":[[0,0],[11.928,-26.533],[-4,-20],[1.5,-2]],"o":[[-4.5,-7],[-12.25,27.25],[0.88,4.401],[-1.5,2]],"v":[[-64.5,-87],[-116.25,-85.75],[-62.5,-7],[-65.5,4]],"c":false}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":18,"s":[{"i":[[0,0],[-11.928,-26.533],[4,-20],[-1.5,-2]],"o":[[4.5,-7],[12.25,27.25],[-0.88,4.401],[1.5,2]],"v":[[64.42,-87],[116.17,-85.75],[62.42,-7],[65.42,4]],"c":false}]},{"i":{"x":0,"y":1},"o":{"x":0.333,"y":0},"t":24,"s":[{"i":[[0,0],[11.928,-26.533],[-4,-20],[1.5,-2]],"o":[[-4.5,-7],[-12.25,27.25],[0.88,4.401],[-1.5,2]],"v":[[-64.5,-87],[-116.25,-85.75],[-62.5,-7],[-65.5,4]],"c":false}]},{"t":50,"s":[{"i":[[0,0],[-11.928,-26.533],[4,-20],[-1.5,-2]],"o":[[4.5,-7],[12.25,27.25],[-0.88,4.401],[1.5,2]],"v":[[64.42,-87],[116.17,-85.75],[62.42,-7],[65.42,4]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.525490196078,0.270588235294,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":20,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[-78.173,-47.836],"ix":2},"a":{"a":0,"k":[-78.173,-47.836],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":18,"st":10,"bm":0},{"ddd":0,"ind":21,"ty":0,"nm":"Pre-comp 1","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":60,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":16,"op":316,"st":16,"bm":0},{"ddd":0,"ind":22,"ty":0,"nm":"Pre-comp 1","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":45,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":11,"op":311,"st":11,"bm":0}],"markers":[]} ================================================ FILE: core/src/main/res/raw/trophy_winner.json ================================================ {"v":"5.5.7","meta":{"g":"LottieFiles AE 0.1.20","a":"Ronit Paul","k":"Winner Trophy Confetti Stars","d":"Winner Trophy","tc":""},"fr":60,"ip":0,"op":150,"w":720,"h":600,"nm":"Final","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":0,"nm":"right c","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[318,358,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[-176.563,176.563,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"left c","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[402,358,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[176.563,176.563,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"left c","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[302,254,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[117.188,116.406,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"right c","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[462,300,0],"ix":2},"a":{"a":0,"k":[256,256,0],"ix":1},"s":{"a":0,"k":[-100,100,100],"ix":6}},"ao":0,"w":512,"h":512,"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"trophy","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[360,394,0],"ix":2},"a":{"a":0,"k":[80,80,0],"ix":1},"s":{"a":0,"k":[280,280,100],"ix":6}},"ao":0,"w":160,"h":160,"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":0,"nm":"star","refId":"comp_3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":1,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[360,300,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[89.5,89.5,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":0,"op":720.720720720721,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Layer 20 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[360,380,0],"ix":2},"a":{"a":0,"k":[76.814,74.241,0],"ix":1},"s":{"a":0,"k":[298.779,298.779,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[42.285,0],[0,-42.285],[-28.625,-11.086],[0,0],[0,32.54]],"o":[[-42.285,0],[0,32.54],[0,0],[28.626,-11.086],[0,-42.284]],"v":[[-0.001,-73.991],[-76.564,2.572],[-27.637,73.991],[27.635,73.991],[76.564,2.572]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.910000011968,0.952999997606,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[76.813,74.241],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0}]},{"id":"comp_1","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"hong 2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":200,"s":[100]},{"t":210,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.982},"o":{"x":0.333,"y":0},"t":0,"s":[4.25,506.406,0],"to":[0,0,0],"ti":[-90.5,-10.844,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.472},"t":17,"s":[245.75,87.656,0],"to":[87.25,0.594,0],"ti":[-2.5,-73.781,0]},{"t":240,"s":[368.75,270.281,0]}],"ix":2},"a":{"a":0,"k":[154.25,-31.594,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[150.969,-34.875],[150.375,-29.531],[157.906,-28.313],[158.125,-33.594]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.980392156863,0.462745098039,0.592156862745,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"vang cam 4","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":183,"s":[100]},{"t":193,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.982},"o":{"x":0.333,"y":0},"t":3,"s":[-11.016,484.75,0],"to":[0,0,0],"ti":[-100.391,1.125,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.471},"t":20,"s":[191.234,115.625,0],"to":[119.353,-2.259,0],"ti":[0,0,0]},{"t":243,"s":[339.984,243.875,0]}],"ix":2},"a":{"a":0,"k":[124.234,13.875,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[123.375,6.5],[116.469,17.5],[125.25,21.25],[132,10.375]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.737254901961,0.121568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":3,"op":243,"st":3,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"xanh min 4","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":205,"s":[100]},{"t":215,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.983},"o":{"x":0.333,"y":0},"t":0,"s":[-0.078,512.859,0],"to":[-6.672,-133.859,0],"ti":[-111.453,-1.391,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.536},"t":17,"s":[239.297,142.859,0],"to":[98.953,-3.359,0],"ti":[0,0,0]},{"t":240,"s":[364.422,266.484,0]}],"ix":2},"a":{"a":0,"k":[75.922,18.484,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.781,3.063],[1.313,-1.25],[-2.563,-0.406],[-1.156,1.094]],"o":[[-1.313,1.344],[0.469,2.656],[1.469,-1.188],[-2.844,-0.875]],"v":[[75.156,13.406],[70.594,18],[76.719,23.563],[81.25,19.281]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.188235294118,0.839215686275,0.686274509804,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"xanh duong 2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":183,"s":[100]},{"t":193,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.982},"o":{"x":0.333,"y":0},"t":0,"s":[-2.234,516.563,0],"to":[12.792,-17.146,0],"ti":[-104.484,5.188,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.452},"t":17,"s":[164.016,151.688,0],"to":[100.967,-16.137,0],"ti":[-1.833,-65.917,0]},{"t":240,"s":[319.766,258.938,0]}],"ix":2},"a":{"a":0,"k":[20.266,28.938,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.969,23.219],[14.594,29.781],[18.563,34.656],[25.938,28.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.247058823529,0.403921568627,0.949019607843,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"do cam 2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":175,"s":[100]},{"t":185,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.984},"o":{"x":0.333,"y":0},"t":0,"s":[0.422,511.531,0],"to":[0,0,0],"ti":[-87.328,-22.969,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.617},"t":17,"s":[241.172,157.031,0],"to":[111.328,1.719,0],"ti":[0,0,0]},{"t":240,"s":[326.672,247.906,0]}],"ix":2},"a":{"a":0,"k":[-167.828,29.156,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,3.344],[1.875,-1.844],[-3.781,-0.125],[-1.875,2]],"o":[[-1.625,1.625],[0.094,2.531],[1.688,-1.656],[-3.906,0.844]],"v":[[-167.875,23.719],[-173.906,29.563],[-167.875,34.594],[-161.75,28.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.505882352941,0.392156862745,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"vang cam 3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":185,"s":[100]},{"t":195,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.982},"o":{"x":0.333,"y":0},"t":0,"s":[-1.891,515.426,0],"to":[0,0,0],"ti":[-130.391,-1.199,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.474},"t":17,"s":[162.359,137.301,0],"to":[158.641,0.949,0],"ti":[0,0,0]},{"t":240,"s":[285.859,265.676,0]}],"ix":2},"a":{"a":0,"k":[-120.641,-26.949,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.063,1.5],[0.688,-1.344],[-2.688,0.719],[-0.031,1.688]],"o":[[-0.719,1.406],[2,2.219],[0.031,-1.5],[-2.375,0.313]],"v":[[-123.156,-30.281],[-125.344,-26.156],[-116.063,-23.875],[-115.938,-28.563]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.737254901961,0.121568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"xanh min 3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":65,"s":[100]},{"t":75,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.983},"o":{"x":0.333,"y":0},"t":0,"s":[3.703,511.641,0],"to":[0,0,0],"ti":[-290.672,-0.234,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.537},"t":17,"s":[275.203,139.141,0],"to":[98.672,-0.766,0],"ti":[0,0,0]},{"t":240,"s":[423.703,272.141,0]}],"ix":2},"a":{"a":0,"k":[-179.781,-30.328,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-180.016,-34.984],[-183.828,-31.438],[-179.547,-25.672],[-175.734,-29.141]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.1882353127,0.839215755463,0.686274528503,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0}]},{"id":"comp_2","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Layer 3 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[131.272,32.19,0],"ix":2},"a":{"a":0,"k":[15.871,11.584,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-2.258],[-0.19,-0.484],[0.543,0.015],[0.361,-2.112],[0,0],[6.917,0.199],[0,0],[-0.053,1.891],[-1.885,-0.045],[0,0],[-0.613,3.46],[0,0],[-5.527,-0.149],[-1.478,-0.8]],"o":[[0,0.55],[-0.466,-0.19],[-2.142,-0.05],[0,0],[-1.201,6.778],[0,0],[-1.892,-0.052],[0.051,-1.891],[0,0],[3.557,0.1],[0,0],[0.931,-5.451],[1.817,0.05],[-2.213,0.174]],"v":[[11.667,-5.577],[11.964,-4.018],[10.447,-4.337],[6.072,-0.738],[6.018,-0.417],[-8.229,11.135],[-12.24,11.025],[-15.568,7.505],[-12.05,4.176],[-8.039,4.288],[-0.73,-1.609],[-0.68,-1.893],[10.637,-11.186],[15.621,-9.855]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.702000038297,0.195999998205,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[15.872,11.584],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Layer 4 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[123.409,66.361,0],"ix":2},"a":{"a":0,"k":[30.677,45.756,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.595,1.016],[1.015,1.595],[-4.112,0],[-0.051,-0.001],[0,0],[-2.334,2.307],[-2.214,11.779],[6.885,0.192],[0.932,-5.451],[0,0],[3.557,0.1],[0,0],[0.051,-1.891],[-1.892,-0.052],[0,0],[-1.201,6.779],[0,0],[-2.142,-0.05],[0.484,-2.575],[24.219,-23.939],[1.399,0.042],[0,0],[-4.98,-7.816]],"o":[[1.596,-1.017],[-2.07,-3.25],[0.051,0],[0,0],[3.266,0.092],[25.543,-25.248],[1.267,-6.749],[-5.528,-0.148],[0,0],[-0.613,3.461],[0,0],[-1.885,-0.046],[-0.053,1.891],[0,0],[6.918,0.198],[0,0],[0.361,-2.113],[2.714,0.076],[-2.067,10.993],[-1.01,1],[0,0],[-9.81,-0.271],[1.017,1.598]],"v":[[-20.719,44.49],[-19.67,39.76],[-15.178,31.932],[-15.025,31.934],[-14.939,31.937],[-6.125,28.451],[29.161,-32.107],[18.501,-45.358],[7.182,-36.065],[7.133,-35.782],[-0.176,-29.884],[-4.188,-29.995],[-7.706,-26.666],[-4.377,-23.147],[-0.367,-23.036],[13.881,-34.589],[13.936,-34.909],[18.31,-38.509],[22.428,-33.372],[-10.941,23.577],[-14.749,25.089],[-14.835,25.087],[-25.448,43.441]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.866999966491,0.250999989229,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[30.677,45.756],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Layer 5 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[28.727,32.19,0],"ix":2},"a":{"a":0,"k":[15.872,11.584,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-2.258],[0.189,-0.484],[-0.543,0.015],[-0.361,-2.112],[0,0],[-6.917,0.199],[0,0],[0.052,1.891],[1.885,-0.045],[0,0],[0.613,3.46],[0,0],[5.527,-0.148],[1.48,-0.799]],"o":[[0,0.55],[0.464,-0.19],[2.144,-0.05],[0,0],[1.199,6.778],[0,0],[1.891,-0.052],[-0.052,-1.891],[0,0],[-3.557,0.1],[0,0],[-0.93,-5.451],[-1.818,0.051],[2.214,0.175]],"v":[[-11.666,-5.577],[-11.962,-4.018],[-10.447,-4.337],[-6.072,-0.738],[-6.016,-0.417],[8.23,11.135],[12.241,11.025],[15.57,7.505],[12.051,4.176],[8.04,4.288],[0.73,-1.609],[0.681,-1.893],[-10.637,-11.186],[-15.622,-9.857]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.702000038297,0.195999998205,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[15.872,11.584],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Layer 6 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[97.823,51.806,0],"ix":2},"a":{"a":0,"k":[6.364,6.364,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-3.377,0],[0,3.377],[3.376,0],[0,-3.376]],"o":[[3.376,0],[0,-3.376],[-3.377,0],[0,3.377]],"v":[[0.001,6.114],[6.114,0],[0.001,-6.114],[-6.114,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.913999968884,0.552999997606,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[6.363,6.364],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Layer 7 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[86.422,58.994,0],"ix":2},"a":{"a":0,"k":[3.661,3.662,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.885,0],[0,1.885],[1.884,0],[0,-1.883]],"o":[[1.884,0],[0,-1.883],[-1.885,0],[0,1.885]],"v":[[0.001,3.411],[3.411,-0.001],[0.001,-3.411],[-3.411,-0.001]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.913999968884,0.552999997606,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3.661,3.662],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Layer 8 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[82.114,113.775,0],"ix":2},"a":{"a":0,"k":[3.661,3.66,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.884,0],[0,1.884],[1.884,0],[0,-1.885]],"o":[[1.884,0],[0,-1.885],[-1.884,0],[0,1.884]],"v":[[0.001,3.411],[3.411,0.001],[0.001,-3.411],[-3.411,0.001]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.913999968884,0.552999997606,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[3.661,3.66],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Layer 9 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[36.59,66.361,0],"ix":2},"a":{"a":0,"k":[30.678,45.756,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.594,1.016],[-1.016,1.595],[4.113,0],[0.051,-0.001],[0,0],[2.334,2.307],[2.213,11.779],[-6.886,0.192],[-0.931,-5.451],[0,0],[-3.557,0.1],[0,0],[-0.052,-1.891],[1.891,-0.052],[0,0],[1.2,6.779],[0,0],[2.143,-0.05],[-0.484,-2.575],[-24.22,-23.939],[-1.4,0.042],[0,0],[4.979,-7.816]],"o":[[-1.596,-1.017],[2.07,-3.25],[-0.051,0],[0,0],[-3.265,0.092],[-25.544,-25.248],[-1.269,-6.749],[5.527,-0.148],[0,0],[0.612,3.461],[0,0],[1.886,-0.046],[0.053,1.891],[0,0],[-6.918,0.198],[0,0],[-0.36,-2.113],[-2.714,0.076],[2.066,10.993],[1.011,1],[0,0],[9.809,-0.271],[-1.017,1.598]],"v":[[20.72,44.49],[19.671,39.76],[15.178,31.932],[15.025,31.934],[14.939,31.937],[6.126,28.451],[-29.159,-32.107],[-18.499,-45.358],[-7.181,-36.065],[-7.132,-35.782],[0.178,-29.884],[4.188,-29.995],[7.707,-26.666],[4.378,-23.147],[0.368,-23.036],[-13.879,-34.589],[-13.935,-34.909],[-18.309,-38.509],[-22.426,-33.372],[10.942,23.577],[14.75,25.089],[14.836,25.087],[25.449,43.441]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.866999966491,0.250999989229,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[30.678,45.756],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Layer 10 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[52.663,28.588,0],"ix":2},"a":{"a":0,"k":[16.074,4.875,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,2.554],[-2.555,0],[0,0],[0,-2.554],[-2.554,0],[0,0]],"o":[[0,-2.554],[0,0],[-2.554,0],[0,2.554],[0,0],[-2.555,0]],"v":[[11.199,-0.001],[15.824,-4.624],[-11.2,-4.624],[-15.824,-0.001],[-11.2,4.624],[15.824,4.624]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.866999966491,0.250999989229,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[16.074,4.875],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Layer 11 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[80,28.588,0],"ix":2},"a":{"a":0,"k":[43.411,4.875,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.554,0],[0,0],[0,2.555],[-2.554,0],[0,0],[0,-2.554]],"o":[[0,0],[-2.554,0],[0,-2.554],[0,0],[2.554,0],[0,2.554]],"v":[[38.537,4.624],[-38.537,4.624],[-43.161,-0.001],[-38.537,-4.624],[38.537,-4.624],[43.161,-0.001]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.913999968884,0.552999997606,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[43.411,4.875],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"Layer 12 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[80,71.304,0],"ix":2},"a":{"a":0,"k":[38.794,39.028,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.081,15.465],[-8.926,-0.001],[0,0],[-0.012,2.167],[0,0],[0.006,0],[-23.652,-22.431],[-5.943,2.144]],"o":[[-1.19,-8.846],[0,0],[0.187,-2.873],[0,0],[-0.006,0],[0.09,15.041],[4.82,4.571],[-14.067,-15.618]],"v":[[-15.785,-14.447],[-1.206,-31.169],[38.271,-31.169],[38.544,-38.777],[-38.527,-38.777],[-38.544,-38.778],[-11.805,33.001],[5.898,36.634]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.702000038297,0.195999998205,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[38.794,39.028],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"Layer 13 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[79.999,71.527,0],"ix":2},"a":{"a":0,"k":[38.794,39.25,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.007,0],[-23.653,-22.431],[-6.556,6.217],[-0.089,15.042]],"o":[[-0.006,0],[0.09,15.042],[6.561,6.222],[23.653,-22.43],[0,0]],"v":[[-38.525,-38.999],[-38.544,-39.001],[-11.803,32.779],[11.805,32.779],[38.544,-38.999]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.866999966491,0.250999989229,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[38.794,39.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"Layer 14 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[80.209,149.83,0],"ix":2},"a":{"a":0,"k":[15.632,5.079,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[3.575,2.031],[4.293,0],[3.483,-1.98],[-4.112,0]],"o":[[4.112,0],[-3.484,-1.98],[-4.294,0],[-3.575,2.031],[0,0]],"v":[[9.256,4.829],[11.807,-1.719],[0,-4.829],[-11.807,-1.719],[-9.256,4.829]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.866999966491,0.250999989229,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[15.631,5.08],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"Layer 15 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[80,113.063,0],"ix":2},"a":{"a":0,"k":[10.142,7.438,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[5.394,3.208],[0,-1.615],[-5.462,0],[0,5.463],[0.686,1.346]],"o":[[-0.686,1.346],[0,5.463],[5.463,0],[0,-1.615],[-5.393,3.206]],"v":[[-8.816,-7.188],[-9.892,-2.704],[0,7.188],[9.892,-2.704],[8.817,-7.188]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.866999966491,0.250999989229,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[10.142,7.438],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"Layer 16 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[74.146,144.019,0],"ix":2},"a":{"a":0,"k":[6.103,10.45,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-3.381,0],[0,0],[0.506,-0.238],[0,0],[0,0]],"o":[[0,0],[-0.545,-0.014],[-6.277,2.949],[0,0],[2.917,-1.266]],"v":[[5.853,8.23],[5.852,-7.077],[4.256,-6.745],[-5.853,-10.199],[-3.662,10.199]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.569000004787,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[6.103,10.45],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"Layer 17 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[74.059,136.348,0],"ix":2},"a":{"a":0,"k":[6.191,18.119,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.352,0.693],[2.104,-2.027],[-0.176,-1.636],[0,0],[-3.382,0],[0,0]],"o":[[-0.353,5.07],[-1.184,1.14],[0,0],[2.917,-1.265],[0,0],[-1.623,0]],"v":[[1.433,-17.87],[-4.176,-6.932],[-5.765,-2.529],[-3.574,17.87],[5.941,15.9],[5.938,-16.783]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.702000038297,0.195999998205,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[6.191,18.119],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"Layer 18 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[85.51,144.015,0],"ix":2},"a":{"a":0,"k":[6.445,10.451,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-2.916,-1.265],[0,0],[0,0.002],[0,-0.002],[2.31,1.138],[0.767,-0.129],[0,0],[-0.23,0]],"o":[[0,0],[0.001,-0.001],[-0.001,0.001],[-3.425,5.705],[-0.715,-0.351],[0,0],[0.228,-0.006],[3.382,0.001]],"v":[[4.005,10.202],[6.194,-10.197],[6.195,-10.202],[6.193,-10.197],[-3.913,-6.678],[-6.195,-7.021],[-6.195,8.251],[-5.51,8.232]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.702000038297,0.195999998205,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[6.445,10.452],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"Layer 19 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[85.598,136.348,0],"ix":2},"a":{"a":0,"k":[6.532,18.119,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.186,1.141],[0.353,5.071],[1.623,0],[0.226,0.016],[0,0],[-0.23,0],[-2.916,-1.265],[0,0]],"o":[[-2.104,-2.025],[-1.352,0.694],[-0.231,0],[0,0],[0.228,-0.007],[3.382,0],[0,0],[0.176,-1.635]],"v":[[4.517,-6.934],[-1.092,-17.87],[-5.598,-16.782],[-6.283,-16.807],[-6.283,15.919],[-5.598,15.902],[3.917,17.87],[6.107,-2.529]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.866999966491,0.250999989229,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[6.532,18.119],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":240,"st":0,"bm":0}]},{"id":"comp_3","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":8.008,"s":[100]},{"t":102.102102102102,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":8.008,"s":[400,400,0],"to":[1.833,-220.667,0],"ti":[85.167,-23.333,0]},{"t":86.0860860860861,"s":[71,152,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.901960784314,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":8.00800800800801,"op":728.728728728729,"st":8.00800800800801,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":6.006,"s":[100]},{"t":100.1001001001,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":6.006,"s":[400,400,0],"to":[101.833,91.333,0],"ti":[27.167,-153.333,0]},{"t":84.0840840840841,"s":[579,746,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.392156869173,0.192156866193,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":6.00600600600601,"op":726.726726726727,"st":6.00600600600601,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":4.004,"s":[100]},{"t":98.0980980980981,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":4.004,"s":[400,400,0],"to":[-84.167,-74.667,0],"ti":[-88.833,-45.333,0]},{"t":82.0820820820821,"s":[503,238,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.392156869173,0.192156866193,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":4.004004004004,"op":724.724724724725,"st":4.004004004004,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 4","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":2.002,"s":[100]},{"t":96.0960960960961,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":2.002,"s":[400,400,0],"to":[-82.167,-112.667,0],"ti":[73.167,-199.333,0]},{"t":80.0800800800801,"s":[91,556,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.392156869173,0.192156866193,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":2.002002002002,"op":722.722722722723,"st":2.002002002002,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Shape Layer 5","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":0,"s":[100]},{"t":94.0940940940941,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":0,"s":[400,400,0],"to":[77.833,91.333,0],"ti":[51.167,60.667,0]},{"t":78.0780780780781,"s":[511,308,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.007843137719,0.254901975393,0.501960813999,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":720.720720720721,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Shape Layer 6","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":2.002,"s":[100]},{"t":96.0960960960961,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":2.002,"s":[400,400,0],"to":[-30.167,-122.667,0],"ti":[95.167,-53.333,0]},{"t":80.0800800800801,"s":[155,280,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.901960844152,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":2.002002002002,"op":722.722722722723,"st":2.002002002002,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Shape Layer 7","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":4.004,"s":[100]},{"t":98.0980980980981,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":4.004,"s":[400,400,0],"to":[143.833,49.333,0],"ti":[-58.833,48.667,0]},{"t":82.0820820820821,"s":[681,388.872,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.392156869173,0.192156866193,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":4.004004004004,"op":724.724724724725,"st":4.004004004004,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Shape Layer 8","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":6.006,"s":[100]},{"t":100.1001001001,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":6.006,"s":[400,400,0],"to":[-24.167,-72.667,0],"ti":[75.167,-5.333,0]},{"t":84.0840840840841,"s":[257,284,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.392156869173,0.192156866193,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":6.00600600600601,"op":726.726726726727,"st":6.00600600600601,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Shape Layer 9","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":8.008,"s":[100]},{"t":102.102102102102,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":8.008,"s":[400,400,0],"to":[-14.167,31.333,0],"ti":[65.167,-13.333,0]},{"t":86.0860860860861,"s":[301,474,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.392156869173,0.192156866193,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"fl","c":{"a":0,"k":[0.945098042488,0.352941185236,0.141176477075,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 2","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":8.00800800800801,"op":728.728728728729,"st":8.00800800800801,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"Shape Layer 10","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":0,"s":[100]},{"t":94.0940940940941,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":0,"s":[400,400,0],"to":[-8.167,63.333,0],"ti":[-62.833,-29.333,0]},{"t":78.0780780780781,"s":[499,570,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.007843137719,0.254901975393,0.501960813999,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"fl","c":{"a":0,"k":[0.007843137719,0.254901975393,0.501960813999,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 2","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":720.720720720721,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"Shape Layer 11","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":2.002,"s":[100]},{"t":96.0960960960961,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":2.002,"s":[400,400,0],"to":[81.833,-104.667,0],"ti":[-58.833,104.667,0]},{"t":80.0800800800801,"s":[557,68,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.901960844152,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.901960844152,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 2","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":2.002002002002,"op":722.722722722723,"st":2.002002002002,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"Shape Layer 12","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":4.004,"s":[100]},{"t":98.0980980980981,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":4.004,"s":[400,400,0],"to":[151.833,-46.667,0],"ti":[-26.833,72.667,0]},{"t":82.0820820820821,"s":[715,138,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.945098042488,0.352941185236,0.141176477075,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"fl","c":{"a":0,"k":[0.945098042488,0.352941185236,0.141176477075,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 2","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":4.004004004004,"op":724.724724724725,"st":4.004004004004,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"Shape Layer 13","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":6.006,"s":[100]},{"t":100.1001001001,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":6.006,"s":[400,400,0],"to":[107.833,89.333,0],"ti":[-92.833,-103.333,0]},{"t":84.0840840840841,"s":[679,664,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.945098042488,0.352941185236,0.141176477075,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"fl","c":{"a":0,"k":[0.945098042488,0.352941185236,0.141176477075,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 2","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":6.00600600600601,"op":726.726726726727,"st":6.00600600600601,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"Shape Layer 14","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":8.008,"s":[100]},{"t":102.102102102102,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":8.008,"s":[400,400,0],"to":[-36.167,53.333,0],"ti":[75.167,-39.333,0]},{"t":86.0860860860861,"s":[97,686,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.945098042488,0.352941185236,0.141176477075,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"fl","c":{"a":0,"k":[0.945098042488,0.352941185236,0.141176477075,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 2","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":8.00800800800801,"op":728.728728728729,"st":8.00800800800801,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"Shape Layer 15","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":10.01,"s":[100]},{"t":104.104104104104,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":10.01,"s":[400,400,0],"to":[-148.167,-132.667,0],"ti":[61.167,-65.333,0]},{"t":88.0880880880881,"s":[65,340,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.007843137719,0.254901975393,0.501960813999,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"fl","c":{"a":0,"k":[0.007843137719,0.254901975393,0.501960813999,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 2","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":10.01001001001,"op":730.730730730731,"st":10.01001001001,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"Shape Layer 16","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":12.012,"s":[100]},{"t":106.106106106106,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.004,"y":0.691},"o":{"x":0.014,"y":0},"t":12.012,"s":[400,400,0],"to":[-55.38,-168.204,0],"ti":[27.03,124.27,0]},{"t":90.0900900900901,"s":[400,23.613,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.901960844152,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.901960844152,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 2","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":12.012012012012,"op":732.732732732733,"st":12.012012012012,"bm":0},{"ddd":0,"ind":17,"ty":4,"nm":"Shape Layer 17","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":14.014,"s":[100]},{"t":108.108108108108,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":14.014,"s":[400,400,0],"to":[109.833,69.333,0],"ti":[97.167,0.667,0]},{"t":92.0920920920921,"s":[303,660,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.945098042488,0.352941185236,0.141176477075,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"fl","c":{"a":0,"k":[0.945098042488,0.352941185236,0.141176477075,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 2","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":14.014014014014,"op":734.734734734735,"st":14.014014014014,"bm":0},{"ddd":0,"ind":18,"ty":4,"nm":"Shape Layer 18","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":16.016,"s":[100]},{"t":110.11011011011,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":16.016,"s":[400,400,0],"to":[41.833,109.333,0],"ti":[-71.833,39.667,0]},{"t":94.0940940940941,"s":[663,498,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.945098042488,0.352941185236,0.141176477075,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"fl","c":{"a":0,"k":[0.945098042488,0.352941185236,0.141176477075,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 2","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":16.016016016016,"op":736.736736736737,"st":16.016016016016,"bm":0},{"ddd":0,"ind":19,"ty":4,"nm":"Shape Layer 19","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":18.018,"s":[100]},{"t":112.112112112112,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":18.018,"s":[400,400,0],"to":[-0.167,-158.667,0],"ti":[89.167,6.667,0]},{"t":96.0960960960961,"s":[187,88,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[0.007843137719,0.254901975393,0.501960813999,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"fl","c":{"a":0,"k":[0.007843137719,0.254901975393,0.501960813999,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 2","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":18.018018018018,"op":738.738738738739,"st":18.018018018018,"bm":0},{"ddd":0,"ind":20,"ty":4,"nm":"Shape Layer 20","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.01],"y":[0]},"t":0,"s":[100]},{"t":94.0940940940941,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.01,"y":0},"t":0,"s":[400,400,0],"to":[-0.167,-158.667,0],"ti":[-92.833,-103.333,0]},{"t":78.0780780780781,"s":[621,234,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[16,16,100],"ix":6}},"ao":0,"shapes":[{"ty":"sr","sy":1,"d":1,"pt":{"a":0,"k":5,"ix":3},"p":{"a":0,"k":[0,0],"ix":4},"r":{"a":0,"k":0,"ix":5},"ir":{"a":0,"k":50,"ix":6},"is":{"a":0,"k":0,"ix":8},"or":{"a":0,"k":100,"ix":7},"os":{"a":0,"k":0,"ix":9},"ix":1,"nm":"Polystar Path 1","mn":"ADBE Vector Shape - Star","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.901960844152,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.901960844152,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 2","mn":"ADBE Vector Graphic - Fill","hd":false}],"ip":0,"op":720.720720720721,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Main","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[360,300,0],"ix":2},"a":{"a":0,"k":[360,300,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":720,"h":600,"ip":0,"op":180,"st":0,"bm":0}],"markers":[]} ================================================ FILE: core/src/main/res/raw/wordle_list.txt ================================================ AABBS AABFS AABGA AABPS AABTM AACAP AACBC AACBP AACDP AACFT AACHS AACJC AACOM AACOS AACPA AACPP AACPR AACPS AACSA AACSB AACSL AACSM AACSS AACTE AACTP AACUS AACVS AADAS AADCP AADCS AADGB AAFES AAHED AAION AALII AALST AAMFT AAMGA AAMOF AANYA AAPAS AAPIS AARAH AARAU AARAV AAREN AARGH AARON AAROO AARTI AASAX AASLT AATXE AAVSO ABAAS ABAAT ABABS ABACA ABACI ABACK ABACO ABACS ABADA ABADE ABADS ABAFT ABAGA ABAHT ABAKA ABAKO ABAMP ABAND ABARE ABASE ABASH ABASI ABASK ABATE ABAYA ABAYS ABAZA ABAZI ABBAS ABBAT ABBAY ABBED ABBES ABBEY ABBIE ABBOT ABBRS ABCCC ABCDE ABCDS ABCPS ABDAL ABDIS ABDON ABDOS ABDOU ABDUL ABEAM ABEAR ABEAT ABEBE ABEDS ABEER ABELE ABELL ABEND ABENG ABERG ABERR ABETS ABGAR ABHAL ABHOR ABIAH ABIDE ABIDS ABIER ABIES ABILA ABILO ABIME ABITE ABIUS ABJAD ABKAR ABLED ABLEN ABLER ABLES ABLET ABLOW ABLUR ABMHO ABNER ABNET ABNEY ABOAT ABODE ABOHM ABOIL ABOMA ABOOD ABOON ABOOT ABORD ABORE ABORN ABORS ABORT ABOUD ABOUE ABOUT ABOVE ABOWT ABRAM ABRAS ABRAY ABREK ABRIM ABRIN ABRIS ABRON ABSDS ABSEY ABSIS ABSIT ABUJA ABUNA ABUNE ABUNG ABUNS ABURA ABURE ABURN ABUSE ABUTS ABUTT ABUYS ABUZZ ABWAB ABWES ABYDE ABYED ABYES ABYME ABYSM ABYSS ACADA ACAIS ACANA ACAPU ACARA ACARI ACARS ACATS ACCAD ACCAS ACCEL ACCOY ACCRA ACCTS ACCUS ACDNR ACEDO ACELA ACENE ACERB ACERO ACERS ACESO ACETO ACHAB ACHAR ACHAZ ACHED ACHEE ACHER ACHES ACHOO ACHOR ACIDS ACIDY ACIES ACING ACINI ACISE ACKEE ACKER ACMES ACMIC ACNED ACNES ACOBA ACOCK ACODS ACOEL ACOFF ACOLD ACOLI ACOMA ACORD ACORN ACOYS ACRAL ACRED ACREE ACRES ACRID ACROA ACRON ACRRS ACRUE ACRUX ACRYL ACTED ACTER ACTIN ACTOL ACTON ACTOR ACTUS ACUFF ACUNA ACUTE ACYLS ADABE ADAGE ADAIR ADAMS ADANA ADANG ADAPT ADARS ADATS ADAWS ADAYS ADBOT ADCOL ADCOX ADDAS ADDAX ADDED ADDER ADDES ADDEY ADDIE ADDIS ADDLE ADDLS ADDON ADDRA ADEBS ADEEM ADELA ADELE ADELL ADENI ADENO ADEPS ADEPT ADFIX ADGER ADHAN ADHIV ADHYA ADIEU ADIGE ADIOS ADIRE ADITI ADITS ADIVE ADJEI ADJPS ADKIN ADLAI ADLAY ADLER ADLEY ADLIB ADMAG ADMAN ADMEN ADMES ADMET ADMIN ADMIT ADMIX ADNAN ADNAS ADNEY ADOBE ADOBO ADOLF ADOPT ADORB ADORE ADORN ADOWN ADOZE ADPLL ADRAW ADREE ADRET ADREW ADRIA ADRIC ADRIP ADRTS ADSCS ADSIT ADSLS ADUFE ADUGE ADULT ADUNC ADURE ADUST ADVIL ADVPS ADVTS ADYTA ADYTS ADZED ADZES AEAEA AECIA AEDES AEDST AEGEN AEGER AEGID AEGIR AEGIS AEGLE AEMTS AEONS AERIE AEROS AERYN AESAH AESIR AESOP AETAS AETAT AETNA AETTS AEVUM AFACE AFADE AFAIC AFAIK AFAIR AFAIU AFARA AFARE AFDBS AFEAR AFERS AFFIX AFFOR AFGOD AFIBS AFIRE AFLAJ AFLAO AFLAT AFLCA AFLOP AFLOW AFLPA AFLUA AFOAM AFOLS AFOOR AFOOT AFORE AFOUL AFOXE AFPAK AFPER AFQTS AFRIC AFRIT AFRMS AFROS AFSMS AFTAH AFTER AFTON AFTRE AFTUH AFULA AGAIN AGAIT AGALS AGAMA AGAMI AGAMY AGANA AGANE AGANS AGAPE AGARD AGARS AGASP AGAST AGATE AGATY AGAVE AGAYN AGAZE AGCAS AGEAN AGEES AGEND AGENE AGENT AGERS AGEST AGETH AGGAT AGGER AGGIE AGGRI AGGRO AGGRY AGHAS AGHUL AGIDA AGILE AGING AGINS AGIOS AGISM AGIST AGITA AGITO AGLEE AGLER AGLET AGLEY AGLOO AGLOW AGMAS AGNEL AGNER AGNES AGNEW AGOES AGOGE AGOGO AGONE AGONS AGONY AGOOD AGORA AGREE AGRIN AGROM AGSMS AGTAS AGUED AGUES AGUEY AGULY AGUON AGUSH AGUST AGYEN AHART AHATA AHAUS AHEAD AHEAP AHEMS AHERN AHHED AHIGH AHIND AHING AHINT AHLIE AHMAD AHMED AHMIR AHNER AHOGE AHOLD AHOLE AHOOL AHOYS AHRSS AHSAN AHTNA AHUJA AHULL AHURA AHVAZ AHWAI AICHI AIDAN AIDED AIDEN AIDER AIDES AIDID AIDOS AIDYN AIEEE AIERY AIFVS AIGER AIGHT AIGIO AIGRE AIKED AIKEN AIKIN AILED AILER AILLT AIMED AIMEE AIMER AIMES AINEC AIOLI AIPAC AIRAG AIRAI AIRAN AIRDS AIRED AIRER AIRES AIRLY AIRNA AIROL AIRTH AIRTS AISHA AISLE AISNE AISPS AITCH AITNE AITUS AITYD AIWOO AIYEE AJAKS AJARA AJARS AJAVA AJAWS AJAYI AJIKA AJISH AJIVA AJMAN AJMER AJUGA AJVAR AJWAN AKABA AKANA AKANE AKARA AKASA AKAZU AKBAR AKCES AKCHE AKEES AKELA AKEMI AKENE AKENI AKENS AKEPA AKERS AKEYS AKHET AKIBA AKIKO AKILL AKING AKINS AKIRA AKITA AKIVA AKKAD AKKUB AKLAN AKNEE AKNOW AKOLI AKPAN AKPES AKRON AKSED AKTAU AKTER AKULE AKUMS AKURI ALAAP ALABI ALACK ALAIA ALALA ALAMO ALAMS ALANA ALAND ALANE ALANG ALANI ALANS ALANT ALAPA ALAPH ALAPS ALARA ALARM ALARP ALARY ALATE ALAVA ALAVI ALAWI ALAYA ALBAN ALBAS ALBAY ALBED ALBEE ALBER ALBES ALBIC ALBIE ALBIN ALBMS ALBOR ALBOS ALBRO ALBUM ALBYN ALCAN ALCID ALCIE ALCON ALCOS ALDAY ALDAZ ALDEN ALDER ALDOL ALEAH ALEAK ALECK ALECS ALECY ALEFS ALEFT ALEJO ALEMU ALEPH ALERT ALESI ALETE ALEUT ALEWS ALEXA ALFAS ALFET ALFIE ALFIN ALFIZ ALGAE ALGAL ALGER ALGIC ALGID ALGIN ALGOL ALGOS ALGUM ALIAS ALIBI ALICE ALIEF ALIEN ALIFE ALIFF ALIFS ALIGN ALIKE ALIMS ALINA ALINE ALIPS ALIRE ALISH ALIST ALITA ALIUE ALIVE ALIYA ALIZA ALKIE ALKIN ALKYD ALKYL ALLAH ALLAN ALLAT ALLAY ALLEE ALLEN ALLEX ALLEY ALLIE ALLLS ALLOA ALLOD ALLOO ALLOT ALLOW ALLOY ALLUS ALLUZ ALLYL ALLYN ALMAH ALMAS ALMEH ALMER ALMES ALMID ALMON ALMRY ALMUD ALMUG ALMYS ALNUM ALODS ALOED ALOES ALOFT ALOGI ALOGY ALOHA ALOIN ALOJA ALONE ALONG ALOOF ALOOS ALORA ALOSA ALOSE ALOUD ALPAC ALPEN ALPER ALPHA ALRED ALSEA ALSIP ALSOP ALSUP ALTAI ALTAR ALTAY ALTER ALTGR ALTHO ALTIE ALTOM ALTON ALTOS ALTRE ALTUS ALTYN ALULA ALUMN ALUMS ALUMU ALUNE ALURE ALUWA ALVAR ALVEI ALVEN ALVEY ALVIA ALVIN ALVIS ALVYN ALWAY ALWIN ALWUZ ALWYN AMADO AMAHS AMAIN AMALA AMAND AMANN AMANO AMAPA AMARA AMARE AMARI AMARO AMARS AMARU AMASI AMASS AMATA AMATE AMATI AMAYA AMAYS AMAZE AMBAI AMBAL AMBAN AMBAR AMBER AMBIT AMBIX AMBLE AMBON AMBOS AMBOT AMBRY AMCIS AMCIT AMDRS AMEBA AMEEN AMEER AMEES AMELE AMELI AMELL AMELS AMEND AMENE AMENS AMENT AMEPD AMERO AMESE AMESS AMGOT AMIAS AMICE AMICI AMICK AMICO AMIDA AMIDE AMIDO AMIGA AMIGO AMIID AMIKA AMINA AMIND AMINE AMINI AMINO AMIRA AMIRI AMIRS AMISH AMISK AMISS AMITE AMITS AMITY AMIYA AMLAH AMLAS AMLCD AMMAN AMMAR AMMAS AMMER AMMON AMMOS AMNIA AMNIO AMOAH AMOCK AMOGS AMOKS AMOLE AMONG AMORC AMORS AMORT AMORY AMOUR AMOVE AMPED AMPER AMPLE AMPLY AMPUL AMPYX AMRAM AMRIT AMUCK AMURS AMUSE AMVIS AMWFS AMYAS AMYLE AMYLS AMYSS ANAFI ANAHI ANALS ANANA ANAND ANAPA ANATH ANAYA ANBAR ANCAP ANCHO ANCLE ANCON ANCUS ANDAI ANDED ANDES ANDIC ANDOA ANDRE ANDRO ANDRY ANEAL ANEAR ANEJO ANELE ANEND ANENT ANETA ANETH ANGAS ANGEL ANGER ANGIE ANGIO ANGLE ANGLO ANGON ANGOR ANGRY ANGST ANGUS ANHUI ANIGH ANILE ANILS ANIMA ANIME ANIMU ANION ANISE ANITA ANITO ANIYA ANJER ANJOU ANJUM ANKER ANKHS ANKIT ANKLE ANKOU ANKUS ANMOL ANNAL ANNAM ANNAN ANNAS ANNAT ANNEN ANNES ANNET ANNEX ANNIE ANNIV ANNOY ANNUL ANNUM ANOAS ANODA ANODE ANOIA ANOIL ANOKA ANOLE ANOMY ANONA ANONS ANOON ANORN ANOVA ANSAE ANSAH ANSEL ANSON ANSUS ANTAE ANTED ANTEM ANTES ANTHE ANTIC ANTIE ANTIS ANTLE ANTLY ANTON ANTRA ANTRE ANTSY ANTWI ANUAK ANURY ANUTA ANVIL ANWAR ANXOM ANYON ANYUS ANZAC ANZAI AOBAO AOCRS AOEDE AOLER AOMEN AONBS AONIA AOOGA AORTA AOSTA AOTID AOUNS APACE APACS APALA APANS APARS APART APAYS APCBC APCDS APCNR APCRS APDTA APDUS APEAK APEEK APELY APERS APERT APERY APETH APGAR APHID APHIS APIAN APIDS APIIN APINE APING APIOL APIPA APISH APLER APLIN APNEA APODE APODS APOID APOLS APOOP APORT APPAL APPAM APPAR APPAY APPEL APPLE APPLY APPOM APPTD APPTS APPUI APPYS APRES APRIL APRNS APRON APSAR APSED APSES APSIS APTER APTLY APUSH AQABA AQALS AQDAS AQMDS AQPIK AQUAE AQUAS AQUIC AQUIN AQUIT AQUOD AQUOX ARABA ARABS ARABY ARACA ARACE ARACK ARADS ARAGE ARAGH ARAKI ARAKS ARAME ARANA ARANS ARANT ARAQI ARARA ARATA ARATI ARAUZ ARAYA ARBAS ARBED ARBIL ARBIT ARBOR ARCAS ARCAY ARCED ARCEO ARCHE ARCHI ARCHY ARCIA ARCIC ARCID ARCUS ARDEB ARDEN ARDON ARDOR ARDRA AREAD AREAL AREAN AREAR AREAS AREBA ARECA AREEK AREET AREFY AREIC ARELS ARELY ARENA ARENE ARENG ARENT AREPA ARETE AREYS ARFAL ARFCN ARFID ARFIE ARGAL ARGAN ARGIC ARGID ARGIE ARGIL ARGOL ARGON ARGOS ARGOT ARGUE ARGUS ARHAT ARHUS ARIAH ARIAL ARIAN ARIAS ARICA ARIEL ARIES ARIID ARILS ARIMA ARINJ ARION ARIOT ARISE ARIST ARITA ARITY ARIUS ARIYA ARIZA ARIZE ARJUN ARKAN ARKIE ARLES ARLON ARLTS ARMED ARMER ARMET ARMIE ARMIL ARMON ARMOR ARMTH ARNAB ARNAV ARNDT ARNEA ARNER ARNEY ARNIE ARNIS ARNKS ARNTZ ARNUT AROAR AROBA AROHA AROID AROMA AROPA AROPH ARORA AROSE AROYL AROZE ARPEN ARPGS ARPIN ARRAH ARRAN ARRAS ARRAY ARRED ARRER ARREY ARRHA ARRIE ARRIS ARRON ARROW ARSED ARSES ARSEY ARSIC ARSIS ARSLE ARSON ARSTE ARTAB ARTAL ARTEL ARTER ARTEX ARTHS ARTIC ARTIE ARTIO ARTLY ARTOI ARTOS ARTSY ARTUX ARUBA ARUMS ARUNA ARURA ARVAK ARVAL ARVEL ARVID ARVIE ARVIL ARVIN ARVOS ARWEL ARXIV ARYAN ARYLS ARYNE ASAAD ASACS ASADA ASADI ASADY ASAFP ASAHP ASAIC ASAIL ASAKO ASAMA ASANA ASANO ASANT ASAPH ASARE ASARO ASARS ASATO ASATS ASAYS ASBCA ASBMS ASBOS ASCAP ASCAR ASCID ASCII ASCOB ASCON ASCOT ASCRA ASCUS ASDET ASDIC ASEAN ASEEM ASEXY ASFAW ASHAM ASHBM ASHBY ASHED ASHEN ASHER ASHES ASHET ASHIK ASHIQ ASHLY ASHOK ASHUR ASIAD ASIAN ASIDE ASILI ASINE ASINS ASITY ASKAR ASKED ASKEE ASKEN ASKER ASKES ASKEW ASKEY ASKIN ASKOI ASKOS ASLUG ASMAN ASMAR ASMRS ASNAC ASOAK ASOKA ASPAC ASPCA ASPDS ASPEN ASPER ASPET ASPIC ASPIE ASPIN ASPIS ASPLS ASPRO ASRAM ASRNA ASROC ASSAD ASSAF ASSAI ASSAM ASSAN ASSAY ASSEN ASSES ASSET ASSIG ASSLE ASSNS ASSOC ASSOT ASSRS ASSUR ASTAY ASTEL ASTER ASTIA ASTIN ASTIR ASTLE ASTMS ASTON ASTOR ASTQB ASTRO ASTUN ASTUR ASUKA ASURA ASVAB ASWAD ASWAN ASWAY ASWIM ASYLA ASYNC ATAFU ATAPI ATARI ATARS ATAXY ATCHA ATCKS ATEJI ATEND ATENE ATERS ATESO ATEST ATGAR ATGMS ATGWS ATHAN ATHAS ATHEL ATHEY ATHOS ATHYR ATILT ATIMY ATKIN ATLAS ATLED ATLEE ATMAN ATMOS ATNIP ATOKA ATOLE ATOLL ATOMI ATOMS ATOMY ATONE ATONG ATONY ATOPY ATRAC ATRAY ATREN ATRIA ATRIN ATRIP ATRYN ATSIT ATTAL ATTAP ATTAR ATTER ATTIA ATTIC ATTID ATTLE ATTOM ATTOW ATTRI ATTRY ATWAL ATWIX ATYAP ATYID AUASS AUBES AUBRI AUBRY AUDAD AUDAX AUDET AUDIE AUDIO AUDIT AUGER AUGET AUGHT AUGIE AUGLE AUGRE AUGUR AUKAN AUKER AULAE AULAS AULDS AULIC AULIS AULLS AULNS AULOI AULOS AULTS AUMAN AUMIL AUNES AUNGS AUNTS AUNTY AUOLS AURAE AURAL AURAR AURAS AUREI AURIC AURIN AURUM AUSAS AUTEC AUTEM AUTEN AUTHS AUTIE AUTIN AUTOM AUTON AUTOS AUTRY AUTUM AUVIL AUXIC AUXIN AVAHI AVAIL AVALE AVALS AVANT AVARS AVAST AVELS AVENS AVERA AVERI AVERS AVERT AVERY AVETA AVEYS AVGAS AVIAN AVIEW AVILA AVILE AVINA AVISE AVISO AVIZE AVLBS AVOID AVOKE AVORE AVOWS AVRES AVRIL AVVID AWABI AWADH AWADS AWAIE AWAIT AWAKE AWALT AWARA AWARD AWARE AWARI AWARN AWASH AWAVE AWAYE AWAYS AWAZE AWDLS AWEEL AWEIL AWENT AWFUL AWING AWKAF AWKLY AWLET AWLUZ AWMRY AWNED AWNER AWNGI AWNRY AWOKE AWOLS AWOOK AWORK AWQAF AWRAH AWRUK AWWAD AWWED AXELS AXETH AXIAL AXIID AXILE AXILS AXING AXINO AXIOM AXION AXITE AXLED AXLES AXMAN AXMEN AXODE AXOID AXONE AXONS AXSOM AXSON AXTON AYAAN AYADS AYAHS AYAKA AYAKO AYALA AYAME AYAMI AYANA AYANO AYATS AYBAR AYDEN AYDIN AYEIN AYELE AYELP AYERS AYINS AYLET AYLLU AYLOR AYMED AYMES AYOND AYONS AYONT AYOUB AYRAB AYRES AYRIE AYTOS AYUBS AYUKO AYUMI AYUSH AYUSO AYVAR AYYAD AZAMS AZANE AZANS AZARI AZATS AZEEZ AZENE AZERA AZERI AZETE AZIDE AZIDO AZIMI AZINE AZINT AZIZI AZKAL AZLON AZOIC AZOLE AZOLO AZONE AZONS AZOTE AZOTH AZOXY AZRAN AZTEC AZUAS AZUAY AZUKI AZUMI AZURE AZURN AZURY AZUSA AZYME AZZAM AZZES BAAAD BAADE BAAED BAALS BAARS BAATH BABAR BABAS BABAX BABBS BABBY BABEE BABEH BABEL BABER BABES BABEZ BABIC BABIN BABIS BABKA BABLE BABOO BABUL BABUS BACAS BACAW BACCS BACCY BACES BACHA BACHE BACHS BACKS BACKY BACNE BACON BACOO BACTA BACUP BADAL BADAM BADDY BADEN BADGE BADIA BADIU BADLA BADLY BADON BADUA BADUN BAEHR BAEKS BAELS BAENA BAEZA BAFCS BAFFS BAFFY BAFTA BAFTS BAFUT BAGAD BAGAN BAGBY BAGEL BAGES BAGGS BAGGY BAGHS BAGLO BAGSY BAGUA BAGUE BAHAI BAHAM BAHAR BAHES BAHIA BAHLS BAHMS BAHTS BAHUS BAHUT BAIAO BAIDU BAIER BAIGS BAIJI BAIKS BAILA BAILE BAILS BAILY BAINE BAING BAINS BAIRD BAIRN BAIRS BAISA BAISO BAITS BAITY BAIZA BAIZE BAJAJ BAJAN BAJAU BAJRA BAJRI BAJWA BAKAR BAKAS BAKAW BAKED BAKEN BAKER BAKES BAKKE BAKMI BAKRI BAKSH BAKSO BALAN BALAS BALDE BALDI BALDO BALDS BALDY BALED BALER BALES BALEY BALKE BALKH BALKO BALKS BALKY BALLA BALLI BALLS BALLY BALMS BALMY BALOG BALOI BALON BALOO BALOR BALOT BALSA BALTA BALTI BALTS BALTZ BALUN BALUT BALVI BAMAR BAMAS BAMBA BAMBI BAMES BAMFS BAMMA BAMMO BAMMY BAMPS BAMUM BANAL BANAN BANAT BANCK BANCO BANCS BANDA BANDH BANDI BANDO BANDS BANDY BANED BANES BANEY BANFF BANGE BANGI BANGS BANGY BANHS BANHU BANIA BANIK BANJA BANJH BANJO BANKE BANKO BANKS BANKU BANKY BANNS BANOS BANTA BANTS BANTU BANTY BANTZ BANYA BAOJI BAOZI BAPUS BARAD BARAK BARBS BARBZ BARCA BARCO BARDO BARDS BARDY BARED BAREN BARER BARES BARFI BARFS BARFY BARGA BARGE BARGO BARIA BARIC BARIL BARIS BARKE BARKS BARKY BARMS BARMY BARNO BARNS BARNY BARON BAROS BAROT BARRA BARRE BARRO BARRY BARSE BARTA BARTH BARTO BARTS BARTZ BARUA BARYE BASAA BASAL BASAN BASAS BASCO BASED BASEL BASEN BASER BASES BASEY BASHA BASHO BASHY BASIC BASIJ BASIL BASIN BASIS BASKS BASKT BASLE BASON BASRA BASSA BASSE BASSI BASSO BASSY BASTA BASTE BASTO BASTS BASUS BASYE BATAB BATAK BATCH BATED BATEN BATES BATEY BATFE BATHE BATHS BATHY BATIE BATIK BATIN BATIS BATNA BATON BATOR BATRA BATTA BATTE BATTS BATTU BATTY BATUM BATYS BAUDS BAUER BAUGH BAULK BAUME BAUNO BAURE BAURS BAUZA BAVES BAVIN BAWAN BAWAS BAWDS BAWDY BAWKU BAWLS BAWLY BAWNS BAWTY BAWWS BAXAS BAXES BAYAN BAYAS BAYED BAYER BAYES BAYEV BAYLY BAYNE BAYOU BAYTS BAYZE BAZAN BAZAR BAZAS BAZES BAZIL BAZOO BAZZA BBBBS BBBJS BBEGS BBIAB BBIAF BBQED BBQER BBSER BBSES BCAAS BCERS BCSMS BCVAS BDAYS BDFLS BDNAS BDSER BEACH BEADS BEADY BEAGS BEAHM BEAKS BEAKY BEALE BEALL BEALS BEAMS BEAMY BEANO BEANS BEANT BEANY BEARD BEARE BEARN BEARS BEAST BEATH BEATI BEATO BEATS BEATY BEAUS BEAUT BEAUX BEAVE BEBAR BEBAY BEBEE BEBEL BEBOP BECAK BECCA BECHT BECKA BECKI BECKS BECKY BECOS BECOZ BECRY BECUE BECUT BEDAD BEDAG BEDAW BEDDE BEDDY BEDEL BEDES BEDEW BEDID BEDIM BEDIP BEDIS BEDOG BEDOY BEDYE BEEBE BEEBS BEECH BEEDE BEEDI BEEFS BEEFY BEEKS BEELS BEELY BEEMS BEENA BEENE BEEPS BEERS BEERY BEEST BEETH BEETS BEEVE BEFAL BEFEL BEFIE BEFIT BEFLY BEFOE BEFOG BEFOR BEFUR BEGAB BEGAD BEGAN BEGAR BEGAS BEGAT BEGAY BEGEM BEGET BEGGE BEGGS BEGIN BEGOB BEGOD BEGOT BEGRY BEGTI BEGUM BEGUN BEHAD BEHAN BEHAP BEHAR BEHAT BEHEN BEHEW BEHLS BEHMS BEHNS BEHRS BEHUE BEIDA BEIER BEIGE BEIGY BEILD BEING BEJAN BEJAR BEJEL BEKAH BEKAS BEKEN BELAH BELAM BELAP BELAR BELAY BELCH BELEM BELEN BELEW BELID BELIE BELIN BELKS BELLA BELLE BELLI BELLO BELLS BELLY BELOW BELTS BELTZ BELUE BELYE BEMAD BEMAN BEMAR BEMAS BEMBA BEMBE BEMES BEMIX BEMOL BEMOW BEMUD BENAR BENAS BENAT BENCH BENDA BENDE BENDS BENDY BENES BENET BENEW BENGA BENGE BENIM BENIN BENJI BENJY BENKE BENKO BENKS BENNA BENNE BENNI BENNO BENNS BENNU BENNY BENOS BENOW BENSE BENTO BENTS BENTY BENTZ BENUE BENXI BENZO BEOTS BEOUR BEPAT BEPIS BERAN BERAY BERDE BEREC BEREK BERET BERGA BERGH BERGS BERHE BERKO BERKS BERME BERMS BERNE BEROB BEROE BEROS BERRA BERRY BERTH BERTI BERYL BESAN BESAT BESAW BESAY BESEE BESET BESEW BESEX BESHT BESIT BESOM BESOS BESOT BESOW BESPY BESRA BESSA BESSE BESSY BESTE BESTS BETAG BETAS BETEE BETEL BETHE BETHS BETHY BETID BETIE BETOL BETOP BETOW BETSY BETTA BETTE BETTY BEVAN BEVEL BEVER BEVIL BEVIN BEVIS BEVOR BEVVY BEWAG BEWAY BEWDY BEWED BEWET BEWIG BEWIN BEWIT BEWON BEYER BEYNG BEYTS BEZEL BEZES BEZIL BEZZY BFAST BFILE BFODS BFPOS BHAGA BHAJI BHAKT BHAMO BHAND BHANG BHATT BHAVA BHEER BHILI BHIMA BHOLA BHOYS BHUMI BHUNA BIACH BIALY BIANS BIBBS BIBBY BIBES BIBIS BIBLE BICCY BICEP BICES BICKS BICKY BICOL BIDAR BIDDY BIDED BIDEN BIDER BIDES BIDET BIDIS BIDON BIEBS BIEHL BIEHN BIELD BIENS BIERE BIERI BIERS BIERY BIFAN BIFFO BIFFS BIFFY BIFID BIFTA BIGAE BIGAM BIGAS BIGBY BIGGE BIGGS BIGGY BIGHA BIGHT BIGLY BIGON BIGOS BIGOT BIGUN BIHAR BIIAB BIIIG BIJEL BIJOU BIKED BIKER BIKES BIKIE BIKOL BILAL BILAT BILBO BILBY BILED BILEN BILES BILGE BILGY BILIN BILJE BILKS BILLI BILLS BILLY BILOS BIMAH BIMAS BIMBO BIMMY BINAL BINDI BINDS BINDY BINER BINES BINGE BINGO BINGS BINIT BINKS BINKY BINNA BINNS BINNY BINOM BINOS BINTS BIOCH BIOGS BIOKO BIOME BIONS BIOSE BIOTA BIOTS BIPAS BIPED BIPOC BIPOD BIPPY BIRBS BIRCH BIRDE BIRDO BIRDS BIRDY BIRGE BIRKS BIRKY BIRLE BIRLS BIRON BIROS BIRRA BIRRS BIRSE BIRSY BIRTH BIRTS BISDN BISES BISET BISKI BISKS BISMS BISON BITAR BITCH BITED BITEE BITER BITES BITEY BITKI BITSY BITTS BITTY BIVIA BIVIS BIVVY BIWAS BIXBY BIXEN BIXIE BIXIN BIZEN BIZET BIZZO BIZZY BJORK BLAAS BLABS BLABY BLACK BLADE BLADS BLADY BLAGG BLAGO BLAGS BLAHA BLAHG BLAHS BLAIN BLAIR BLAIS BLAKE BLAME BLAMS BLANC BLAND BLANE BLANK BLANS BLAOW BLARE BLARG BLASE BLAST BLATE BLATO BLATS BLAWG BLAYS BLAZE BLDGS BLDSC BLEAD BLEAH BLEAK BLEAM BLEAR BLEAS BLEAT BLEAU BLEBS BLECH BLECK BLEDS BLEED BLEEN BLEEP BLEES BLEGS BLEND BLENK BLENT BLESH BLESS BLEST BLETS BLEVE BLEWE BLEWS BLEYS BLIAR BLICK BLIDA BLIES BLIGE BLIKE BLIKS BLIMP BLIMS BLIMY BLIND BLING BLINI BLINK BLINN BLINS BLINY BLIPS BLIRT BLISK BLISS BLITE BLITS BLITZ BLIVE BLMER BLOAK BLOAT BLOBS BLOCH BLOCK BLOCS BLOGS BLOHM BLOKE BLOND BLOOD BLOOK BLOOM BLOOP BLOPS BLORE BLORP BLORT BLOSE BLOTE BLOTS BLOUD BLOVE BLOWE BLOWN BLOWS BLOWY BLOYD BLRGS BLUBS BLUDS BLUDY BLUED BLUEN BLUER BLUES BLUET BLUEY BLUFF BLUME BLUNK BLUNT BLUPS BLURB BLURP BLURR BLURS BLURT BLUSH BLUST BLUTH BLUTO BLVDS BLYES BLYNX BLYPE BLYTH BMEPS BMING BMOCS BMXED BMXER BMXES BNOCS BNPER BOACO BOAKS BOALS BOANN BOARD BOARS BOART BOAST BOATE BOATS BOATY BOBAC BOBAK BOBAR BOBAS BOBBI BOBBS BOBBY BOBER BOBES BOBET BOBOL BOBOS BOCAL BOCAT BOCCA BOCCE BOCCI BOCHE BOCKS BOCOR BODAI BODAS BODDY BODED BODEN BODES BODGE BODHI BODIC BODIE BODIG BODIN BODIS BODLE BODOS BODYE BOECK BOENS BOEPS BOERS BOESE BOETS BOFAN BOFDS BOFFO BOFFS BOFHS BOGAN BOGAR BOGER BOGEY BOGGO BOGGS BOGGY BOGIE BOGLE BOGOF BOGON BOGOS BOGUE BOGUS BOHAC BOHAN BOHEA BOHIO BOHLS BOHNS BOHOL BOHON BOHOS BOHRA BOHRS BOIAR BOICE BOIDS BOIKE BOILS BOINC BOINE BOING BOINK BOISE BOIST BOKED BOKEH BOKES BOKKE BOKOR BOKOS BOLAK BOLAN BOLAR BOLAS BOLDO BOLDS BOLDT BOLDU BOLEN BOLER BOLES BOLEY BOLIN BOLIS BOLIX BOLLS BOLLY BOLON BOLOS BOLSA BOLTE BOLTH BOLTI BOLTS BOLTY BOLTZ BOLUS BOMAN BOMAR BOMAS BOMBA BOMBE BOMBS BOMMY BOMOH BONAC BONAR BONCE BONCH BONDE BONDI BONDS BONDY BONED BONER BONES BONET BONEY BONGE BONGO BONGS BONIN BONKS BONNE BONNY BONOS BONSU BONTS BONUS BONZA BONZE BOOBS BOOBY BOODY BOOED BOOER BOOES BOOFS BOOFY BOOID BOOKE BOOKS BOOKY BOOLE BOOLS BOOLY BOOMS BOOMY BOONE BOONG BOONK BOONS BOOPS BOORD BOORS BOORT BOORU BOOSE BOOST BOOSY BOOTH BOOTS BOOTY BOOYA BOOZA BOOZE BOOZY BOPET BOPPS BOPPY BOPUS BORAH BORAL BORAS BORAX BORCK BORDA BORDS BORED BOREE BOREK BOREL BORER BORES BORGO BORGS BORIA BORIC BORID BORIS BORKO BORKS BORKY BORNA BORNE BORNS BORNT BORNU BOROK BORON BOROS BORRA BORRY BORST BORTH BORTS BORTZ BORUM BORVO BORYL BORYS BOSAK BOSAL BOSAS BOSCO BOSCS BOSER BOSEY BOSHA BOSKS BOSKY BOSMA BOSNA BOSOM BOSON BOSOX BOSRA BOSSY BOSTS BOSUN BOTAN BOTCH BOTEH BOTEL BOTES BOTHA BOTHE BOTHY BOTOS BOTOX BOTTI BOTTO BOTTS BOTTY BOTUS BOUCH BOUCK BOUDS BOUGE BOUGH BOUIE BOUKS BOULA BOULD BOULE BOULS BOULT BOUMA BOUND BOUNS BOURD BOURG BOURI BOURN BOURS BOUSE BOUSY BOUTS BOVAS BOVEE BOVES BOVID BOWAB BOWED BOWEL BOWEN BOWER BOWES BOWGE BOWIE BOWKS BOWLE BOWLO BOWLS BOWND BOWNE BOWNS BOWRE BOWSE BOWSY BOXED BOXEN BOXER BOXES BOXLA BOXTY BOYAR BOYAU BOYCE BOYEA BOYED BOYER BOYFS BOYKO BOYLE BOYNE BOYOS BOYSY BOYTE BOYTS BOYUM BOZAL BOZAS BOZEK BOZES BOZON BOZOS BOZZO BPAAS BRAAI BRACC BRACE BRACH BRACK BRACT BRACY BRADS BRADT BRADY BRAES BRAGG BRAGS BRAHM BRAHS BRAID BRAIL BRAIN BRAIT BRAKE BRAKY BRALY BRAMA BRAME BRAND BRANE BRANG BRANK BRANN BRANS BRANT BRARS BRASE BRASH BRASS BRAST BRATH BRATS BRATT BRAUD BRAVA BRAVE BRAVO BRAWL BRAWN BRAWT BRAXY BRAYS BRAZE BRBNS BRBPR BREAD BREAK BREAM BREAS BRECK BREDA BREDE BREED BREEN BREES BREHM BREHS BREIT BREKS BREME BRENS BRENT BREON BREST BRETT BRETZ BREVE BREWS BREYS BRIAN BRIAR BRIBE BRICE BRICK BRICS BRIDE BRIDG BRIDI BRIEF BRIEN BRIER BRIGS BRILL BRILS BRIMM BRIMS BRINE BRING BRINK BRINN BRINS BRINY BRION BRISE BRISK BRISS BRITS BRITT BRITZ BRIZE BRMMM BROAD BROAS BROBS BROCH BROCK BRODA BRODE BRODT BRODY BROGS BROHA BROID BROIL BROKE BROMA BROME BROMO BRONC BROND BRONJ BRONK BRONX BRONY BROOD BROOK BROOL BROOM BROON BROSE BROSS BROST BROSY BROTH BROTT BROWN BROWS BROXY BRUCE BRUCK BRUDS BRUEN BRUGH BRUHN BRUHS BRUIN BRUIT BRULE BRUME BRUMM BRUNE BRUNG BRUNI BRUNK BRUNN BRUNO BRUNT BRUSH BRUSK BRUSO BRUTE BRUVS BRYAN BRYCE BRYER BRYID BRYKS BRYNN BRYON BSAER BSCCO BSKYB BSODS BSSID BTAIM BTECS BUALA BUANG BUATS BUAZE BUBAL BUBAR BUBBA BUBBE BUBBS BUBBY BUCAK BUCCA BUCCI BUCHT BUCHU BUCIO BUCKO BUCKS BUCKY BUCYS BUDAI BUDDA BUDDE BUDDY BUDGE BUDJU BUDKE BUELL BUELS BUENA BUERS BUESO BUFFA BUFFO BUFFS BUFFY BUFIS BUFOS BUGAN BUGGS BUGGY BUGLE BUGLY BUGSY BUGUN BUHID BUHLS BUICE BUILD BUILT BUIST BUKER BUKOS BULAT BULAU BULBS BULBY BULGA BULGE BULGY BULKS BULKY BULLA BULLE BULLS BULLY BULSE BULTI BUMBO BUMED BUMMY BUMPF BUMPH BUMPS BUMPY BUNCE BUNCH BUNCO BUNDA BUNDH BUNDS BUNDT BUNDU BUNDY BUNGI BUNGO BUNGS BUNGU BUNGY BUNIA BUNJY BUNKO BUNKS BUNKY BUNNS BUNNY BUNTS BUNTY BUNUN BUNYA BUOLT BUONO BUOYS BUPPS BURAN BURAO BURAU BURBA BURBS BURCH BURCO BURDA BURDO BURDS BUREK BUREL BUREN BURES BURET BURFI BURGE BURGH BURGO BURGS BURHS BURIN BURKA BURKE BURKS BURKY BURLS BURLY BURMA BURNE BURNS BURNT BURON BUROO BUROS BUROW BURPS BURPY BURQA BURRA BURRO BURRS BURRY BURSA BURSE BURST BURTT BURUM BURYE BUSAA BUSAN BUSAS BUSBY BUSCO BUSED BUSES BUSEY BUSHA BUSHI BUSHY BUSIE BUSKE BUSKS BUSKY BUSSU BUSSY BUSTA BUSTO BUSTS BUSTY BUTAS BUTCH BUTEO BUTHS BUTLE BUTOH BUTTE BUTTS BUTTY BUTUT BUTYL BUUUT BUXOM BUYED BUYEI BUYER BUYOU BUYUP BUZAS BUZAU BUZUQ BUZZY BWANA BWITI BWOYS BYAMS BYARD BYBEE BYDLO BYEFE BYERS BYGLY BYION BYKES BYLAW BYLDE BYLER BYNOE BYNUM BYRAM BYRES BYRGE BYRNE BYROM BYRON BYRUM BYSSI BYTES BYUNS BYWAY BZZED BZZZT CAABA CAAPI CABAL CABAN CABAR CABAS CABBY CABER CABES CABIN CABLE CABOB CABOC CABOT CABRA CABRE CABRI CACAO CACAS CACHE CACHO CACIK CACKS CACKY CACTI CADAM CADDO CADDR CADDY CADED CADEL CADEN CADER CADES CADET CADEW CADGE CADGY CADIE CADIS CADIX CADIZ CADLE CADMS CADRE CADRS CADYS CAECA CAFES CAFFE CAFFS CAFIZ CAFOS CAFTA CAGED CAGER CAGES CAGEY CAGLE CAGOT CAGOU CAGRS CAHNS CAHOW CAHUE CAIDS CAILS CAINE CAINS CAIRD CAIRN CAIRO CAITO CAJON CAJUN CAKED CAKES CAKEY CAKRA CALAS CALCS CALEB CALEY CALFS CALID CALIF CALIN CALIX CALKS CALLA CALLI CALLS CALMA CALME CALMS CALMY CALNE CALOS CALPA CALPS CALUM CALVA CALVE CALYX CAMAS CAMBA CAMBS CAMEL CAMEO CAMES CAMIA CAMIS CAMMY CAMOS CAMPA CAMPI CAMPO CAMPS CAMPY CAMRA CAMUN CAMUS CANAL CANAR CANDI CANDO CANDY CANED CANER CANES CANEX CANEZ CANGS CANID CANNA CANNE CANNS CANNY CANOD CANOE CANON CANST CANTO CANTS CANTU CANTY CANUL CANUN CANUP CAOLI CAPAC CAPAS CAPEA CAPED CAPEL CAPEN CAPER CAPES CAPEX CAPIZ CAPLE CAPMS CAPOC CAPON CAPOS CAPOT CAPPA CAPPS CAPRI CAPTS CAPUL CAPUT CAPYS CARAC CARAT CARAU CARBO CARBS CARBY CARDA CARDI CARDO CARDS CARDY CARED CAREN CARER CARES CARET CAREX CAREY CARGO CARIA CARIB CARID CARIN CARKS CARKY CARLA CARLE CARLI CARLL CARLO CARLS CARLY CARME CARNS CARNY CAROB CAROL CAROM CARON CARPI CARPO CARPS CARRA CARRI CARRS CARRY CARSE CARSO CARSY CARTA CARTE CARTS CARTY CARUS CARVE CARYN CARYS CASAL CASAS CASCO CASED CASEN CASER CASES CASEY CASKS CASON CASOS CASSE CASSI CASSO CASTA CASTE CASTO CASTS CASZH CATCH CATEL CATER CATES CATHI CATHS CATHY CATIA CATIE CATIO CATLY CATOE CATOM CATSO CATTE CATTY CAUKS CAULI CAULK CAULS CAUMA CAUPS CAURI CAUSE CAVAE CAVAL CAVAN CAVAS CAVED CAVER CAVES CAVEY CAVIE CAVIL CAVIN CAVOK CAVUM CAVUS CAWED CAWKS CAWKY CAWLS CAXON CAYCE CAYER CAYES CAYOR CAYOS CAZAS CAZIC CAZMA CAZZY CBARS CBIRS CBVIR CCAFS CCDEV CCFLS CCING CCITT CCLCM CCRCS CCSIS CCSNE CCTLD CCTVS CCVOS CDDRS CDERS CDING CDISC CDKIS CDNAS CDRAM CDRES CEARA CEASE CEBID CEBIL CEBUS CECAL CECIE CECIL CECUM CEDAC CEDAR CEDED CEDER CEDES CEDIA CEDIS CEDRY CEEBS CEFRL CEGEP CEIBA CEILI CEILS CEINT CEJAS CELEB CELIA CELIS CELLA CELLI CELLO CELLS CELLY CELOM CELTA CELTS CEMIS CENAS CENES CENSE CENSI CENTO CENTS CEORL CEOSE CEPES CEPHS CEPIN CEQLI CEQUE CERAS CERCI CERDA CERED CERES CERGE CERIA CERIC CERIN CERNA CERNY CEROC CERON CEROS CERRA CERTS CERYL CERYS CESAR CESGS CESTI CESTS CETES CETIN CETPS CETUS CETYL CEUTA CEZVE CFHQS CFLER CFLIP CFWHS CGMPS CHAAS CHAAT CHACE CHACK CHACO CHADD CHADS CHAES CHAFE CHAFF CHAFT CHAGA CHAGO CHAIM CHAIN CHAIR CHAIS CHAIT CHAJA CHALK CHALS CHAMA CHAMP CHAMS CHANA CHAND CHANG CHANK CHANS CHANT CHAON CHAOS CHAPA CHAPE CHAPS CHAPT CHAPU CHAQU CHARA CHARD CHARE CHARK CHARM CHARR CHARS CHART CHARY CHASE CHASM CHAST CHASU CHATE CHATI CHATS CHAUN CHAUR CHAUS CHAVE CHAVS CHAWL CHAWS CHAYA CHAYS CHEAM CHEAP CHEAR CHEAS CHEAT CHEBS CHECE CHECK CHECO CHEDI CHEEK CHEEM CHEEP CHEER CHEEZ CHEFS CHEIF CHEJU CHEKA CHEKI CHEKS CHELA CHELE CHELF CHELL CHELM CHELP CHELS CHELY CHEMO CHEMY CHENA CHENG CHENS CHERI CHERS CHERT CHERY CHESS CHEST CHETH CHETS CHEVE CHEVS CHEVY CHEWA CHEWN CHEWS CHEWY CHHAY CHHIM CHHUN CHIAN CHIAS CHIBA CHIBI CHIBS CHICA CHICH CHICK CHICO CHICS CHIDE CHIEF CHIEL CHIEM CHIEN CHIFF CHIGS CHIHS CHIKA CHILD CHILE CHILI CHILL CHIMB CHIME CHIMO CHIMP CHINA CHINE CHING CHINK CHINN CHINO CHINS CHINT CHIOS CHIOT CHIOU CHIPA CHIPS CHIRK CHIRL CHIRM CHIRO CHIRP CHIRR CHIRU CHISM CHIST CHITA CHITS CHIUS CHIVA CHIVE CHIVS CHIVY CHIYO CHKPT CHLOE CHOAD CHOAK CHOAT CHOCK CHOCO CHOCS CHODA CHODE CHODS CHOES CHOGM CHOIL CHOIR CHOIS CHOKA CHOKE CHOKO CHOKY CHOLE CHOLI CHOLO CHOLY CHOMO CHOMP CHONE CHONG CHONS CHOOF CHOOK CHOOM CHOON CHOOS CHOPE CHOPI CHOPP CHOPS CHOPT CHORD CHORE CHORO CHORS CHOSE CHOSS CHOTT CHOUS CHOUT CHOUX CHOWK CHOWS CHOYS CHRIS CHRON CHUBA CHUBB CHUBI CHUBS CHUCK CHUDE CHUDS CHUDY CHUET CHUFA CHUFF CHUGS CHUIS CHULA CHUMP CHUMS CHUNG CHUNI CHUNK CHUNN CHUNS CHUPA CHUPE CHUPP CHURI CHURL CHURN CHURR CHURS CHURU CHUSE CHUTE CHUTS CHUUK CHYLE CHYME CHYNA CIAAW CIANO CIAOS CIARA CIBER CIBOA CIBOL CICHY CICUS CIDER CIELO CIELS CIFAL CIGAR CIGGY CIHAK CIIDS CIJIN CILIA CILLA CILLS CILLY CIMAR CIMEX CIMID CINCA CINCH CINCO CINCS CINCT CINCY CINDI CINDY CINQS CINTI CIOID CIONS CIPPI CIPRO CIRAS CIRCA CIRCE CIRCS CIRES CIRLS CIRON CIRRI CISCO CISCS CISID CISPA CISSE CISSP CISSY CISTI CISTS CITAL CITED CITEH CITER CITES CITIE CIVES CIVET CIVEY CIVIC CIVIL CIVVY CIZEK CLAAR CLACK CLADE CLADI CLADS CLAES CLAGG CLAGS CLAIK CLAIM CLAIR CLAIT CLAKE CLAMP CLAMS CLANA CLANE CLANG CLANK CLANS CLAPE CLAPP CLAPS CLAPT CLARA CLARE CLARK CLARO CLART CLARY CLASH CLASP CLASS CLAST CLAUD CLAUS CLAUT CLAVA CLAVE CLAVI CLAVY CLAWS CLAWY CLAYE CLAYM CLAYS CLDCS CLEAN CLEAR CLEAT CLECK CLECS CLEEK CLEEP CLEER CLEFS CLEFT CLEGG CLEGS CLEIT CLEMS CLEPE CLEPT CLERK CLETA CLEVE CLEWS CLEYS CLICK CLIED CLIES CLIFF CLIFT CLIMB CLIME CLINE CLING CLINK CLINT CLIOS CLIPS CLIPT CLITS CLIVE CLIVI CLOAK CLOAM CLOBS CLOCK CLODS CLOER CLOFF CLOGS CLOIS CLOKE CLOMB CLOME CLOMP CLONE CLONG CLONK CLOOM CLOOP CLOPS CLORA CLORE CLOSE CLOSH CLOSS CLOST CLOTE CLOTH CLOTS CLOUD CLOUR CLOUS CLOUT CLOVE CLOWD CLOWN CLOWS CLOYD CLOYS CLOZE CLSID CLUBB CLUBS CLUCK CLUED CLUES CLUEY CLUFF CLUMB CLUMP CLUMS CLUNE CLUNG CLUNK CLUON CLUTE CLUTS CLUTZ CLYDE CLYNE CLYPT CMAVO CMDRE CMENE CMIIW CNATT CNDER CNIDA COACH COACT COADD COADS COADY COAGS COAKS COALE COALS COALY COANS COAPT COARB COARC COAST COATE COATH COATI COATS COBBS COBBY COBIA COBIE COBLE COBOL COBOS COBOT COBRA COBZA COCAL COCAS COCCI COCCO COCKE COCKS COCKY COCOA COCOS COCOT COCPS COCUS COCUY CODAS CODDY CODEC CODED CODEL CODEN CODER CODES CODEX CODGE CODGY CODLE CODON COECA COEDS COEND COEUS COFED COFER COFFA COGAN COGAR COGEN COGER COGON COGUE COHAB COHAN COHEE COHEN COHNS COHOE COHOG COHOS COIFS COIGN COILE COILS COILY COINE COINS COION COIPS COIRS COITS COKED COKER COKES COLAS COLBY COLDE COLDS COLED COLEN COLES COLET COLEY COLIC COLID COLIN COLLA COLLE COLLS COLLY COLMA COLNE COLOG COLON COLOR COLPI COLPS COLTS COLZA COMAE COMAL COMAN COMAR COMAS COMBE COMBI COMBO COMBS COMEN COMER COMES COMET COMEY COMFY COMIC COMIN COMIX COMLY COMMA COMMO COMMS COMMY COMOS COMOX COMPO COMPS COMPT COMPY COMTE COMUS CONAL CONAN CONCH CONDE CONDO CONDS CONED CONES CONEY CONFS CONGA CONGE CONGO CONGY CONIA CONIC CONID CONJS CONKS CONKY CONLY CONNS CONNY CONOR CONRY CONST CONTD CONTE CONTO CONTR CONUS CONVO CONWY COOCH COODY COOED COOEE COOER COOEY COOFS COOJA COOKE COOKS COOKT COOKY COOLE COOLI COOLS COOLY COOMB COOMS COONS COOPS COOPT COORD COORG COORT COOSA COOST COOTS COOTY COOWN COOZE COPAL COPAY COPDS COPED COPEL COPEN COPER COPES COPHA COPPA COPPE COPPS COPPY COPRA COPRO COPSE COPSY COPTS COQUE COQUI CORAH CORAL CORAM CORAN CORBA CORBE CORBO CORBS CORBY CORDS CORDY COREA CORED COREQ CORER CORES COREY CORFE CORFS CORFU CORGI CORGY CORIA CORIN CORKS CORKY CORLE CORLS CORMI CORMO CORMS CORNA CORNO CORNS CORNU CORNY COROL CORPS CORRO CORRS CORRY CORSE CORSI CORSO CORTE CORUH CORUM CORVE CORVI CORZO COSBY COSED COSEN COSES COSET COSEY COSHH COSIE COSIO COSMO COSTA COSTE COSTS COTAS COTCH COTED COTES COTHE COTOS COTTA COTTO COTYS COUAS COUCH COUEY COUGH COUIS COULD COULE COUNT COUPE COUPS COURB COURE COURS COURT COURY COUTH COUTO COUTU COVED COVEL COVEN COVER COVES COVET COVEY COVIL COVIN COWAL COWAN COWCH COWED COWEN COWER COWES COWIE COWIN COWLD COWLS COWPS COWRY COXAE COXAL COXED COXES COXIB COXIE COYED COYER COYLE COYLY COYNE COYNS COYPU COZAD COZEN COZIE COZZA COZZI CPAPS CPCFC CPCMS CPCTC CPDNA CPFTS CPTPP CPUSA CRAAL CRABB CRABS CRACE CRACK CRADY CRAFT CRAGG CRAGO CRAGS CRAIC CRAIG CRAIK CRAIL CRAIN CRAKE CRALL CRAME CRAMM CRAMP CRAMS CRANE CRANG CRANK CRANS CRANT CRAPE CRAPO CRAPS CRAPY CRARE CRARY CRASE CRASH CRASS CRATE CRATS CRAUN CRAVE CRAWF CRAWK CRAWL CRAWS CRAYE CRAYS CRAZE CRAZY CREAK CREAM CREAR CREAS CREAT CREDO CREDS CREED CREEK CREEL CREEP CREER CREES CREGO CREMA CREME CREMS CRENA CREON CREOS CREPE CREPS CREPT CREPY CRESC CRESS CREST CRETE CREWE CREWS CRIAS CRIBB CRIBO CRIBS CRICK CRIED CRIER CRIES CRIKS CRIME CRIMI CRIMP CRIMS CRIPE CRIPS CRISP CRITH CRITS CRIUS CRNAC CRNAS CROAK CROAT CROCE CROCI CROCK CROCS CROFF CROFT CROKE CROLL CROMA CROME CRONE CRONK CRONS CRONY CROOK CROOL CROOM CROON CROOP CROPE CROPP CROPS CRORE CROSE CROSS CROST CROUD CROUP CROUT CROWD CROWE CROWL CROWN CROWS CROYN CROYS CROZE CRPGS CRREL CRRNA CRUCE CRUCK CRUDE CRUDS CRUDY CRUEL CRUES CRUET CRUFT CRULL CRUMB CRUME CRUMP CRUMS CRUNK CRUOR CRUPI CRUPS CRURA CRUSE CRUSH CRUST CRUSY CRUTE CRUTH CRUTS CRUVE CRUZE CRWTH CRYAN CRYER CRYIC CRYPT CSACS CSARS CSCHE CSIBS CSNPS CSPES CSRFS CSSOM CSTIT CTCED CTCFS CTCRM CTCSS CTENE CTORS CTTEE CUBAN CUBBY CUBEB CUBED CUBER CUBES CUBIC CUBIE CUBIT CUBOP CUCCI CUCKS CUDDS CUDDY CUDES CUECA CUENS CUERO CUERS CUETO CUEVA CUFFS CUFFY CUFIC CUICA CUING CUISH CUIUI CUKES CULCH CULET CULEX CULLS CULLY CULMS CULPA CULPE CULTS CULTY CUMAE CUMAN CUMBO CUMBY CUMEC CUMED CUMES CUMIN CUMMY CUMYL CUNAS CUNCH CUNDS CUNEI CUNEO CUNGS CUNJI CUNNY CUNTS CUNTY CUOMO CUPAN CUPAR CUPEL CUPID CUPIN CUPIT CUPON CUPOS CUPPA CUPPS CUPPY CUPRO CURAS CURAT CURBS CURBY CURCH CURCI CURDS CURDY CURED CURER CURES CURET CURIA CURIE CURIO CURLI CURLS CURLY CURRS CURRY CURSE CURSI CURST CURTO CURVA CURVE CURVY CUSCO CUSEC CUSHY CUSIC CUSKS CUSMA CUSPS CUSPY CUSSY CUSUM CUTAN CUTCH CUTEN CUTER CUTES CUTEY CUTIE CUTIN CUTIS CUTTO CUTTY CUTUP CUVEE CUVET CUWPL CUYAR CUZCO CUZZA CUZZO CVADS CWMWD CWOTS CWTCH CYANO CYANS CYBER CYBIL CYCAD CYCAS CYCLE CYCLI CYCLO CYDER CYENS CYLES CYLIX CYLOR CYMAE CYMAR CYMAS CYMES CYMOL CYMRU CYMRY CYMWD CYNDI CYNIC CYONS CYPIN CYRIL CYRUS CYSER CYSTS CYTEE CYTES CYTOL CYTON CZAJA CZARS CZECH DAADS DAALS DAANA DAASI DABAO DABBA DABBS DABCO DABOG DACCA DACES DACEY DACHA DACIA DACKS DACLS DADAS DADDA DADDY DADED DADES DADLY DADOS DADYL DADYS DAEGU DAESH DAEVA DAFFS DAFFY DAFNI DAFNS DAFOE DAFTY DAFUQ DAGDA DAGEN DAGGA DAGGE DAGGY DAGON DAGOR DAGOS DAGUE DAGUR DAHAL DAHAN DAHER DAHIR DAHLE DAHLS DAHMS DAHNS DAHUR DAHUS DAIES DAILY DAINS DAINT DAIRY DAISE DAISH DAISY DAJID DAJRE DAKAR DAKER DAKES DAKIN DAKIR DAKKA DALAL DALBY DALCA DALED DALEK DALEO DALES DALET DALEY DALIT DALIU DALKE DALKS DALLS DALLY DALRY DALUZ DALYN DAMAN DAMAR DAMBO DAMEL DAMES DAMFS DAMIR DAMMA DAMME DAMMY DAMND DAMNS DAMON DAMPS DAMPY DANAE DANBO DANCE DANCY DANDA DANDI DANDO DANDS DANDY DANEK DANES DANFO DANGO DANGS DANHS DANIC DANIM DANIO DANIQ DANKO DANKS DANNA DANNI DANNO DANNY DANSK DANSO DANTE DANTS DANZA DANZY DAOUD DAOWN DAPPA DARAF DARAH DARAS DARBS DARBY DARCY DARDA DARDS DARED DARER DARES DARGS DARHA DARIA DARIC DARIN DARJI DARKE DARKO DARKS DARKY DARLA DARLO DARLS DARNS DARON DARPA DARRS DARST DARTH DARTS DARTY DARUG DARVO DARYL DARZI DASHI DASHT DASHY DATED DATEM DATER DATES DATIL DATTA DATTO DATUM DAUBE DAUBS DAUBY DAUER DAUGH DAULS DAUNT DAURA DAURS DAUWS DAVAO DAVED DAVEN DAVEY DAVID DAVIE DAVIL DAVIN DAVIS DAVIT DAVOR DAVOS DAVUL DAVYS DAWAH DAWAS DAWBS DAWDY DAWED DAWEI DAWES DAWGS DAWKS DAWNA DAWNS DAWRO DAWTS DAYAK DAYAL DAYAN DAYED DAYEE DAYER DAYES DAYGO DAYLY DAYNT DAYSE DAYUM DAZAS DAZED DAZEN DAZES DBAAS DBASE DBEYR DBSES DCNNS DCXOS DDNTP DEADE DEADS DEAFO DEAFS DEAIR DEAKS DEALE DEALS DEALT DEALY DEANE DEANG DEANO DEANS DEARE DEARM DEARN DEARS DEARY DEASH DEASS DEATH DEBAG DEBAR DEBBY DEBEL DEBEN DEBIT DEBOS DEBRA DEBTS DEBUG DEBUS DEBUT DEBYE DECAD DECAF DECAL DECAN DECAR DECAY DECEL DECET DECIC DECIM DECKO DECKS DECON DECOR DECOY DECRY DECYL DEDAL DEEBS DEECH DEEDE DEEDS DEEDY DEEIN DEEKS DEEKY DEELS DEEME DEEMS DEENA DEEPE DEEPS DEERE DEERS DEESE DEESS DEEST DEETS DEEVS DEFAT DEFER DEFFO DEFIB DEFIX DEFLY DEFOE DEFOG DEFRA DEGAN DEGAR DEGAS DEGEL DEGEN DEGRE DEGUM DEGUS DEGUT DEHEX DEHNS DEICE DEIES DEIFY DEIGN DEINK DEISM DEIST DEITY DEITZ DEKED DEKES DEKKO DEKLE DELAM DELAO DELAS DELAY DELED DELEO DELES DELFT DELHI DELIA DELID DELIS DELKS DELLA DELLE DELLS DELMA DELOS DELPH DELPS DELTA DELTS DELVE DEMAN DEMAO DEMAP DEMAS DEMES DEMIC DEMIS DEMIT DEMIX DEMOB DEMOI DEMON DEMOS DEMUR DEMUS DEMUX DENAR DENAS DENAY DENCH DENDY DENEB DENES DENET DENGA DENIB DENIE DENIM DENIN DENIS DENNE DENNY DENOS DENSE DENTS DENYS DEOIL DEOLS DEONS DEOPS DEOXY DEPAZ DEPEW DEPOK DEPOS DEPOT DEPPS DEPTH DEPTS DEQUE DERAT DERAY DERBY DERED DEREK DERES DEREZ DERIC DERMA DERMO DERMS DERNE DERNS DERNY DEROS DERPS DERPY DERRO DERRS DERRY DERTH DERYS DESAI DESAL DESAS DESAT DESEX DESHA DESHI DESID DESIR DESIS DESKS DESMA DESMO DESTS DETAG DETAX DETER DETHS DETIN DETOX DETTE DETUR DEUCE DEUEL DEUTY DEVAN DEVAS DEVEL DEVER DEVEX DEVIC DEVIL DEVIN DEVOE DEVON DEVOS DEVOW DEWAN DEWAR DEWAX DEWDS DEWED DEWET DEWEY DEXES DEXEU DEYES DEYOS DFACS DFDAU DFLED DFLER DFTBA DHAAL DHABA DHAKA DHAKI DHAKS DHALS DHAMI DHAPS DHARS DHIKR DHIME DHOBI DHOBY DHOLE DHOLS DHONI DHONY DHOOP DHOTI DHOWS DHROP DHTML DHUHR DHUTI DIABS DIADS DIAGS DIALS DIAMS DIANA DIAND DIANE DIANN DIARY DIAZA DIAZO DIBBA DIBLE DICED DICER DICES DICEY DICKS DICKY DICOT DICTA DICTY DIDAL DIDDY DIDEE DIDGE DIDIE DIDJA DIDNA DIDNT DIDNY DIDOS DIDST DIDYA DIDYM DIEGO DIEHL DIELS DIEMS DIENE DIEPS DIERI DIERS DIEST DIETH DIETS DIETZ DIFFS DIGBY DIGGS DIGHT DIGIS DIGIT DIGNE DIGON DIGUE DIHEX DIION DIJET DIJON DIKED DIKER DIKES DIKKA DILAL DILAN DILDO DILFS DILLI DILLS DILLY DILSA DILSK DIMBO DIMED DIMER DIMES DIMIT DIMLY DIMMS DIMMY DIMPS DINAH DINAR DINDU DINED DINER DINES DINGE DINGO DINGS DINGY DINHS DINIC DINKA DINKS DINKY DINNA DINOI DINOS DINTS DIOCH DIODE DIOIC DIOID DIOLS DIONE DIOPS DIOSE DIOTA DIOXO DIPAS DIPHE DIPIT DIPLE DIPOD DIPPY DIPSE DIPSO DIPSY DIPTE DIRAC DIRAE DIRAM DIRCK DIRER DIRGE DIRGY DIRHM DIRKE DIRKS DIRTH DIRTS DIRTY DISAD DISCO DISCS DISHY DISIR DISKS DISKY DISME DISNA DISTY DITAG DITAL DITAU DITCH DITED DITES DITIN DITSY DITTI DITTO DITTY DITZY DIVAN DIVAS DIVED DIVEL DIVER DIVES DIVET DIVEY DIVIS DIVOS DIVOT DIVVY DIVYA DIWAN DIXEL DIXER DIXEY DIXID DIXIE DIXIT DIXON DIYAS DIYAT DIYED DIYER DIYNE DIYYA DIZEN DIZIS DIZON DIZZY DJAHI DJAHY DJARU DJELI DJENT DJING DJINN DJINS DLBCL DLING DLLME DMACA DMAPA DMARD DMCAS DMING DNAED DNASE DNEPR DNFTT DNIPR DNSBL DNTPS DOABS DOAKS DOANE DOANS DOATS DOBBS DOBBY DOBIE DOBLA DOBLE DOBRA DOBRO DOBYS DOCKS DOCKY DOCOS DOCUS DODAD DODDS DODDY DODEA DODGE DODGY DODIE DODOL DODOS DOEGE DOEKE DOEKS DOERR DOERS DOEST DOETH DOFFS DOGAL DOGAN DOGES DOGGE DOGGO DOGGY DOGIE DOGIS DOGLY DOGMA DOGME DOGON DOGRI DOGSO DOHAN DOHCS DOHMS DOHYO DOIDS DOILY DOING DOINK DOIRA DOITS DOJIN DOJOS DOJRA DOKDA DOKES DOKHA DOKKA DOKOS DOKSA DOLAN DOLBY DOLCE DOLDS DOLED DOLES DOLIA DOLIN DOLLS DOLLY DOLMA DOLOR DOLOS DOLPH DOLTS DOLUS DOMAL DOMAN DOMED DOMER DOMES DOMET DOMKE DOMME DOMMY DOMPT DOMRA DOMUS DONAS DONAT DONAX DONDA DONEE DONEK DONER DONEY DONGA DONGS DONIA DONKS DONNA DONNS DONNY DONOR DONSA DONUT DOOBS DOOBT DOODS DOODY DOODZ DOOFI DOOFS DOOFY DOOGH DOOKS DOOKY DOOLE DOOLY DOOMS DOOMY DOONA DOONE DOONS DOOPS DOORE DOORS DOOSH DOOTH DOOTY DOOZY DOPAC DOPED DOPER DOPES DOPEY DOPPS DORAN DOREA DOREE DOREY DORFS DORGI DORIC DORID DORIS DORKS DORKY DORMS DORMY DORNS DOROW DORPS DORRS DORSA DORSE DORTA DORTS DORTY DORUS DORYS DOSAS DOSCH DOSED DOSEH DOSEL DOSES DOSHA DOSHI DOSRI DOTAL DOTAR DOTED DOTEL DOTEN DOTER DOTES DOTID DOTTY DOTYS DOUAI DOUAR DOUAY DOUBS DOUBT DOUCE DOUCS DOUDU DOUFU DOUGH DOUGO DOUIT DOULA DOUMA DOUMS DOUNE DOUPE DOUPS DOURA DOURO DOUSE DOUST DOUTH DOUTS DOVEL DOVER DOVES DOVEY DOVRE DOWAR DOWDS DOWDY DOWED DOWEL DOWER DOWLE DOWNE DOWNS DOWNY DOWPS DOWRY DOWSE DOWST DOWTY DOXAS DOXED DOXER DOXES DOXEY DOXIE DOYAL DOYEN DOYLE DOYLY DOYRA DOZED DOZEN DOZER DOZES DPCCH DPDCH DPRFP DRABS DRACO DRAFF DRAFT DRAGN DRAGS DRAIL DRAIN DRAIT DRAKE DRAMA DRAMS DRANE DRANK DRANT DRAPA DRAPE DRAPS DRATE DRATS DRAUG DRAVA DRAVE DRAWE DRAWL DRAWN DRAWS DRAYS DREAD DREAM DREAP DREAR DRECK DREDS DREED DREEP DREES DREGS DREHU DREIN DRESS DREST DREUL DREVE DREYS DRIBS DRIED DRIER DRIES DRIFT DRILL DRILY DRINA DRING DRINK DRINS DRIPS DRIPT DRITE DRITH DRIUE DRIVE DRNIS DRNJE DROCK DROFF DROID DROIL DROLL DROME DROMI DRONA DRONE DRONY DROOB DROOG DROOL DROOP DROPS DROPT DROSS DROST DROTT DROVE DROWN DROZD DRUBS DRUGG DRUGS DRUID DRUMM DRUMS DRUNK DRUPE DRURY DRUSE DRUSY DRUXY DRUZE DRUZY DRYAD DRYAS DRYER DRYES DRYLY DRYTE DRYTH DRYTT DSDNA DSLAM DSLRS DSMLT DSPHS DSRNA DSRVS DSVPS DTIMS DTMCS DTMFA DTORS DUADS DUALA DUALS DUANE DUANG DUANS DUARS DUATS DUBAI DUBAY DUBBO DUBBS DUBBY DUBES DUBEY DUBHE DUBIN DUBKI DUBON DUBUC DUBYA DUBYS DUCAL DUCAS DUCAT DUCES DUCET DUCEY DUCHY DUCKS DUCKY DUCTS DUDDY DUDED DUDES DUDEY DUDHI DUDOU DUDUK DUELO DUELS DUERR DUERS DUESY DUETS DUETT DUFFS DUFFY DUFUS DUGAN DUGAR DUGLA DUHES DUHON DUKAS DUKED DUKES DUKUN DULAC DULAS DULAY DULCE DULEY DULIA DULIC DULID DULIN DULLS DULLY DULSE DUMAN DUMAS DUMBO DUMBS DUMKA DUMKE DUMKY DUMMS DUMMY DUMPS DUMPY DUNAL DUNAM DUNAR DUNCE DUNCH DUNCY DUNDY DUNED DUNES DUNEY DUNGS DUNGY DUNIV DUNKS DUNKY DUNNE DUNNO DUNNY DUNSH DUNTS DUNUM DUNUN DUNZO DUOMI DUOMO DUONG DUPED DUPEE DUPER DUPES DUPLA DUPLE DUPLY DUPPY DUPRE DUPUY DUQQA DUQUE DURAG DURAL DURAS DURED DUREN DUREX DURGA DURGY DURIF DURNS DUROC DURON DUROY DURRA DURRS DURRY DURSO DURST DURTY DURUM DURZI DUSEK DUSES DUSKS DUSKY DUSTS DUSTY DUSUN DUTAN DUTAR DUTCH DUTIE DUTKO DUTRA DUTTA DUTTY DUTYS DUVAL DUVAS DUVEL DUVET DUWAL DUXES DUZAN DVIGU DVRED DWAAL DWALE DWALM DWANG DWARF DWECK DWEEB DWEET DWELL DWELT DWEMS DWERE DWINE DWIRE DWORD DWYER DXERS DXING DYADS DYAKS DYALS DYANA DYARS DYBLE DYCHE DYDOE DYERS DYERY DYEST DYETH DYETS DYING DYKED DYKES DYKEY DYKON DYLAN DYNAE DYNAM DYNES DYNOS DYONS DYSON DYULA DYUTI DZHOS DZOMO DZONG DZUDS EADDY EADIE EADYS EAFRE EAGAN EAGAR EAGEN EAGER EAGLE EAGRE EAKER EAKIN EAKLE EAKLY EALES EALEY EALYS EAMES EAMON EANED EARAL EARBY EARED EARES EARLE EARLS EARLY EARNS EARNT EAROM EARPS EARSH EARST EARTH EASED EASEL EASEN EASER EASES EASIE EASON EASTS EATED EATEN EATER EATHY EATON EAUDE EAVED EAVES EBARB EBAYS EBBED EBBER EBBIE EBCTA EBELS EBENE EBERT EBERY EBEYE EBICS EBISU EBITA EBITS EBKAC EBLEN EBLES EBLIS EBMER EBNER EBOLA EBONS EBONY EBOOK EBRON ECADS ECARD ECASH ECCER ECCHI ECCHO ECCLE ECDIS ECDLS ECECS ECENI ECFVS ECHED ECHES ECHOE ECHOI ECHOS ECKEL ECKER ECKIE ECKLE ECLAT ECLOG ECLSS ECONS ECRUS ECTAD ECTAL ECTOR ECTWT EDDER EDDIC EDDIE EDDOE EDDOS EDEMA EDENS EDGAR EDGED EDGEL EDGER EDGES EDGIE EDGIN EDICK EDICT EDIFY EDILE EDINA EDITH EDITS EDLER EDLIN EDLYN EDMAN EDMIN EDNEY EDNOS EDOID EDSEL EDSON EDUCE EDUCT EDWIN EEFER EEFIN EEFIS EEJIT EEKED EELED EELER EENSY EEPED EERIE EETCH EEVEN EEVNS EFATE EFATO EFAWS EFFED EFFER EFFET EFFIE EFFRA EFIGS EFIRD EFOLD EFREN EGADS EGALL EGBOS EGEAN EGERS EGEST EGGAH EGGAR EGGED EGGER EGGES EGGIE EGGON EGHAM EGLES EGLIS EGLOG EGNER EGNOR EGOIC EGOLF EGRES EGRET EGUIA EGUSI EGYPT EHEVI EHHED EHIME EHING EHLEN EHLER EHLES EHMAN EHMKE EHRET EHSAN EIBAR EICKS EIDER EIDOI EIDOS EIFEL EIGHT EIGNE EIKON EILER EIRIE EIRUV EISBN EISCH EISEL EITEL EJECT EJIDO EKING EKIRI EKKAS EKKER EKMAN EKOID EKOTI EKPES ELAHI ELAIN ELAND ELANS ELARA ELATE ELAYL ELBAZ ELBIE ELBOW ELCHI ELCIC ELDAR ELDEN ELDER ELDON ELECT ELEET ELEGY ELEMI ELENA ELENI ELEYS ELFED ELFEN ELFIN ELGAN ELGAR ELGIN ELIAN ELIAS ELICK ELIDA ELIDE ELIEL ELIGA ELINA ELINT ELIOT ELISA ELISE ELITE ELIZA ELKES ELKIN ELLEN ELLER ELLIE ELLIS ELMAN ELMEN ELMER ELMID ELOGE ELOGY ELOIN ELONG ELOPE ELORA ELOYI ELPEE ELPIS ELRIG ELROD ELRON ELROY ELSAN ELSEA ELSEN ELSER ELSEY ELSIE ELSIN ELSON ELSPA ELTON ELUDE ELUTE ELVAN ELVEN ELVER ELVES ELVIE ELVIN ELVIR ELVIS ELYSE ELZEY ELZYS EMACS EMAIL EMAKI EMALS EMBAR EMBAY EMBED EMBER EMBLA EMBOG EMBOW EMBOX EMBRY EMBUE EMBUS EMCEE EMDEN EMEAI EMEER EMEIA EMEME EMEND EMERG EMERI EMERT EMERY EMESA EMEUS EMEWS EMGES EMIGH EMIGS EMIKO EMILE EMILY EMIRI EMIRP EMIRS EMITS EMLYN EMMAS EMMEL EMMEN EMMER EMMET EMMEW EMMIE EMMYS EMOJI EMOND EMONG EMORY EMOTE EMOVE EMPEY EMPTY EMTFS EMULE EMYDS ENACT ENALS ENARM ENATE ENCKS ENCUR ENDED ENDER ENDEW ENDIF ENDLY ENDOS ENDOW ENDUE ENEAS ENEGO ENEID ENEMA ENEMY ENETS ENEWS ENGEN ENGES ENGHS ENGIN ENGLE ENGMA ENGOS ENGSH ENIAC ENIDS ENIOY ENJOY ENKAI ENKES ENLAY ENLIL ENLIT ENLOE ENLOW ENMEW ENMIX ENNET ENNEW ENNIS ENNOG ENNUI ENOCH ENODE ENOKI ENOLA ENOLS ENONE ENORM ENOSE ENOYL ENROL ENRON ENSES ENSET ENSEW ENSEY ENSHI ENSKY ENSOR ENSUE ENTAD ENTAL ENTED ENTER ENTIA ENTRE ENTRY ENUFF ENUMS ENURE ENVIE ENVOI ENVOY ENYLS ENYNE ENZED ENZES ENZYM EOFAN EOFEN EOFFS EOFYS EOLED EOLIA EOLIC EOLID EOLIS EOLUS EONIA EONIC EORLS EOSIN EOTEN EOUTE EOZOA EPACT EPCOT EPEEN EPEES EPETO EPHAH EPHAS EPHOD EPHOR EPHUS EPICK EPICS EPIRB EPLER EPLEY EPOCH EPODE EPONA EPOPT EPOXY EPPLE EPRIX EPROM EPSIN EPSOM EPSPS EPTFE EPUBS EQUAL EQUES EQUID EQUIP EQUOL ERAMO ERASE ERATH ERATO ERAZO ERBIA ERBIL ERBYS ERDUT ERECT ERGAL ERGED ERGON ERGOT ERHUA ERHUS ERICA ERICH ERICK ERICS ERIDU ERIKA ERIKO ERISA ERITH ERLER ERMIN ERMIT ERNAI ERNAS ERNED ERNES ERNIE ERNST ERODE EROGE EROSE ERRED ERRNO ERROL ERROR ERSPS ERTEL ERTLE ERTLS ERTMS ERUBS ERUCA ERUCT ERUPT ERUVS ERVEN ERVIL ERWAU ERWAY ERWIN ERZYA ESAUL ESBAT ESCAE ESCIN ESCOS ESCUE ESHER ESHES ESIAS ESKAR ESKER ESKEW ESKIE ESLER ESLOP ESMEH ESNES ESNET ESOPS ESPER ESPEY ESPIN ESPOO ESPYS ESRAJ ESRIN ESRUM ESSAS ESSAY ESSEN ESSER ESSES ESSEX ESSIE ESTEC ESTEP ESTER ESTES ESTEY ESTHS ESTON ESTOP ESTRE ETAAC ETAGE ETAIN ETALE ETAPE ETCHI ETEXT ETHAL ETHAN ETHEA ETHEL ETHER ETHIC ETHOI ETHOS ETHYL ETHYR ETLAS ETNAS ETOPS ETSOI ETTEN ETTER ETTIN ETTLE ETUDE ETUIS ETWEE ETYID ETYMA ETYMS ETZEL EUCHE EUCRE EUERY EUFOD EUGHS EUILL EULAH EULAS EULER EURGH EUROS EURUS EURYS EUSSR EUTHS EVACS EVADE EVALS EVANS EVATS EVATT EVENE EVENK EVENS EVENT EVERE EVERT EVERY EVETS EVETT EVICT EVILL EVILS EVITA EVITE EVOKE EVOLA EWALT EWART EWEDU EWELL EWERS EWERT EWERY EWING EWOKS EWOOD EWWIE EXACT EXAFS EXALT EXAMS EXAOP EXCEL EXCOS EXCSC EXCUR EXDIS EXEAT EXECS EXECT EXECX EXEME EXEPT EXERT EXFAT EXFIL EXGOD EXHIB EXILE EXINE EXING EXIRA EXIST EXITS EXLEY EXNER EXODE EXODY EXOME EXONS EXORD EXPAT EXPEL EXPOS EXPWY EXPYS EXSEC EXTGS EXTOL EXTRA EXTRY EXUDE EXULT EXUMA EXUMS EXURB EXXON EYAKS EYASS EYELY EYERS EYETS EYGRE EYING EYLER EYMAN EYNON EYOTS EYRAS EYREN EYRES EYRIE EYRIR EZAFE EZELL EZHES EZHOU EZINE EZRIN EZZIE EZZOS FAAAN FAANG FAANS FABBO FABBY FABER FABID FABLE FABOO FABRE FABRY FACED FACEP FACER FACES FACET FACEY FACIA FACIO FACKS FACPS FACTA FACTO FACTS FACTY FADAS FADDY FADED FADEL FADER FADES FADGE FADOS FAENA FAERY FAFDA FAFFS FAFFY FAFIA FAFSA FAGAN FAGEN FAGER FAGES FAGGS FAGGY FAGIN FAGMO FAGOT FAGUS FAHAM FAHEY FAHMY FAIAL FAIFO FAILE FAILS FAINE FAINS FAINT FAIRD FAIRE FAIRS FAIRY FAITH FAKED FAKEN FAKER FAKES FAKEY FAKIE FAKIR FAKON FALAJ FALBO FALCK FALDA FALKS FALLA FALLS FALSA FALSE FALSY FALTS FALUN FALVO FALWE FAMED FAMES FAMIL FANAC FANAL FANAM FANCY FANDS FANED FANES FANGA FANGO FANGS FANGY FANKS FANNE FANNS FANNY FANON FANOS FANTA FANTE FANTI FANUM FAQIH FAQIR FARAD FARAG FARAH FARAJ FARBS FARBY FARCE FARCY FARDS FARED FARER FARES FARGO FARIA FARID FARIK FARIO FARKS FARLS FARMS FARMY FARRA FARRE FARRO FARRY FARSE FARSI FARTS FARTY FASDS FASEL FASIQ FASON FASPA FASTI FASTS FATAH FATAL FATAS FATED FATES FATHA FATHS FATLY FATSO FATTY FATWA FAUCH FAUGH FAULD FAULE FAULK FAULS FAULT FAUNA FAUNE FAUNI FAUNS FAUST FAUTH FAVAS FAVED FAVEL FAVES FAVID FAVOR FAVUS FAWAZ FAWCE FAWNS FAWNY FAXED FAXER FAXES FAXON FAYAD FAYAL FAYED FAYNE FAYRE FAYTH FAYUM FAZED FAZES FAZIO FBIER FBSES FCEVS FEAKS FEALS FEARD FEARE FEARN FEARS FEASE FEASS FEAST FEATS FEAZE FEBSS FECAL FECES FECHT FECKS FEDAI FEDAK FEDAN FEDEX FEDGE FEEBS FEEDS FEELE FEELS FEELY FEENS FEERE FEESE FEETE FEETS FEEZE FEGAN FEHER FEHLS FEHRS FEICK FEIGN FEIGS FEINS FEINT FEIST FEITH FEITS FEJEE FELAN FELCH FELDT FELID FELIX FELLA FELLS FELLY FELON FELTE FELTS FELTY FELTZ FEMAL FEMES FEMME FEMMY FEMUR FENCE FENDS FENDY FENER FENGS FENGU FENIS FENKS FENNE FENNI FENNY FENTS FENYA FEODS FEOFF FEOLA FEORM FERAL FERAS FERDS FERES FERIA FERIE FERKO FERME FERMI FERMS FERNS FERNY FERPA FERRA FERRE FERRI FERRO FERRY FERYS FESSE FESTA FESTS FESTY FETAL FETAS FETCH FETED FETES FETID FETII FETIS FETOR FETTA FETTY FETUS FETWA FEUAR FEUDS FEUED FEVER FEVRE FEWEL FEWER FEWLS FEWLY FEWTE FEYER FEYLY FEZES FFDCA FHLBB FIARS FIATS FIBER FIBRE FIBRO FIBUA FICEK FICES FICHE FICHU FICID FICIN FICKE FICTS FICUS FIDAA FIDDY FIDED FIDEL FIDEO FIDES FIDGE FIDOS FIEFS FIELD FIEND FIENE FIERO FIERS FIERY FIFED FIFER FIFES FIFOS FIFTH FIFTY FIGGS FIGGY FIGHT FIGLU FIGMO FIGOS FIKED FIKES FILAR FILCH FILED FILER FILES FILET FILKS FILLO FILLS FILLY FILMI FILMS FILMY FILOS FILST FILTH FILUM FINAL FINAN FINAU FINCA FINCH FINCK FINDE FINDS FINDY FINED FINER FINES FINEW FINIF FINIS FINKE FINKS FINNA FINNS FINNY FINOS FIONA FIORD FIORI FIQUE FIRBY FIRED FIREE FIRER FIRES FIREY FIRIE FIRKS FIRMS FIRNS FIRRY FIRST FIRTH FISBO FISCS FISES FISHO FISHY FISKE FISKS FISTS FISTY FITBA FITCH FITES FITLY FITNA FITRA FITTE FITTS FITTY FIVED FIVER FIVES FIXED FIXEN FIXER FIXES FIXIE FIXIT FIXLY FIXNA FIXUP FIZBO FIZER FIZZY FJARD FJELD FJORD FKING FLABS FLACK FLAGG FLAGS FLAIG FLAIL FLAIM FLAIN FLAIR FLAKE FLAKI FLAKS FLAKY FLAME FLAMM FLAMS FLAMY FLANG FLANK FLANS FLAPS FLAPT FLARE FLARF FLARY FLASH FLASK FLATS FLATT FLAVA FLAWN FLAWS FLAWY FLAXY FLAYS FLEAD FLEAK FLEAM FLEAS FLECK FLEDA FLEED FLEEK FLEEN FLEER FLEES FLEET FLEHM FLEIG FLEME FLERD FLESH FLETS FLETT FLEUR FLEVO FLEWS FLEXO FLEXY FLEYS FLICE FLICK FLICS FLIDS FLIED FLIER FLIES FLIFO FLIMP FLING FLINK FLINN FLINT FLIPE FLIPS FLIPT FLIRT FLISK FLISS FLIST FLITE FLITS FLOAT FLOBS FLOCC FLOCK FLOCS FLODE FLOES FLOGS FLOHR FLONE FLONG FLOOD FLOOF FLOOK FLOOM FLOOR FLOPS FLORA FLORS FLORY FLOSH FLOSS FLOTA FLOTE FLOUD FLOUR FLOUT FLOWE FLOWK FLOWN FLOWS FLOWY FLOYD FLUBS FLUDD FLUED FLUES FLUEY FLUFF FLUID FLUKE FLUKY FLUME FLUMP FLUNG FLUNK FLUOR FLURR FLURT FLURY FLUSH FLUTD FLUTE FLUTY FLUYT FLYBY FLYER FLYES FLYNN FLYNT FLYPE FLYTE FMAIT FMOLE FMOLS FNARR FNESE FNIRS FNORD FOAFS FOALE FOALS FOAMS FOAMY FOARD FOBBY FOBTS FOCAL FOCHT FOCUS FODEN FOEHN FOETI FOGAS FOGEL FOGES FOGEY FOGGY FOGIE FOGLE FOGOU FOGTS FOHES FOHNS FOIBA FOIDS FOILS FOIND FOINE FOINS FOISM FOIST FOISY FOLAN FOLAR FOLDS FOLEY FOLIA FOLIC FOLIO FOLKS FOLKY FOLLY FOLSE FOLTZ FOLWE FOLYL FOMBA FOMBY FOMES FONDA FONDS FONDU FONIO FONLY FONTS FOODS FOODY FOOEY FOOFY FOOKS FOOLE FOOLS FOONS FOORS FOOSA FOOSE FOOTE FOOTS FOOTY FORAM FORAN FORAY FORBS FORBY FORCE FORDE FORDO FORDS FORDY FOREL FORET FOREX FORGE FORGO FORKS FORKY FORLI FORME FORML FORMS FORMY FORNE FORRO FORRY FORTE FORTH FORTI FORTS FORTY FORUM FOSHO FOSSA FOSSE FOSSY FOSTA FOTCH FOTHS FOTIS FOTOG FOTOS FOUAT FOUCH FOUDS FOUET FOUGH FOULE FOULS FOUND FOUNT FOUPS FOURE FOURS FOUSE FOUST FOUTA FOUTH FOUTY FOUTZ FOVEA FOWER FOWEY FOWLE FOWLS FOXED FOXEN FOXER FOXES FOXIE FOXLY FOYER FOYES FOYLE FPCON FPGAS FPSES FQDNS FQIHS FRABS FRACK FRACS FRACT FRADY FRAGS FRAHM FRAID FRAIL FRAIM FRAIN FRALA FRAMA FRAME FRANC FRAND FRANK FRANS FRANZ FRAPE FRAPS FRARY FRASE FRASS FRATE FRATI FRATS FRAUD FRAUS FRAYS FRAZE FRCOG FRCSS FRCVS FREAK FREAM FREAR FREAT FRECK FREDA FREDK FREDS FREED FREEL FREEP FREER FREES FREET FREIA FREIT FREKE FREMD FRENA FRENS FREON FREQS FRERE FRESE FRESH FRESS FRETA FRETS FRETT FRETZ FREUD FREVO FREWS FREYA FREYR FRIAR FRIAS FRICE FRICK FRIDA FRIED FRIEL FRIER FRIES FRIGG FRIGO FRIGS FRILL FRINE FRINK FRISK FRIST FRITH FRITS FRITZ FRIZE FRIZZ FROBS FROCK FRODO FROES FROGS FROIN FROLF FROME FROMM FROND FRONK FRONS FRONT FROOD FROOM FRORE FRORY FROSH FROSK FROST FROTE FROTH FROTS FROUN FROUP FROWN FROWS FROWY FROYO FROZE FRUEH FRUGE FRUGS FRUIT FRUMP FRUNK FRUSH FRUTH FRYAR FRYER FRYUP FSBOS FSCKS FSECS FSING FTBFS FTIRA FTIRS FTMFW FTPED FUAGE FUARS FUBAR FUBBY FUBSY FUBUS FUCAN FUCCI FUCHS FUCKA FUCKO FUCKS FUCKY FUCUS FUDDS FUDGE FUDGY FUELS FUERO FUETS FUFFS FUFFY FUGAL FUGAS FUGGO FUGGS FUGGY FUGIE FUGIT FUGLE FUGLY FUGOS FUGUE FUHRS FUJIE FUJII FUJIN FUJIS FUKUI FULAH FULAS FULBE FULDA FULES FULKS FULLA FULLS FULLY FULLZ FULOP FULPS FULTH FULTZ FUMED FUMER FUMES FUMET FUMID FUMIE FUNAN FUNCT FUNDA FUNDI FUNDS FUNDU FUNDY FUNEN FUNEZ FUNGE FUNGI FUNGO FUNGS FUNIC FUNIS FUNJI FUNKS FUNKY FUNNY FUNTS FUOCO FUPAS FUQUA FURAN FURBY FUREY FURIN FURLS FUROL FUROR FUROS FURPS FURRY FURST FURTH FURYL FURZE FURZY FUSCO FUSED FUSEE FUSEN FUSER FUSES FUSIL FUSON FUSOR FUSSY FUSTS FUSTY FUTAS FUTCH FUTON FUZED FUZEE FUZES FUZIL FUZZY FWDED FWEEP FWEND FWOOM FWSAR FYALL FYFES FYFFE FYKES FYLDE FYLES FYNDE FYNNE FYOCK FYRDS FYRKS FYROM FYRST FYTTE GAALS GAANG GABAE GABAR GABAY GABBA GABBY GABEL GABER GABEY GABLE GABOB GABON GABOR GABRA GACEK GACHA GACHE GACKS GADAS GADDI GADDS GADDY GADEA GADES GADGE GADIC GADID GADIS GADJE GADSO GAEDE GAELS GAFFA GAFFE GAFFS GAFIA GAGED GAGER GAGES GAGGY GAGNE GAGRA GAHAN GAIAN GAIDA GAIER GAIJI GAIKA GAILY GAINS GAITS GAJDA GALAH GALAN GALAS GALAZ GALBE GALBI GALDR GALEA GALED GALEN GALES GALEX GALEY GALGE GALKA GALLO GALLS GALLY GALOP GALPS GALVO GAMAS GAMAY GAMBA GAMBE GAMBO GAMBS GAMED GAMEL GAMER GAMES GAMEY GAMEZ GAMIC GAMIE GAMIN GAMMA GAMMY GAMON GAMPI GAMPS GAMUT GANAI GANCA GANCH GANCI GANDA GANDY GANEF GANEV GANEY GANGA GANGE GANGI GANGL GANGS GANIF GANIL GANJA GANKS GANNA GANNS GANOE GANOF GANOS GANPI GANSA GANSU GANTA GANTE GANTT GANTZ GANZA GANZY GAOHU GAOLS GAONA GAONS GAOTU GAOUR GAPED GAPER GAPES GAPIK GAPPA GAPPY GARAY GARBO GARBS GARCH GARDA GARDE GARDS GAREY GARGE GARON GAROS GARRI GARRO GARRS GARRY GARST GARTH GARUA GARUM GARZA GARZE GASCA GASER GASES GASHI GASPE GASPS GASPT GASPY GASSY GASTS GATAH GATAS GATCH GATED GATES GATHA GATHO GATKA GATOR GATRA GATTA GATTI GAUDS GAUDY GAUER GAUGE GAULS GAULT GAUMS GAUMY GAUNA GAUNT GAUPS GAURA GAURS GAUSE GAUSS GAUTS GAUZE GAUZY GAVAR GAVEL GAVER GAVID GAVIN GAVOT GAVYN GAWBY GAWDS GAWED GAWEL GAWFS GAWKS GAWKY GAWMS GAWNS GAWPS GAWTH GAYAL GAYBO GAYBY GAYED GAYER GAYLA GAYLE GAYLY GAYMO GAYNE GAZAL GAZAN GAZAR GAZDA GAZED GAZEE GAZEL GAZER GAZES GAZET GAZID GAZON GAZOO GAZZA GCNSS GCSES GCWRS GDNAS GEACH GEALS GEANS GEARE GEARS GEARY GEASA GEATS GEAUX GEBOS GEBRE GEBUR GECKO GECKS GEDDS GEDGE GEECH GEEKS GEEKY GEEPS GEERS GEESE GEEST GEHLS GEHRS GEHUS GEIBS GEIKO GEILS GEIRE GEISE GEISH GEIST GEJIA GELAO GELDS GELEE GELID GELLO GELLS GELLY GELTS GELUG GEMAS GEMEL GEMMA GEMMY GEMOT GENAL GENAM GENAO GENCO GENES GENET GENGS GENIC GENIE GENII GENIN GENIO GENIP GENNA GENNY GENOA GENON GENOS GENRE GENRO GENTA GENTS GENTY GENTZ GENUA GENUS GEODE GEOFF GEOID GEONS GEPID GERAH GERBE GERBS GERDA GEREN GERES GERIG GERIM GERIS GERKE GERMS GERMY GERNS GEROS GEROW GERRA GERRI GERRY GERSH GERST GERTH GERTZ GESHE GESHO GESKE GESSO GESTS GETAS GETER GETGO GETTS GETTY GETUP GEUMS GEWEX GEWOG GEYER GEZVE GFCIS GHANA GHANI GHANS GHAST GHATS GHAUS GHAUT GHAYN GHAZI GHEDE GHEEN GHEES GHENT GHERE GHESS GHITS GHIYA GHODS GHOLE GHOOD GHOSH GHOST GHOTI GHOUL GHUSL GHUZZ GHYLL GIANA GIANT GIBBS GIBBY GIBED GIBEL GIBER GIBES GIBLI GIBUN GIBUS GIDDY GIDJI GIENS GIESE GIFED GIFFY GIFTS GIFTY GIGER GIGOT GIGUE GIHON GIING GIIPS GIJON GILAN GILAS GILBY GILDS GILES GILET GILFS GILIA GILLE GILLS GILLY GILPY GILSE GILTS GIMEL GIMLI GIMME GIMPS GIMPY GINAS GINCH GINES GINGE GINGS GINKS GINNS GINNY GINOS GINZO GIPES GIPPO GIPPY GIPSY GIRAH GIRBY GIRDS GIRES GIRIS GIRLE GIRLF GIRLS GIRLY GIRLZ GIRMA GIRNS GIROD GIRON GIROS GIRSH GIRTH GIRTS GIRUS GIRYA GISED GISLE GISMO GISMU GISSA GISTS GITCH GITES GITMO GITTY GIUEN GIVAN GIVED GIVEE GIVEN GIVER GIVES GIZAH GIZMO GIZZA GIZZI GLACE GLADE GLADS GLADY GLAIK GLAIR GLAKY GLAMS GLAND GLANS GLARE GLARK GLARY GLASS GLATT GLAUM GLAUR GLAVE GLAZE GLAZY GLBTI GLBTQ GLEAD GLEAM GLEAN GLEBA GLEBE GLEBY GLEDE GLEED GLEEK GLEEM GLEEN GLEES GLEET GLEGS GLEIM GLEIS GLENE GLENN GLENS GLENT GLEWS GLEYS GLIAL GLIBS GLICK GLIDE GLIFF GLIFT GLIKE GLIMS GLINA GLINT GLIPS GLISS GLIST GLITZ GLOAK GLOAM GLOAR GLOAT GLOBE GLOBS GLOBY GLOCK GLODE GLOGG GLOME GLOMP GLOMS GLOND GLOOK GLOOM GLOOP GLOPE GLOPS GLORE GLORY GLOSA GLOSS GLOST GLOUR GLOUT GLOVE GLOWR GLOWS GLOWY GLOYD GLOZE GLUBS GLUCK GLUED GLUER GLUES GLUEY GLUGS GLUME GLUMP GLUMS GLUNT GLUON GLUTE GLUTS GLWTS GLYDE GLYME GLYNN GLYNS GLYPE GLYPH GMAIL GMATS GMDSS GMINA GMING GMPLS GMVLS GNAFF GNAGY GNARL GNARS GNASH GNASS GNAST GNATS GNAWA GNAWN GNAWS GNIDE GNITS GNODS GNOFF GNOLL GNOME GNUDI GNVQS GOADS GOAFS GOALS GOAMS GOAND GOANS GOATS GOATY GOBAN GOBAR GOBBY GOBEL GOBEN GOBER GOBHI GOBIN GOBLE GOBOS GODAS GODBY GODDA GODDY GODEK GODET GODIN GODIS GODLY GODOY GODSO GOEDE GOEKE GOELS GOELZ GOENS GOERS GOEST GOETH GOETY GOETZ GOFER GOFFS GOFIO GOGEL GOGES GOGGA GOGOL GOGOS GOHLS GOHNS GOIAS GOIIM GOING GOINS GOITS GOJES GOJKO GOJRI GOKEY GOLAN GOLAY GOLDA GOLDE GOLDS GOLDY GOLEM GOLEY GOLFS GOLFY GOLGI GOLLA GOLLI GOLLS GOLLY GOLOK GOLPE GOLTZ GOLUB GOLVA GOMAD GOMAN GOMAS GOMBO GOMEL GOMER GOMEZ GOMPA GONAD GONCE GONCH GONDA GONDI GONDS GONEF GONER GONEY GONGE GONGO GONGS GONIA GONIF GONIO GONJA GONKS GONNA GONNO GONOF GONYS GONZO GOOCH GOODE GOODO GOODS GOODY GOOED GOOEY GOOFS GOOFY GOOGE GOOGS GOOID GOOKS GOOKY GOOLD GOOLE GOOLY GOOMS GOONA GOONS GOONY GOOPS GOOPY GOORI GOOSE GOOSH GOOSY GOPAK GOPAL GOPAR GOPER GOPIK GOPIS GOPUZ GORAL GORAN GORAS GORBY GORCE GORDO GORDS GORDY GORED GOREE GOREN GORER GORES GOREY GORGE GORIN GORIS GORKI GORKS GORKY GORMS GORMY GORNO GORNY GORRA GORRS GORRU GORRY GORSE GORSY GOSAN GOSAS GOSES GOSHA GOSHT GOSIP GOSTS GOTAY GOTCH GOTES GOTHA GOTHS GOTHY GOTOS GOTRA GOTTA GOTTI GOTTS GOUDA GOUDS GOUDY GOUGE GOUGH GOUIN GOULD GOUND GOUNG GOURA GOURD GOURS GOUTS GOUTY GOVAN GOVEA GOVER GOVES GOVTS GOVVY GOWAN GOWAR GOWDA GOWDY GOWEN GOWER GOWIN GOWKS GOWLS GOWND GOWNS GOYAL GOYEM GOYER GOYIM GOYLE GOZAS GPCRS GPIOS GPLED GPPSS GPSED GPSES GPUSA GRAAL GRABS GRACE GRADE GRADO GRADS GRADY GRAEF GRAFF GRAFS GRAFT GRAGG GRAHL GRAHN GRAIL GRAIN GRAMA GRAME GRAMP GRAMS GRANA GRAND GRANE GRANO GRANS GRANT GRAPE GRAPH GRAPY GRASE GRASP GRASS GRATE GRATS GRATZ GRAUE GRAUL GRAVE GRAVS GRAVY GRAWL GRAYS GRAZE GREAR GREAT GREBE GREBO GREBS GRECE GREED GREEK GREEN GREER GREES GREET GREGA GREGG GREGO GREIG GREIN GREIP GREIT GRELL GRENZ GREPS GRESH GRETA GREWE GREYS GRICE GRIDE GRIDS GRIEB GRIEF GRIEP GRIER GRIES GRIFF GRIFT GRIGG GRIGS GRIKE GRIKO GRILF GRILL GRILT GRIME GRIMM GRIMS GRIMY GRIND GRINS GRIOT GRIPE GRIPS GRIPT GRIPY GRISE GRISM GRIST GRISY GRITH GRITS GRIZE GRIZZ GRNAS GROAN GROAT GROBE GROBS GROCE GROCK GRODY GROEN GROFF GROFT GROGG GROGS GROHL GROHS GROID GROIN GROKS GROMA GROMS GRONE GRONK GROOK GROOM GROOP GROPE GROPP GROPY GROSE GROSS GROSZ GROTE GROTH GROTS GROUD GROUP GROUT GROVE GROVY GROWE GROWL GROWN GROWS GROZE GRRLS GRRRL GRRRR GRUBB GRUBS GRUDS GRUED GRUEL GRUEN GRUES GRUFF GRUFT GRUHN GRUID GRUIT GRUME GRUMP GRUNT GRUPE GRYDE GRYKE GRYME GRYMS GRYPE GRYPT GSELL GSIGN GSKEW GSSPS GTIDS GTINS GTLDS GUACO GUAGE GUAIC GUAJE GUALE GUANA GUANO GUANS GUARA GUARD GUARS GUASA GUATS GUAVA GUAYS GUAZU GUBER GUBUS GUCKS GUDES GUDGE GUDOK GUELF GUELS GUESS GUEST GUEVI GUEYE GUFFS GUGAS GUGEL GUHRL GUIAC GUIBS GUICE GUIDA GUIDE GUIDI GUIDO GUIDS GUIGE GUIJO GUILD GUILE GUILL GUILT GUINN GUINS GUIRA GUIRL GUIRO GUISE GUIST GUITY GUJAR GUJIA GUJJU GULAE GULAG GULAL GULAR GULAS GULCH GULES GULET GULFS GULFY GULLA GULLO GULLS GULLY GULPH GULPS GULPY GUMBA GUMBE GUMBO GUMMA GUMMI GUMMY GUMPH GUMPS GUNAI GUNAS GUNCH GUNDI GUNDY GUNFU GUNGE GUNGS GUNGY GUNIA GUNJA GUNKS GUNKY GUNMA GUNNA GUNNY GUNTS GUNYA GUOYU GUPPY GUPTA GUQIN GURAS GURGE GURIA GURLS GURLY GURMY GURNS GUROO GURPS GURRS GURRY GURTS GURUS GUSAN GUSAU GUSES GUSHT GUSHY GUSII GUSLA GUSLE GUSLI GUSSY GUSTO GUSTS GUSTY GUTHS GUTKA GUTSY GUTTA GUTTY GUUAM GUWAR GUYED GUYER GUYNN GUYOT GUYSE GUZES GUZIK GUZLA GUZZI GUZZO GUZZY GVOZD GVWRS GWAAI GWALL GWEEP GWENO GWENT GWERE GWERZ GWINE GWINN GWINS GWUAP GWYNN GYAHO GYALL GYALS GYANT GYBED GYBES GYGER GYILS GYLES GYMED GYNAE GYNES GYNIE GYNOS GYOJI GYOZA GYPES GYPPO GYPPY GYPSE GYPSY GYRAE GYRAL GYRED GYRES GYRON GYROS GYRUS GYSES GYVED GYVES GZHEL GZIPS HAAKE HAANS HAARP HAARS HAART HAASE HAAST HABBO HABER HABIB HABIT HABLE HABTM HABUS HACCP HACEK HACKS HACKY HADAD HADAL HADDA HADDY HADED HADEN HADES HADIS HADJE HADJI HADNA HADST HADUD HADZA HAEKE HAEMO HAEMS HAENS HAETS HAFIZ HAFTA HAFTS HAGAN HAGAR HAGEN HAGER HAGGE HAGGI HAGGS HAGIN HAGLE HAGMA HAGOP HAGUE HAGYS HAHAS HAHMS HAIDA HAIDS HAIFA HAIGH HAIKS HAIKU HAILE HAILL HAILS HAILU HAILY HAINS HAINT HAIRE HAIRS HAIRY HAITH HAITI HAIXI HAJEK HAJES HAJIB HAJIS HAJJI HAKAM HAKAS HAKEA HAKED HAKEN HAKES HAKHA HAKIM HAKKA HAKOB HAKOS HALAB HALAL HALAU HALDI HALEB HALED HALER HALES HALEY HALFA HALFE HALFS HALGI HALID HALIM HALKI HALKO HALLE HALLO HALLS HALLY HALMA HALMS HALON HALOO HALOS HALPS HALSE HALTS HALVA HALVE HALWA HAMAD HAMAL HAMAM HAMAN HAMAS HAMBO HAMBY HAMED HAMEL HAMER HAMES HAMID HAMIL HAMMY HAMON HAMPS HAMRE HAMSA HAMZA HANAE HANAI HANAN HANAP HANBY HANCE HANCH HANDE HANDI HANDS HANDY HANEL HANER HANES HANEY HANGE HANGI HANGS HANIA HANIF HANJA HANKE HANKO HANKS HANKY HANLY HANNA HANNS HANOI HANOK HANOR HANSE HANTS HANYU HANZI HAOLE HAOMA HAORA HAORS HAPAS HAPAX HAPLY HAPPI HAPPY HAPUS HAQUE HARAM HARAN HARAS HARBS HARCH HARDS HARDT HARDY HARED HAREM HAREN HARER HARES HARIG HARIM HARJO HARKI HARKS HARLE HARLS HARMS HARNS HARNT HARPE HARPS HARPY HARRS HARRY HARSE HARSH HARTE HARTH HARTI HARTL HARTS HARTT HARTY HARTZ HARUN HARUO HASES HASHI HASHY HASID HASKS HASMA HASNA HASPS HASSA HASSE HASTA HASTE HASTY HATCH HATED HATEE HATEL HATEM HATER HATES HATHA HATHE HATIF HATOS HATRA HATTA HATTI HATTY HAUCH HAUCK HAUFF HAUGH HAUGS HAUKE HAULM HAULS HAULT HAUMS HAUNS HAUNT HAUPT HAUSA HAUSE HAVAN HAVEL HAVEN HAVER HAVES HAVEY HAVOC HAVRE HAVTA HAWAY HAWED HAWES HAWKE HAWKS HAWKY HAWMS HAWNS HAWSE HAWTS HAXOR HAYAH HAYAS HAYDN HAYED HAYEK HAYER HAYES HAYEY HAYLE HAYNE HAYSE HAYSI HAYTI HAZAN HAZED HAZEE HAZEL HAZEN HAZER HAZES HAZLE HBARS HBCUS HCDNA HCFCS HCOOH HDQRS HEADS HEADY HEAGY HEALD HEALS HEALY HEAMS HEANS HEAPS HEAPY HEARD HEARE HEARK HEARN HEARO HEARS HEART HEAST HEATE HEATH HEATS HEATY HEAUY HEAVE HEAVY HEBEI HEBEN HEBER HEBES HECKA HECKS HEDER HEDGE HEDGY HEDIN HEDON HEEBS HEEDS HEEDY HEEEY HEELD HEELS HEEPS HEERD HEERE HEERS HEEZE HEFCE HEFEI HEFTS HEFTY HEGDE HEGEL HEGER HEGGE HEGGS HEIAU HEIDI HEIDS HEIDT HEIER HEIGH HEIHE HEIJO HEILS HEINE HEINS HEINZ HEIRE HEIRS HEISE HEIST HEITA HEITI HEITS HEITZ HEJAB HEJAZ HEJRA HELDT HELED HELEN HELES HELEY HELGA HELIO HELIX HELLA HELLO HELLP HELLS HELLY HELMS HELOC HELOS HELOT HELPE HELPS HELPT HELTH HELTS HELVE HEMAL HEMAN HEMBY HEMEL HEMES HEMIC HEMIN HEMME HEMPS HEMPY HENAN HENCE HENCH HENDE HENDS HENDY HENGE HENGS HENID HENKE HENKS HENNA HENNE HENNY HENRY HENTS HENTZ HENZE HEPAR HEPES HEPPS HERAT HERBS HERBY HERDS HEREM HERES HERLS HERMA HERMS HERNE HERNS HEROA HEROD HEROE HERON HEROS HERPS HERRO HERRY HERSE HERSH HERTS HERTZ HESCS HESHE HESPS HESSE HESTS HETAS HETHS HETOL HETRO HETTY HEUGH HEUKS HEUNS HEVEA HEVVA HEWAT HEWED HEWER HEWES HEWWO HEXAD HEXED HEXER HEXES HEXIC HEXIT HEXOL HEXON HEXYL HEYDT HEYEM HEYEN HEYER HEYLS HEYNE HEYNS HEZBO HGPRT HGWYS HIATT HIBBS HIBLE HICES HICKS HIDED HIDEL HIDEO HIDER HIDES HIDLE HIELD HIETT HIFFS HIGAB HIGAS HIGBY HIGGS HIGHS HIGHT HIGRE HIJAB HIJAZ HIJRA HIJRI HIKED HIKER HIKES HIKOI HILAL HILAR HILCH HILDA HILDS HILER HILES HILLO HILLS HILLY HILMA HILOT HILSA HILTS HILTY HILTZ HILUM HILUS HIMBO HIMEL HIMYM HINCK HINDE HINDI HINDS HINDU HINER HINES HINEY HINGE HINGS HINKS HINKY HINNY HINTS HINTZ HINZE HIOTT HIPAA HIPED HIPER HIPES HIPLY HIPPO HIPPS HIPPY HIRAI HIRAM HIRDS HIRED HIREE HIREN HIRER HIRES HIRIK HIRIQ HIRNS HIRSH HIRST HIRTH HISEL HISER HISES HISEY HISLE HISPI HISSY HISTO HISTS HITCH HITES HITHE HITTS HITUP HIVED HIVER HIVES HIWIS HIXES HIXON HIZZY HJELM HKSAR HLEGU HLIRG HLLVS HMMED HMMPH HMMWV HMNZS HMONG HMPHS HMTSS HMXBS HNELS HNLMS HNNNG HNOMS HNRNA HNRNP HOAGS HOAGY HOAKS HOAKY HOANG HOARD HOARE HOARS HOARY HOAST HOBAG HOBAN HOBBS HOBBY HOBDY HOBEI HOBIT HOBLS HOBOE HOBOS HOBOY HOCCO HOCKS HOCUM HOCUS HODAD HODAG HODEL HODES HODGE HODJA HODLS HODOS HOEFT HOEHN HOELS HOERR HOERS HOEYS HOFEI HOFER HOFFA HOFUL HOGAN HOGEL HOGES HOGGE HOGGS HOGGY HOGHS HOGLE HOGUE HOHLS HOICK HOIDA HOIHO HOIKS HOISE HOIST HOITS HOKAN HOKED HOKER HOKES HOKEY HOKIE HOKKU HOKLO HOKUM HOKYO HOLAP HOLDE HOLDS HOLED HOLEN HOLER HOLES HOLEY HOLIE HOLIN HOLKS HOLLA HOLLE HOLLI HOLLO HOLLS HOLLY HOLME HOLMS HOLON HOLOS HOLTE HOLTS HOLTZ HOMAM HOMAN HOMAS HOMED HOMEE HOMEL HOMER HOMES HOMEY HOMIE HOMOS HOMSI HONAN HONDA HONDO HONEA HONED HONER HONES HONEY HONGI HONGS HONIE HONKS HONKY HONNE HONNS HONOR HONTZ HONUS HOOAH HOOCH HOODS HOODY HOOEY HOOFS HOOFY HOOIE HOOKA HOOKE HOOKS HOOKY HOOLE HOOLS HOOLY HOOND HOONS HOOPS HOOPY HOORD HOOSE HOOSH HOOTS HOOTY HOOVE HOPAK HOPED HOPEH HOPEI HOPER HOPES HOPFS HOPIA HOPIS HOPLO HOPPE HOPPO HOPPS HOPPY HOQUE HORAE HORAH HORAI HORAK HORAL HORAN HORAS HORDE HORDS HORIS HORKS HORNE HORNS HORNY HORRY HORSE HORST HORSY HORTA HORUS HOSEA HOSED HOSEL HOSEN HOSER HOSES HOSEY HOSHO HOSPO HOSTA HOSTS HOTAN HOTCH HOTEI HOTEL HOTEP HOTHS HOTLY HOTOT HOTRS HOTTY HOUCK HOUDE HOUGH HOULD HOULE HOULT HOUMA HOUND HOUPT HOURE HOURI HOURS HOUSE HOUSH HOUSS HOUTS HOUTZ HOUZE HOVAN HOVAS HOVDE HOVED HOVEL HOVEN HOVER HOVES HOVEY HOWAT HOWAY HOWBE HOWDY HOWEL HOWER HOWES HOWEY HOWFF HOWFS HOWGH HOWIE HOWJA HOWLE HOWLS HOWPS HOWSE HOWSO HOWTO HOWVE HOWZE HOXED HOXES HOXHA HOXIE HOXIT HOYAS HOYAY HOYED HOYER HOYES HOYIN HOYLE HOYTE HPIVS HPSCS HRACH HRBPS HREBS HRENS HRITZ HRMPH HRUBY HSCSD HSCTS HSDNM HSDPA HSECN HSERS HSIAO HSIAS HSIEH HSIEN HSUEH HSUPA HSWMS HSYNC HTOOS HTPCS HTTPD HTTPS HUANG HUARD HUAVE HUAXI HUBAL HUBAY HUBBY HUBEI HUBER HUBUS HUCED HUCKS HUCOW HUDAK HUDDY HUDEC HUDGE HUDNA HUDON HUDUD HUERS HUETT HUEVO HUEYS HUEZO HUFFS HUFFY HUGAG HUGEN HUGER HUGGS HUGGY HUGOS HUHUS HUIAS HUIES HUITT HUJRA HUKES HUKIN HUKKA HUKOU HULAN HULAS HULCH HULEN HULET HULIN HULKS HULKY HULLO HULLS HULLY HULME HULSE HULTS HUMAN HUMET HUMFS HUMIC HUMID HUMIN HUMMS HUMMY HUMOR HUMPH HUMPS HUMPY HUMUS HUNAN HUNCH HUNDI HUNDO HUNDT HUNDY HUNGS HUNKS HUNKY HUNNY HUNTS HUNTY HUOTS HUPAH HUPAS HUPEH HUPIA HUPOT HUPPS HUQAS HUQIN HUQQA HURAL HURDS HURLS HURLY HURNS HURON HURRA HURRS HURRY HURSH HURST HURTE HURTS HURTT HURTY HUSAK HUSBY HUSER HUSES HUSHT HUSHY HUSKS HUSKY HUSON HUSOS HUSSY HUSTS HUTCH HUTHS HUTIA HUTTO HUTUS HUVAL HUXES HUYCK HUYNH HUZUN HUZZA HVACS HWAIR HWANG HWANS HYANG HYATT HYAWA HYCHE HYDEL HYDEN HYDER HYDES HYDRA HYDRO HYENA HYENS HYEST HYGGE HYGHT HYING HYIPS HYKES HYLEG HYLER HYLIC HYLID HYMAN HYMEL HYMEN HYMER HYMIE HYMNS HYNDE HYOGO HYOID HYOTE HYPED HYPER HYPES HYPHA HYPHY HYPOS HYPSD HYPSM HYRAX HYRES HYRSE HYRUM HYSON HYTHE HYUNS HYZER HZRGS IAAPA IAIDO IAMAT IAMBI IAMBS IAMFI IAMID IAMMS IANAD IANAE IANAL IANAS IANNI IARTK IASPS IAWTC IAWTP IBADA IBADI IBANS IBATS IBELL IBIZA IBLIS IBMER IBOGA IBOOK IBRIK IBSON ICANN ICASM ICBMS ICCDS ICCPR ICDLS ICENI ICENS ICERS ICEVS ICFVS ICHOR ICIER ICILY ICING ICKER ICKLE ICMES ICNCP ICONS ICQED ICQER ICTAL ICTIC ICTUS ICWUC ICYDK ICYMI ICYWW IDAAS IDAES IDAFA IDAHO IDANT IDDAH IDDAT IDEAL IDEAN IDEAR IDEAS IDEAT IDEES IDELE IDENT IDEOT IDERS IDGAF IDGAS IDGET IDING IDIOM IDIOT IDISM IDIST IDJIT IDJUT IDLED IDLER IDLES IDLIS IDMCS IDOLA IDOLL IDOLS IDOSE IDPOL IDREN IDRIS IDYLL IDYLS IELTS IESHA IESNA IESUS IFDEF IFERE IFFEN IFIDS IFILL IFMAS IFRIT IFSMA IFTAR IFTTT IGAPO IGBOS IGBTS IGCSE IGGED IGGIE IGILS IGLOO IGLUS IGOES IGOUS IHDES IHLES IHOOD IHRAM IHRIG IHRKE IIFES IIOPS IIPAS IJAWS IJAZA IJOID IKATS IKEDA IKERD IKEYS IKIZU IKKAT IKNER IKONS IKTAR IKUKO IKUMI ILALA ILAMA ILAND ILEAC ILEAL ILECS ILERS ILEUM ILEUS ILHAM ILIAC ILIAD ILIAL ILIAN ILICS ILIFF ILINX ILION ILISH ILITY ILIUM ILLER ILLIG ILLOS ILLTH ILLUI ILONA ILOOY ILYSM IMAAM IMACS IMAGE IMAGO IMAMS IMANI IMANS IMARI IMAUM IMBAR IMBAT IMBAY IMBED IMBER IMBES IMBOW IMBOX IMBUE IMEIS IMELL IMELS IMENE IMERS IMHOF IMIDE IMIDO IMIDS IMINE IMINO IMINS IMLAY IMLER IMMEL IMMEW IMMID IMMIE IMMIT IMMIX IMPED IMPEL IMPEN IMPEX IMPHM IMPIS IMPLY IMPOV IMPRO IMRAN IMSIS IMXBS INABA INAJA INANE INAPT INARI INATE INAUS INAWE INAWS INBOX INBYE INCAN INCAS INCEL INCHI INCLE INCOG INCUR INCUS INCUT INDAS INDEF INDEL INDEW INDEX INDIA INDIC INDIE INDIN INDOC INDOL INDON INDOW INDRA INDRE INDRI INDUE INDUS INEPT INERM INERT INEYE INFER INFIT INFIX INFON INFRA INGAS INGEN INGLE INGOT INIAD INIAL INIAS INIID INION INITS INITY INJEN INJUN INKAN INKAS INKED INKER INKES INKLE INKOM INLAW INLAY INLET INLOW INMAN INMEW INMID INMIX INMON INNED INNER INNES INNEW INNIE INNIT INOAS INODE INORB INOUE INPAT INPUT INRIA INROS INRUN INSEE INSET INSPO INSTA INSUE INTEL INTER INTHA INTIS INTIV INTRO INUIT INULA INURE INURN INUST INVAR INVEX INVIS INWIT IODAL IODIC IODID IODIN IODOL IOLTA IONIA IONIC IOQUA IORAS IOTAS IOWAN IOWAS IPINA IPOCK IPODS IPPON IPRGC IPSCS IPSID IQAMA IQBAL IQYAX IRAAN IRADE IRAKW IRANI IRAQI IRAQW IRATE IRBIL IRBMS IRBYS IRCED IRCER IRCOP IRENE IREYS IRFAN IRGUN IRIAN IRIBE IRICK IRIDS IRING IRINT IRION IRISH IRKED IRKUT IRLAM IRMOI IRMOS IROIJ IROKO IRONE IRONS IRONY IRORI IRQLS IRREP IRULA IRVEN IRVIN IRWIN ISAAC ISAPI ISAZA ISBAS ISBNS ISCII ISCSI ISDNS ISERE ISERS ISEUM ISHAK ISHAM ISHAN ISHAQ ISHAS ISHEE ISHES ISHII ISIAC ISIAH ISICS ISINT ISLAM ISLAY ISLER ISLES ISLET ISLEY ISNAD ISNAE ISNER ISOKO ISOLA ISOMS ISONS ISPIR ISRHA ISRUS ISSAC ISSAS ISSEI ISSES ISSID ISSNS ISSUE ISSUS ISTAR ISTEA ISTLE ISTQB ISTRE ISUZU ISWAP ISWAS ISWYM ITALA ITALO ITALS ITALY ITARD ITCHY ITEMS ITERS ITHES ITIES ITIVE ITTAR ITUNA ITZEL IUDAS IUDGE IUELL IUNNO IUPAC IUSSI IVAIN IVALO IVERY IVIED IVIES IVINS IVORY IVRIT IVYED IWANA IWANS IWARS IWASA IWATA IWATE IXIAS IXION IXNAY IXORA IXTAB IXTLE IYERS IYOBA IYORA IZAAR IZARD IZBAS IZEDI IZLES IZMEL IZMIR IZMIT IZORA IZORI IZUMI IZZAT IZZIE IZZIS IZZIT IZZOS JAADI JAALI JABBA JABBY JABER JABEZ JABOT JACAL JACKO JACKS JACKY JACOB JACOS JADED JADEN JADES JADON JADOO JAFAR JAFAS JAFFA JAFFE JAFFY JAFOS JAFRI JAGER JAGGS JAGGY JAGIR JAGRA JAHAI JAHAN JAHVE JAIDA JAILS JAIME JAINS JAKED JAKES JAKEY JAKIE JAKOS JAKUN JALAP JALEN JALEO JALIL JALIS JALOP JAMAL JAMAR JAMAS JAMAT JAMBI JAMBO JAMBS JAMBU JAMES JAMIE JAMIL JAMIR JAMMU JAMMY JAMON JAMOS JAMUN JANAE JANAK JANDA JANEA JANES JANET JANEU JANEY JANGS JANIE JANIS JANKE JANKO JANKY JANNA JANNS JANNY JANTO JANTS JANTU JANTY JANTZ JANUS JAPAN JAPED JAPER JAPES JAPHS JAPIE JAPOW JAPPY JARAI JARDS JARED JARKS JARLS JARON JARPS JARRA JARUL JARVI JARVY JASES JASEY JASIN JASMS JASON JASOS JASPE JASSO JATHA JATIS JATOS JAUMS JAUNT JAUPS JAVAN JAVAS JAVED JAVEL JAVON JAWAD JAWAN JAWAR JAWED JAWNS JAXEN JAXON JAYCE JAYDA JAYDE JAYET JAYLA JAYNE JAYSE JAZEL JAZZY JDBCS JEANS JEARS JEAST JEATS JEBEL JEBYA JECTS JEDEK JEDGE JEDIS JEELS JEEMS JEEPS JEERA JEERS JEESH JEEZE JEFES JEFFI JEFFO JEFFS JEFFY JEGOG JEHAD JEHAI JEHOL JEHUS JEJUS JELAB JELLO JELLS JELLY JELQS JEMBE JEMMA JEMMY JENBE JENGA JENGS JENKS JENNA JENNE JENNI JENNS JENNY JENTS JEONG JEONS JEREZ JERIB JERID JERKS JERKY JERRI JERRY JESKE JESSA JESSE JESSI JESTS JESTY JESUS JETER JETES JETON JETTY JEUDY JEUNE JEWED JEWEL JEWES JEWIE JEWRY JEZVE JEZZA JFETS JFIFS JFKED JHALA JHEEL JHILS JHOOM JHOVE JHUNA JIANG JIANS JIAOS JIAYI JIAYU JIBBS JIBED JIBER JIBES JIDDA JIFFS JIFFY JIGGY JIGOS JIGOT JIHAD JIHUN JIJUS JIKOS JILDI JILDY JILEK JILIN JILLS JILLY JILTS JIMBE JIMBO JIMMI JIMMY JIMPY JINAN JINDY JINGO JINGS JINJA JINJU JINKS JINKY JINNI JINNS JINNY JINTS JIRDS JIRGA JIRON JISEI JISMS JISTS JITED JITTY JIVED JIVER JIVES JIVEY JIZYA JIZZY JNANA JNDIS JNOVS JNTSC JOANN JOANY JOBBE JOBBY JOBED JOBES JOBIN JOCKO JOCKS JOCKY JODEL JODHS JODIE JOEYS JOFFE JOHAD JOHAL JOHAN JOHAR JOHNE JOHNS JOHNY JOHOR JOICE JOIKS JOINS JOINT JOIST JOKED JOKER JOKES JOKEY JOKIS JOLED JOLES JOLIE JOLIN JOLLS JOLLY JOLOF JOLTS JOLTY JOLYS JOMOS JONAH JONAS JONES JONGS JONNA JONTY JOOKS JOOLS JOPPA JORAM JORBS JORDY JORJA JORTS JORUM JOSEI JOSEY JOSHI JOSIE JOSUE JOTAS JOTTO JOTTY JOTUN JOUAL JOUGS JOUKS JOULE JOULS JOURS JOUST JOVEL JOWAN JOWAR JOWLS JOWLY JOYAL JOYAS JOYCE JOYED JOYEN JOYES JOYNE JOYNT JPDAS JPEGS JROTC JRPGS JSDKS JSWDK JTLYK JUANE JUBAE JUBAS JUBBA JUBBE JUBES JUCHE JUCOS JUDAH JUDAS JUDEA JUDGE JUDGY JUDIE JUELZ JUGAL JUGER JUGGS JUGUM JUHAR JUHYO JUICE JUICY JUJUS JUJUY JUKED JUKES JULAP JULEP JULES JULEY JULIA JULID JULIE JULIO JULIP JULUS JULYE JULYS JUMAR JUMAS JUMBI JUMBO JUMBY JUMNA JUMPS JUMPY JUMUA JUNCO JUNDT JUNES JUNIE JUNJO JUNKO JUNKS JUNKY JUNTA JUNTO JUPED JUPES JUPON JURAL JURAT JUREL JURIE JUROR JURYO JUSSI JUSSY JUSTE JUSTS JUTES JUTTI JUTTY JUUKA JUVES JUVEY JUVIA JUVIE JUXTA JUYCE JVMDI JVMTI JVPNE JWSDP KAABA KAAFS KAAMA KAAPS KAATZ KABAB KABAM KABAT KABIR KABOB KABSA KABUL KACEE KACHA KACIE KADAM KADEL KADEN KADES KADET KADHI KADIS KAEDE KAENS KAFAL KAFIR KAFIS KAFKA KAFTA KAGAN KAGES KAGOK KAGUS KAGYU KAHAL KAHAN KAHAU KAHLE KAHLS KAIAK KAIDS KAIJU KAIKI KAIKS KAILA KAILS KAIMA KAINI KAINS KAISO KAIST KAIYA KAJAL KAJIK KAJIN KAJIS KAJUS KAKAR KAKAS KAKDI KAKIE KAKIS KAKKE KAKRO KALAM KALAN KALAO KALBI KALEB KALEL KALER KALES KALEY KALIE KALIF KALIL KALIN KALIS KALON KALPA KALRA KALUA KAMAL KAMAS KAMAU KAMBA KAMBO KAMEN KAMES KAMIK KAMIS KAMPA KAMSA KAMUT KAMUY KANAB KANAK KANAN KANAS KANAT KANDA KANDY KANEH KANGA KANGO KANGS KANIA KANJI KANNA KANNS KANNY KANOE KANON KANOS KANSA KANSU KANTZ KANUN KANZO KANZU KAONS KAORI KAORU KAOYU KAPAN KAPAS KAPES KAPHS KAPIA KAPOK KAPOS KAPOW KAPPA KAPUR KAPUS KAPUT KARAJ KARAM KARAS KARAT KARBI KARCH KAREN KAREZ KARGS KARIM KARIN KARKI KARLA KARLE KARLI KARLY KARMA KARNA KARNS KAROB KAROK KAROO KAROW KARPS KARRI KARRS KARST KARTS KARTU KARUK KARWA KARYN KARYS KARZY KASEN KASER KASEY KASHA KASHI KASON KASOS KASRA KASSU KASTS KATAH KATAL KATAR KATAS KATES KATHA KATHI KATHY KATIA KATIE KATIS KATSU KATTI KATTY KATYA KAUAI KAUPS KAURI KAUTZ KAVAL KAVAS KAVYA KAWED KAWIT KAWNS KAYAH KAYAK KAYAS KAYKO KAYLA KAYLE KAYOS KAZAK KAZAN KAZEE KAZIS KAZMI KAZOO KAZUE KAZUO KBARS KCALS KEALY KEANE KEANO KEANS KEANU KEARA KEARS KEAST KEATS KEAVY KEBAB KEBAP KEBOB KECAK KECHI KECKS KEDAH KEDDY KEDGE KEECH KEEDS KEEFE KEEFS KEEHN KEEHO KEEKS KEELE KEELS KEELY KEEMA KEENA KEENE KEENO KEENS KEEPE KEEPS KEESE KEESH KEETH KEETS KEEVE KEFIR KEGEL KEGGY KEHLS KEHMS KEHNS KEHOE KEHRS KEHUA KEIJI KEIJO KEIKO KEIRA KEIRS KEITH KEITT KELCH KELEN KELEP KELIM KELKS KELLI KELLS KELLY KELMS KELPS KELPY KELSO KELTS KELTY KEMBS KEMET KEMPE KEMPF KEMPO KEMPS KEMPT KENAF KENAI KENAN KENCH KENDI KENDO KENES KENJI KENKE KENKY KENNA KENNY KENPO KENTA KENTE KENTS KENYA KENZO KEOGH KEOWN KEPIS KEPOS KERBS KERBY KERCH KERES KERFS KERIN KERIS KERLS KERMA KERNE KERNS KEROS KERRI KERRY KERSH KERUV KERVE KESAR KESAS KETAL KETAS KETCH KETOL KETOS KETYL KEVAN KEVEL KEVIL KEVIN KEVVY KEVYN KEWDA KEWRA KEXES KEYED KEYER KEYES KEYLA KEYTE KEZIA KEZZA KFCER KGALS KGOSI KHADI KHAEN KHAFD KHAFS KHAKI KHANA KHANG KHANS KHAPH KHASA KHASH KHASI KHATA KHATS KHAYA KHAZI KHEER KHELS KHENE KHENS KHETH KHETS KHIMS KHIOS KHIPU KHIVA KHLOE KHMER KHNUM KHOJA KHOND KHONG KHORS KHOUM KHOYA KHUBZ KHUDS KHUFU KHULA KHUMS KHUUS KIAAT KIACK KIAIS KIANA KIANG KIARA KIASI KIASU KIATO KIAWE KIBBE KIBBY KIBED KIBEI KIBES KIBLA KIBOR KIBUN KICKS KICKT KICKY KIDDO KIDDY KIDEL KIDLY KIDON KIEHL KIEHN KIELY KIERA KIERS KIEUS KIEVE KIEVS KIFER KIGER KIGHT KIGOS KIHNU KIKAI KIKAR KIKAY KIKER KIKES KIKIS KIKOI KILAS KILBY KILES KILEY KILGO KILIG KILIJ KILIM KILLS KILNS KILOS KILPE KILTS KIMBA KIMBU KIMES KIMIE KIMMY KIMRY KIMYE KINAH KINAS KINCH KINDA KINDE KINDS KINDT KINDY KINER KINES KINGA KINGE KINGS KINGY KINIC KININ KINIT KINJO KINKI KINKS KINKY KINLY KINOK KINOO KINOS KINOT KINQU KINTZ KIOEA KIORE KIOSK KIOTO KIOWA KIPAH KIPAS KIPED KIPER KIPES KIPOT KIPPA KIPPS KIPSY KIPUS KIPUT KIRAN KIRAT KIRBY KIRIN KIRKE KIRKS KIRNS KIRON KIROV KIRST KIRUV KISEL KISER KISHI KISIR KISOR KISRA KISSY KISTS KITAI KITAN KITED KITEE KITER KITES KITFO KITHE KITHS KITON KITTS KITTY KIVAS KIVER KIVES KIWIS KIXES KIYIS KIZER KIZZY KKKER KLADD KLAHN KLANG KLANN KLAPA KLAPS KLARS KLATT KLAUS KLEIN KLEMM KLEMP KLEMS KLENK KLICK KLIEG KLIER KLIKS KLIMP KLIMS KLINE KLING KLINK KLITE KLOCK KLOCS KLOMP KLONG KLOOF KLOPP KLOSE KLUCK KLUDD KLUGE KLUGS KLUMB KLUMP KLUNK KLUTH KLUTZ KMERS KMETS KMETZ KMHMU KMIEC KNAAK KNABS KNACK KNAGG KNAGS KNAPE KNAPP KNAPS KNARE KNARL KNARR KNARS KNAUB KNAUE KNAUR KNAVE KNAWN KNAWS KNEAD KNECK KNEED KNEEL KNEEN KNEES KNELL KNELT KNEPP KNERR KNIBS KNICK KNIES KNIFE KNIPE KNIPP KNISH KNITS KNIVE KNOBS KNOCK KNODE KNOKE KNOLL KNOPS KNORK KNORR KNORS KNOSP KNOTS KNOTT KNOUD KNOUT KNOWE KNOWN KNOWS KNUBS KNUFF KNUPP KNURD KNURL KNURR KNURS KNUTE KNUTH KNUTS KNYFE KOALA KOANS KOBAN KOBAS KOBER KOBON KOBOS KOBYZ KOBZA KOCAY KOCHI KOCKS KODAK KODOR KOEHL KOEHN KOELS KOEPP KOFFS KOFTA KOFTE KOFUN KOGAL KOGAN KOGAS KOGER KOGUT KOHAI KOHAN KOHAS KOHEI KOHEN KOHLI KOHLS KOHNS KOHRS KOHUT KOINE KOJAC KOKAS KOKAY KOKEN KOKER KOKIS KOKRA KOKUM KOKUS KOKYO KOLAK KOLAM KOLAR KOLAS KOLBE KOLBS KOLBY KOLEA KOLEY KOLKS KOLOA KOLOB KOLOS KOLPS KOMAR KOMBI KOMBU KOMIS KOMKU KOMUZ KONAK KONBU KONDA KONDE KONDO KONEN KONES KONGO KONGS KONIG KONKS KONOS KONPA KONYA KONZE KONZO KOOBS KOOKA KOOKS KOOKY KOOPS KOORD KOORI KOOTS KOPEC KOPEK KOPER KOPHS KOPIS KOPJE KOPKA KOPPA KOPUZ KORAI KORAN KORAS KORAT KOREA KORES KOREY KORFF KORFS KORIN KORIS KORLA KORMA KOROR KORPI KORSI KORTE KORTH KORTS KORTZ KORUN KORUS KOSAR KOSEK KOSER KOSHA KOSHY KOSIK KOSKA KOSKO KOTAS KOTEL KOTHE KOTHS KOTKA KOTOE KOTOR KOTOS KOTOW KOTTE KOTTU KOUBA KOURA KOURI KOURY KOUSE KOVAC KOVAL KOVAR KOVIL KOVSH KOWAL KOWAN KOYOK KOZAR KOZAS KOZEL KOZIK KOZUE KRAAL KRAAR KRABI KRABS KRAFT KRAHN KRAIS KRAIT KRALS KRAMA KRANG KRANS KRANZ KRAPF KRARS KRATT KRATZ KRAUL KRAUT KRAYS KREBS KREEL KREEP KREES KREFT KREIN KRELL KRENG KRENZ KRETZ KREWE KREYS KRIEK KRIER KRIGE KRILL KRING KRIOL KRISA KRISH KRIYA KRNOV KROHN KROHS KROLL KROLS KRONA KRONE KRONI KRONK KRONS KROON KROOS KROUT KRSAN KRSNA KRUBI KRUFT KRULL KRUMM KRUMP KRUNK KRUPA KSARS KSERS KSLOC KSOUR KTULU KUANG KUANS KUBAN KUBAT KUBBA KUBER KUBES KUBIE KUBIK KUCHA KUCKS KUDAS KUDER KUDLA KUDOS KUDUS KUDZU KUEHL KUEHN KUFFS KUFIC KUFIS KUFTA KUGEL KUHAR KUHLS KUHMO KUHRS KUKIS KUKLA KUKRI KUKUI KULAK KULAN KULAS KULFI KULIG KULIK KULIS KULKA KULLS KULOW KULPA KULPS KUMAN KUMAR KUMDO KUMIS KUMMS KUMPF KUMPS KUMST KUMUX KUMYK KUMYS KUNAI KUNAS KUNCE KUNES KUNIK KUNJU KUNQU KUNTI KUNTZ KUNYA KUNZE KUPER KUPKA KURAN KURDS KUREK KURIA KURMA KURSK KURTA KURTH KURTI KURTZ KURUS KUSEK KUTAS KUTCH KUTUM KUYAS KUZMA KVASS KVELL KVENS KVITL KWAAI KWAKS KWAME KWANS KWASO KWAZA KWEEN KWELA KWENG KWERE KWICS KWISE KWONG KWONS KYACK KYAKS KYARN KYATS KYAWS KYBOS KYDST KYERS KYKED KYKER KYLAH KYLAN KYLEE KYLEN KYLER KYLES KYLEY KYLIE KYLIX KYLOE KYMRY KYMYS KYMYZ KYNDE KYNDS KYNGE KYNGS KYOKO KYOTO KYPES KYREE KYRIE KYSAR KYSER KYSON KYTAY KYTES KYTHE KYTLE KYUDO KYZER KYZYL LAABS LAAMS LAARI LABAN LABAR LABAT LABBA LABBE LABDA LABEL LABER LABES LABIA LABIN LABIS LABOR LABOY LABRA LACED LACER LACES LACEY LACID LACIS LACKE LACKS LACKY LADAS LADDS LADDU LADDY LADED LADEN LADER LADES LADIE LADIN LADLE LADSY LADUE LADYE LAFFS LAFON LAGAN LAGER LAGGY LAGID LAGOS LAHAR LAHEY LAHMU LAHOH LAHRS LAHTI LAHUE LAICK LAICS LAIKS LAILA LAILS LAINE LAING LAINO LAINS LAIRD LAIRS LAIRY LAITH LAITS LAITY LAIUS LAKAO LAKED LAKER LAKES LAKEY LAKHA LAKHS LAKIN LAKKA LAKOU LAKSA LALLA LALLI LALLS LALLY LALOR LAMAN LAMAR LAMAS LAMAY LAMBA LAMBE LAMBO LAMBS LAMBY LAMDA LAMED LAMEL LAMEN LAMEO LAMER LAMES LAMEY LAMIA LAMIE LAMIN LAMMY LAMPP LAMPS LAMYS LANAI LANAO LANAP LANCE LANCH LANCS LANDA LANDE LANDI LANDS LANDY LANED LANER LANES LANEY LANGI LANGS LANKA LANKS LANKY LANNI LANTS LANTZ LANUM LANZA LAOIS LAOSI LAOYA LAOZI LAPAN LAPAS LAPEL LAPES LAPIN LAPPA LAPPS LAPPY LAPSE LAPTI LAPUA LARBS LARCH LARDO LARDS LARDY LARED LAREN LARES LARFS LARGE LARGO LARGS LARID LARIN LARIS LARKS LARKY LARNS LARNT LAROE LARPS LARRY LARTS LARUE LARUM LARVA LARVE LARYS LASED LASEK LASER LASES LASHA LASHI LASHT LASIK LASIX LASKA LASKO LASKS LASKY LASSI LASSO LASSU LASSY LASTE LASTS LATAH LATCH LATED LATEN LATER LATES LATEX LATHE LATHI LATHS LATHY LATIC LATID LATIF LATIK LATIN LATKA LATKE LATON LATTA LATTE LATUS LAUAN LAUCK LAUDS LAUER LAUES LAUGH LAUJE LAUND LAURA LAURI LAURO LAURY LAUTH LAVAL LAVAN LAVAS LAVED LAVER LAVES LAVEY LAVIC LAVIN LAVOY LAVRA LAVVY LAVYS LAWAL LAWDY LAWED LAWES LAWKS LAWLY LAWND LAWNS LAWNY LAWRO LAWRY LAXED LAXEN LAXER LAXES LAXEY LAXLY LAXMI LAYAN LAYBY LAYED LAYER LAYES LAYIN LAYLA LAYNE LAYUP LAZAR LAZED LAZEN LAZER LAZES LAZIO LAZOR LAZOS LAZZI LAZZO LBFMS LBFTS LBSCR LCACS LCCNS LCTLS LDIFS LEACH LEADE LEADS LEADY LEAFS LEAFY LEAGS LEAHY LEAKE LEAKS LEAKY LEAMS LEANE LEANO LEANS LEANT LEANY LEAPS LEAPT LEARD LEARN LEARS LEARY LEASE LEASH LEAST LEASY LEATH LEATS LEAUE LEAVE LEAVY LEAZE LEBAN LEBBO LEBEL LEBES LEBNI LEBOS LEBOW LECCE LECCO LECCY LECHE LECHI LECHS LECHY LECKY LECTS LEDAY LEDCS LEDDY LEDEN LEDES LEDET LEDGE LEDGY LEDOS LEDUC LEECH LEEDS LEEDY LEEKS LEEKY LEELA LEEMS LEENS LEEPS LEERE LEERS LEERY LEESE LEESY LEETH LEETS LEEZA LEFFS LEFSA LEFSE LEFTE LEFTS LEFTY LEFUL LEGAL LEGAN LEGAS LEGCO LEGED LEGER LEGES LEGGE LEGGO LEGGY LEGIT LEGOS LEHAN LEHEW LEHNS LEHRS LEHUA LEIBY LEICS LEIDS LEIDY LEIGH LEIHE LEIJA LEILA LEINE LEINT LEISH LEIST LEITE LEITH LEITZ LEIVA LEJAS LELLA LEMAN LEMAR LEMAS LEMAY LEMBO LEMEL LEMEN LEMER LEMHI LEMKE LEMKO LEMMA LEMME LEMMS LEMMY LEMON LEMUR LEMUS LENDS LENES LENGA LENGS LENIN LENIS LENNS LENNY LENOI LENON LENOS LENOX LENSE LENTO LENTS LENTZ LENZI LEOIS LEOLA LEONA LEONE LEONG LEORA LEOTI LEPAK LEPAL LEPAS LEPEL LEPER LEPES LEPID LEPOS LEPPS LEPPY LEPRA LEPRY LEPTA LEPTO LEPUS LERCH LERKY LERMA LEROI LEROY LERPS LESBI LESBO LESBY LESES LESHY LESKE LESKO LESKS LESSE LETCH LETHE LETHY LETTS LETTY LETUP LEUCK LEUDS LEUNG LEVAN LEVAS LEVEE LEVEL LEVEN LEVER LEVET LEVEY LEVIN LEVIR LEVIS LEVIT LEWED LEWER LEWES LEWIN LEWIS LEWTH LEXAS LEXED LEXER LEXES LEXIA LEXIC LEXIE LEXIS LEXIT LEXON LEYAK LEYBA LEYNS LEYTE LEYVA LEZBO LEZGI LEZZA LEZZO LEZZY LFSRS LGATS LGBTA LGBTI LGBTQ LGBTS LHASA LHRNA LIAGE LIALH LIANA LIANE LIANG LIANS LIARD LIARS LIASE LIBBY LIBEL LIBER LIBOR LIBRA LIBRE LIBRY LIBYA LIBYC LICCA LICEA LICEY LICHI LICIT LICKS LICKT LICKY LICON LIDAR LIDDY LIDGE LIDIA LIDOL LIDOS LIEBL LIEBS LIEGE LIEMS LIENS LIERA LIERS LIEST LIETH LIETZ LIEUS LIEWS LIFEN LIFER LIFES LIFEY LIFFE LIFIE LIFTS LIGAF LIGAN LIGAS LIGED LIGER LIGHT LIGON LIHOU LIHUE LIKAM LIKED LIKEE LIKEN LIKER LIKES LIKEY LIKIN LIKOU LIKUD LILAC LILAH LILES LILIA LILLE LILLS LILLY LILOS LILTS LIMAN LIMAS LIMBA LIMBI LIMBO LIMBS LIMBU LIMBY LIMED LIMEN LIMER LIMES LIMEY LIMID LIMIT LIMMA LIMMU LIMNS LIMON LIMOS LIMPA LIMPS LIMUS LINAC LINAM LINAN LINCH LINCK LINCS LINDA LINDO LINDS LINDY LINEA LINED LINEL LINEN LINER LINES LINGA LINGE LINGO LINGS LINGY LININ LINKS LINKT LINKY LINNA LINNE LINNS LINNY LINOS LINTS LINTY LINTZ LINUS LINUX LINYI LINZY LIONS LIOUS LIPAN LIPAS LIPES LIPIC LIPID LIPIK LIPIN LIPKA LIPKE LIPPS LIPPY LIPYL LIRAE LIRAS LIRES LIRGS LIRKS LIROT LIRTS LISIS LISKA LISKS LISLE LISNE LISPS LISPY LISSE LISSY LISTS LISTY LISZT LITAI LITAS LITED LITER LITES LITHA LITHE LITHO LITHP LITHS LITHY LITKE LITRA LITRE LITTS LITTY LITUI LIULI LIVED LIVEN LIVER LIVES LIVIA LIVID LIVIN LIVOR LIVRE LIVVI LIVVY LIXIN LIZAS LIZZY LJBFS LJERS LKDNA LLAMA LLANI LLANO LLETZ LLOYD LLSVP LMBAO LMFAO LMHCS LMRNA LMTVS LMWAO LMWDS LMXBS LNWIS LOACH LOADS LOAEL LOAFS LOAMS LOAMY LOANS LOARS LOATH LOAVE LOBAL LOBAR LOBBS LOBBY LOBED LOBEL LOBES LOBOR LOBOS LOBUE LOBUS LOCAL LOCAO LOCAS LOCHE LOCHS LOCIN LOCKE LOCKS LOCKT LOCKY LOCON LOCOS LOCUM LOCUS LODDE LODEN LODER LODES LODGE LOEHR LOERA LOESS LOEWE LOEZA LOFER LOFTS LOFTY LOGAN LOGES LOGGE LOGGY LOGIA LOGIC LOGIN LOGIT LOGKO LOGOI LOGON LOGOS LOGUE LOHAN LOHAS LOHRS LOHSE LOIAL LOIDS LOIKE LOINC LOING LOINS LOIPE LOIRE LOIRS LOKAO LOKEN LOKES LOKEY LOKMA LOKUN LOLED LOLER LOLIS LOLLS LOLLY LOLOL LOLOT LOMAN LOMAS LOMAX LOMID LOMLS LONAS LONCO LONER LONEY LONGA LONGE LONGO LONGS LONGY LONKO LONON LOOBS LOOBY LOOCH LOOED LOOEY LOOFA LOOFS LOOGY LOOIE LOOKE LOOKS LOOKT LOOKY LOOMS LOONG LOONS LOONY LOOPS LOOPY LOORD LOORS LOOSE LOOSH LOOTS LOPED LOPER LOPES LOPEZ LOPHS LOPPS LOPPY LORAH LORAL LORAN LORDS LORDY LORED LOREE LOREL LOREN LORES LORIA LORID LORIE LORIO LORIS LORKS LORNA LORRA LORRI LORRY LOSAR LOSED LOSEE LOSEL LOSER LOSES LOSEY LOSSY LOTAG LOTAH LOTAS LOTED LOTES LOTHE LOTHS LOTIC LOTID LOTIS LOTOS LOTSA LOTTA LOTTE LOTTO LOTTS LOTUS LOUBS LOUDE LOUER LOUES LOUGH LOUIE LOUIS LOUKS LOUNS LOUPE LOUPS LOURI LOURS LOURY LOUSE LOUSY LOUTH LOUTS LOUTY LOUVE LOVAN LOVAS LOVAT LOVED LOVEE LOVEL LOVEN LOVER LOVES LOVEY LOVEZ LOVIE LOVIN LOVOS LOWED LOWER LOWES LOWLY LOWNE LOWNS LOWRY LOWTH LOWYS LOXIA LOYAL LOYAS LOYCE LOYDS LOZAS LOZEL LPERS LPWAN LRAAM LRADS LRASM LRBMS LRRPS LSFES LSFMS LSSAH LTCOL LUANA LUANN LUANS LUAUS LUBEC LUBED LUBER LUBES LUBIN LUBKI LUBOK LUBRA LUBYS LUCAN LUCAS LUCCA LUCCI LUCEA LUCES LUCET LUCEY LUCHI LUCIA LUCID LUCKE LUCKS LUCKY LUCMO LUCRE LUDDY LUDES LUDIC LUDOS LUDYS LUECK LUERA LUETH LUFFA LUFFS LUFTY LUGAR LUGED LUGER LUGES LUGOJ LUHYA LUING LUISI LUJAN LUJVO LUKAC LUKAN LUKAS LUKER LUKES LUKIS LULAV LULEA LULES LULLA LULLS LULLY LULUS LULZY LUMAD LUMAN LUMAS LUMEN LUMIA LUMIC LUMME LUMMY LUMPS LUMPY LUNAR LUNAS LUNCH LUNDA LUNDY LUNEL LUNES LUNET LUNGE LUNGI LUNGO LUNGS LUNGU LUNKS LUNKY LUNTS LUNYU LUOHU LUONG LUPER LUPIN LUPPA LUPUS LUQUE LURAY LURCH LURED LURER LURES LUREX LURGI LURGY LURID LURIE LURKS LURKY LURRY LURTS LURTY LURVE LUSBY LUSCA LUSER LUSES LUSHY LUSKS LUSTS LUSTY LUTAR LUTEA LUTED LUTER LUTES LUTHI LUTHS LUTHY LUTON LUTSK LUVAR LUVVY LUXED LUXES LUXON LUXOR LUZON LUZZI LUZZU LVMPD LWALU LWEIS LWINS LWUIT LYALL LYAMS LYARS LYASE LYATE LYBIA LYCEE LYCIA LYCID LYCRA LYDAY LYDEN LYDES LYDIA LYDIC LYDON LYEGE LYELL LYEST LYETH LYING LYKES LYLAB LYLAH LYLAS LYLES LYMAN LYMED LYMER LYMES LYMON LYMPH LYNAM LYNCH LYNDA LYNDE LYNDS LYNES LYNKS LYNNE LYONS LYRAS LYRES LYRIC LYRID LYRIE LYSED LYSES LYSIN LYSIS LYSOL LYSSA LYSYL LYTHE LYTIC LYTLE LYTTA LYVES LYZED LYZES MAAAN MAACK MAAED MAAFA MAARE MAARS MAATS MABBE MABBY MABEE MABEL MABEN MABES MABEY MABIA MABIE MABLE MABON MABRY MABYE MACAO MACAU MACAW MACCA MACCO MACED MACER MACES MACEY MACHA MACHE MACHI MACHO MACIE MACIN MACKS MACKY MACLE MACON MACOS MACRA MACRO MADAM MADAN MADAR MADAY MADDA MADDY MADEJ MADER MADES MADGE MADID MADLY MADRY MAEDA MAEDI MAERL MAESE MAEVE MAFFS MAFIA MAFIC MAFOO MAGAN MAGAR MAGAS MAGAT MAGBY MAGEE MAGER MAGES MAGGI MAGHA MAGIC MAGID MAGIN MAGMA MAGNA MAGOG MAGOT MAGRI MAGRO MAGTF MAGUS MAGWE MAHAL MAHAN MAHAR MAHDI MAHER MAHEU MAHLI MAHOE MAHON MAHRS MAHUA MAHWA MAIDA MAIDE MAIDS MAIDU MAIKA MAIKO MAILE MAILO MAILS MAILY MAIMS MAINA MAINE MAINS MAINZ MAIRE MAIST MAISY MAIZE MAJAT MAJEL MAJID MAJKA MAJOR MAJUN MAJUR MAKAH MAKAI MAKAR MAKED MAKEE MAKEM MAKEN MAKER MAKES MAKHI MAKIN MAKIS MAKOS MAKUA MAKWA MALAI MALAK MALAM MALAR MALAS MALAX MALAY MALDA MALDI MALEC MALEK MALEO MALER MALES MALEY MALHI MALIA MALIC MALIK MALIN MALIS MALKY MALLE MALLO MALLS MALLU MALMO MALMS MALOS MALOY MALTA MALTS MALTY MALTZ MALUM MALUS MALVA MALVI MALWA MALYS MAMAK MAMAS MAMAW MAMBA MAMBO MAMEY MAMIE MAMIL MAMMA MAMMY MAMOS MAMPY MAMSY MAMTA MANAL MANAM MANAS MANAT MANCA MANCE MANCS MANDA MANDE MANDI MANDO MANDS MANDU MANDY MANEB MANED MANEH MANER MANES MANET MANEY MANGA MANGE MANGO MANGS MANGU MANGY MANIA MANIC MANID MANIP MANIS MANIX MANJI MANJO MANJU MANKA MANKE MANKS MANKY MANLY MANNA MANNY MANOR MANOS MANRY MANSA MANSE MANSI MANSO MANTA MANTI MANTO MANTY MANTZ MANUL MANUS MANYE MANZI MANZO MAOHI MAOLI MAORE MAORI MAPAU MAPLE MAPLY MAPOU MAQAF MAQAM MAQTA MAQUI MARAE MARAH MARAI MARAJ MARAS MARAW MARAY MARCH MARCO MARCS MARCY MARDI MARDS MARDY MAREN MARES MAREZ MARFA MARGA MARGE MARGO MARGS MARIA MARIE MARIL MARIN MARIO MARIS MARKE MARKO MARKS MARKT MARKY MARLA MARLE MARLS MARLY MARMA MARMO MARMS MARNE MAROG MARON MAROR MARRA MARRI MARRO MARRY MARSE MARSH MARTA MARTH MARTS MARTY MARTZ MARUA MARVS MARVY MARYS MASAI MASAM MASAO MASCI MASEK MASER MASES MASHA MASHY MASIH MASIS MASKS MASON MASRI MASSA MASSE MASSI MASSO MASSY MASTA MASTS MASTY MASYU MATAI MATAM MATAR MATCH MATED MATEI MATER MATES MATEY MATHA MATHO MATHS MATHY MATIE MATIN MATJE MATKI MATOS MATRA MATSU MATTA MATTE MATTY MATZA MATZO MAUBY MAUCH MAUCK MAUDE MAUDS MAUKA MAUKS MAULE MAULL MAULS MAUMA MAUMS MAUMY MAUND MAUNG MAURA MAURI MAURY MAUSS MAUST MAUVE MAUVY MAUZA MAUZY MAVEN MAVIN MAVIS MAWAN MAWED MAWKS MAWKY MAWLA MAWLE MAWNS MAXAM MAXED MAXES MAXEY MAXIE MAXIM MAXIS MAXON MAYAN MAYAS MAYBE MAYED MAYEN MAYER MAYES MAYLE MAYME MAYNE MAYON MAYOR MAYOS MAYRS MAYSE MAYST MAZAK MAZAS MAZDA MAZED MAZER MAZES MAZEY MAZON MAZOS MAZUT MAZZA MBARI MBBCH MBILA MBIRA MBITS MBOGA MBOUR MBRET MBUBE MBUGA MBUNA MBYTE MCATS MCBEE MCCAR MCCAW MCCAY MCGAW MCGEE MCHEM MCING MCJOB MCKAY MCKIE MCNEW MCPON MCRAE MCRIT MCSES MCTFS MDINA MDNAS MEACH MEACO MEADE MEADS MEADY MEAKS MEALS MEALY MEANE MEANS MEANT MEANY MEARE MEARS MEASE MEATH MEATI MEATS MEATY MEAUX MEAWL MEAWS MEBBE MEBBY MEBIN MEBOS MECCA MECHA MECHE MECHS MECKS MECON MECOS MECUM MEDAK MEDAL MEDAN MEDAR MEDCS MEDDY MEDEA MEDEL MEDEN MEDER MEDES MEDEX MEDIA MEDIC MEDII MEDLI MEDLY MEDOC MEECE MEECH MEEDS MEEJA MEEKS MEEMS MEEPS MEERS MEESE MEESS MEETE MEETS MEFFS MEFOS MEGAM MEGAN MEGAW MEGNA MEHAN MEHDI MEHRA MEHTA MEIDE MEIDO MEIGS MEIJI MEIKO MEINY MEISM MEJIA MEKHI MEKIN MEKON MELAM MELAN MELAS MELBA MELBY MELDS MELEE MELEM MELES MELFI MELIC MELID MELIN MELLO MELLS MELON MELOS MELOY MELTS MELTY MEMAW MEMED MEMEL MEMER MEMES MEMEX MEMIC MEMMS MEMON MEMOS MENAE MENDE MENDS MENDY MENGS MENID MENKE MENLO MENNE MENNO MENON MENOR MENOW MENPO MENSA MENSE MENSK MENTA MENTO MENTZ MENUS MEOLA MEOUS MEOWL MEOWS MEOWY MERAL MERAZ MERBY MERCE MERCH MERCI MERCK MERCS MERCY MERDE MERED MERES MERGE MERIT MERKS MERLE MERLO MERLS MERMS MERNA MEROE MEROI MERON MEROS MERRY MERSH MERTZ MERUS MERYA MERYL MESAD MESAL MESAS MESEL MESEM MESEN MESHY MESIC MESNA MESNE MESON MESOR MESSI MESSY MESTA MESTO MESYL METAL METAR METAS METED METEG METER METES METHI METHO METHS METHY METIC METIF METIS METOL METOO METRA METRE METRO METTS MEUSE MEUTE MEWED MEWER MEWLS MEWPS MEWUK MEXXY MEYER MEYNT MEZAS MEZES MEZZE MEZZO MFPRS MFVSG MFWIC MFWTK MGTOW MGUHS MGUKS MHORR MHTML MIAMI MIANO MIAOU MIAOW MIASM MIAUL MICAH MICAS MICEK MICKS MICKY MICOS MICRO MIDAN MIDAS MIDAZ MIDDX MIDDY MIDGE MIDIR MIDIS MIDST MIEKO MIENS MIERA MIEVE MIFFS MIFFY MIGAS MIGGY MIGHT MIGID MIGOD MIHIS MIHMS MIJIU MIKAN MIKED MIKES MIKEY MIKOS MIKOV MIKVA MIKVE MILAB MILAM MILAN MILBY MILCH MILDS MILER MILES MILEY MILFS MILIA MILKO MILKS MILKT MILKY MILLS MILLY MILNE MILOS MILPA MILSE MILTS MIMAS MIMED MIMEO MIMER MIMES MIMIC MIMID MIMIR MIMSY MINAC MINAE MINAH MINAS MINBU MINCE MINCH MINCO MINCY MINDA MINDE MINDS MINDY MINED MINER MINES MINGA MINGE MINGO MINGS MINGW MINGY MINIM MINIS MINIX MINKE MINKS MINMI MINNY MINOR MINOS MINOT MINOW MINSE MINSK MINTO MINTS MINTY MINTZ MINUM MINUS MINXY MIPSS MIRAA MIRED MIRES MIREX MIRID MIRIN MIRIS MIRKY MIRNA MIRON MIRRS MIRTH MIRVS MIRZA MISAL MISDO MISER MISES MISGO MISHA MISIN MISKO MISLE MISLS MISLY MISOS MISRA MISSA MISSY MISTS MISTY MITCH MITER MITES MITEY MITIS MITRA MITRE MITTA MITTS MITTY MITZI MIURA MIWOK MIWUK MIXED MIXEL MIXEN MIXER MIXES MIXIE MIXIN MIXON MIXTE MIXUP MIZAR MIZEN MIZER MIZES MIZUE MIZZY MKEKA MLBER MLJET MLLES MLSCN MLSER MLSES MMANI MMAPI MMATH MMBTU MMCDS MMELS MMHMM MMIWG MMKAY MMOLE MMOLS MNEME MNIST MNRAS MOABI MOABS MOAIS MOAKS MOALD MOANS MOANY MOATS MOBAD MOBAS MOBAY MOBBY MOBED MOBES MOBEY MOBIL MOBLE MOBOS MOBOT MOCAP MOCCS MOCHA MOCHE MOCHI MOCKS MOCKT MOCOS MOCVD MODAK MODAL MODCA MODED MODEL MODEM MODER MODES MODIE MODII MODOC MODON MODUS MODYS MOEKO MOELS MOERS MOESI MOFFA MOGAN MOGAS MOGGS MOGGY MOGOK MOGOR MOGRA MOGUL MOHAN MOHAR MOHEL MOHLS MOHOS MOHRS MOHUA MOHUR MOILE MOILS MOIRA MOIRE MOIRS MOISE MOIST MOJOS MOJRI MOKEN MOKES MOKOS MOLAL MOLAP MOLAR MOLAS MOLDE MOLDS MOLDY MOLEK MOLES MOLID MOLIN MOLLA MOLLE MOLLO MOLLS MOLLY MOLOC MOLOI MOLOK MOLTS MOLVE MOMAN MOMES MOMIC MOMIN MOMLY MOMMA MOMME MOMMY MOMOE MOMOS MOMOT MOMSY MOMUS MONAD MONAL MONAS MONCK MONDE MONDO MONDY MONED MONEL MONER MONES MONET MONEY MONGE MONGO MONGS MONIC MONIE MONIZ MONJE MONKS MONKY MONME MONNA MONNS MONOI MONOM MONOS MONRO MONTE MONTH MONTS MONTY MONTZ MONZO MOOBS MOOCH MOOCS MOODS MOODY MOOED MOOER MOOEY MOOFS MOOGS MOOKS MOOLA MOOLE MOOLI MOOLS MOONE MOONG MOONS MOONY MOORE MOORI MOORS MOORY MOOSE MOOSH MOOTA MOOTS MOOTW MOOTZ MOOVE MOPAN MOPED MOPER MOPES MOPEY MOPLA MOPOL MOPPY MOPSY MOPTI MOPUS MOQUI MORAE MORAI MORAL MORAN MORAS MORAT MORAY MORBS MOREA MORED MOREE MOREL MORES MORET MOREY MORFS MORGA MORIA MORIC MORID MORIL MORIN MORIO MORIS MORKS MORMO MORNA MORNE MORNS MORON MOROS MOROW MOROZ MORPH MORRA MORRO MORRS MORSE MORTA MORTO MORTS MORTY MORUA MORWE MOSBY MOSCA MOSCO MOSER MOSES MOSEY MOSHE MOSKS MOSSI MOSSO MOSSY MOSTE MOSTS MOSUL MOTAS MOTDS MOTED MOTEL MOTEN MOTES MOTET MOTHS MOTHY MOTIA MOTIF MOTON MOTOR MOTOS MOTSS MOTTA MOTTE MOTTI MOTTO MOTTS MOTTY MOTZA MOUAS MOUCH MOUDY MOUES MOUFS MOULD MOULT MOUND MOUNT MOURN MOUSA MOUSE MOUSY MOUTH MOUWS MOUZA MOVAL MOVED MOVER MOVES MOVIE MOVPE MOWED MOWEN MOWER MOWRY MOXAS MOXIE MOYAS MOYCE MOYER MOYES MOYLE MOZEE MOZOS MPEGS MPHYS MPIGI MPNST MPOTO MPPDA MPREG MPRET MRAAM MRAMS MRAPS MRBMS MRCAS MRCGP MRCVS MRHDS MRNAS MRROW MSASA MSCTA MSECS MSFCS MSIAN MSMES MSNBC MSRPC MSRPS MSTAR MSTED MSTID MSTIE MTBFS MTCNS MTDNA MTECS MTHFR MTOCS MTORR MTRNA MUBAH MUCAL MUCCI MUCES MUCHA MUCHO MUCIC MUCID MUCIN MUCKS MUCKY MUCOR MUCRO MUCTC MUCUS MUDAR MUDDY MUDER MUDGE MUDIK MUDIR MUDIS MUDON MUDRA MUFFS MUFFY MUFTI MUGAM MUGGY MUGIL MUGUP MUGUS MUHAS MUHLY MUIDS MUIFA MUING MUISE MUISM MUJIK MUJRA MUKES MUKIM MULAI MULAM MULAO MULAY MULCH MULCT MULED MULES MULEY MULGA MULID MULIS MULLA MULLO MULLS MULOS MULSE MULSH MULTI MUMAK MUMAS MUMAW MUMIE MUMMA MUMMS MUMMY MUMPS MUMPY MUMSY MUMUS MUNAR MUNCH MUNCY MUNDA MUNDO MUNDS MUNDT MUNDU MUNDY MUNGA MUNGE MUNGO MUNGS MUNIA MUNIR MUNIS MUNIZ MUNKS MUNOZ MUNRO MUNTS MUNTU MUNTZ MUONG MUONS MURAD MURAL MURAT MURAY MURCH MURDO MURED MURES MUREX MURFF MURGA MURGH MURHA MURID MURKS MURKY MUROS MURPH MURRE MURRI MURRS MURRY MURSE MURTH MURTI MURUS MURVA MURZA MUSAC MUSAF MUSAL MUSAR MUSCA MUSEA MUSED MUSER MUSES MUSET MUSHA MUSHY MUSIC MUSIT MUSKS MUSKY MUSOS MUSSO MUSSY MUSTA MUSTE MUSTH MUSTO MUSTS MUSTY MUTAH MUTAT MUTCH MUTED MUTER MUTES MUTEX MUTHA MUTHS MUTIC MUTIS MUTON MUTOS MUTSU MUTTS MUTUA MUXED MUXER MUXES MUZAK MUZZY MVULE MWANI MWDEU MWERA MWERU MYALL MYATT MYCIN MYDID MYDST MYEIK MYEKS MYERS MYHRE MYIDS MYINT MYLAR MYLER MYLES MYLNE MYNAH MYNAS MYNDE MYOGA MYOID MYOMA MYOPE MYOPS MYOPY MYRIE MYRNA MYRON MYRRH MYSIA MYSID MYSIS MYTHE MYTHI MYTHS MYTHY MYTON MYUNG MZEES NAABS NAACP NAAFI NAAIS NAANS NAATI NAATS NABAL NABAM NABAN NABBY NABER NABES NABID NABIS NABKS NABLA NABOB NABOR NABQS NACBA NACES NACHO NACKS NACRE NADAR NADER NADES NADIA NADIR NADPH NAEEM NAEPP NAEVE NAEVI NAFDI NAFEC NAFEN NAFKA NAFLD NAFTA NAGAI NAGAK NAGAS NAGGY NAGIS NAGLE NAGOR NAGRA NAHAR NAHUA NAHUM NAIAD NAIBS NAIDU NAIFS NAIJA NAIKS NAILS NAILY NAIMS NAING NAION NAIOP NAIRA NAIRN NAIRS NAIRU NAITO NAITP NAIVE NAJAF NAJAR NAJAS NAJIS NAKAI NAKBA NAKED NAKEN NAKER NAKES NAKEY NAKFA NAKHI NAKIE NAKIR NALAS NALCA NALED NALES NALGO NALLS NALLY NAMAZ NAMBA NAMBO NAMED NAMER NAMES NAMIB NAMMU NAMOA NAMPO NAMUL NAMUR NAMUS NANAS NANCE NANCY NANDA NANDI NANDO NANDS NANDU NANEA NANEZ NANGA NANGS NANMU NANNA NANNY NANTI NANTO NANTY NANTZ NAOCL NAOKO NAOMI NAPAS NAPED NAPES NAPOO NAPPA NAPPE NAPPI NAPPY NAPTS NAQBA NAQIB NAQVI NARAS NARCO NARCS NARDI NARDO NARDS NARES NAREW NAREZ NARGS NARIS NARKA NARKS NARKY NAROM NARON NARRA NARTH NARVA NARVI NASAL NASBA NASER NASES NASHI NASHO NASIR NASKH NASKY NASON NASOS NASPA NASRI NASRS NASSE NASTS NASTY NASUS NATAL NATCH NATES NATOS NATTO NATTY NATYA NAULT NAUNT NAURU NAUTA NAVAL NAVAR NAVED NAVEL NAVES NAVEW NAVIA NAVID NAVIE NAVIN NAVMC NAVVY NAWAB NAWAR NAWAZ NAWIN NAWLS NAWTH NAXAL NAXAR NAXOS NAYAK NAYED NAZAR NAZCA NAZES NAZIR NAZIS NBAER NBOME NCADD NCAUR NCCAM NCDNA NCIPC NCLDV NCOIC NCRNA NDALA NDALI NDARI NDNAS NDPER NDUJA NEACE NEACP NEAFL NEALE NEALS NEALY NEAMS NEAPS NEAPY NEARE NEARS NEARY NEASE NEATH NEATO NEATS NEAVE NEBBY NEBEK NEBEL NECCO NECHE NECKE NECKS NECRO NEDDS NEDDY NEEBS NEECE NEEDA NEEDE NEEDS NEEDY NEEKS NEELD NEELE NEELS NEELY NEEMB NEEMS NEENY NEEPS NEERA NEERS NEESE NEETS NEEZE NEFAS NEFFY NEGAR NEGEB NEGER NEGEV NEGRI NEGRO NEGUS NEHER NEHRA NEICE NEIDE NEIFE NEIFS NEIGH NEILL NEIRA NEIST NELDA NELGS NELIA NELLY NELMA NELON NEMAN NENAD NENES NENGE NENIA NEONS NEPAL NEPER NEPHI NEPHS NEPID NEPIT NERAL NERDO NERDS NERDY NERFS NERIA NERIO NEROL NEROS NERSC NERTS NERTZ NERVE NERVY NERYS NESBY NESES NESKI NESOI NESSA NESTS NETHS NETOP NETRA NETTS NETTY NEUKS NEUME NEUMS NEURA NEURO NEVAH NEVEL NEVEN NEVER NEVES NEVIL NEVIN NEVIS NEVUH NEVUS NEVVY NEWAH NEWAR NEWAY NEWBS NEWBY NEWBZ NEWCO NEWED NEWEL NEWER NEWES NEWFS NEWIE NEWLY NEWRY NEWSY NEWTH NEWTS NEXAL NEXIN NEXIT NEXUM NEXUS NEYRA NFBSK NFLER NGAIO NGAKA NGALA NGANS NGAPI NGEOS NGINA NGINX NGOMA NGONI NGRAM NGSOS NGSTS NGUNI NGUYS NGWEE NGWEI NHANS NHLBI NHLER NHSES NHTSA NIAAA NIAID NIALL NIANG NIAZI NICAD NICAM NICAS NICCA NICED NICEN NICER NICES NICEY NICHE NICKI NICKS NICKY NICOL NICOS NICUS NIDAL NIDAS NIDAY NIDCR NIDDA NIDES NIDGE NIDOR NIDRI NIDUS NIECE NIEFS NIEHS NIELD NIESE NIETO NIEVE NIFFS NIFFY NIFLE NIFOC NIFTS NIFTY NIGEL NIGER NIGES NIGGA NIGGY NIGHS NIGHT NIGHY NIGMS NIGON NIGRA NIGRE NIGUA NIHOA NIKAH NIKAU NIKES NIKIS NIKKA NIKKI NIKKO NIKOS NIKUD NILAS NILES NILLA NILLS NIMBI NIMBS NIMBY NIMES NIMHD NIMMO NIMOY NIMPH NIMUE NINAN NINAS NINDS NINER NINES NINGS NINJA NINNY NINON NINPO NINTH NINTY NINUS NIOBE NIOPO NIORT NIOSH NIPAS NIPPS NIPPY NIPSY NIQAB NIRLS NIRPS NISAB NISAN NISBA NISBE NISEI NISEY NISHI NISIN NISKU NISSE NISUS NITCH NITER NITES NITHE NITIC NITID NITON NITRE NITRO NITRY NITTA NITTI NITTO NITTY NIVAL NIVEN NIVER NIVKH NIXED NIXER NIXES NIXIE NIXON NIZAM NIZAR NJACS NJAHI NJIES NJOKU NKISI NKORE NKOTB NLETS NLRGS NMOLE NMOLS NNESS NNEWI NNSES NOACK NOAEL NOAHS NOBBE NOBBS NOBBY NOBEL NOBES NOBLE NOBLY NOBOA NOBOS NOCES NOCKS NODAL NODDY NODED NODES NODIS NODUS NOELE NOELL NOELS NOEMA NOGAI NOGAS NOGGY NOHOW NOIBN NOICE NOIDS NOIER NOILS NOILY NOINT NOIRS NOISE NOISY NOITS NOKIA NOLAN NOLDS NOLEN NOLES NOLID NOLIE NOLIN NOLLS NOLTE NOLTS NOMAD NOMAN NOMAP NOMAS NOMEN NOMES NOMIC NOMID NOMOI NOMOS NONAD NONCE NONCY NONDA NONDO NONES NONET NONGS NONIC NONIS NONNA NONNO NONNY NONOS NONYL NOOBS NOOBY NOOBZ NOOCH NOOGY NOOIT NOOKS NOOKY NOONE NOONS NOOOW NOORD NOORI NOOSE NOPAL NOPEC NOPED NOPES NOPEY NORAD NORAH NORAM NORBA NORBY NORCO NOREN NORGE NORIA NORIC NORIE NORIS NORKS NORMA NORML NORMS NORNS NORRA NORRY NORSE NORSK NORTE NORTH NOSAL NOSED NOSEK NOSEL NOSER NOSES NOSEY NOSHI NOSLE NOSQL NOSUH NOSYL NOTAH NOTAL NOTAM NOTAR NOTCH NOTEC NOTED NOTER NOTES NOTIF NOTTS NOTUM NOTUS NOUCH NOULD NOULE NOUNS NOUNY NOURI NOUSE NOUST NOUTS NOVAE NOVAK NOVAS NOVEL NOVEX NOVIS NOVOA NOVOS NOVUM NOVYS NOWAK NOWAY NOWCH NOWDS NOWED NOWEL NOWHY NOWTS NOXAL NOYAN NOYAU NOYED NOYER NOYLS NOYON NOYPI NOYSE NOZLE NPAPI NPSCS NRARP NRDNA NREMT NRNAS NROFF NROTC NSAID NSDAP NSERC NSIMA NSPCC NSRNA NSWBC NTBCW NTIDS NTLDR NTUZU NUBBY NUBIA NUCCI NUCHA NUCIN NUDDY NUDER NUDES NUDEY NUDGE NUDGY NUDIE NUDLE NUDZH NUEIR NUERS NUEVO NUFFY NUHOU NUKED NUKER NUKES NUKUS NULAB NULLA NULLS NULPH NUMBS NUMBY NUMEN NUMIC NUMMI NUMMY NUMPS NUNEZ NUNGA NUNKI NUNLY NUNNS NUNTY NUNUS NUNYA NUORO NUPUR NUQTA NUQUE NURBS NURDS NURDY NURLS NURSE NURST NURSY NURUK NUSAS NUTIL NUTLY NUTSO NUTSY NUTTS NUTTY NUTZO NUWHO NUWSS NUZUM NUZZI NUZZO NVCJD NVLDS NVOCC NVRAM NWEMS NWFZS NWHRC NWOSU NWTCA NWTSC NYALA NYAMI NYAYA NYCER NYCES NYCTA NYDRI NYEMS NYEPI NYIHA NYJER NYLAH NYLON NYMPH NYSSA NYULA NZADI NZEMA NZERS NZIMA NZPIF OADBY OADMS OAERS OAKED OAKEN OAKER OAKES OAKUM OARED OASAL OASES OASIS OASTS OATEN OATER OATES OATHS OATUS OAVES OBAKE OBAMA OBANG OBEAH OBEDT OBELI OBESE OBEYS OBGYN OBHWF OBIES OBION OBITS OBLEY OBOES OBOLE OBOLI OBOLO OBOLS OBROK OCCUR OCEAN OCHER OCHES OCHRA OCHRE OCHRY OCKER OCOID OCRAS OCREA OCRED OCRES OCTAD OCTAL OCTAN OCTAS OCTET OCTGS OCTIC OCTLI OCTYL OCULI ODALS ODDEN ODDER ODDLY ODELL ODEMA ODEON ODESA ODEUM ODHNI ODIAS ODIHR ODIID ODISM ODIST ODIUM ODIZE ODMYL ODORS ODOUR ODTAA ODYLE OECUS OENIN OENUS OFALA OFAYS OFERS OFFAL OFFED OFFEN OFFER OFFIE OFFLY OFFRE OFGEM OFLAG OFLOT OFSPS OFTEN OFTER OFUDA OFURO OGAMS OGATA OGAWA OGDEN OGEED OGEES OGEMA OGGIN OGGLE OGHAM OGHUZ OGIVE OGLED OGLER OGLES OGLIO OGMIC OGONI OGRES OHANA OHELO OHIAH OHIAS OHING OHKOS OHMIC OHRID OIDIA OIKOI OIKOS OILED OILER OINKS OINTS OIRAN OISHI OITNB OJEKS OJHAS OJIME OKADA OKAPI OKARA OKAWA OKAYS OKEHS OKERS OKIES OKINA OKING OKISH OKIYA OKOLE OKRAS OKRUG OKTAS OKUNG OKWAS OLAND OLDEN OLDER OLDIE OLDLY OLEAS OLEDS OLEHS OLEIC OLEIN OLEMA OLENE OLENT OLEON OLEOS OLESH OLEUM OLEYL OLIGO OLIOS OLIVA OLIVE OLLAM OLLAS OLLAV OLLIE OLMEC OLNEY OLOGY OLOID OLONA OLPES OLSON OLTPS OLWEN OLWYN OMAGH OMAHA OMAKE OMANI OMARI OMASA OMATA OMBER OMBRE OMBUD OMBUS OMDBS OMEES OMEGA OMENA OMENS OMERS OMIAI OMICS OMIKO OMINE OMITS OMKAR OMLAH OMOTO OMRLP OMULS OMURA ONAWA ONAYE ONCED ONCER ONCET ONCOM ONDED ONDES ONDOL ONEGA ONEGO ONEGS ONELY ONERS ONERY ONEST ONETH ONGEE ONIDA ONING ONION ONIUM ONKUS ONLAP ONLAY ONLEY ONLIE ONLYS ONSEN ONSET ONTIC ONTOP ONTOS ONYON ONZAS OOAKS OOBES OOBIT OOCSS OOFDA OOFTA OOGLE OOHED OOIDS OOJAH OOMPF OOMPH OONTS OONTZ OOOHS OOOOH OOPAK OOPED OOPLS OOPSY OORAH OORIE OORYA OOSED OOSER OOSES OOTID OOWOP OOXML OOYAH OOZED OOZEL OOZES OOZIE OPAHS OPAKE OPALS OPAMP OPARA OPCON OPENS OPEPE OPERA OPERE OPERS OPHAN OPHIR OPIAS OPINE OPING OPIOD OPIUM OPLAX OPOLE OPPED OPPOS OPRAF OPRAH OPROV OPSEC OPSIN OPSON OPTED OPTER OPTIC ORACH ORACY ORALE ORALS ORANG ORANS ORANT ORATE ORBAN ORBAT ORBED ORBIC ORBIT ORCAS ORCIN ORCOS ORCUS ORDER ORDES ORDIE ORDOS ORDRE OREAD OREOS ORFED ORFEN ORFES ORFUL ORGAL ORGAN ORGED ORGEL ORGIC ORGIE ORGUE ORGUL ORIBI ORIEL ORIGO ORINS ORIOL ORION ORISA ORIXA ORIYA ORLAY ORLED ORLES ORLON ORLOP ORLOS ORMER ORMUS ORNED ORNEE ORNGE ORNIS OROMO OROYA ORPED ORPIN ORREN ORRIN ORRIS ORSON ORTED ORTET ORTHO ORTIZ ORTOL ORTON ORUJO ORUTU ORVAL ORVET ORVIN ORYOL OSAGE OSAKA OSAMA OSAMU OSARS OSAWA OSCAN OSCAR OSETE OSETI OSHAC OSIER OSITE OSLER OSMAN OSMIC OSMOL OSONE OSSEO OSSES OSSET OSSIA OSSIE OSSOM OSTEA OSTEO OSTIA OSTIC OSTOJ OSTRO OTAGO OTAKU OTANI OTARY OTECS OTERO OTHER OTOMI OTOPY OTTAR OTTER OTTIE OTTIS OTTOS OTWAY OUCHE OUCHI OUCHY OUENS OUGHT OUGLY OUIJA OUKIE OULES OUNCE OUNDY OUNSI OUPHE OUPHS OURAY OUREA OURIE OUSED OUSEL OUSES OUSIA OUSIE OUSTS OUTBY OUTDO OUTED OUTEN OUTER OUTGO OUTIE OUTRE OUTRO OUTTA OUVRE OUZEL OUZOS OVALS OVANT OVARY OVATE OVENS OVERO OVERS OVERT OVEST OVFST OVINE OVISM OVIST OVOID OVOLO OVOOS OVULA OVULE OVULO OWARE OWARI OWCHE OWEGO OWENS OWERS OWEST OWETH OWIES OWING OWLED OWLER OWLET OWNED OWNER OWORD OWRES OWSEN OWTTE OWWIE OWZAT OXANE OXBOW OXEAE OXEAS OXEIA OXENE OXENS OXERS OXEYE OXFAM OXFLY OXHEY OXIAE OXIDE OXIDS OXIME OXIMS OXINE OXISH OXLEY OXLIP OXOLE OXONS OXTED OXTER OXYDE OXYDS OXYLS OYAMA OYENS OYLET OYRUR OZALJ OZARK OZAWA OZEKI OZIAN OZIER OZITE OZONA OZONE OZREN OZUMO OZZIE PAAMA PAANS PAARL PABON PABST PACAS PACAY PACED PACER PACES PACEY PACHA PACHY PACIS PACKS PACKY PACOS PACTS PACUS PADAM PADAR PADAS PADDY PADEK PADEL PADEN PADGE PADIN PADLE PADMA PADOW PADRE PADRO PADUA PAEAN PAEDO PAEDS PAEKS PAEON PAFFS PAGAN PAGDI PAGED PAGEL PAGER PAGES PAGET PAGNE PAGOD PAGRI PAGUS PAHAN PAHIS PAHLS PAHUS PAICH PAIDE PAIGE PAIKS PAILA PAILS PAIME PAINE PAINS PAINT PAINY PAIPO PAIRE PAIRS PAISA PAISE PAITS PAIZA PAJAK PAKER PAKIS PAKOL PAKUL PALAE PALAK PALAS PALAU PALEA PALED PALEN PALEO PALER PALES PALET PALEY PALIN PALKA PALKI PALKO PALLA PALLS PALLU PALLY PALMA PALME PALMS PALMY PALOV PALPI PALPS PALSA PALSY PALUS PAMIR PAMMY PAMPA PANAY PANCE PANCH PANCY PANDA PANDO PANDU PANDY PANED PANEK PANEL PANES PANGA PANGS PANGU PANIA PANIC PANIM PANIR PANKO PANKS PANNE PANNI PANNU PANSY PANTH PANTO PANTS PANTY PANUS PANYM PANZA PAOLA PAOLI PAOLO PAONE PAPAD PAPAL PAPAS PAPAU PAPAW PAPDI PAPER PAPES PAPIK PAPIN PAPIS PAPKE PAPPA PAPPI PAPPY PAPRI PAPRS PAPUA PARAM PARAS PARCH PARCS PARDI PARDO PARDS PARDY PARED PAREE PAREN PAREO PARER PARES PAREV PARGA PARGE PARID PARIS PARKA PARKE PARKI PARKS PARKY PARLA PARLE PARMA PARMO PARNU PAROL PAROS PARPI PARPS PARRA PARRO PARRS PARRY PARSA PARSE PARSI PARTE PARTI PARTS PARTY PARVE PARVO PASAN PASCH PASCO PASEK PASEO PASGT PASHA PASKA PASMA PASOK PASOS PASPY PASSE PASTA PASTE PASTS PASTY PATAS PATCH PATED PATEL PATEN PATER PATES PATHE PATHS PATHY PATIA PATIL PATIN PATIO PATIS PATKA PATLY PATNA PATON PATSY PATTE PATTI PATTY PATUS PAUAS PAUCE PAUGH PAULA PAULI PAULK PAULL PAULS PAULY PAUMS PAUNE PAUNI PAUSA PAUSE PAUTZ PAUXI PAVAN PAVED PAVEE PAVEK PAVEL PAVEN PAVER PAVES PAVEY PAVIA PAVID PAVIS PAVON PAWAR PAWAS PAWAW PAWED PAWER PAWKS PAWKY PAWLE PAWLS PAWNE PAWNS PAWTY PAXES PAXIL PAXIS PAYAM PAYAN PAYAO PAYED PAYEE PAYEN PAYER PAYES PAYGO PAYNE PAYNI PAYOR PAYOS PAYOT PAYSE PBAOE PBARS PBGVS PBMER PCASP PCAST PCATS PCDNA PCING PCISM PCRED PCSOS PDFED PDGFS PDLCS PDNOS PDOCS PEABO PEACE PEACH PEAGE PEAKE PEAKS PEAKY PEALS PEANO PEANS PEARE PEARL PEARS PEART PEARY PEASE PEASY PEATE PEATS PEATY PEAVY PEAYS PEBAS PEBLO PEBLY PECAN PECCO PECHA PECHS PECHT PECKE PECKS PECKY PECOR PECOS PECUL PEDAL PEDAS PEDEN PEDES PEDHA PEDIA PEDIS PEDON PEDOS PEDOT PEDRO PEDUM PEECE PEEDS PEEKS PEELE PEELS PEENS PEENT PEEPE PEEPS PEEPY PEERS PEERT PEERY PEETE PEEVE PEEVY PEFCS PEGAH PEGAN PEGGS PEGGY PEGOL PEGOS PEGYL PEICE PEILS PEINE PEISE PEITC PEIZE PEKAN PEKAR PEKES PEKID PEKIE PEKIN PEKOE PELAU PELCS PELEA PELED PELEG PELLA PELLS PELLY PELMA PELOG PELTA PELTO PELTS PELTZ PELVE PEMON PENAL PENAM PENCE PENDS PENED PENEM PENES PENGE PENGO PENHA PENII PENIN PENIS PENIX PENKS PENNA PENNE PENNY PENRY PENTA PENTS PENTZ PENUP PENZA PEONS PEONY PEOPS PEPAW PEPEL PEPER PEPIN PEPLA PEPLE PEPLI PEPOS PEPPY PEPSI PEPTO PEPYS PEQUI PERAK PERAS PERCE PERCH PERCY PERDU PERDY PEREA PEREQ PERES PERET PEREZ PERFS PERIL PERIN PERIS PERKO PERKS PERKY PERMS PERNA PERNE PERNS PERNT PEROS PEROT PEROX PERPS PERQS PERRI PERRL PERRO PERRY PERSE PERTH PERTS PERUN PERVE PERVO PERVS PERVY PESCE PESEK PESEN PESES PESKY PESOS PESTA PESTO PESTS PESTY PETAL PETAR PETER PETEY PETHA PETIE PETIT PETRA PETRE PETRO PETRY PETTI PETTO PETTS PETTY PETUN PEUCE PEUGH PEUMO PEVEY PEWED PEWEE PEWET PEWIT PEYER PFAAS PFAFF PFALZ PFARR PFCAS PFIGS PFLUM PFOHL PFOSA PGACC PGCES PGDMS PGLAF PGRNA PGSEM PHAAL PHACO PHAGE PHAIR PHALL PHALP PHALS PHAMS PHANE PHANG PHANS PHARE PHARM PHARO PHARR PHASE PHASM PHATS PHEBE PHEEP PHEER PHELP PHEMY PHENE PHEON PHEVS PHFFT PHIAL PHIFE PHILO PHILP PHIPP PHIPS PHISH PHITS PHLOG PHLOX PHOBE PHOCA PHONE PHONG PHONO PHONS PHONY PHOTO PHOTS PHPER PHPHT PHRAE PHRAG PHREN PHUBS PHUCK PHUNG PHUTS PHYLA PHYLE PHYMA PHYSA PIANO PIATT PIAUI PIAYE PIBAL PICAS PICCS PICCY PICES PICHA PICHE PICID PICKS PICKT PICKY PICON PICOS PICOT PICOU PICOW PICRA PICTS PICUL PICUS PIDES PIDGE PIECE PIEHL PIELS PIEMS PIEND PIERS PIETA PIETS PIETY PIETZ PIEZO PIFER PIFFS PIGAS PIGGS PIGGY PIGHT PIGMS PIGMY PIHLS PIIGG PIIGS PIING PIJIN PIKAS PIKED PIKER PIKES PIKEY PIKIE PIKUL PILAE PILAF PILAR PILAT PILAU PILAV PILAW PILCH PILEA PILED PILEI PILER PILES PILID PILIN PILIS PILLA PILLS PILLY PILON PILOT PILOW PILUM PILUS PIMAN PIMPS PIMPY PINAL PINAS PINAX PINAY PINCH PINDA PINED PINER PINES PINEY PINGO PINGS PINGY PINHO PINID PININ PINKO PINKS PINKY PINNA PINNY PINON PINOT PINOY PINSK PINTA PINTO PINTS PINUP PINUS PIONS PIONY PIOTS PIOUS PIPAL PIPAS PIPED PIPEL PIPER PIPES PIPET PIPID PIPIL PIPIS PIPIT PIPPA PIPPY PIPRA PIPUL PIQUA PIQUE PIRAI PIREP PIRGS PIRIE PIRKS PIRLS PIRNA PIRNS PIROG PIROS PIRRY PISAN PISCO PISES PISIQ PISKY PISSY PISTE PITAS PITBS PITCH PITHS PITHY PITIE PITIS PITON PITOT PITRE PITTA PITTS PITTY PIVKA PIVOT PIWIS PIXAR PIXEL PIXES PIXEY PIXIE PIXIU PIYAG PIZED PIZER PIZES PIZZA PIZZE PIZZI PIZZO PKERS PLAAS PLACE PLACK PLAGA PLAGE PLAID PLAIN PLAIR PLAIT PLANA PLANE PLANK PLANO PLANS PLANT PLAPS PLARN PLASH PLASM PLATA PLATE PLATH PLATO PLATS PLATT PLATY PLATZ PLAUD PLAWS PLAYA PLAYE PLAYS PLAZA PLCCS PLDNA PLEAD PLEAS PLEAT PLEBE PLEBS PLECK PLECO PLECS PLEDG PLEDS PLEID PLEMS PLENA PLENE PLEON PLESH PLEWA PLEWD PLEWS PLEXI PLEYT PLICA PLIED PLIER PLIES PLIGS PLIMS PLING PLINK PLINY PLIPS PLISH PLITT PLMNS PLOCE PLODS PLONK PLOOF PLOOP PLOOT PLOPS PLOTS PLOTZ PLOVS PLOWS PLOYE PLOYS PLUCK PLUFF PLUGS PLUMA PLUMB PLUME PLUMP PLUMS PLUMY PLUNK PLUOT PLUSH PLUTA PLUTE PLUTO PLYER PLYGS PLYOS PLZEN PMIDS PMING PMOLE PMOLS PMOYS PMSED PMSES PMTCT POACH POAGS POAKA POAST POBBY POBER POCAN POCHE POCKS POCKY POCOS PODDY PODES PODGE PODGY PODIA POEAA POEAN POEME POEMS POESY POETE POETS POFFS POGEY POGGE POGGI POGGY POGIE POGOS POGUE POIDS POILU POIND POINT POISE POIZE POJOS POKAL POKED POKER POKES POKEY POKIE POKOT POLAN POLAO POLAR POLED POLER POLES POLEY POLIO POLIS POLJE POLKA POLKI POLKS POLLS POLLY POLOS POLSA POLTS POLUS POLYP POLYS POMAS POMBE POMBO POMES POMEY POMME POMMY POMPA POMPS PONCA PONCE PONCY PONDS PONDY PONES PONEY PONGA PONGO PONGS PONGY PONIR PONOR PONTY PONZI PONZO PONZU POOCH POODS POOED POOER POOEY POOFS POOFY POOGH POOHS POOID POOJA POOKA POOLE POOLS POOMA POONA POONS POOPS POOPY POORE POORI POOTS POOTY POOVE POOVY POPAL POPAS POPED POPES POPOS POPOV POPPA POPPE POPPY POPSY POPUP PORAL PORCH PORCO POREC PORED PORER PORES PORGA PORGY PORIA PORIN PORKI PORKS PORKY PORNO PORNS PORNY PORTA PORTE PORTO PORTS PORTZ POSCA POSED POSEK POSER POSES POSET POSEY POSHO POSIO POSIT POSIX POSSA POSSE POSTH POSTS POTCH POTDS POTED POTES POTHS POTIN POTOO POTSY POTTO POTTS POTTY POTUS POUCH POUDS POUFS POUFY POUGH POUIS POULE POULP POULT POUND POURS POUTS POUTY POVEY POVVO POWAH POWAN POWEN POWER POWES POWND POWRE POWYS POXED POXES POYER POYNT POYSE POZED POZES POZOS POZZI POZZY PPBAR PPPOA PPPOE PRAAM PRACS PRADS PRAHL PRAHM PRAHU PRAIA PRAKS PRALL PRAME PRAMS PRANA PRANG PRANK PRAOS PRAPS PRASE PRATA PRATE PRATO PRATS PRATT PRATY PRAUS PRAWN PRAYE PRAYS PRDNA PREDE PREDS PREDY PREEM PREEN PREFS PREGO PREGS PREKE PRELL PREMO PREMS PREMY PREON PREOP PREPS PRESA PRESH PRESO PRESS PREST PRETA PRETE PRETT PREVE PREVO PREXY PREYS PREZA PRIAL PRIAM PRIAN PRIAR PRICE PRICK PRICY PRIDE PRIED PRIEF PRIER PRIES PRIGS PRILE PRILL PRIMA PRIME PRIMM PRIMO PRIMP PRIMS PRIMY PRINE PRINK PRINT PRION PRIOR PRISE PRISM PRISS PRITT PRIVS PRIVY PRIYA PRIZE PRNDL PRNGS PROAS PROBE PROBS PROCK PROCS PRODD PRODS PROEM PROFS PROGG PROGS PROIN PROKE PROLE PROLL PROLY PROME PROMO PROMS PRONE PRONG PRONK PRONS PROOF PROOT PROPP PROPS PRORE PROSE PROSO PROSS PROST PROSY PROTO PROUD PROUL PROUT PROVE PROVO PROWD PROWL PROWS PROXY PRRSV PRUCE PRUCK PRUDE PRUNE PRUNO PRUNT PRUNY PRUSS PRUTA PRYAN PRYCE PRYDE PRYER PRYOR PSAKI PSALM PSAPS PSARA PSDPS PSEUD PSHAW PSHHT PSION PSKOV PSNRS PSOAE PSOAI PSOAS PSORA PSPOS PSRNA PSSHT PSSST PSTNS PSYCH PSYOP PTAKS PTBNL PTDFS PTDNA PTRNA PTSAS PUACS PUBBY PUBCO PUBED PUBES PUBEY PUBIC PUBIS PUCCA PUCEL PUCES PUCKA PUCKS PUCKY PUDDS PUDGE PUDGY PUDIC PUDOR PUDUS PUETS PUETT PUETZ PUFAS PUFFA PUFFS PUFFY PUGAS PUGET PUGGY PUGHE PUGIL PUGIO PUGNE PUHLS PUITS PUJAH PUJAS PUKAO PUKAS PUKED PUKER PUKES PUKEY PUKIS PUKKA PUKUS PULAO PULAS PULED PULEO PULER PULES PULIK PULIS PULKA PULKS PULLA PULLI PULLS PULLY PULPS PULPY PULSE PUMAS PUMPS PUMSE PUNAS PUNCH PUNGA PUNGI PUNGS PUNGY PUNIC PUNIM PUNJI PUNKA PUNKS PUNKY PUNNY PUNTA PUNTE PUNTI PUNTO PUNTS PUNTY PUOYS PUPAE PUPAL PUPAS PUPES PUPIL PUPOS PUPPP PUPPY PURAM PURDA PURDY PURED PUREE PURER PURES PUREX PUREY PURGA PURGE PURIF PURIM PURIS PURLE PURLS PURLY PUROK PUROS PURRE PURRS PURRY PURSE PURSY PURTY PURUM PUSAN PUSCA PUSEY PUSHT PUSHY PUSIL PUSLE PUSSY PUSTA PUTER PUTID PUTIN PUTOO PUTRE PUTRY PUTTI PUTTO PUTTS PUTTY PUYAS PUZIO PWISE PWNED PYATT PYCNA PYELO PYETS PYGAL PYGMY PYKAR PYKES PYKNA PYLAE PYLES PYLON PYLOS PYNED PYNES PYNNE PYOID PYORE PYOTR PYOTS PYOWS PYPER PYRAL PYRAM PYRAN PYREE PYRES PYREX PYRIN PYRON PYROS PYSMA PYXED PYXES PYXIE PYXIS PZAZZ PZEVS QAAFS QABAH QADAR QADIR QADIS QADRI QAFAR QAFIZ QAGAN QAHAL QAIDS QAJAQ QALAM QALYS QAMAS QANAT QANGO QANON QANUN QAPIK QAPPS QARGI QARIN QARIS QASAB QASRS QATAR QAWAL QAZAQ QAZAX QAZIS QBICS QBISM QBITS QCEPO QDIIS QEEMA QEPIK QEROS QFETS QFIIS QIANA QIANG QIANS QIAOS QIBLA QIBLI QILIN QIMEN QINAH QINGS QINOT QINPU QIPAO QIRAN QIRAT QIRSH QISAS QIYAM QIYAS QLEDS QOBAR QOBYZ QOMUZ QOPHS QOPPA QORAN QORMA QOSQO QPCRS QRAZY QSOED QSSES QTIES QTPOC QUABS QUACH QUACK QUADE QUADI QUADS QUAFF QUAGS QUAID QUAIL QUAKE QUAKY QUALE QUALM QUALS QUAMS QUANS QUANT QUARE QUARG QUARK QUARL QUARS QUART QUASH QUASI QUASS QUAST QUATA QUATE QUATS QUAVE QUAYD QUAYE QUAYS QUBIT QUDIT QUDSI QUEAL QUEAN QUEEB QUEEF QUEEK QUEEN QUEEP QUEER QUEGH QUELL QUEME QUENE QUENK QUERK QUERL QUERN QUERO QUERY QUESO QUEST QUEUE QUEYN QUEYS QUHAR QUHAT QUHEN QUHER QUHOW QUIBS QUICA QUICE QUICH QUICK QUIDS QUIET QUIFF QUIGG QUILL QUILT QUIMP QUIMS QUINE QUINK QUINN QUINS QUINT QUIPO QUIPS QUIPU QUIRE QUIRK QUIRL QUIRN QUIRT QUISM QUIST QUITE QUITO QUITS QUIZZ QULFI QULIN QULIS QUMIS QUMIX QUOAD QUOBS QUODS QUOGS QUOIF QUOIL QUOIN QUOIT QUOKE QUOLL QUONK QUONS QUOOK QUOPS QUORA QUORN QUOTA QUOTE QUOTH QUOYS QURAN QURSH QUYAS QWORD QYOOT RAABE RAADS RAAGS RAAHE RAATZ RAAYA RABAB RABAT RABBI RABEL RABER RABES RABHA RABIA RABIC RABID RABIN RABIP RABIS RABIZ RABON RABOT RABUN RABYS RACCA RACCS RACED RACER RACES RACHE RACKA RACKS RACON RACWA RADAR RADAS RADAY RADDE RADEL RADER RADES RADGE RADHA RADHI RADIF RADII RADIN RADIO RADIX RADKE RADLE RADON RADOS RADYS RAFFA RAFFE RAFFI RAFFS RAFIE RAFIQ RAFSI RAFTE RAFTS RAFTY RAGAM RAGAN RAGAS RAGED RAGER RAGES RAGEY RAGGA RAGGY RAGHU RAGIN RAGLE RAGON RAGOO RAGOS RAGOU RAGUS RAHAB RAHAL RAHES RAHIM RAHNS RAHUI RAHUL RAIAS RAIDS RAIGN RAIKS RAILE RAILS RAIMS RAINA RAINE RAING RAINS RAINY RAION RAIPS RAISA RAISE RAITA RAITE RAJAB RAJAH RAJAN RAJAS RAJID RAJIV RAJMA RAKAI RAKED RAKEE RAKER RAKES RAKHI RAKIA RAKIS RAKKA RAKOW RAKSI RALES RALEY RALLO RALLS RALLY RALPH RAMAL RAMAN RAMBO RAMEA RAMED RAMEE RAMEN RAMER RAMES RAMET RAMEX RAMEY RAMIC RAMIE RAMIN RAMMY RAMPS RAMUS RANCE RANCH RANCK RANDO RANDS RANDY RANEE RANEY RANGA RANGE RANGO RANGY RANID RANIS RANKE RANKO RANKS RANNS RANNY RANOO RANTS RANTY RANTZ RANUA RAPAN RAPED RAPEE RAPER RAPES RAPEY RAPHE RAPID RAPOS RAPPA RAPSO RAPTS RAQQA RARED RAREE RARER RARES RARFS RASAM RASAS RASCH RASCO RASED RASES RASHI RASHY RASIC RASKA RASON RASOR RASOS RASPS RASPY RASSE RASTA RASUL RATAN RATAS RATCH RATED RATEE RATEL RATER RATES RATHE RATHO RATHS RATIO RATNA RATON RATOS RATTI RATTO RATTY RATUS RAUCH RAUCO RAUDA RAUEN RAUFS RAUHS RAUMA RAUPP RAVAL RAVED RAVEL RAVEN RAVER RAVES RAVEY RAVIN RAWAP RAWER RAWKS RAWLY RAXED RAXES RAXLE RAYAH RAYAN RAYAS RAYED RAYES RAYLE RAYLS RAYMO RAYNA RAYNE RAYON RAYOS RAZAS RAZED RAZEE RAZER RAZES RAZON RAZOO RAZOR RAZOS RBOCS RCTIS RDBMS RDNAS RDRAM REAAL REACH REACT READD READE READS READY REAFS REAIM REAIR REAIS REAKS REALE REALM REALS REAME REAMS REANS REAPS REAPT REARD REARM REARS REASK REAST REATA REAVE REAYS REBAB REBAG REBAR REBBE REBEC REBED REBEL REBER REBID REBIN REBIT REBOP REBOX REBUD REBUS REBUT REBUY RECAM RECAN RECAP RECCE RECCY RECIO RECIT RECKS RECON RECTA RECTE RECTI RECTO RECUR RECUT REDAN REDAS REDDS REDDY REDER REDES REDEX REDIA REDIC REDID REDIE REDIF REDIG REDIP REDLY REDON REDOS REDOX REDRY REDUB REDUG REDUX REDYE REEBS REECE REECH REEDE REEDS REEDY REEFS REEFY REEKS REEKY REELS REELY REEMS REENS REESE REESK REEST REETH REETZ REEVE REFAN REFAX REFED REFEL REFER REFFO REFIT REFIX REFLY REFRY REFTS REGAL REGAN REGAS REGEN REGER REGET REGEX REGIO REGLE REGMA REGNA REGOS REGOT REGUR REHAB REHAK REHAL REHAT REHEM REHEW REHID REHMS REICH REIDY REIFF REIFY REIGN REIKI REIKO REILS REILY REIMS REINK REINS REIRD REISH REISM REIST REISZ REITH REITZ REIVE REJET REJIG REJOG REJOY REKEY RELAX RELAY RELET RELIC RELIT RELLA RELLO RELLY RELOG RELOS REMAN REMAP REMER REMET REMEW REMEX REMFS REMIC REMIS REMIT REMIX REMOP REMOW REMPE REMUS RENAL RENAY RENCH RENDA RENDE RENDS RENEE RENEW RENGA RENGO RENIG RENIN RENJU RENKS RENNA RENNE RENNS RENOD RENOS RENTE RENTS RENTZ REOIL REONG REOPS REORG REPAT REPAY REPEG REPEL REPEW REPIN REPKA REPKO REPLA REPLY REPOD REPOP REPOS REPOT REPPS REPRO REPUB REPUG REPUT RERAN RERES RERIG RERUB RERUN RESAT RESAW RESAY RESCH RESED RESEE RESEL RESEN RESER RESES RESET RESEW RESIA RESIN RESIP RESIT RESOD RESOW RESPA RESPS RESTO RESTS RESTY RESUB RESUE RESUM RESUS RETAG RETAP RETAR RETAX RETCH RETEE RETEM RETES RETEX RETHE RETIA RETIC RETIE RETIP RETOX RETRO RETRY REUEL REULE REUME REUNE REUPS REUSE REVAL REVEL REVER REVES REVET REVIE REVIS REVOW REVUE REVVY REWAX REWED REWET REWIN REWIS REWLE REWON REXES REYER REYES REYEZ REYNS REYSE REYSH REZAC REZAS REZES REZIP RFIDS RFLPS RHABD RHADE RHAME RHEAN RHEAS RHEES RHEIC RHEID RHEIN RHEME RHEMS RHETT RHEUM RHEWS RHIAN RHIBS RHIME RHINE RHINO RHODA RHODE RHODO RHODY RHOMB RHONA RHONE RHOVA RHUDY RHUES RHUMB RHYME RHYNE RHYTA RIAAN RIADS RIALS RIANT RIATA RIBAT RIBBE RIBBY RIBIN RICCA RICCO RICED RICER RICES RICHE RICHY RICIN RICKE RICKI RICKS RICKY RIDED RIDEN RIDER RIDES RIDGE RIDGY RIDIC RIEBE RIECK RIEDL RIEDY RIEHL RIEKE RIELS RIEMS RIERA RIETH RIETI RIFAS RIFED RIFER RIFFS RIFFY RIFLE RIFTS RIGAN RIGBY RIGEL RIGGI RIGGS RIGHT RIGID RIGOL RIGOR RIHAS RIJOS RIKER RIKES RILED RILES RILEY RILIE RILLA RILLE RILLS RILLY RIMAE RIMED RIMEL RIMER RIMES RIMIC RIMUS RINCK RINDS RINDY RINED RINER RINES RINEY RINGE RINGO RINGS RINGY RINIS RINKE RINKS RINNS RINOS RINSE RIOJA RIONI RIOTS RIPED RIPEN RIPER RIPES RIPON RIPPS RIPPY RISAN RISCH RISCS RISED RISEL RISEN RISER RISES RISHI RISKE RISKO RISKS RISKY RISON RISOS RISPS RISTS RITCH RITER RITES RITHE RITHS RITSA RITUX RITZY RIUER RIVAL RIVAS RIVED RIVEL RIVEN RIVER RIVES RIVET RIVKA RIVNE RIWON RIXON RIYAL RIYAZ RIYOS RIZAL RIZER RIZKS RIZLA RIZOM RIZVI RIZZA RIZZI RMSES RNASE RNTPS RNUTR ROACH ROADS ROAMS ROANA ROANE ROANS ROAPS ROARK ROARS ROAST ROATH ROATS ROAVE ROBAB ROBAK ROBBI ROBBO ROBBS ROBBY ROBED ROBEL ROBES ROBEY ROBIE ROBIN ROBLE ROBOT ROBYN ROCAS ROCCO ROCHA ROCHE ROCKS ROCKY ROCOA ROCTA RODDA RODDS RODDY RODEN RODEO RODER RODES RODGE ROEHL ROEHM ROEHR ROELL ROENA ROENS ROGAN ROGEL ROGEN ROGER ROGET ROGGE ROGOR ROGUE ROGUY ROHAN ROHDE ROHES ROHLS ROHNS ROHOB ROHUS ROIAL ROICC ROIDS ROILS ROILY ROINS ROIST ROJAK ROJOS ROKER ROKES ROKOS ROLAG ROLAP ROLED ROLEN ROLES ROLEX ROLEY ROLFE ROLFS ROLIN ROLLA ROLLO ROLLS ROLLY ROLON ROLPH ROMAL ROMAN ROMAS ROMBO ROMEO ROMER ROMES ROMEX ROMIC ROMIG ROMPS ROMPU ROMPY RONAN RONDA RONDE RONDO RONEO RONES RONEY RONGA RONIN RONKO RONKS RONNY RONTS ROODS ROODY ROOFE ROOFS ROOFY ROOKE ROOKS ROOKY ROOLS ROOME ROOMS ROOMY ROOPS ROOPY ROOSE ROOST ROOTS ROOTY ROPAR ROPED ROPER ROPES ROPEY ROQUE RORAL RORIC RORID RORIE ROROS RORTS RORTY ROSAL ROSAS ROSCA ROSED ROSEL ROSEN ROSER ROSES ROSET ROSID ROSIE ROSIN ROSSO ROSTI ROSTS ROTAL ROTAN ROTAS ROTED ROTES ROTFL ROTHE ROTIS ROTLS ROTON ROTOR ROTTA ROTTS ROTTY ROTZO ROUEN ROUES ROUET ROUFS ROUGE ROUGH ROULE ROUND ROUNS ROUPE ROUPS ROUPY ROUSE ROUSH ROUST ROUTE ROUTH ROUTS ROUTT ROUZE ROVAL ROVED ROVER ROVES ROVNO ROWAN ROWDY ROWED ROWEL ROWEN ROWER ROWIE ROWKA ROWSE ROWTS ROYAL ROYCE ROYES ROYLE ROYSE ROZAS ROZEK ROZOS ROZZI RPERS RPGER RRATS RRAUP RRNAS RRSPS RSEOH RSFSR RSMLT RSPCA RSVPS RTECS RTSED RTSES RTTYS RUACH RUAKH RUANA RUANE RUANO RUANS RUARK RUBAB RUBAI RUBEN RUBES RUBIN RUBIO RUBLE RUBLI RUBLU RUBOR RUBUS RUCCI RUCHE RUCKS RUDAS RUDDS RUDDY RUDEN RUDER RUDGE RUDIE RUDIN RUDRA RUEDA RUEHL RUELS RUFFE RUFFO RUFFS RUFFY RUFIE RUFOL RUFUS RUGAE RUGAL RUGBY RUGEN RUGER RUGES RUGGS RUGHS RUGIA RUGIN RUHLS RUHNU RUIBE RUINE RUING RUINS RUISM RUIST RUJAK RUKHS RULED RULER RULES RULEY RULEZ RULLO RUMAL RUMBA RUMBO RUMEN RUMER RUMEX RUMLY RUMMY RUMOR RUMPH RUMPS RUMPY RUNCH RUNDI RUNDS RUNED RUNER RUNES RUNET RUNGS RUNGU RUNIC RUNKS RUNNS RUNNY RUNTE RUNTS RUNTY RUNUP RUNZA RUOFF RUPAR RUPEE RUPEL RUPES RUPIA RUPIS RUPLE RUPPE RUPPS RURAL RURUS RUSCH RUSED RUSES RUSHY RUSKS RUSKY RUSMA RUSSO RUSTO RUSTS RUSTY RUSUL RUSYN RUTAN RUTIN RUTTY RUTUL RUVID RUYLE RVING RWNJS RYALL RYALS RYANN RYANS RYBAK RYBAS RYDEN RYDER RYERS RYGHT RYKER RYLAN RYLEE RYLEN RYLES RYLEY RYLIE RYMAN RYMER RYNDS RYNGE RYONS RYOTS RYPES RYSER RYTHS SAADH SAADS SAAFA SAALA SAALE SAAMI SABAH SABAL SABAN SABAR SABAT SABBS SABEL SABER SABHA SABIA SABIC SABIN SABIO SABIR SABJI SABLE SABLY SABOL SABOS SABOT SABRA SABRE SABZI SACAE SACAR SACBE SACCI SACCO SACDS SACHS SACKS SACKT SACRA SACRE SACTO SADAS SADDO SADED SADEK SADES SADET SADHE SADHS SADHU SADIA SADIC SADIE SADIK SADIQ SADIS SADLY SADZA SAEED SAEKO SAENZ SAETA SAFAR SAFED SAFEN SAFER SAFES SAFFA SAFFI SAFFY SAFIS SAFRE SAFWA SAGAN SAGAR SAGAS SAGED SAGER SAGES SAGEY SAGGY SAGOS SAGUM SAGUN SAHAR SAHAS SAHDS SAHEB SAHEL SAHIB SAHIH SAHIN SAHLI SAHMS SAHNI SAHOS SAHPS SAHRS SAHUI SAHUL SAHUR SAHUS SAIAS SAICE SAICK SAICS SAIDA SAIDE SAIDS SAIES SAIFS SAIGA SAIGE SAIKI SAILE SAILS SAILY SAIMS SAINE SAINS SAINT SAINZ SAIPH SAIST SAITH SAITO SAIVA SAJOU SAKAI SAKAS SAKAU SAKER SAKES SAKHA SAKIA SAKIS SAKRA SAKTA SAKTI SAKWA SALAD SALAF SALAH SALAK SALAL SALAM SALAS SALAT SALAZ SALCE SALEB SALEH SALEM SALEP SALES SALET SALIB SALIC SALII SALIM SALIX SALLA SALLE SALLY SALMA SALMI SALMO SALMS SALOL SALON SALOP SALOU SALPA SALPS SALSA SALSE SALTA SALTS SALTY SALTZ SALVA SALVE SALVI SALVO SAMAD SAMAJ SAMAN SAMAR SAMAS SAMBA SAMBO SAMEE SAMEK SAMEY SAMFU SAMIC SAMIR SAMIS SAMMI SAMMO SAMMY SAMOA SAMOS SAMPA SAMPI SAMPS SAMRA SANAA SANDA SANDE SANDO SANDS SANDT SANDY SANER SANFL SANGA SANGO SANGU SANHE SANJO SANKS SANKY SANMA SANNA SANNY SANON SANOS SANOW SANSA SANTA SANTI SANTS SANTY SANUR SANYA SANZA SAOLA SAONE SAPAN SAPID SAPOL SAPOR SAPPS SAPPY SARAF SARAH SARAI SARAN SARAY SARDO SARDS SAREE SARGE SARGO SARIC SARIN SARIS SARKS SARKY SARMA SARNA SARNS SAROD SAROH SAROI SARON SAROS SAROT SARPE SARPY SARRE SARRO SARRS SARSA SARSE SARSI SARTI SARTS SARVE SARVO SARWA SARZA SASAE SASAK SASER SASES SASHA SASIN SASSE SASSO SASSY SATAI SATAN SATAY SATCH SATED SATEM SATER SATES SATIN SATOS SATUN SATYR SAUBA SAUCE SAUCY SAUDI SAUER SAUGA SAUKS SAULE SAULS SAULT SAUNA SAUNF SAUNG SAUNS SAURY SAUTE SAUVE SAVAK SAVED SAVER SAVES SAVIN SAVOR SAVOY SAVVA SAVVY SAWAH SAWCE SAWCY SAWED SAWER SAWIN SAWNY SAXBY SAXED SAXES SAXIS SAXON SAYDE SAYED SAYEN SAYER SAYES SAYID SAYON SAYRE SAYSO SAYST SAYTH SAZAN SAZES SCABS SCADA SCADS SCAGS SCALA SCALD SCALE SCALF SCALI SCALL SCALP SCALY SCAMP SCAMS SCANO SCANS SCANT SCAPE SCAPI SCAPS SCARD SCARE SCARF SCARI SCARN SCARP SCARS SCART SCARY SCATE SCATH SCATS SCATT SCAUP SCAUR SCAVO SCAWS SCBAS SCBUS SCDMA SCDNA SCEAS SCEAT SCELP SCENA SCEND SCENE SCENT SCERN SCHAD SCHAH SCHAK SCHAV SCHER SCHMO SCHON SCHOR SCHOW SCHUG SCHUM SCHUR SCHUT SCHWA SCHWI SCIDS SCIEN SCIMA SCINK SCION SCIOT SCISE SCISM SCISS SCITA SCJDS SCJPS SCLAV SCOAT SCOBE SCOBS SCOBY SCOFF SCOKE SCOLA SCOLD SCOMM SCOMO SCONE SCONS SCOON SCOOP SCOOT SCOPA SCOPE SCOPS SCOPY SCORE SCORM SCORN SCOSS SCOTE SCOTS SCOTT SCOUR SCOUT SCOVE SCOWL SCOWS SCPHN SCRAB SCRAG SCRAM SCRAN SCRAP SCRAT SCRAW SCRAY SCREE SCREW SCRID SCRIM SCRIP SCRNA SCROD SCROG SCROW SCRUB SCRUM SCUBA SCUDI SCUDO SCUDS SCUFF SCUFT SCUGS SCULD SCULK SCULL SCULP SCUMS SCUNS SCUPS SCURF SCURS SCUTA SCUTE SCUTS SCUTT SCUZZ SCYEN SCYES SCYLE SCYON SCYTH SDAER SDOWN SDRAM SDRNA SDWTS SEAGO SEAHS SEALE SEALS SEALY SEAMS SEAMY SEANS SEARL SEARS SEASE SEATO SEATS SEAVE SEAVY SEAZE SEBAT SEBIC SEBID SEBIL SEBUM SECAM SECCO SECKS SECLE SECOR SECTS SECUS SEDAN SEDER SEDES SEDEX SEDGE SEDGY SEDNA SEDOR SEDUM SEECH SEEDE SEEDS SEEDY SEEIN SEEKE SEEKH SEEKS SEELS SEELY SEEME SEEMS SEENE SEENS SEENT SEEPS SEEPY SEERS SEERY SEESE SEEST SEETA SEETH SEEYA SEGAL SEGAR SEGAS SEGER SEGNI SEGNO SEGOL SEGOS SEGOU SEGRA SEGUE SEGUI SEHAR SEHRA SEIBS SEIDL SEIFS SEIGE SEIJI SEIKO SEINE SEINS SEIPP SEIPS SEISE SEISM SEITH SEITY SEITZ SEIZA SEIZE SEJID SEJMS SEKOI SEKOS SELAH SELBY SELES SELFE SELFS SELFY SELIG SELKE SELKS SELLA SELLE SELLO SELLS SELLY SELMA SELPH SELVA SEMAJ SEMAN SEMBA SEMED SEMEE SEMEN SEMES SEMEY SEMIC SEMIS SEMLA SEMON SEMUR SENAT SENCE SENCH SENCO SENDE SENDS SENES SENET SENEY SENFT SENGA SENGI SENGS SENIE SENKO SENNA SENNE SENOI SENOR SENSE SENSI SENTE SENTI SENTS SENTZ SENVY SEOUL SEPAL SEPAT SEPES SEPIA SEPIC SEPON SEPOY SEPPO SEPTA SEPTS SEPUH SERAC SERAI SERAL SERBS SERCA SERDA SERER SERES SERFS SERGE SERIC SERIE SERIF SERIN SERIR SERIS SERJA SERMS SERNA SERON SEROW SERPS SERRS SERRY SERTS SERUE SERUM SERVE SERVO SERVS SERYL SERZH SESAY SESHA SESIA SESMA SESSA SESTA SETAE SETAL SETAR SETAS SETEE SETER SETHI SETIM SETON SETOS SETTS SETTY SETUP SEUEN SEVAN SEVEN SEVER SEVYS SEWED SEWEL SEWEN SEWER SEWIN SEXED SEXER SEXES SEXET SEXLY SEXPS SEXTE SEXTO SEXTS SEYDE SEYNT SEYON SEZEE SFAIK SFIHA SFNAL SFSRS SGACC SGRAM SGRNA SGROI SGROS SHABO SHABS SHABU SHACK SHADE SHADS SHADY SHAEF SHAFI SHAFT SHAGS SHAHI SHAHS SHAHY SHAIK SHAIL SHAIN SHAKA SHAKE SHAKO SHAKU SHAKY SHALA SHALE SHALK SHALL SHALM SHALT SHALY SHAMA SHAME SHAMP SHAMS SHAMY SHANA SHAND SHANE SHANG SHANI SHANK SHANS SHANT SHAOS SHAPE SHAPS SHAPT SHARD SHARE SHARI SHARK SHARM SHARN SHARP SHART SHASH SHASS SHATS SHATT SHAUB SHAUL SHAUN SHAVE SHAWL SHAWM SHAWN SHAWS SHAYA SHAYE SHAYS SHCHA SHCHI SHDNA SHEAD SHEAF SHEAL SHEAN SHEAR SHEAS SHEAT SHEAY SHEBA SHECK SHEDD SHEDS SHEEN SHEEP SHEER SHEET SHEFA SHEFF SHEHE SHEIK SHELD SHELF SHELL SHEMA SHEMU SHEND SHENG SHENK SHENS SHENT SHEOL SHEPS SHERD SHERI SHERK SHERN SHERO SHERS SHETH SHETS SHEVA SHEWA SHEWE SHEWN SHEWS SHIAH SHIAS SHIBE SHICK SHIDE SHIDO SHIDS SHIED SHIEH SHIEL SHIER SHIES SHIFE SHIFT SHIFU SHIGA SHIHS SHIJO SHIKO SHILD SHILF SHILL SHILY SHIMA SHIMP SHIMS SHINA SHINE SHINK SHINN SHINS SHINY SHIOK SHIPE SHIPP SHIPS SHIRA SHIRE SHIRK SHIRL SHIRO SHIRR SHIRT SHISH SHISO SHIST SHITE SHITO SHITS SHITY SHIUR SHIUS SHIVA SHIVE SHIVS SHIZZ SHLEP SHLLV SHLUB SHMIR SHMOE SHMOO SHMUK SHMUP SHNOR SHOAD SHOAF SHOAH SHOAL SHOAR SHOAT SHOBE SHOCK SHODE SHOED SHOER SHOES SHOEY SHOFF SHOGI SHOGS SHOJI SHOJO SHOLA SHOLD SHOLE SHOLL SHOMA SHOMO SHOMU SHONA SHONE SHONK SHONS SHOOD SHOOK SHOOL SHOON SHOOP SHOOS SHOOT SHOPE SHOPS SHORB SHORE SHORL SHORN SHORT SHORY SHOTA SHOTE SHOTS SHOTT SHOUP SHOUT SHOVE SHOWN SHOWS SHOWY SHOYU SHRAB SHRAG SHRAM SHRAP SHRED SHREK SHREW SHRNA SHROG SHROW SHRUB SHRUG SHRUM SHSPS SHTIK SHTML SHTOF SHTUM SHTUP SHTUS SHUAH SHUAR SHUBI SHUCK SHUDE SHUDO SHUDS SHUES SHUEY SHUFF SHUGS SHUKA SHULD SHULI SHULL SHULS SHUMS SHUNK SHUNS SHUNT SHUPE SHUPP SHURA SHURE SHUSH SHUTE SHUTS SHUTT SHVAS SHWAG SHWAS SHYER SHYLA SHYLY SIADH SIAFU SIANG SIANO SIANY SIBEH SIBIA SIBIU SIBYL SICAS SICCA SICEL SICES SICHS SICHT SICKE SICKO SICKS SICKY SICLE SIDAL SIDAS SIDED SIDER SIDES SIDEY SIDHE SIDHU SIDLE SIDON SIDOR SIDTH SIECK SIEGE SIENA SIENS SIENT SIERS SIEVA SIEVE SIEZE SIFAC SIFTS SIFUS SIGGY SIGHS SIGHT SIGIL SIGLA SIGMA SIGNA SIGNE SIGNS SIHRS SIIRT SIJOS SIKAR SIKAS SIKED SIKER SIKES SIKHI SIKHS SILAS SILAT SILDS SILED SILEO SILER SILES SILEX SILKE SILKS SILKY SILLA SILLS SILLY SILOS SILTS SILTY SILVA SILYL SIMAP SIMAR SIMAS SIMBA SIMEK SIMES SIMIC SIMIT SIMMS SIMON SIMPS SIMPY SIMUL SIMUN SINAI SINCE SINCH SINDE SINDH SINDI SINES SINEW SINGE SINGH SINGS SINHA SINIC SINIK SINKO SINKS SINKT SINNE SINNY SINOP SINOR SINTA SINTI SINTO SINTU SINUS SIONS SIOUX SIPED SIPES SIPID SIPLE SIPPS SIPPY SIRAC SIRAS SIRED SIREE SIREN SIRES SIRIH SIRIS SIRJI SIRMA SIRNA SIROC SIRON SIROP SIRTE SIRTF SIRUP SIRVA SISAK SISAL SISAM SISCO SISEL SISES SISIG SISKA SISKS SISON SISSY SISTA SISTI SISTS SITAR SITCH SITED SITES SITHE SITHS SITKO SITUP SITUS SIVAK SIVAN SIVER SIXER SIXES SIXMO SIXTE SIXTH SIXTY SIYAN SIZAR SIZED SIZEL SIZER SIZES SIZEY SKAAR SKAGS SKAIL SKAIN SKALD SKALL SKANK SKARE SKARN SKART SKATE SKATS SKATT SKAWS SKEAN SKEAR SKEDS SKEED SKEEL SKEEN SKEER SKEES SKEET SKEGS SKEIN SKELL SKELP SKENE SKENG SKENS SKEOS SKEPS SKETE SKETS SKEWB SKEWS SKIBA SKIDI SKIDS SKIED SKIER SKIES SKIEY SKIFF SKIFT SKIIS SKILL SKILS SKIMP SKIMS SKINK SKINS SKINT SKIOS SKIPS SKIPT SKIRL SKIRR SKIRT SKITE SKITS SKIVE SKLAR SKOAL SKODA SKOGS SKOLL SKOLS SKOLT SKOOG SKOOL SKORT SKOSH SKOTS SKOUT SKOWS SKRIK SKRIM SKRRT SKUAS SKUDS SKUED SKUES SKUGS SKULD SKULK SKULL SKUMS SKUNK SKURF SKUTE SKYED SKYES SKYEY SKYFS SKYLA SKYLY SKYPE SKYTE SLABS SLABY SLACK SLADE SLAGS SLAIE SLAIN SLAKE SLAMA SLAMS SLANE SLANG SLANK SLANS SLANT SLAPE SLAPP SLAPS SLAPT SLASH SLATE SLATS SLATT SLATY SLAVA SLAVE SLAVI SLAVS SLAWS SLAYN SLAYS SLAZY SLBMS SLCMS SLEAN SLEBS SLECK SLEDD SLEDS SLEEK SLEEP SLEES SLEET SLEID SLEMP SLENT SLEPT SLEWS SLEYS SLICE SLICH SLICK SLIDE SLIDY SLIER SLIGH SLIGO SLIKE SLILY SLIME SLIMS SLIMY SLING SLINK SLIPE SLIPS SLIPT SLISH SLITE SLITS SLIVA SLIVE SLIWA SLOAM SLOAN SLOAT SLOBS SLOCK SLOES SLOGO SLOGS SLOID SLOJD SLOKA SLOKE SLOMO SLONE SLOOD SLOOM SLOOP SLOOS SLOOT SLOPE SLOPS SLOPY SLORA SLORC SLORE SLOSH SLOTH SLOTS SLOVE SLOWN SLOWS SLOYD SLUBS SLUDS SLUED SLUES SLUFF SLUFS SLUGS SLUMP SLUMS SLUNE SLUNG SLUNJ SLUNK SLUNT SLURP SLURS SLUSH SLUTS SLUTT SLYER SLYES SLYLY SLYMY SLYPE SMAAK SMACK SMAIL SMAKE SMALE SMALL SMALT SMARK SMARM SMARR SMART SMASH SMAZE SMBSS SMEAC SMEAD SMEAL SMEAR SMEES SMEGS SMELL SMELT SMERD SMERK SMEWS SMEXY SMICK SMIDT SMIFT SMILE SMILT SMIRK SMIRT SMITE SMITH SMITS SMITT SMIZE SMLES SMOAK SMOCK SMOFS SMOGS SMOKE SMOKO SMOKY SMOLA SMOLT SMOOR SMOOT SMORC SMORE SMOTE SMOUT SMRNA SMSAS SMSED SMSES SMSFS SMUGS SMUON SMURF SMURS SMUSH SMUTS SMYTH SNACK SNAFU SNAGS SNAIL SNAKE SNAKY SNAPE SNAPP SNAPS SNAPT SNARE SNARF SNARK SNARL SNARR SNARS SNARY SNASH SNAST SNATH SNAYS SNEAD SNEAK SNEAP SNEBS SNECK SNEDS SNEED SNEER SNEES SNELL SNERK SNERT SNETS SNEWS SNIBS SNICE SNICK SNIDE SNIED SNIES SNIFF SNIFT SNIGG SNIGS SNIKT SNIPE SNIPS SNIPY SNIRT SNITE SNITS SNITZ SNIVE SNIZZ SNOAD SNOBS SNODS SNOEK SNOFF SNOGS SNOHR SNOIL SNOKE SNOOD SNOOF SNOOK SNOOL SNOOP SNOOT SNORE SNORT SNOTS SNOUT SNOWE SNOWL SNOWN SNOWS SNOWY SNOZE SNRIS SNRNA SNRNP SNUBA SNUBS SNUCK SNUFF SNUGS SNURF SNUSS SNYES SOADY SOAKS SOAKT SOAKY SOALS SOAMS SOANE SOAPS SOAPY SOARS SOAVE SOBBY SOBEK SOBEL SOBER SOBHD SOBOL SOCAL SOCBS SOCCA SOCHA SOCHI SOCIA SOCIE SOCII SOCIO SOCKO SOCKS SOCKY SOCLE SOCOM SODAS SODDY SODER SODHI SODIC SODOM SODOR SOFAR SOFAS SOFER SOFFI SOFIA SOFIC SOFIS SOFKY SOFTA SOFTS SOFTY SOFUE SOGER SOGGY SOHAL SOHCS SOHLS SOILE SOILS SOILY SOINI SOJER SOJKA SOKAR SOKEN SOKES SOKOL SOKOP SOKOS SOLAH SOLAN SOLAR SOLDI SOLDO SOLED SOLEI SOLEM SOLEN SOLER SOLES SOLFA SOLID SOLIE SOLIN SOLIS SOLIZ SOLLY SOLON SOLOS SOLUM SOLVE SOLVI SOLWY SOMAJ SOMAL SOMAS SOMEN SOMER SOMMA SOMME SOMMY SOMON SONAR SONAS SONDE SONES SONET SONGE SONGS SONGY SONIA SONIC SONIS SONJA SONJO SONLY SONNE SONNY SONSY SONYA SOOEE SOOEY SOOJI SOOKS SOOKY SOOLS SOONE SOONG SOONS SOOPS SOOQS SOORD SOOTH SOOTS SOOTY SOPER SOPES SOPHA SOPHI SOPHS SOPHY SOPJE SOPKO SOPOR SOPOS SOPPY SOPVS SORAL SORAS SORBS SORCE SORDS SORED SOREE SOREL SORER SORES SOREY SORFS SORGO SORGS SORIA SORIE SORNA SORNS SOROR SOROS SORRA SORRS SORRY SORTA SORTE SORTO SORTS SORUS SOSES SOTHE SOTHO SOTOL SOTON SOTTO SOTUS SOUCE SOUCY SOUDA SOUGH SOUKS SOULE SOULS SOUND SOUPS SOUPY SOUQS SOURS SOUSA SOUSE SOUTH SOUTO SOUZA SOVAS SOVKI SOVOK SOWAR SOWAS SOWCE SOWED SOWEI SOWEN SOWER SOWLE SOWLS SOWRE SOWSE SOWTH SOWWY SOYLE SOYUZ SOZAS SOZZI SPAAD SPACE SPACK SPACY SPADA SPADE SPADO SPADS SPADY SPAES SPAFF SPAHI SPAHN SPAHR SPAID SPAIN SPAKE SPAKY SPALD SPALE SPALL SPALT SPAMS SPANE SPANG SPANK SPANO SPANS SPARE SPARK SPARR SPARS SPARY SPASM SPATE SPATH SPATS SPAUG SPAVE SPAWL SPAWN SPAWS SPAYS SPAZA SPAZM SPAZZ SPCUM SPEAK SPEAN SPEAR SPECI SPECK SPECS SPECT SPEDE SPEED SPEEL SPEER SPEET SPEIR SPEKE SPELD SPELK SPELL SPELT SPEND SPENT SPEOI SPEOS SPERA SPERE SPERG SPERL SPERM SPERO SPESH SPETH SPETS SPEWN SPEWS SPEWY SPHEX SPIAL SPICA SPICE SPICK SPICS SPICY SPIDE SPIED SPIEL SPIER SPIES SPIFE SPIFF SPIKE SPIKY SPILE SPILL SPILT SPIME SPIMS SPINA SPINE SPING SPINK SPINO SPINS SPINY SPIRE SPIRO SPIRT SPIRY SPISS SPITE SPITS SPITZ SPIVA SPIVS SPLAT SPLAY SPLIF SPLIT SPLOG SPLOT SPOAK SPOCK SPODS SPOFS SPOHN SPOIL SPOKE SPOLE SPONG SPONK SPOOF SPOOK SPOOL SPOOM SPOON SPOOR SPORE SPORK SPORT SPOSE SPOSH SPOTO SPOTS SPOUT SPPSS SPRAD SPRAG SPRAT SPRAY SPRED SPREE SPREP SPREW SPRIG SPRIT SPROC SPROD SPROG SPROW SPRUE SPRUG SPRYS SPUDS SPUED SPUES SPUIS SPUME SPUMY SPUNK SPUNS SPURN SPURR SPURS SPURT SPUTA SPUTE SPUTS SPYAL SPYCE SPYED SPYRE SQEPS SQRRR SQUAB SQUAD SQUAM SQUAT SQUAW SQUEE SQUEG SQUIB SQUID SQUIG SQUIR SQUIT SQUIZ SQUOP SQUSH SRAAM SRAMS SRANG SRBMS SREYS SRLVS SRNAS SROKA SRSES SRSGS SRSLY SRUTI SSADM SSADV SSBBW SSBIS SSBNS SSDLS SSDNA SSEES SSGNS SSHED SSHES SSHRC SSIDS SSNRI SSRES SSRIB SSRIS SSRNA SSRNS SSSIS SSTHS STAAB STABS STACE STACI STACK STACY STADE STAFF STAGE STAGG STAGS STAGY STAHL STAHR STAIB STAID STAIL STAIN STAIR STAKE STALE STALK STALL STAME STAMP STAMS STAND STANE STANG STANK STANO STANS STAPE STAPH STAPP STAPS STARE STARK STARN STARR STARS START STARY STASH STASI STATE STATS STATZ STAUS STAVE STAWS STAYS STDIN STEAD STEAK STEAL STEAM STEAN STEAR STECK STECS STEDS STEDY STEED STEEK STEEL STEEM STEEN STEEP STEER STEES STEEZ STEFF STEGS STEHR STEIB STEIK STEIL STEIN STELA STELE STELL STEME STEMM STEMS STEND STENO STENT STEPH STEPP STEPS STEPT STERE STERK STERN STETS STETZ STEVE STEVO STEWS STEWY STEYS STFAN STFEN STFSY STHAL STIAN STICE STICH STICK STIED STIES STIFF STIGS STIHL STIKE STILB STILE STILI STILL STILP STILT STIME STIMS STIMY STING STINK STINT STIOB STIPE STIPP STIPS STIRK STIRP STIRS STITH STITT STIVE STIVY STOAE STOAI STOAK STOAS STOAT STOBS STOCK STOEP STOGA STOGY STOHR STOIC STOKE STOLA STOLE STOLI STOLL STOLN STOLP STOMA STOMP STOND STONE STONG STONK STONY STOOD STOOK STOOL STOOM STOOP STOOR STOPE STOPS STOPT STORE STORK STORM STORY STORZ STOSH STOSS STOTE STOTS STOTT STOTZ STOUK STOUP STOUR STOUT STOVE STOVL STOWE STOWP STOWS STOYS STRAD STRAM STRAP STRAT STRAW STRAY STREB STREP STREW STREY STRIA STRID STRIG STRIM STRIP STRIX STROG STROP STROW STROY STRUB STRUM STRUT STUBS STUCK STUDS STUDT STUDY STUFA STUFF STUFT STUHR STUIE STUKA STUKE STULL STULM STULP STULT STUMP STUMS STUNG STUNK STUNS STUNT STUPA STUPE STURB STURK STURS STURT STUSH STUTS STYCA STYED STYEN STYER STYES STYLE STYLI STYLL STYLO STYME STYMY STYPE SUADE SUAGE SUAKU SUANT SUAVE SUAVO SUAZO SUBAH SUBBY SUBER SUBIA SUBJS SUBMM SUBZI SUCAN SUCCI SUCHY SUCKA SUCKS SUCKT SUCKY SUCOS SUCRE SUDAK SUDAN SUDDS SUDEP SUDER SUDHA SUDIP SUDOL SUDOR SUDRA SUDSY SUEBI SUEDE SUENS SUENT SUERO SUERS SUEST SUETH SUETS SUETY SUEVI SUFIC SUFIS SUGAN SUGAR SUGGS SUGOI SUGYA SUHRS SUHUR SUIDS SUINE SUING SUINT SUIRE SUIST SUITE SUITS SUITT SUJEE SUJUD SUJUK SUKEY SUKHS SUKIE SUKIS SUKLA SUKUK SUKUN SULAK SULCI SULEN SULEV SULFA SULID SULKS SULKY SULLS SULLY SULUS SUMAC SUMAN SUMAT SUMER SUMET SUMIE SUMMA SUMMY SUMON SUMOS SUMPH SUMPS SUMPY SUMTI SUNDA SUNDE SUNGA SUNGS SUNIS SUNJA SUNLY SUNNA SUNNE SUNNI SUNNY SUNUP SUOMI SUONA SUPER SUPES SUPPA SUPRA SURAH SURAJ SURAL SURAS SURAT SURAU SURDS SUREN SURER SURFS SURFY SURGE SURGY SURIN SURIS SURLA SURLY SURMA SUROZ SURRA SURRY SURSY SURUC SURYA SUSAN SUSEL SUSHI SUSIE SUSKI SUSPS SUSSO SUSTO SUSUS SUSYS SUTER SUTLE SUTOR SUTRA SUTTA SUWAR SUWON SUYAS SUYOG SUZIE SVANS SVECS SVELT SVETA SVOIP SWABE SWABS SWABY SWACK SWADS SWAGE SWAGS SWAIM TROWL TROWS TRRNA TRSES TRUAR TRUAX TRUBS TRUBY TRUCE TRUCK TRUDI TRUDY TRUED TRUEL TRUER TRUES TRUEX TRUGG TRUGS TRUKU TRULL TRULY TRUMP TRUNK TRURO TRUSS TRUST TRUTH TRUTI TRYAL TRYED TRYER TRYMA TRYNA TRYON TRYST TRYTE TSADE TSADI TSAIS TSAMA TSANG TSANS TSAOS TSARS TSCHK TSEBE TSENG TSHEG TSINE TSKED TSMVS TSOIS TSOPK TSUBA TSUBO TSUGA TSUIS TSUJI TSUNA TSUNS TSZUJ TTBAR TTEOK TTHMS TTIPS TUANS TUART TUATH TUBAE TUBAL TUBAR TUBAS TUBAX TUBBO TUBBS TUBBY TUBED TUBER TUBES TUBOG TUCAN TUCET TUCKS TUCUM TUDOR TUDUN TUELL TUELS TUFAS TUFFS TUFTS TUFTY TUGGY TUGRA TUILE TUINA TUISM TUITE TUITS TUJIA TUKEY TUKUL TULES TULEY TULIA TULIP TULKA TULKU TULLE TULLS TULLY TULOU TULPA TULSA TULSI TUMAH TUMBI TUMEN TUMEY TUMID TUMIS TUMMO TUMMY TUMOR TUMPS TUMPY TUNAS TUNED TUNER TUNES TUNGA TUNGS TUNIC TUNIS TUNKS TUNKU TUNNY TUOBA TUOHY TUPAN TUPAS TUPEK TUPIK TUPLE TUPOS TUPOU TUQUE TURAN TURAY TURBO TURCA TURCO TURDS TURDY TURFS TURFY TURGS TURIA TURIN TURIO TURIS TURKE TURKO TURKS TURKU TURME TURMS TURNS TURNT TURPS TURRA TURSI TURUQ TUSAS TUSHY TUSKS TUSKY TUTEE TUTEN TUTES TUTIN TUTOR TUTOY TUTSI TUTTI TUTTS TUTTY TUTUS TUVAN TUXED TUXES TUXIE TUYAS TUYER TUZLA TWAIN TWANG TWANK TWATS TWEAG TWEAK TWEDT TWEEB TWEED TWEEK TWEEL TWEEN TWEEP TWEER TWEET TWELL TWERK TWERP TWICE TWICT TWIER TWIGG TWIGS TWILL TWILT TWIMC TWINE TWINK TWINS TWINY TWIPS TWIRE TWIRK TWIRL TWIRP TWISP TWIST TWITE TWITS TWIXT TWOCS TWOER TWOMP TWONK TWOTE TWOTH TWOTY TWUNT TWYER TXTED TYAGI TYAPS TYCHE TYCHO TYDES TYEES TYEKS TYERS TYETS TYGER TYING TYIYN TYKES TYLED TYLER TYLES TYLID TYLKA TYMES TYMPS TYNAN TYNED TYNER TYNES TYPAL TYPED TYPER TYPES TYPEY TYPIC TYPOS TYRAN TYRED TYREE TYRES TYROL TYRON TYROS TYRUS TYSON TYTHE TZADI TZARS UARTS UBACS UBAID UBEDA UBERS UBERT UBUME UBYKH UCAVS UCCJA UCHEE UCONN UDALF UDALL UDALS UDAND UDDER UDDIN UDELL UDEPT UDERT UDINE UDOLL UDONS UDPGA UDULT UDUPI UEDAS UELEN UFFDA UFTAA UGALI UGALS UGARI UGGED UGGER UGGLE UGGOS UGMOS UGRIC UHART UHERS UHHUH UHLAN UHLER UHLIG UHRIG UIGGS UIGUR UIIDS UINAL UKASE UKIAH UKIES UKIRT UKONU UKSSR UKWMO ULAID ULAMA ULANS ULCCS ULCER ULCUS ULDBS ULDLS ULEMA ULERY ULING ULIRG ULLAH ULLET ULLOA ULLOM ULMAN ULMIC ULMIN ULNAD ULNAE ULNAR ULNAS ULOCS ULOID ULPAN ULREY ULRIC ULSAN ULTRA ULUIT ULURU UMALI UMAMI UMANA UMARA UMARI UMARS UMASS UMBEL UMBER UMBOS UMBRA UMBRE UMBRI UMEDA UMEKO UMIAC UMIAK UMIAQ UMIAT UMICH UMIKO UMIST UMMAH UMMAS UMMED UMMON UMOCS UMPAN UMPED UMPPS UMPTY UMQAN UMRAH UNADD UNAGI UNAIS UNAMI UNANI UNAPT UNARC UNARM UNARY UNASK UNASS UNATE UNAUS UNBAG UNBAN UNBAR UNBAY UNBED UNBET UNBID UNBIT UNBOW UNBOX UNBOY UNCAL UNCAN UNCAP UNCIA UNCLE UNCLY UNCOY UNCTS UNCUP UNCUS UNCUT UNDAM UNDEF UNDER UNDEX UNDID UNDIE UNDIG UNDMS UNDOG UNDOS UNDRY UNDUE UNDUG UNDYE UNEAT UNEND UNFAT UNFED UNFIT UNFIX UNFOG UNFRY UNFUN UNGAG UNGAY UNGER UNGET UNGKA UNGOD UNGOT UNGUM UNGUT UNHAP UNHAT UNHEM UNHEX UNHID UNHIP UNHIT UNHOT UNHUG UNIAT UNIFY UNIKE UNION UNIOS UNISI UNITE UNITS UNITY UNIWB UNIXY UNJAM UNJAR UNJOY UNKED UNKEL UNKEN UNKET UNKEY UNKID UNKIE UNKLE UNLAP UNLAW UNLAX UNLAY UNLED UNLET UNLID UNLIT UNMAD UNMAN UNMAP UNMET UNMEW UNMIK UNMIX UNNUN UNODC UNODE UNOIL UNORN UNPAY UNPEG UNPEN UNPIN UNPOP UNPOT UNRAR UNRED UNREP UNRID UNRIG UNRIP UNRUG UNRUH UNRUN UNSAW UNSAY UNSEE UNSER UNSET UNSEW UNSEX UNSHY UNSIN UNSLY UNSUB UNTAG UNTAP UNTAR UNTAX UNTIE UNTIL UNTIN UNTYE UNUSE UNWAD UNWEB UNWED UNWET UNWIG UNWIN UNWIT UNWMK UNWON UNZIP UORFS UPALI UPBAR UPBOW UPCUT UPDIP UPDOG UPDOS UPDRY UPEND UPENN UPFIT UPFLY UPGMA UPGUN UPHAM UPHER UPJET UPLAY UPLED UPLIT UPOLU UPPAL UPPED UPPER UPPON UPRAN UPRUN UPSEE UPSES UPSET UPSEY UPSON UPSOT UPSUN UPTER UPTIE UPTIL UPTON UPUPA URAEI URALI URALS URARE URARI URASE URATE URBAN URBES URBEX URCEI URDEE UREAL UREAS UREDO UREIC UREID URENA UREYS URGED URGER URGES URIAH URIAL URIAS URIBE URICH URICK URIEL URIES URINE URING URITE URIUS URKAS URLAR URMAN URMIA URNAL URNAS URNED URNES UROPI URREA URSAL URSID URSIN URSON URTIS URUBU URUMI URUPA URUTS URVAS URVED URZUA USAAC USAAF USAAS USADA USAFA USAGE USAID USAMA USAMU USBEG USBEK USCGC USDOT USEES USERS USERY USEST USETH USHAS USHED USHER USHES USIAN USIES USING USMAN USMCA USMJP USNEA USNIC USNTS USONA USPIS USPPD USPTO USQUE USREY USRYS USSIE USTAD USTAV USTIC USTOX USUAL USURE USURP USURY UTADA UTAHN UTAHS UTCHY UTERI UTFSE UTHER UTHES UTHLU UTIAN UTIAS UTICA UTILE UTILS UTLEY UTSEY UTSUL UTTER UTZED UTZES UUERS UUIDS UUISM UVALA UVATE UVCES UVEAL UVEAN UVEAS UVITE UVULA UWAIS UYEDA UYEZD UYGUR UZBEG UZBEK UZSSR UZZAH UZZLE VAASA VAAYU VACAS VACAY VACCA VACEK VACUA VADAS VADED VADEL VADEN VADES VADGE VADUZ VAGAL VAGUE VAGUS VAHAG VAHAN VAHLE VAILS VAIRE VAIRS VAIRY VAJRA VAKAS VAKIL VALEK VALES VALET VALEW VALID VALIS VALKA VALKS VALLA VALOR VALPO VALPY VALSE VALUE VALVA VALVE VALVO VALYL VAMPS VAMPY VANAS VANCE VANCO VANED VANEK VANES VANGA VANGS VANIN VANIR VANJI VANNA VANNS VANTS VANYA VAPED VAPER VAPES VAPID VAPOR VARAN VARAS VARDO VARDY VAREC VARES VARGO VARIS VARIX VARNA VARNS VARON VAROS VARRY VARTS VARUN VARUS VARVE VASAL VASAS VASCO VASES VASEY VASID VASKE VASKO VASSE VASTA VASTS VASTU VASTY VASUS VATES VATHS VATIC VATOS VATUS VAULT VAUNT VAUSE VAUTS VAUTY VAVRA VAXEN VAYES VAZES VBACS VBIED VBLOG VCRNA VCSES VEACH VEALE VEALS VEALY VECHE VEDAS VEDDA VEDDY VEDGE VEDIC VEDRO VEELS VEENA VEEPS VEERS VEERY VEGAN VEGAS VEGES VEGGO VEGIE VEGOS VEIGA VEILS VEINS VEINY VEITH VEJAR VELAR VELCT VELDS VELDT VELES VELEZ VELIZ VELLA VELLS VELMA VELOZ VELUM VELVA VELYE VENAL VENAS VENDA VENDS VENEW VENEY VENGE VENGO VENIN VENOM VENOS VENTA VENTI VENTO VENTS VENUE VENUS VERAS VERBS VERDE VERDI VERGE VERIE VERMA VERNA VERNE VERRE VERRY VERSE VERSO VERST VERTS VERTU VERVE VERYE VESAK VESEY VESPA VESTA VESTS VETCH VETKA VETMB VETOS VETTE VEVAY VEVES VEXED VEXER VEXES VEZIR VFATS VHEMT VHLLS VHOST VHSES VHSIC VIALL VIALS VIANA VIAND VIARS VIARY VIAUS VIBED VIBES VIBEX VIBEY VICAR VICED VICES VICHY VICKI VICKY VICUS VIDAR VIDAS VIDDY VIDED VIDEO VIDES VIDIN VIDUI VIDYA VIELE VIELS VIENS VIERS VIETH VIEWS VIEWY VIFDA VIGAS VIGER VIGIA VIGIL VIGNA VIGOR VIGUE VIHAR VIJAY VIKAS VIKES VIKKI VIKKY VILAS VILDE VILED VILER VILLA VILLI VILLS VILNA VIMBA VIMEN VIMEO VIMPA VINAL VINAS VINAY VINCA VINCE VINED VINER VINES VINEW VINEY VINHO VINIC VINKS VINNY VINTS VINYL VIOLA VIOLS VIPER VIRAL VIRAY VIRCH VIRED VIREO VIRES VIRGA VIRGE VIRGO VIRID VIRII VIRJE VIRKS VIRLS VIRON VIRSC VIRSS VIRTU VIRUS VISAP VISAS VISCA VISCO VISCT VISED VISES VISHU VISIT VISNE VISON VISOR VISTA VISTO VITAE VITAL VITAS VITEK VITES VITEX VITKI VITRE VITTA VITTI VITTS VITUG VITUS VIURA VIURE VIVAR VIVAS VIVAT VIVDA VIVES VIVID VIXEN VIZIR VIZOR VJING VLACH VLANS VLCCS VLCEK VLDBS VLDLS VLEIS VLEYS VLIES VLOCS VLOGS VMPFC VNDER VOBLA VOCAB VOCAL VOCIN VODAN VODDY VODER VODKA VODOU VODUN VOELS VOGAD VOGAN VOGEL VOGGY VOGIE VOGLE VOGLS VOGUE VOGUL VOHRA VOICE VOICY VOIDE VOIDS VOIGT VOILA VOILE VOITS VOJKO VOKEN VOLAE VOLAR VOLCY VOLDS VOLDY VOLED VOLES VOLET VOLGA VOLGE VOLIA VOLIN VOLKS VOLLS VOLOF VOLOS VOLOW VOLPE VOLPI VOLTA VOLTE VOLTI VOLTS VOLTZ VOLVA VOLVE VOLVI VOLVO VOLYN VOMER VOMIC VOMIT VOMMA VONAS VONCE VONDA VONGS VOOKS VOONG VOPOS VORAS VORED VORES VOROS VOSES VOSIP VOTAW VOTED VOTER VOTES VOTHS VOTIC VOUCH VOUGE VOULA VOULD VOWED VOWEL VOWER VOWES VOXEL VOXES VOYCE VOYDE VOYDS VOYOL VOZHD VPPON VPSOS VRAIC VRAKA VRANA VRBAS VRBJE VRNAS VROCK VROOM VROUW VROWS VRSAR VRTRA VSATS VSEPR VSICS VSOPS VSPAN VTBLS VUGGS VUGGY VUGHS VUGHY VUKAC VULGO VULNS VULVA VUNJO VUONG VURPS VUSAS VWORP VXERS VYASA VYGIE VYING VYSAR VYTIN WAAAY WAAGS WAAMA WAAWS WACKE WACKO WACKS WACKY WADDS WADDY WADED WADER WADES WADGE WADIS WAEGS WAFER WAFFS WAFTS WAFTY WAGAH WAGAR WAGED WAGEL WAGER WAGES WAGGA WAGGE WAGGY WAGLE WAGON WAGYU WAHAB WAHBA WAHEY WAHID WAHOO WAIDE WAIDS WAIES WAIFS WAIFT WAIFU WAIFY WAILE WAILS WAINS WAINT WAIRS WAIST WAITE WAITS WAIVE WAJDA WAJIB WAKAS WAKAW WAKED WAKEN WAKER WAKES WAKEY WAKFS WAKIF WAKIL WALCH WALCK WALDO WALDS WALED WALER WALES WALIS WALKE WALKO WALKS WALKT WALLA WALLE WALLS WALLY WALMS WALPS WALRI WALSH WALTY WALTZ WAMES WAMPS WAMUS WANDA WANDE WANDS WANDY WANED WANEK WANES WANEY WANGA WANGO WANGS WANJI WANKA WANKE WANKS WANKY WANLI WANLY WANNA WANSE WANST WANTA WANTE WANTS WANTY WANZE WAPED WAPPS WAQFS WAQIF WARAL WARAY WARDA WARDS WARED WARES WAREZ WARFS WARGI WARGO WARGS WARIA WARKS WARLI WARLY WARMS WARMY WARNE WARNO WARNS WARPS WARRA WARRE WARRS WARRU WARRY WARSH WARTA WARTH WARTS WARTY WASCO WASES WASHI WASHT WASHY WASIK WASKO WASLA WASMS WASNA WASNT WASON WASPS WASPY WASSY WASTA WASTE WASTS WASTY WASUP WATAP WATCH WATER WATHA WATHE WATHS WATRY WATSU WATTS WAUGH WAUJA WAUKS WAULK WAULS WAVED WAVER WAVES WAVEY WAWED WAWES WAWLS WAXED WAXEN WAXER WAXES WAXIE WAYED WAYES WAYNE WAYNS WAYTS WAYUU WAZEE WAZIR WAZOO WAZUP WAZZA WBAFC WCDMA WDTHS WDYFW WEAKE WEAKY WEALD WEALE WEALS WEALY WEANS WEARE WEARS WEARY WEASE WEAST WEASY WEAVE WEAVY WEBBY WEBER WEBRE WECHT WECKS WEDGE WEDGY WEEBS WEEDS WEEDY WEEKE WEEKS WEELE WEELS WEELY WEENS WEENY WEEPE WEEPS WEEPY WEERO WEESE WEEST WEETS WEFIE WEFTS WEGER WEICK WEIER WEIGH WEILS WEIRD WEIRS WEISM WEISS WEIST WEISZ WEITZ WEIVE WEKAS WEKAU WELAN WELCH WELCS WELDS WELDY WELKE WELKS WELLS WELLY WELPS WELSH WELTE WELTS WELTY WENCH WENDI WENDS WENDT WENDY WENGE WENIS WENKS WENNY WENTE WENTS WENTZ WEPTE WEREN WERES WERKE WERKS WERLE WERNS WERRE WERRY WERSH WERST WERTH WERTZ WESER WESIL WESTS WESTY WETAS WETLY WETUS WEUNS WEVER WEVIL WEXED WEXES WEXIS WEYER WEYES WEYVE WEZON WFSPS WGARA WGWAG WHAAP WHACK WHALE WHALL WHAME WHAMO WHAMS WHANG WHANK WHAPS WHARE WHARF WHARL WHARP WHATH WHATS WHAUL WHAUP WHEAL WHEAT WHEEK WHEEL WHEEN WHEFT WHEKI WHELK WHELM WHELP WHENS WHEPT WHERE WHETS WHEWS WHEYN WHEYS WHICH WHIDS WHIES WHIFF WHIGS WHILE WHILK WHIMP WHIMS WHINE WHING WHINS WHINY WHIOS WHIPP WHIPS WHIPT WHIRL WHIRR WHIRS WHISH WHISK WHISP WHIST WHITE WHITH WHITS WHITT WHITY WHIZZ WHMIS WHOAH WHOAM WHOAS WHOES WHOIS WHOLE WHOLY WHOME WHOMP WHOOF WHOOP WHOOS WHOOT WHOPS WHORE WHORL WHORT WHOSE WHOSO WHOZE WHRRR WHUMP WHUPS WHURS WHURT WHUSS WHYDA WHYJA WHYLE WHYOS WHYTE WIANT WIBLE WIBNI WICCA WICHI WICHU WICKE WICKS WICKY WIDDY WIDEN WIDER WIDES WIDGE WIDIA WIDOW WIDTH WIEBE WIECK WIELD WIENS WIERD WIERS WIERY WIEST WIFES WIFEY WIFIE WIFIS WIFTY WIGAN WIGGA WIGGO WIGGS WIGGY WIGHT WIKES WIKIA WIKIS WILBE WILBY WILCO WILDE WILDS WILDT WILED WILES WILEY WILGA WILJA WILKE WILKS WILLA WILLE WILLS WILLY WILMA WILMS WILNA WILTS WILTY WILTZ WIMAX WIMBO WIMEN WIMER WIMEX WIMIN WIMPS WIMPY WINAL WINCE WINCH WINDA WINDE WINDS WINDY WINED WINER WINES WINEY WINGE WINGO WINGS WINGY WINJS WINKS WINKT WINKY WINLY WINNE WINNI WINOS WINPE WINRT WINTS WINTU WINTZ WINXP WINZE WIPED WIPER WIPES WIPFS WIRED WIRER WIRES WIRRA WIRTH WIRTZ WISED WISEN WISER WISES WISHA WISHE WISHT WISLY WISOR WISPS WISPY WISSE WISTS WITAN WITCH WITED WITEK WITES WITHE WITHS WITHY WITTE WITTS WITTY WIVED WIVER WIVES WIWAL WIXES WIXOM WIXON WIYOT WIZEN WKBKS WKEND WKIDS WKNDS WKSHT WKSTS WLANS WLRGS WMAFS WMATA WNEKS WOADS WOADY WOALD WOANT WOBLA WOCAS WOCUS WODEN WODGE WOFUL WOIDS WOJUS WOKAS WOKED WOKEN WOKOU WOKUS WOLAK WOLDE WOLDS WOLFE WOLFS WOLFY WOLIN WOLKS WOLLY WOLOF WOLVE WOMAN WOMBS WOMBY WOMEN WOMER WOMIN WOMON WOMXN WOMYN WONCE WONED WONES WONGA WONGI WONGS WONKS WONKY WONNE WONTS WONUT WOODE WOODS WOODY WOOED WOOER WOOES WOOFS WOOFY WOOLD WOOLF WOOLS WOOLY WOOPS WOOSH WOOSY WOOTZ WOOZE WOOZY WORAS WORCS WORDE WORDS WORDY WORES WORKE WORKS WORKU WORKY WORLD WORME WORMS WORMY WORNE WORRA WORRY WORSE WORST WORTH WORTS WORTY WOSBS WOUGH WOULD WOULS WOUND WOVEN WOWED WOWEE WOWIE WOWKE WOWND WOWZA WOXEN WOZNY WPANS WPBSA WPGMA WRACK WRAKE WRAMP WRANG WRAPS WRAPT WRATH WRATS WRAWL WRAYS WREAK WRECK WREDE WRENN WRENS WREST WREYE WRICK WRIDE WRIED WRIER WRIES WRIGS WRILY WRINE WRING WRIST WRITE WRITH WRITO WRITS WROCK WROKE WRONA WRONG WROOS WROTE WROTH WRPGS WRUNG WRYER WRYLY WSDLS WSDOT WSOPS WSSLS WTFPL WUDHU WUDUP WUEST WUFFO WUFFS WUHAN WUHSI WUISM WULFF WULST WUNCH WUNGA WUNGU WUNNA WUNST WURLY WURST WURTH WURTZ WUSHU WUSSY WUSTL WUSUN WUVED WUXGA WUXIA WUXUE WUZHI WUZZA WUZZY WWIII WWOOF WWTPS WWWWW WYANT WYATT WYBLE WYCHE WYDAH WYDER WYDES WYERS WYLER WYLIE WYMAN WYMER WYMYN WYNDS WYNES WYNGE WYNGZ WYNIA WYNNE WYNNS WYPES WYRDS WYRES WYRMS WYSES WYTCH WYTED WYTES WYTHE WYVES XACML XALAM XALWO XAMIR XANAX XANNY XANOL XASER XBOWS XEBEC XEGWI XEMES XENAS XENIA XENIC XENID XENON XENYL XERIC XERIF XEROX XFELS XFERS XHOSA XHTML XIALU XIANG XIANS XIAOS XINCA XINGS XINGU XIONG XIQIN XLINK XNAND XOANA XOLOS XORED XORNS XOSAS XPATH XPCOM XPERS XPOST XQUIC XRAYS XSRFS XTALS XTIAN XTMAS XUETA XUIXO XUXOS XXXED XXXES XYLAN XYLEM XYLIC XYLOL XYLOS XYLYL XYRID XYRIS XYSMA XYSTA XYSTI XYSTS XYZZY YAAAS YAAAY YAAKU YAARS YABBY YABIM YABOO YABUT YACCA YACHT YACKS YACON YADAO YADAV YADDA YADIM YADON YADUS YAEKO YAFFE YAGAR YAGER YAGIS YAGNA YAGNI YAGYA YAHIR YAHNS YAHOO YAHVE YAHWE YAHYA YAIRD YAJNA YAKAL YAKIN YAKKA YAKKY YAKOW YAKUT YALAH YALDA YALES YALIE YALIS YALLO YALTA YAMAL YAMEN YAMMA YAMMY YAMPA YAMPI YAMPY YAMUN YANAS YANBU YANCE YANCY YANDY YANEZ YANGS YANKE YANKS YANNI YANNO YANOS YANTS YANZI YAPAN YAPLE YAPOK YAPON YAPPS YAPPY YARAK YARAM YARBS YARCO YARDS YAREN YARER YARFA YARKE YARKS YARLS YARMS YARNE YARNS YARNY YAROO YARRI YARRS YARTA YARUM YASAK YASDS YASHT YASNA YASSA YASSY YASUO YATAI YATCH YATES YATGA YATHI YATRA YATRI YATTA YATTS YATZY YAUDS YAULD YAULS YAUPS YAVIS YAVNE YAWED YAWEY YAWLS YAWNS YAWNY YAWPS YAYAS YAYOI YAYUE YAYUH YAZHS YAZOO YBEEN YBENT YBORE YBORN YCLAD YCOND YDDIM YEADS YEALM YEANS YEARA YEARD YEARE YEARN YEARS YEARY YEAST YEATS YEBRA YECCH YECHS YECHY YEDDO YEECH YEEES YEEHA YEELD YEENS YEEPS YEERE YEERS YEESH YEETS YEEZY YEGGS YEILD YEKES YEKKE YELEK YELKS YELLA YELLE YELLO YELLS YELMS YELOW YELPS YELPT YELPY YELVE YEMAN YEMEN YENNA YENOM YENTA YENTE YEPEZ YEPLY YEPPO YERBA YERBS YERBY YERKS YERNS YERON YEROS YERTH YESES YESKS YESSO YESSS YESTS YESTY YETIS YETTS YEUCH YEUGH YEUKS YEUKY YEUNG YEWEI YEWEN YEXED YEXES YEYSK YEZDI YFERE YGONE YHJBT YIBIN YIDAM YIDDO YIELD YIFFS YIFFY YIISH YIKES YILAN YILLS YIMBY YINGS YIPEE YIPES YIPPY YIRKS YITES YIVEN YIVES YKWIM YLAID YLAND YLEFT YLIDE YLIDS YLIKE YLOND YMADE YMCAS YMIXT YMOLT YMPES YNOLS YOABU YOAKE YOAKS YOBBO YOBOS YOCKS YOCOM YOCUM YODEL YODER YODHS YODLE YOGAS YOGEE YOGHS YOGIC YOGIN YOGIS YOHES YOHNS YOHOS YOICK YOIKS YOINK YOISM YOITS YOJAN YOKAI YOKAN YOKED YOKEL YOKER YOKES YOKUL YOKUM YOLES YOLKS YOLKY YOLLA YOMPS YONAN YONCE YONGE YONGS YONIC YONIS YONKS YONNE YONTZ YOOFS YOONG YOONS YOOPS YOPON YOPPS YORDY YORES YORGA YORKE YORKS YORON YOSEF YOSHI YOTED YOTES YOUCH YOUED YOUEE YOUKS YOUNG YOUNS YOUNT YOURE YOURN YOURS YOURT YOUSE YOUTH YOUTS YOUZE YOWCH YOWES YOWIE YOWLS YOWLY YOWZA YOYOS YPAID YPIPO YPRES YRAFT YRAPT YRARE YRAST YREKA YRENT YRIVD YRNEH YTHAN YTHES YTOLD YTOST YUANS YUCAS YUCCA YUCCH YUCHI YUCKS YUCKY YUENS YUFTS YUGAS YUGEN YUGUR YUIKO YUINA YUKED YUKES YUKIE YUKIO YUKKY YUKON YUKOS YULAN YULES YUMAN YUMAS YUMIS YUMMO YUMMY YUMPS YUNFU YUNGS YUNNO YUNXI YUORY YUPIK YUPON YUPPO YUPPY YURES YURIE YURKO YURLA YUROK YUROP YURTA YURTS YURUK YUSES YUSHO YUSKO YUTAN YUTZY YUZUS YVORY YWAIN YWCAS YWENT ZABAD ZABKA ZABOK ZABRA ZACKS ZADAK ZADAR ZADOK ZADOQ ZAFAR ZAFRA ZAFUS ZAGAL ZAGAR ZAGER ZAHID ZAHIR ZAHLE ZAHMS ZAHRA ZAIDI ZAILS ZAIMS ZAINO ZAINS ZAIRE ZAISU ZAIWA ZAJAC ZAJAL ZAKAH ZAKAT ZAKIS ZAMAK ZAMAN ZAMAS ZAMBO ZAMIA ZAMIS ZAMOR ZANDE ZANGI ZANGS ZANJA ZANJE ZANJS ZANTE ZANZA ZANZE ZANZY ZAPAS ZAPFS ZAPPA ZAPPY ZARBI ZARCO ZARDA ZARFS ZARIA ZARIF ZARMA ZARPH ZARPS ZATIS ZATOR ZAUGG ZAWNS ZAXES ZAYAT ZAYDE ZAYDI ZAYED ZAYIN ZAYNE ZAYNS ZAZAO ZAZAS ZAZEN ZAZZY ZDDPS ZEALE ZEALS ZEBAK ZEBEC ZEBRA ZEBUB ZEBUL ZEBUS ZEEKS ZEERA ZEERS ZEHRS ZEIDS ZEIDY ZEINS ZEISS ZEITZ ZELDA ZELIG ZELMA ZELUS ZEMIS ZEMKE ZEMNI ZENDE ZENDO ZENER ZENGI ZENGS ZENIK ZENKS ZENON ZENTS ZENTZ ZEOLI ZEPPS ZERBE ZERBY ZERDA ZERDE ZEREN ZERGS ZERKS ZEROA ZEROS ZERRS ZESTS ZESTY ZETAS ZEZES ZHABA ZHAIS ZHANG ZHANS ZHAPU ZHEES ZHENG ZHENS ZHLUB ZHOMO ZHONG ZHUOS ZHUSH ZHUXI ZHUZH ZIBAR ZIBET ZICKS ZIEBA ZIEGA ZIEGE ZIFTS ZIGGY ZIGUA ZIGUI ZIIPS ZIKAT ZIKRI ZIKRS ZILAS ZILCH ZILDE ZILKA ZILLS ZIMAS ZIMBA ZIMBI ZIMBO ZIMBS ZINAB ZINCK ZINCO ZINCS ZINCY ZINDA ZINEB ZINER ZINES ZINGS ZINGY ZINKE ZINKS ZINKY ZINOS ZINZA ZIONS ZIPPO ZIPPS ZIPPY ZIRAM ZIRPS ZITAN ZITIS ZITSO ZITTY ZIVES ZIZEL ZIZIT ZIZZO ZIZZY ZLOTE ZLOTY ZMINJ ZMUDA ZOAEA ZOARS ZOBOS ZOCCO ZOCKS ZOCLE ZOEAE ZOEAL ZOEAS ZOHAR ZOIDS ZOIGL ZOIKS ZOILI ZOISM ZOIST ZOITE ZOKOR ZOLLO ZOLLY ZOMBI ZOMBS ZOMES ZOMFG ZOMIA ZOMMA ZONAE ZONAL ZONAS ZONDA ZONED ZONER ZONES ZONKS ZONKY ZOOEA ZOOEY ZOOID ZOOKS ZOOMS ZOOMY ZOONS ZOOTS ZOOTY ZOPAS ZOPPO ZOQUE ZORBS ZORCH ZORID ZORIL ZORIS ZORRO ZORSE ZOSHI ZOUKS ZOWEE ZOWIE ZOWLS ZRAZY ZUBER ZUBIA ZUBRS ZUCCO ZUCHE ZUISM ZUIST ZUKES ZULES ZULLO ZULUS ZUMAT ZUMBA ZUMBI ZUMMO ZUNIS ZUNOS ZUPAN ZUPAS ZUPPA ZUPPE ZUREK ZURFS ZURLA ZURMA ZURNA ZURNS ZUZES ZUZIM ZWART ZWICK ZWNJS ZYGAL ZYGON ZYMAD ZYMES ZYMIC ZYMIN ZYXIN ZZYZX ZZZED ================================================ FILE: core/src/main/res/raw/wordle_list_es.txt ================================================ ABABA ABACA ABACO ABADA ABADI ABAJA ABAJE ABAJO ABALA ABALE ABALO ABANA ABANE ABANO ABASI ABATA ABATE ABATI ABATO ABECE ABEJA ABETE ABETO ABIAR ABIAS ABINA ABINE ABINO ABISO ABITA ABITE ABITO ABOBA ABOBE ABOBO ABOCA ABOCO ABOFA ABOFE ABOFO ABOGA ABOGO ABOLI ABONA ABONE ABONO ABOYA ABOYE ABOYO ABOZO ABRAN ABRAS ABREN ABRES ABRIA ABRID ABRIL ABRIO ABRIR ABRIS ABSIT ABUBO ABUCE ABUJE ABURA ABURE ABURO ABUSA ABUSE ABUSO ABUZA ABUZO ACABA ACABE ACABO ACAMA ACAME ACAMO ACANA ACARA ACARE ACARO ACASO ACATA ACATE ACATO ACEBO ACECE ACEDA ACEDE ACEDO ACEMA ACEPA ACEPE ACEPO ACERA ACERE ACERO ACETA ACETO ACEZA ACEZO ACENA ACHIN ACHIS ACIAL ACIAR ACIDA ACIDO ACIJE ACILO ACIMO ACION ACLES ACLLA ACMES ACNES ACOCA ACOCO ACODA ACODE ACODO ACOGE ACOGI ACOJA ACOJO ACOLA ACOLE ACOLO ACOPA ACOPE ACOPO ACORA ACORE ACORO ACOSA ACOSE ACOSO ACOTA ACOTE ACOTO ACRES ACROE ACROY ACTAS ACTEA ACTOR ACTOS ACTUA ACTUE ACTUO ACUDA ACUDE ACUDI ACUDO ACUEA ACUEO ACULA ACULE ACULO ACUNA ACUNE ACUNO ACURE ACUSA ACUSE ACUSO ACUTA ACUTI ACUTO ACUYO ADALA ADAMA ADAME ADAMO ADAZA ADEMA ADEME ADEMO ADIAD ADIAN ADIAR ADIAS ADIEN ADIES ADIOS ADIVA ADIVE ADOBA ADOBE ADOBO ADORA ADORE ADORO ADOSA ADOSE ADOSO ADRAD ADRAL ADRAN ADRAR ADRAS ADREN ADRES ADUAR ADUCE ADUCI ADUFE ADUJA ADUJE ADUJO ADULA ADULE ADULO ADUNA ADUNE ADUNO ADURA ADURE ADURI ADURO ADVEN AEDAS AEDOS AEREA AEREO AETAS AFACA AFACE AFAGA AFAGO AFAMA AFAME AFAMO AFANA AFANE AFANO AFARA AFARE AFATA AFATE AFATO AFEAD AFEAN AFEAR AFEAS AFEEN AFEES AFIAR AFICE AFIJA AFIJO AFILA AFILE AFILO AFINA AFINE AFINO AFIZO AFLUI AFOCA AFOCO AFOFA AFOFE AFOFO AFOGA AFOGO AFONA AFONO AFORA AFORE AFORO AFOSA AFOSE AFOSO AFTAS AFUFA AFUFE AFUFO AFUMA AFUME AFUMO AGACE AGAMI AGANA AGANE AGANO AGAPE AGATA AGAVE AGIOS AGITA AGITE AGITO AGNUS AGOLA AGOLE AGOLO AGORA AGORE AGORO AGOTA AGOTE AGOTO AGRAS AGRAZ AGRES AGRIA AGRIE AGRIO AGROR AGROS AGUAD AGUAI AGUAN AGUAR AGUAS AGUAY AGUCE AGUDA AGUDO AGUEN AGUES AGUIN AGUIO AGUJA AGUTI AGUZA AGUZO AHAJA AHAJE AHAJO AHIJA AHIJE AHIJO AHILA AHILE AHILO AHINA AHITA AHITE AHITO AHOGA AHOGO AHORA AHOYA AHOYE AHOYO AHUMA AHUME AHUMO AHUSA AHUSE AHUSO AILLO AILLU AINAS AIRAD AIRAN AIRAR AIRAS AIREA AIREE AIREN AIREO AIRES AIRON AISAS AISLA AISLE AISLO AITES AJABA AJADA AJADO AJAIS AJAJA AJARA AJARE AJASE AJEAD AJEAN AJEAR AJEAS AJEBE AJEEN AJEES AJEIS AJENA AJENO AJEOS AJERA AJERO AJETE AJICE AJIES AJIPA AJIZA AJIZO AJOBO AJORA AJORE AJORO AJOTA AJOTE AJOTO AJUAR AJUMA AJUME AJUMO AJUNA AJUNO ALABA ALABE ALABO ALACO ALADA ALADO ALAFA ALAGA ALAGO ALAJU ALALA ALALO ALAMA ALAMO ALANA ALANO ALAUI ALAZO ALBAR ALBAS ALBEA ALBEE ALBEO ALBIN ALBOR ALBOS ALBUM ALBUR ALCAS ALCEA ALCEN ALCES ALCOR ALDEA ALEAD ALEAN ALEAR ALEAS ALECE ALEDA ALEEN ALEES ALEFS ALEGA ALEGO ALEJA ALEJE ALEJO ALELA ALELE ALELI ALELO ALEMA ALERO ALETA ALETO ALEVE ALEYA ALEZO ALFAD ALFAN ALFAR ALFAS ALFEN ALFES ALFIL ALFIZ ALFOZ ALGAR ALGAS ALGOL ALGOS ALGUN ALHOZ ALIAD ALIAN ALIAR ALIAS ALICA ALIEN ALIER ALIES ALIFA ALIGA ALIGO ALIJA ALIJE ALIJO ALIMO ALIMS ALIOJ ALISA ALISE ALISO ALINA ALINE ALINO ALJEZ ALJOR ALLEN ALMAS ALMEA ALMEZ ALMOS ALMUD ALNAS ALNOS ALOBA ALOBE ALOBO ALOCA ALOCO ALOES ALOJA ALOJE ALOJO ALOLA ALOLE ALOLO ALOMA ALOME ALOMO ALONA ALORA ALOSA ALOTA ALOTE ALOTO ALOYA ALPES ALTAR ALTAS ALTEA ALTEE ALTEO ALTOR ALTOS ALUAS ALUCE ALUDA ALUDE ALUDI ALUDO ALULA ALUNA ALUNE ALUNO ALUZA ALUZO ALVEO ALZAD ALZAN ALZAR ALZAS ALZOS AMABA AMADA AMADO AMAGA AMAGO AMAIS AMALA AMALE AMALO AMANA AMANE AMANO AMARA AMARE AMARO AMASA AMASE AMASO AMATA AMATE AMATO AMBAR AMBAS AMBLA AMBLE AMBLO AMBON AMBOS AMEBA AMEIS AMELA AMELE AMELO AMENA AMENO AMEOS AMERA AMERE AMERO AMIAS AMIBA AMIBO AMIDA AMIGA AMIGO AMINA AMINE AMINO AMIRI AMITO AMOLA AMOLE AMOLO AMOMO AMONA AMONE AMONO AMOVE AMOVI AMPAY AMPLA AMPLO AMPON AMPOS AMPRA AMPRE AMPRO AMUGA AMUGO AMULA AMULE AMULO AMURA AMURE AMURO AMUSO ANABI ANACO ANADE ANAFE ANAMU ANANA ANATA ANCAS ANCHA ANCHE ANCHO ANCLA ANCLE ANCLO ANCON ANCUA ANDAD ANDAN ANDAR ANDAS ANDEL ANDEN ANDES ANDON ANEAD ANEAN ANEAR ANEAS ANEEN ANEES ANEGA ANEGO ANEJA ANEJE ANEJO ANETO ANEXA ANEXE ANEXO ANGEL ANGLA ANGLO ANGOR ANGRA ANGUS ANIDA ANIDE ANIDO ANIMA ANIME ANIMO ANION ANISA ANISE ANISO ANITO ANINA ANINE ANINO ANJEO ANOAS ANODO ANOLA ANOLE ANOLO ANONA ANOTA ANOTE ANOTO ANSAR ANSAS ANSIA ANSIE ANSIO ANTAS ANTES ANTIA ANTIS ANTRO ANUAL ANUAS ANUDA ANUDE ANUDO ANULA ANULE ANULO ANUOS ANURA ANURO AOCAR AOJAD AOJAN AOJAR AOJAS AOJEN AOJES AOJOS AONIA AONIO AORTA AOVAD AOVAN AOVAR AOVAS AOVEN AOVES APAGA APAGO APALE APARA APARE APARO APANA APANE APANO APEAD APEAN APEAR APEAS APEEN APEES APEGA APEGO APELA APELE APELO APENA APENE APENO APEOS APERA APERE APERO APESE APICE APILA APILE APILO APIOS APIPA APIPE APIPO APIRI APITA APITE APITO APINA APINE APINO APNEA APOCA APOCE APOCO APODA APODE APODO APOLA APOLE APOLO APONE APOSA APOSE APOSO APOYA APOYE APOYO APOZA APOZO APRES APROA APROE APROO APTAR APTAS APTOS APUNA APUNE APUNO APURA APURE APURO APUSE APUSO AQUEA AQUEL AQUEO ARABA ARABE ARABI ARABO ARADA ARADO ARAIS ARANA ARARA ARARE ARASA ARASE ARAZA ARANE ARANO ARBOL ARBOR ARCAD ARCAN ARCAR ARCAS ARCEA ARCEN ARCES ARCHA ARCHI ARCON ARCOS ARDAN ARDAS ARDEA ARDED ARDEN ARDER ARDES ARDIA ARDID ARDIL ARDIO ARDOR ARDUA ARDUO AREAS ARECA AREIS ARELA ARELE ARELO ARENA ARENE ARENO AREPA ARETE ARFAD ARFAN ARFAR ARFAS ARFEN ARFES ARFIL ARGAN ARGEL ARGEN ARGON ARGOS ARGOT ARGUE ARGUI ARIAS ARICA ARICO ARIDA ARIDO ARIES ARIJA ARIJE ARIJO ARILO ARIOS ARLAD ARLAN ARLAR ARLAS ARLEN ARLES ARLOS ARMAD ARMAN ARMAR ARMAS ARMEN ARMES ARMON ARMOS ARNAS ARNES AROCA AROMA AROME AROMO ARPAD ARPAN ARPAR ARPAS ARPEN ARPEO ARPES ARPIA ARPON ARQUE ARRAS ARRAZ ARREA ARREE ARREO ARRES ARRIA ARRIE ARRIO ARROZ ARRUA ARRUE ARRUI ARRUO ARTAL ARTAS ARTES ARTOS ARULA ARUPO ARUNA ARUNE ARUNO ARZON ASABA ASACA ASACO ASADA ASADO ASAIS ASARA ASARE ASARO ASASE ASCAR ASCAS ASCIA ASCIO ASCOS ASCUA ASEAD ASEAN ASEAR ASEAS ASEDA ASEDE ASEDO ASEEN ASEES ASEIS ASELA ASELE ASELO ASEOS ASESA ASESE ASESO ASGAN ASGAS ASIAN ASIAS ASICA ASICO ASIDA ASIDO ASILA ASILE ASILO ASINA ASIRA ASIRE ASMAR ASMAS ASNAL ASNAS ASNOS ASOLA ASOLE ASOLO ASOMA ASOME ASOMO ASONA ASONE ASONO ASPAD ASPAN ASPAR ASPAS ASPEA ASPEE ASPEN ASPEO ASPES ASPIC ASPID ASPRO ASTAS ASTER ASTIL ASTRO ASTUR ASUMA ASUME ASUMI ASUMO ASURA ASURE ASURO ASUSO ATABA ATABE ATACA ATACO ATADA ATADO ATAIS ATAJA ATAJE ATAJO ATAPA ATAPE ATAPO ATARA ATARE ATASE ATAUD ATANA ATANE ATANO ATEAR ATEAS ATECE ATEIS ATEJE ATEOS ATERI ATESA ATESE ATESO ATETA ATETE ATETO ATEZA ATEZO ATIBA ATIBE ATIBO ATICA ATICE ATICO ATINA ATINE ATINO ATIPA ATIPE ATIPO ATIZA ATIZO ATLAS ATOAD ATOAN ATOAR ATOAS ATOBA ATOBE ATOBO ATOEN ATOES ATOJA ATOJE ATOJO ATOLE ATOMO ATONA ATONO ATORA ATORE ATORO ATRAE ATRAS ATRIL ATRIO ATROZ ATUFA ATUFE ATUFO ATURA ATURE ATURO ATUSA ATUSE ATUSO ATUVE ATUVO AUCAS AUDAZ AUDIO AUGES AUGUR AULAS AULLA AULLE AULLO AUNAD AUNAN AUNAR AUNAS AUNEN AUNES AUPAD AUPAN AUPAR AUPAS AUPEN AUPES AURAS AUREA AUREO AUSOL AUTAN AUTOR AUTOS AVADA AVADE AVADO AVAHA AVAHE AVAHO AVALA AVALE AVALO AVARA AVARO AVATI AVECE AVENA AVENE AVENI AVENO AVEZA AVEZO AVIAD AVIAN AVIAR AVIAS AVICA AVIDA AVIDO AVIEN AVIES AVINE AVINO AVION AVIOS AVISA AVISE AVISO AVIVA AVIVE AVIVO AVOCA AVOCO AVUGO AXIAL AXILA AYACO AYATE AYEAD AYEAN AYEAR AYEAS AYEEN AYEES AYORA AYOTE AYUAS AYUDA AYUDE AYUDO AYUGA AYUNA AYUNE AYUNO AYUSO AZADA AZAGA AZAGO AZALA AZARA AZARE AZARO AZCON AZERI AZIMO AZOAD AZOAN AZOAR AZOAS AZOCA AZOCO AZOEN AZOES AZOGA AZOGO AZOLA AZOLE AZOLO AZORA AZORE AZORO AZOTA AZOTE AZOTO AZTOR AZUAS AZUCE AZUDA AZULA AZULE AZULO AZUTS AZUZA AZUZO ANADA ANADI ANADO ANEDA ANEDE ANEDI ANEDO ANERA ANERO ANIDI ANILA ANILE ANILO ANOJA ANOJO ANORA ANORE ANORO ANOSA ANOSO BABAS BABEA BABEE BABEL BABEO BABIS BABLE BABOR BACAN BACAS BACHE BACIA BACIN BACON BADAL BADAN BADAS BADEA BADEN BADIL BAFLE BAGAD BAGAN BAGAR BAGAS BAGOS BAGRE BAGUE BAHAI BAHIA BAIDA BAIFA BAIFO BAILA BAILE BAILO BAJAD BAJAN BAJAR BAJAS BAJEA BAJEE BAJEL BAJEN BAJEO BAJES BAJEZ BAJIA BAJIN BAJIO BAJON BAJOS BALAD BALAJ BALAN BALAR BALAS BALAY BALDA BALDE BALDO BALEA BALEE BALEN BALEO BALES BALIN BALON BALOS BALSA BALSO BALTA BALTO BAMBA BAMBU BANAL BANAS BANCA BANCE BANCO BANDA BANDO BANIR BANJO BANTU BANYO BANZO BAQUE BARBA BARBE BARBO BARCA BARCO BARDA BARDE BARDO BARES BARIA BARIL BARIO BARIS BARNS BARON BAROS BARRA BARRE BARRI BARRO BARZA BASAD BASAL BASAN BASAR BASAS BASCA BASEN BASES BASIS BASNA BASTA BASTE BASTO BATAN BATAS BATEA BATEE BATEL BATEN BATEO BATES BATEY BATIA BATID BATIN BATIO BATIR BATIS BATON BATOS BATUA BAULA BAURE BAUSA BAUZA BAYAL BAYAS BAYON BAYOS BAYUA BAYUS BAZAR BAZAS BAZOS BANAD BANAN BANAR BANEN BANES BANIL BANOS BEATA BEATO BEBAN BEBAS BEBED BEBEN BEBER BEBES BEBIA BEBIO BECAD BECAN BECAR BECAS BEDEL BEFAD BEFAN BEFAR BEFAS BEFEN BEFES BEFOS BEFRE BEGUM BEIGE BEJIN BELDA BELDE BELDO BELEN BELES BELEZ BELFA BELFO BELGA BELIO BELLA BELLO BELUA BEMBA BEMBE BEMBO BEMOL BENES BEODA BEODO BEORI BEQUE BERBI BERMA BERON BERRA BERRE BERRO BERTA BERZA BESAD BESAN BESAR BESAS BESEN BESES BESOS BETAS BETEL BETUN BEUDA BEUDO BEYES BEZAR BEZON BEZOS BIAZA BIBIS BICAL BICHA BICHE BICHO BICIS BICOS BIDES BIDON BIELA BIFAZ BIFES BIGAS BIJAO BIJAS BIJOL BILAO BILES BILIS BILLA BILMA BILME BILMO BIMBA BINAD BINAN BINAR BINAS BINEA BINEE BINEN BINEO BINES BINGO BINZA BIOTA BIRAS BIRLA BIRLE BIRLI BIRLO BIROS BISAD BISAN BISAR BISAS BISEL BISEN BISES BISOS BISTE BITAD BITAN BITAR BITAS BITEN BITER BITES BITOR BIZAS BIZCA BIZCO BIZMA BIZME BIZMO BIZNA BLAOS BLAVA BLAVO BLEDA BLEDO BLOCA BLOCO BLOCS BLUES BLUSA BOATO BOBAS BOBEA BOBEE BOBEO BOBOS BOCAL BOCAS BOCEA BOCEE BOCEL BOCEO BOCHA BOCHE BOCHO BOCIN BOCIO BOCON BOCOY BODAS BODES BODON BOFAN BOFAR BOFAS BOFEN BOFES BOFIA BOFOS BOGAD BOGAN BOGAR BOGAS BOGUE BOHIO BOINA BOIRA BOITE BOJAD BOJAN BOJAR BOJAS BOJEA BOJEE BOJEN BOJEO BOJES BOJOS BOLAR BOLAS BOLDO BOLEA BOLEE BOLEO BOLES BOLIN BOLIS BOLLA BOLLE BOLLO BOLON BOLOS BOLSA BOLSO BOMBA BOMBE BOMBO BONAL BONES BONGA BONGO BONOS BONZO BOQUE BOQUI BORAX BORDA BORDE BORDO BOREO BORIA BORLA BORNA BORNE BORNI BOROS BORRA BORRE BORRO BORTO BOSAR BOSON BOSTA BOTAD BOTAN BOTAR BOTAS BOTEA BOTEE BOTEN BOTEO BOTES BOTIN BOTON BOTOR BOTOS BOXEA BOXEE BOXEO BOXER BOXES BOYAD BOYAL BOYAN BOYAR BOYAS BOYEN BOYES BOZAL BOZAS BOZON BOZOS BRACA BRACO BRAGA BRAMA BRAME BRAMO BRASA BRAVA BRAVO BRAZA BRAZO BRANA BREAD BREAN BREAR BREAS BRECA BRECE BRECO BREEN BREES BREGA BREGO BRETE BREVA BREVE BREZA BREZO BRENA BRIAL BRIBA BRICE BRIDA BRIOL BRIOS BRISA BRISE BRISO BRIZA BRIZO BROAS BROCA BROCE BROMA BROME BROMO BROTA BROTE BROTO BROZA BROZO BRUCE BRUGO BRUJA BRUJE BRUJI BRUJO BRUMA BRUME BRUMO BRUNA BRUNO BRUTA BRUTO BRUTS BRUZA BRUZO BRUNE BRUNI BUARO BUBAS BUBIS BUBON BUCAL BUCEA BUCEE BUCEN BUCEO BUCES BUCHE BUCIO BUCLE BUCOS BUDAS BUDIN BUEGA BUENA BUENO BUERA BUFAD BUFAN BUFAR BUFAS BUFEN BUFEO BUFES BUFET BUFIA BUFON BUFOS BUGLE BUHIO BUHOS BUIDA BUIDO BUJEO BUJES BUJIA BUJOS BULAR BULAS BULBO BULDA BULES BULIN BULIS BULLA BULLE BULLI BULLO BULON BULOS BULTO BUNAS BUNIO BUQUE BURAS BURDA BURDO BUREL BUREO BURGA BURGO BURIL BURIO BURIS BURLA BURLE BURLO BUROS BURRA BURRO BUSCA BUSCO BUSES BUSTO BUTEN BUTIA BUYOS BUZAD BUZAN BUZAR BUZAS BUZON BUZOS CABAL CABAS CABED CABEN CABER CABES CABIA CABIO CABLE CABOS CABRA CABRE CABRO CACAN CACAO CACAS CACEA CACEE CACEN CACEO CACES CACHA CACHE CACHO CACHU CACLE CACOS CACTO CACUY CADAS CADIS CADOS CAEIS CAENA CAERA CAERE CAFES CAFIZ CAFRE CAGAD CAGAN CAGAR CAGAS CAGON CAGUE CAHIZ CAIAN CAIAS CAICO CAIDA CAIDO CAIES CAIGA CAIGO CAIMA CAIRE CAITE CAJAS CAJEL CAJIN CAJIS CAJON CAJOS CALAD CALAN CALAO CALAR CALAS CALCA CALCE CALCO CALDA CALDO CALED CALEN CALER CALES CALIA CALIO CALIS CALIZ CALLA CALLE CALLO CALMA CALME CALMO CALON CALOR CALOS CALTA CALVA CALVE CALVO CALZA CALZO CAMAL CAMAO CAMAS CAMBA CAMBE CAMBO CAMIO CAMON CAMPA CAMPE CAMPO CANAL CANAS CANDA CANDE CANDI CANDO CANEA CANEE CANEO CANES CANEY CANEZ CANGA CANGO CANIA CANIL CANJE CANOA CANON CANOS CANSA CANSE CANSO CANTA CANTE CANTO CANTU CAOBA CAOBO CAPAD CAPAN CAPAR CAPAS CAPAZ CAPEA CAPEE CAPEL CAPEN CAPEO CAPES CAPIA CAPIN CAPIO CAPIS CAPON CAPOS CAPPA CAPTA CAPTE CAPTO CAPUZ CAQUI CARAO CARAS CARAU CARAY CARBA CARCA CARDA CARDE CARDO CAREA CAREE CAREL CAREO CARES CAREY CARGA CARGO CARIA CARIE CARIO CARIS CARIZ CARLA CARLO CARME CARNE CARON CAROS CARPA CARPE CARPI CARPO CARRA CARRO CARTA CARVI CASAD CASAL CASAN CASAR CASAS CASCA CASCO CASEA CASEN CASEO CASES CASIA CASIS CASON CASOS CASPA CASTA CASTO CATAD CATAN CATAR CATAS CATEA CATEE CATEN CATEO CATES CATEY CATIN CATON CATOS CATRE CAUBA CAUCA CAUCE CAUDA CAUJE CAULA CAUNO CAURI CAURO CAUSA CAUSE CAUSO CAUTA CAUTO CAUZA CAVAD CAVAN CAVAR CAVAS CAVEA CAVEN CAVES CAVIA CAVIO CAVIS CAVON CAVOS CAYAN CAYAS CAYOS CAZAD CAZAN CAZAR CAZAS CAZON CAZOS CAZUZ CANAD CANAN CANAR CANEN CANIS CEAJA CEAJO CEBAD CEBAN CEBAR CEBAS CEBEN CEBES CEBIL CEBON CEBOS CEBRA CEBRO CEBTI CEBUS CECAL CECAS CECEA CECEE CECEO CEDAN CEDAS CEDED CEDEN CEDER CEDES CEDIA CEDIO CEDRO CEFEA CEFEE CEFEO CEFOS CEGAD CEGAR CEGAS CEGUA CEGUE CEIBA CEIBO CEJAD CEJAN CEJAR CEJAS CEJEN CEJES CEJOS CELAD CELAN CELAR CELAS CELDA CELEN CELES CELFO CELIA CELLA CELLO CELOS CELTA CEMAS CEMBO CENAD CENAL CENAN CENAR CENAS CENCA CENCO CENEN CENES CENIA CENIS CENIT CENSA CENSE CENSO CENTS CEPAS CEPOS CEPTI CEQUI CERAS CERCA CERCO CERDA CERDO CEREA CEREO CERIO CERNA CERNE CERNI CERNO CERON CEROS CERPA CERRA CERRE CERRO CESAD CESAN CESAR CESAS CESEN CESES CESIO CESTA CESTO CETIL CETIS CETME CETRA CETRE CETRO CEUTI CENID CENIR CENOS CHACA CHACE CHACO CHAFA CHAFE CHAFO CHAIS CHAJA CHALA CHALE CHALO CHAMA CHAME CHAMO CHANA CHANE CHANO CHAPA CHAPE CHAPO CHATA CHATO CHAUL CHAUZ CHAVA CHAVE CHAVO CHAYA CHAYE CHAYO CHAZA CHAZO CHECA CHECO CHEFS CHEJE CHELA CHELE CHELI CHELO CHEPA CHEPE CHEPO CHERA CHERO CHESA CHESO CHETA CHETO CHIAD CHIAN CHIAR CHIAS CHICA CHICO CHICS CHIDA CHIDO CHIEN CHIES CHIFA CHIIS CHILE CHIMA CHIME CHIMO CHIMU CHINA CHINE CHINO CHIPA CHIPE CHIPS CHIRA CHIRI CHISA CHIST CHITA CHITE CHITO CHIVA CHIVE CHIVO CHIZA CHOBA CHOCA CHOCO CHOFE CHOLA CHOLO CHONA CHONO CHOPA CHOPE CHOPO CHORA CHORE CHORI CHORO CHOTA CHOTE CHOTO CHOVA CHOYA CHOYE CHOYO CHOZA CHOZO CHUAS CHUCA CHUCE CHUCO CHUFA CHUFE CHUFO CHULA CHULE CHULO CHUNA CHUPA CHUPE CHUPO CHURA CHURO CHURU CHUTA CHUTE CHUTO CHUTS CHUVA CHUYA CHUYO CHUZA CHUZO CHUNO CIABA CIADO CIAIS CIANI CIARA CIARE CIASE CIATO CIBAL CIBIS CICAS CICCA CICLA CICLE CICLO CIDES CIDRA CIDRO CIECA CIEGA CIEGO CIEIS CIELO CIEMO CIENO CIFRA CIFRE CIFRO CIGUA CIJAS CILIO CILLA CIMAR CIMAS CIMBA CIMIA CIMPA CINAS CINCA CINCO CINCS CINES CINIA CINTA CINTE CINTO CIPES CIPOS CIRCA CIRCE CIRCO CIRIO CIRRO CISCA CISCO CISMA CISME CISMO CISNE CISTA CITAD CITAN CITAR CITAS CITEN CITES CITRA CIVIL CINAN CINEN CLACO CLACS CLAMA CLAME CLAMO CLAPA CLARA CLARO CLASE CLAVA CLAVE CLAVO CLEMA CLERO CLICA CLICS CLIMA CLIPS CLISA CLISE CLISO CLOCA CLOCO CLONA CLONE CLONO CLORA CLORE CLORO CLOTA CLUBE CLUBS COANA COATI COBAS COBEA COBEZ COBIL COBLA COBOS COBRA COBRE COBRO COCAD COCAL COCAN COCAR COCAS COCEA COCED COCEE COCEO COCER COCES COCHA COCHE COCHI COCHO COCIA COCIO COCOL COCOS COCUI COCUY CODAL CODAS CODEA CODEE CODEO CODEZ CODIN CODON CODOS COEVA COEVO COFAN COFAS COFIA COFIN COFRE COGED COGEN COGER COGES COGIA COGIO COGON COIMA COIME COINE COIPO COITA COITE COITO COJAL COJAN COJAS COJEA COJEE COJEO COJIN COJON COJOS COLAD COLAN COLAR COLAS COLEA COLEE COLEN COLEO COLES COLGA COLGO COLIN COLLA COLMA COLME COLMO COLON COLOR COLOS COLPA COLPE COLZA COMAL COMAN COMAS COMBA COMBE COMBO COMED COMEN COMER COMES COMIA COMIC COMIO COMIS COMTA COMTO COMUN CONCA CONDE CONGA CONGO CONOS CONTA CONTE CONTO COONA COPAD COPAL COPAN COPAR COPAS COPEA COPEC COPEE COPEN COPEO COPES COPEY COPIA COPIE COPIN COPIO COPLA COPON COPOS COPRA COPTA COPTO COQUE COQUI CORAD CORAL CORAN CORAR CORAS CORBE CORCA CORCO CORDA COREA COREE COREN COREO CORES CORIO CORIS CORLA CORLE CORLO CORMA CORNO COROS CORPA CORPS CORRA CORRE CORRI CORRO CORSA CORSE CORSO CORTA CORTE CORTO CORUA CORVA CORVE CORVO CORZA CORZO COSAN COSAS COSCA COSCO COSED COSEN COSER COSES COSIA COSIO COSOS COSPE COSTA COSTE COSTO COTAD COTAN COTAR COTAS COTEN COTES COTIN COTIS COTON COTOS COTUA COVAD COVAN COVAR COVAS COVEN COVES COXAL COXAS COXIS COYAN COYAS COYES COYOL CONAC CONAS CONEA CONEE CONEO CONON CRACS CRASA CRASO CRAZA CREAD CREAN CREAR CREAS CRECE CRECI CREDO CREED CREEN CREER CREES CREIA CREMA CREME CREMO CREPE CREPS CRESA CRESO CRETA CREYO CRIAD CRIAN CRIAR CRIAS CRIBA CRIBE CRIBO CRICA CRICS CRIDA CRIEN CRIES CRINA CRINE CRINO CRIOS CROAD CROAN CROAR CROAS CROCO CROEN CROES CROMA CROME CROMO CRONO CROSS CROTO CROZA CRUCE CRUDA CRUDO CRUEL CRUJA CRUJE CRUJI CRUJO CRUOR CRUPS CRUZA CRUZO CUABA CUACO CUADA CUADO CUAJA CUAJE CUAJO CUAPE CUASI CUATA CUATE CUATI CUBAS CUBIL CUBOS CUBRA CUBRE CUBRI CUBRO CUCAD CUCAN CUCAR CUCAS CUCHA CUCHE CUCHI CUCHO CUCOS CUCUS CUCUY CUECA CUECE CUECO CUELA CUELE CUELO CUERA CUERO CUETE CUETO CUEVA CUEZA CUEZO CUICA CUICO CUIDA CUIDE CUIDO CUIJA CUILO CUINA CUINO CUITA CUJAS CUJES CUJIN CUJIS CUJON CULAR CULAS CULEA CULEE CULEN CULEO CULIA CULIO CULIS CULLE CULON CULOS CULPA CULPE CULPO CULTA CULTO CUMAS CUMBA CUMBE CUMBO CUMEL CUMPA CUNAD CUNAN CUNAR CUNAS CUNDA CUNDE CUNDI CUNDO CUNEA CUNEE CUNEN CUNEO CUNES CUOTA CUPES CUPLE CUPON CUPOS CUQUE CURAD CURAL CURAN CURAR CURAS CURCA CURCO CURDA CURDO CUREN CURES CURIA CURIE CURIL CURIO CURIS CUROS CURRA CURRE CURRO CURRY CURSA CURSE CURSI CURSO CURTA CURTE CURTI CURTO CURUL CURVA CURVE CURVO CUSAN CUSAS CUSCA CUSCO CUSCU CUSEN CUSES CUSIA CUSID CUSIO CUSIR CUSIS CUSMA CUSPA CUSUL CUTAS CUTER CUTES CUTIO CUTIR CUTIS CUTOS CUTRA CUTRE CUYAS CUYEO CUYES CUYOS CUZAS CUZCO CUZMA CUZOS CUZUL CUNAL CUNOS DABAN DABAS DABLE DACHA DACIA DACIO DADAS DADOR DADOS DAGAS DAHIR DAIFA DAJAO DALAS DALGO DALIA DALLA DALLE DALLO DAMAS DAMIL DAMOS DANCE DANDI DANDO DANES DANGO DANTA DANTE DANTO DANZA DANZO DAQUI DARAN DARAS DARDO DARES DARGA DARIA DATAD DATAN DATAR DATAS DATEA DATEE DATEN DATEO DATES DATIL DATOS DAUCO DAUDA DAZAS DANAD DANAN DANAR DANAS DANEN DANOS DEBAN DEBAS DEBDA DEBDO DEBED DEBEN DEBER DEBES DEBIA DEBIL DEBIO DEBLA DEBOS DEBUT DECAE DECAI DECIA DECID DECIR DECIS DECOR DEDAL DEDEO DEDIL DEDOS DEESA DEJAD DEJAN DEJAR DEJAS DEJEN DEJES DEJOS DELCO DELES DELGA DELIA DELIO DELLA DELLO DELTA DEMAS DEMOS DENDE DENSA DENSO DENTA DENTE DENTO DEPON DEQUE DERBI DESCA DESDA DESDE DESDI DESEA DESEE DESEO DESES DESGA DESOI DESTA DESTE DESTO DESUS DETAL DETEN DEUDA DEUDO DEVEN DEYES DEZMA DEZME DEZMO DENAR DIADA DIADO DIANA DIANO DICAZ DICEN DICES DICHA DICHO DICTA DICTE DICTO DIEGO DIERA DIERE DIESE DIESI DIETA DIETE DIETO DIGAN DIGAS DIGNA DIGNE DIGNO DIJES DILUI DIMAN DIMAS DIMEN DIMES DIMIA DIMID DIMIO DIMIR DIMIS DIMOS DINAR DINAS DINES DINOS DIODO DIOSA DIOSO DIQUE DIRAN DIRAS DIRIA DISCA DISCO DISON DISTA DISTE DISTO DITAS DIUCA DIVAN DIVAS DIVOS DINAD DINAN DINEN DOBLA DOBLE DOBLO DOCAS DOCES DOCIL DOCTA DOCTO DODOS DOGAL DOGAS DOGMA DOGOS DOGRE DOLAD DOLAR DOLAS DOLED DOLER DOLES DOLIA DOLIO DOLOR DOLOS DOMAD DOMAN DOMAR DOMAS DOMBO DOMEN DOMES DOMOS DONAD DONAN DONAR DONAS DONDE DONEN DONEO DONES DOPAD DOPAN DOPAR DOPAS DOPEN DOPES DORAD DORAL DORAN DORAR DORAS DOREN DORES DORIA DORIO DORMI DORNA DORSO DOSEL DOSES DOSIS DOTAD DOTAL DOTAN DOTAR DOTAS DOTEN DOTES DOTOR DONEA DONEE DRABA DRAGA DRAGO DRAMA DREAS DRENA DRENE DRENO DRIAS DRINO DRIZA DROGA DROGO DROPE DRUPA DRUSA DRUSO DSEDA DUBAS DUBDA DUBIO DUCAL DUCAS DUCES DUCHA DUCHE DUCHO DUCOS DUCTO DUDAD DUDAN DUDAR DUDAS DUDEN DUDES DUELA DUELE DUELO DUETO DUENA DUENO DUGOS DUJOS DULAR DULAS DULCE DULIA DUMAN DUMAS DUMEN DUMES DUMIA DUMID DUMIO DUMIR DUMIS DUNAS DUNDA DUNDO DUPLA DUPLO DUQUE DURAD DURAN DURAR DURAS DUREN DURES DUROS EBANO EBRIA EBRIO ECHAD ECHAN ECHAR ECHAS ECHEN ECHES ECUAS ECUOS EDEMA EDILA EDITA EDITE EDITO EDRAD EDRAN EDRAR EDRAS EDREN EDRES EDUCA EDUCE EDUCI EDUCO EDUJE EDUJO EFEBO EFETA EFETO EFLUI EFORO EGENA EGENO EGIDA EGUAR EIRAS EJIDO EJION EJOTE ELAMI ELATA ELATO ELCHE ELEGA ELEGI ELEGO ELEMI ELEPE ELETA ELETO ELEVA ELEVE ELEVO ELFOS ELIDA ELIDE ELIDI ELIDO ELIGE ELIJA ELIJE ELIJO ELITE ELLAS ELLES ELLOS ELOTE ELUDA ELUDE ELUDI ELUDO EMANA EMANE EMANO EMBAI EMITA EMITE EMITI EMITO EMPOS EMPRA EMPRE EMPRO EMUES EMULA EMULE EMULO ENANA ENANO ENCIA ENEAL ENEAS ENEJA ENEJE ENEJO ENEMA ENEOS ENERO ENOJA ENOJE ENOJO ENRIA ENRIE ENRIO ENSAY ENTEO ENTES ENTRA ENTRE ENTRO ENULA ENVES ENVIA ENVIE ENVIO ENZAS EOLIA EOLIO EONES EPALE EPATA EPATE EPATO EPICA EPICO EPOCA EPODA EPODO EPOTA EPOTO EPOXI EQUIS ERABA ERADA ERADO ERAIS ERAJE ERALA ERARA ERARE ERASE ERBIO ERCER EREBO EREIS ERGIO ERGUI ERIAL ERIAS ERICE ERIGE ERIGI ERIJA ERIJO ERINA ERIOS ERIZA ERIZO ERMAR EROGA EROGO ERRAD ERRAJ ERRAN ERRAR ERRAS ERREN ERRES ERROR ERROS ERUTA ERUTE ERUTO ESCAS ESCAY ESCOA ESMUI ESNOB ESPAY ESPIA ESPIE ESPIN ESPIO ESQUI ESTAD ESTAN ESTAR ESTAS ESTAY ESTEN ESTER ESTES ESTIL ESTIO ESTOL ESTOR ESTOS ESTOY ESTRO ESULA ETANO ETAPA ETICA ETICO ETILO ETIMO ETNEA ETNEO ETNIA ETOLA ETOLO ETUSA EUBEA EUBEO EUROS EVADA EVADE EVADI EVADO EVITA EVITE EVITO EVOCA EVOCO EVOHE EXIDA EXIGE EXIGI EXIJA EXIJO EXILA EXILE EXILO EXIMA EXIME EXIMI EXIMO EXITO EXODO EXORA EXORE EXORO EXPIA EXPIE EXPIO EXPON EXTRA EXUDA EXUDE EXUDO FABAS FABLA FABOS FABRO FACAS FACER FACES FACHA FACHE FACHO FACIL FACON FACTO FADAS FADOS FAENA FAENE FAENO FAGOS FAGOT FAINA FAINO FAJAD FAJAN FAJAR FAJAS FAJEA FAJEE FAJEN FAJEO FAJES FAJIN FAJOL FAJON FAJOS FALAZ FALCA FALCE FALCO FALDA FALLA FALLE FALLO FALOS FALSA FALSE FALSO FALTA FALTE FALTO FALUA FAMAS FANAL FANES FANGO FAQUI FARAD FARAS FARDA FARDE FARDO FARIA FARIO FAROL FARON FAROS FARPA FARRA FARRO FARSA FARTE FASES FASOL FASOS FASTA FASTO FATAL FATAS FATOR FATOS FATUA FATUO FAUNA FAUNO FAVOR FAVOS FAXEA FAXEE FAXEO FAXES FAYAS FANAD FANAN FANAR FANAS FANEN FEBEA FEBEO FEBLE FECAL FECES FECHA FECHE FECHO FEEZA FEJES FELIZ FELON FELPA FELPE FELPO FELUS FEMAD FEMAN FEMAR FEMAS FEMEN FEMES FEMUR FENAL FENDA FENDI FENIX FENOL FERAL FERAZ FERIA FERIE FERIO FERIR FERMI FEROZ FERRA FERRE FERRO FERRY FESTA FETAL FETAS FETEN FETOR FETOS FETUA FEUCA FEUCO FEUDA FEUDE FEUDO FEURA FIABA FIACA FIADA FIADO FIAIS FIANA FIARA FIARE FIASE FIATS FIBRA FICAR FICEN FICES FICHA FICHE FICHO FICUS FIDEO FIEIS FIEMO FIERA FIERO FIFAD FIFAN FIFAR FIFAS FIFEN FIFES FIFIS FIGLE FIGON FIJAD FIJAN FIJAR FIJAS FIJEN FIJES FIJON FIJOS FILAD FILAN FILAR FILAS FILEN FILES FILFA FILIA FILIE FILIN FILIO FILIS FILLO FILMA FILME FILMO FILMS FILON FILOS FIMOS FINAD FINAL FINAN FINAR FINAS FINCA FINCO FINEN FINES FINGE FINGI FINIA FINID FINIO FINIR FINIS FINJA FINJO FINOS FINTA FINTE FINTO FIQUE FIRMA FIRME FIRMO FISAN FISCO FISGA FISGO FISTA FISTO FIZAD FIZAN FIZAR FIZAS FIZON FLACA FLACO FLAMA FLAON FLASH FLATO FLAVA FLAVO FLECO FLEJA FLEJE FLEJO FLEMA FLEME FLEOS FLETA FLETE FLETO FLEXO FLIPA FLIPE FLIPO FLOJA FLOJO FLORA FLORE FLORO FLOTA FLOTE FLOTO FLUIA FLUID FLUIR FLUIS FLUJO FLUOR FLUYA FLUYE FLUYO FOBIA FOCAL FOCAS FOCHA FOCIA FOCIO FOCOS FOFAS FOFOS FOGON FOISA FOISO FOJAS FOLGA FOLGO FOLIA FOLIE FOLIO FOLLA FOLLE FOLLO FOLUZ FOMES FONDA FONDO FONES FONIL FONIO FONJE FONOS FOQUE FORAL FORAS FORCA FORCE FORJA FORJE FORJO FORMA FORME FORMO FORNO FOROS FORRA FORRE FORRO FORTE FORUM FORZA FORZO FOSAD FOSAL FOSAN FOSAR FOSAS FOSCA FOSCO FOSEN FOSES FOSIL FOSOR FOSOS FOTON FOTOS FOVEA FRACS FRADA FRADE FRADO FRAGA FRASE FRANA FRANE FRANI FRANO FRECE FREDO FREGA FREGO FREIA FREID FREIR FREIS FRENA FRENE FRENO FREON FREOS FRESA FRESE FRESO FRETA FRETE FRETO FREZA FREZO FRIAN FRIAS FRICA FRICO FRIEN FRIES FRIOR FRIOS FRISA FRISE FRISO FRITA FRITE FRITO FROGA FROGO FROTA FROTE FROTO FRUIA FRUID FRUIR FRUIS FRUTA FRUTE FRUTO FRUYA FRUYE FRUYO FUCAR FUCHI FUCIA FUCOS FUDRE FUEGO FUERA FUERE FUERO FUESA FUESE FUETS FUFAD FUFAN FUFAR FUFAS FUFEN FUFES FUFOS FUFUS FUGAN FUGAR FUGAS FUGAZ FUGIR FUGUE FUINA FULAR FULAS FULGE FULGI FULJA FULJO FULLA FUMAD FUMAN FUMAR FUMAS FUMEN FUMES FUMON FUNCA FUNCO FUNDA FUNDE FUNDI FUNDO FUNGE FUNGI FUNJA FUNJO FURAS FURIA FUROR FUROS FURTO FUSAS FUSCA FUSCO FUSIL FUSOR FUSOS FUSTA FUSTE FUSTO FUTIL FUTON FUTRE FUNAR GABAN GABAR GACEL GACHA GACHE GACHI GACHO GAFAD GAFAN GAFAR GAFAS GAFEA GAFEE GAFEN GAFEO GAFES GAFOS GAGAS GAGOS GAITA GAJES GAJOS GALAN GALAS GALCE GALEA GALEO GALES GALGA GALGO GALIO GALLA GALLE GALLO GALON GALOP GALOS GALUA GAMAS GAMBA GAMMA GAMON GAMOS GANAD GANAN GANAR GANAS GANEN GANES GANGA GANSA GANSO GANTA GANTE GARAS GARAY GARBA GARBE GARBO GARFA GARIA GARIO GARLA GARLE GARLO GARMA GAROS GARPA GARPE GARPO GARRA GARRE GARRI GARRO GARUA GARUE GARUO GARZA GARZO GASAS GASEA GASEE GASEO GASES GASON GASTA GASTE GASTO GATAS GATEA GATEE GATEO GATOS GAUSS GAVIA GAYAD GAYAN GAYAR GAYAS GAYEN GAYES GAYOS GAZAS GAZNA GAZNE GAZNO GANIA GANID GANIL GANIN GANIR GANIS GANON GELAN GELAR GELAS GELEN GELES GELFE GEMAS GEMIA GEMID GEMIR GEMIS GENES GENIO GENOL GENTE GEODA GERBO GESTA GESTE GESTO GETAS GIBAD GIBAN GIBAO GIBAR GIBAS GIBEN GIBES GIBON GIGAS GILAS GILES GILIS GILVA GILVO GIMAN GIMAS GIMEN GIMES GIMIO GINEA GIRAD GIRAN GIRAR GIRAS GIREN GIRES GIROS GISES GISTE GLASE GLAYO GLEBA GLERA GLIAL GLIAS GLIDE GLIFO GLOBO GLOSA GLOSE GLOSO GLUMA GNEIS GNOMO GOBEN GOBIO GOCEN GOCES GOCHA GOCHO GODAS GODEO GODOS GOFAS GOFIO GOFOS GOFRA GOFRE GOFRO GOLAS GOLEA GOLEE GOLEO GOLES GOLFA GOLFO GOLFS GOLPE GOMAR GOMAS GOMEL GOMER GOMIA GONCE GONGO GORDA GORDO GORGA GORJA GORMA GORME GORMO GORRA GORRO GOTAS GOTEA GOTEE GOTEO GOTON GOYAS GOYOS GOZAD GOZAN GOZAR GOZAS GOZNE GOZON GOZOS GRABA GRABE GRABO GRADA GRADE GRADO GRAFO GRAIS GRAJA GRAJO GRAMA GRAME GRAMO GRANA GRAND GRANE GRANO GRANT GRAOS GRAPA GRAPE GRAPO GRASA GRASO GRATA GRATE GRATO GRAVA GRAVE GRAVO GREBA GRECA GRECO GREDA GREEN GRELO GRENO GRENA GRIAL GRIDA GRIFA GRIFE GRIFO GRIJA GRILL GRIMA GRIPA GRIPE GRIPO GRISA GRISU GRITA GRITE GRITO GROAD GROAN GROAR GROAS GROEN GROES GROGS GROJO GROMO GROSA GROSO GRUAS GRUIA GRUID GRUIR GRUIS GRUJA GRUJE GRUJI GRUJO GRUMO GRUPA GRUPI GRUPO GRUTA GRUYA GRUYE GRUYO GRUNA GRUNE GRUNI GRUNO GUABA GUABO GUACA GUACO GUADO GUAIS GUAJA GUAJE GUALA GUAMA GUAME GUAMO GUANO GUAOS GUAPA GUAPE GUAPO GUARA GUARE GUARI GUARO GUASA GUASO GUATA GUATE GUATO GUAYA GUAYE GUAYO GUBIA GUERA GUERO GUETO GUENA GUIAD GUIAN GUIAR GUIAS GUIDA GUIDO GUIEN GUIES GUIFA GUIJA GUIJO GUILA GUILO GUINA GUINO GUION GUIPA GUIPE GUIPO GUIRA GUIRE GUIRI GUIRO GUISA GUISE GUISO GUITA GUITE GUITO GUIYE GUINE GUJAS GULAG GULAR GULAS GULAY GULES GUMIA GURDA GURDO GURIS GURUS GUSTA GUSTE GUSTO GUZGA GUZGO GUZLA HABAR HABAS HABER HABIA HABIL HABIZ HABLA HABLE HABLO HABON HABRA HABRE HABUS HACAN HACED HACEN HACER HACES HACHA HACHE HACHO HACIA HADAR HADAS HADOS HAFIZ HAGAN HAGAS HAIGA HALAD HALAN HALAR HALAS HALDA HALEN HALES HALLA HALLE HALLO HALON HALOS HAMEZ HAMPA HAMPO HANZO HAPAX HARAN HARAS HARBA HARBE HARBO HARCA HARDA HAREM HAREN HARIA HARMA HARON HARPA HARRE HARTA HARTE HARTO HASTA HATEA HATEE HATEO HATOS HAUTE HAVAR HAVOS HAYAL HAYAN HAYAS HAYOS HAZAS HEBEN HEBRA HECES HECHA HECHO HEDED HEDER HEDES HEDIA HEDIO HEDOR HELAD HELAR HELAS HELEA HELEE HELEO HELIO HELOR HEMOS HENAL HENAR HENDE HENDI HENIL HENOS HENRY HERBA HERBE HERBO HERIA HERID HERIL HERIR HERIS HERMA HEROE HERPE HERRA HERRE HERRO HERTZ HERVE HERVI HESPA HESPE HESPI HESPO HETEA HETEO HEVEA HENIA HENID HENIR HENIS HIATO HICOS HIDRA HIEDA HIEDE HIEDO HIELA HIELE HIELO HIENA HIERA HIERE HIERO HIGAS HIGOS HIGUI HIJAS HIJEA HIJEE HIJEO HIJOS HILAD HILAN HILAR HILAS HILEN HILES HILIO HILOS HIMEN HIMNO HIMPA HIMPE HIMPO HINCA HINCO HINDI HINDU HIPAD HIPAN HIPAR HIPAS HIPEN HIPER HIPES HIPOS HIPPY HIRCO HIRIO HIRMA HIRME HIRMO HISCA HISPA HISPE HISPI HISPO HITAD HITAN HITAR HITAS HITEN HITES HITON HITOS HINAN HINAS HINEN HINES HINIA HINID HINIR HINIS HOBBY HOBOS HOCEN HOCES HOGAR HOGOS HOJAS HOJEA HOJEE HOJEO HOLAN HOLCO HOLEA HOLEE HOLEO HOLGA HOLGO HOLLA HOLLE HOLLO HOMES HONDA HONDO HONGO HONOR HONRA HONRE HONRO HOPAN HOPAR HOPAS HOPEA HOPEE HOPEN HOPEO HOPES HOPOS HOQUE HORAS HORCA HORCO HORDA HORMA HORNA HORNE HORNO HORRA HORRE HORRO HOSCA HOSCO HOSPA HOSTE HOTEL HOTOS HOVES HOYAD HOYAN HOYAR HOYAS HOYEN HOYES HOYOS HOZAD HOZAN HOZAR HOZAS HUACA HUACO HUAJE HUAOS HUCHA HUCHO HUCIA HUECA HUECO HUEGO HUELA HUELE HUELO HUERA HUERO HUESA HUESO HUEVA HUEVE HUEVO HUIAN HUIAS HUICH HUIDA HUIDO HUIFA HUILA HUILO HUIRA HUIRE HUIRO HULAD HULAN HULAR HULAS HULEA HULEE HULEN HULEO HULES HULLA HULTE HUMAD HUMAN HUMAR HUMAS HUMEA HUMEE HUMEN HUMEO HUMES HUMIL HUMOR HUMOS HUMUS HUNAS HUNDA HUNDE HUNDI HUNDO HUNOS HUPES HURAS HURGA HURGO HURIS HURON HURRA HURTA HURTE HURTO HUSAR HUSMA HUSME HUSMO HUSOS HUTAS HUTIA HUYAN HUYAS HUYEN HUYES IBAIS IBERA IBERO IBICE ICACO ICEIS ICHAL ICHOS ICHUS ICONO ICTUS IDEAD IDEAL IDEAN IDEAR IDEAS IDEAY IDEEN IDEES IDEOS IDOLO IGLUS IGNEA IGNEO IGUAL IGUAR IJADA IJIYO IJUJU ILEON ILEOS ILESA ILESO ILION ILOTA ILUDA ILUDE ILUDI ILUDO ILUSA ILUSO IMADA IMANA IMANE IMANO IMBUI IMELA IMITA IMITE IMITO IMPAR IMPIA IMPIO IMPLA IMPLE IMPLO IMPON INANE INCAS INCOA INCOE INCOO INDAS INDEX INDIA INDIO INDOS INFLA INFLE INFLO INGAS INGLE INGON INGRE INOPE INPUT INRIS INSTA INSTE INSTO INTER INTIS INTUI INVAR IONES IOTAS IPSIS IRADA IRADO IRANI IREIS IRGAN IRGAS IRGUE IRIAN IRIAS IRIDE IRISA IRISE IRISO IRRUI IRUPE ISBAS ISLAM ISLAN ISLAS ISLEO ISOCA ISTMO ITALA ITALO ITEMS ITERA ITERE ITERO ITRIA ITRIO ITZAJ IZABA IZADA IZADO IZAIS IZARA IZARE IZASE IZOTE JABAS JABIS JABLE JABON JABRA JABRE JABRI JABRO JACAL JACAS JACER JACHA JACOS JACTA JACTE JACTO JADAS JADEA JADEE JADEO JADES JADIA JADIE JADIO JAECE JAEZA JAEZO JAGUA JAIBA JAIMA JAJAY JALAD JALAN JALAR JALAS JALDA JALDE JALDO JALEA JALEE JALEN JALEO JALES JALMA JALON JAMAD JAMAN JAMAR JAMAS JAMBA JAMBE JAMBO JAMEN JAMES JAMON JANES JAPON JAQUE JARAL JARAS JARBA JARBE JARBO JARCA JARDA JAROS JARRA JARRE JARRO JASAD JASAN JASAR JASAS JASEN JASES JASPE JATAS JATEO JATES JATIB JATOS JAUDA JAUDO JAUJA JAULA JAUTA JAUTO JAVAS JAVOS JAYAN JANAS JANOS JEBES JEDAD JEDAN JEDAR JEDAS JEDEN JEDES JEFAS JEFES JEITO JEJEN JELIZ JEMAL JEMES JEQUE JERAS JERBO JEREZ JERGA JERPA JETAD JETAN JETAR JETAS JETEA JETEE JETEN JETEO JETES JETON JETOS JIBES JIBIA JICOS JIFAS JIFIA JIGAS JIGUE JIJAS JIJEA JIJEE JIJEO JIMAD JIMAN JIMAR JIMAS JIMEN JIMES JIMIA JIMIO JINDA JINES JIOTE JIPAS JIPIA JIPIE JIPIO JIPIS JIRAS JIREL JIRON JISCA JITAD JITAN JITAR JITAS JITEN JITES JINAD JINAN JINAR JINAS JINEN JOBAR JOBOS JOCHA JOCHE JOCHO JOCON JOCOS JODAN JODAS JODED JODEN JODER JODES JODIA JODIO JODON JOFOR JOLIN JONDO JONIA JONIO JOPAN JOPAR JOPAS JOPEA JOPEE JOPEN JOPEO JOPES JOPOS JORAS JORCO JORFE JORGA JORGE JORRO JOSAS JOTAS JOTES JOTOS JOULE JOVEN JOYAS JOYEL JOYON JOYOS JUANA JUBAS JUBON JUBOS JUCAS JUCOS JUDAS JUDIA JUDIO JUDOS JUEGA JUEGO JUERA JUEZA JUGAD JUGAR JUGAS JUGOS JUGUE JUJEA JUJEE JUJEO JULIA JULIO JULOS JUMAN JUMAR JUMAS JUMEA JUMEE JUMEN JUMEO JUMES JUMIL JUMOS JUNCE JUNCI JUNCO JUNIO JUNTA JUNTE JUNTO JUNZA JUNZO JUPAS JUPEA JUPEE JUPEO JUPON JURAD JURAN JURAR JURAS JURCO JUREL JUREN JURES JUROS JUSIS JUSTA JUSTE JUSTO JUTAS JUTIA JUVIA JUZGA JUZGO JUNAN JUNAS JUNEN JUNES JUNIA JUNID JUNIR JUNIS LABEO LABES LABIA LABIL LABIO LABOR LABRA LABRE LABRO LACAD LACAN LACAR LACAS LACEA LACEE LACEN LACEO LACES LACHA LACHO LACIA LACIO LACON LACRA LACRE LACRO LACTA LACTE LACTO LADAS LADEA LADEE LADEO LADON LADOS LADRA LADRE LADRO LAGAR LAGOS LAGUA LAICA LAICO LAIDA LAIDO LAJAS LAMAN LAMAS LAMBA LAMBE LAMBI LAMBO LAMED LAMEN LAMER LAMES LAMIA LAMIN LAMIO LAMPA LAMPE LAMPO LANAR LANAS LANCE LANDA LANDE LANDO LANGA LANIA LANIO LANZA LANZO LAPAS LAPIZ LAPON LAPOS LAPSA LAPSO LAQUE LARDA LARDE LARDO LARES LARGA LARGO LARRA LARVA LASAR LASAS LASCA LASCO LASER LASOS LASTA LASTE LASTO LASUN LATAN LATAS LATAZ LATEA LATEE LATEN LATEO LATES LATEX LATIA LATID LATIN LATIO LATIR LATIS LATON LATOS LAUDA LAUDE LAUDO LAUNA LAURO LAUTA LAUTO LAVAD LAVAN LAVAR LAVAS LAVEN LAVES LAXAD LAXAN LAXAR LAXAS LAXEN LAXES LAXOS LAYAD LAYAN LAYAR LAYAS LAYEN LAYES LAZAD LAZAN LAZAR LAZAS LAZOS LANAD LANAN LANEN LANES LEAIS LECHA LECHE LECHO LECOS LEDAS LEDON LEDOS LEEIS LEERA LEERE LEGAD LEGAL LEGAN LEGAR LEGAS LEGON LEGOS LEGRA LEGRE LEGRO LEGUA LEGUE LEGUI LEIAN LEIAS LEIDA LEIDO LEILA LEIMA LEJAS LEJIA LEJIO LEJOS LELAS LELOS LEMAN LEMAS LEMBO LEMPO LEMUR LENAS LENCA LENES LENON LENTA LENTE LENTO LEONA LEPRA LERAS LERDA LERDO LESAS LESEA LESEE LESEO LESNA LESOS LESTE LETAL LETEA LETEO LETON LETRA LEUCO LEUDA LEUDE LEUDO LEVAD LEVAN LEVAR LEVAS LEVEN LEVES LEYES LEZDA LEZNA LEZNE LENAD LENAN LENAR LENEN LENOS LIABA LIADA LIADO LIAIS LIANA LIARA LIARE LIASE LIAZA LIBAD LIBAN LIBAR LIBAS LIBEN LIBER LIBES LIBIA LIBIO LIBON LIBRA LIBRE LIBRO LICEO LICIA LICIO LICOR LICUA LICUE LICUO LIDER LIDES LIDIA LIDIE LIDIO LIDON LIEGA LIEGO LIEIS LIEVA LIEVE LIGAD LIGAN LIGAR LIGAS LIGHT LIGIO LIGON LIGUE LIGUR LIJAD LIJAN LIJAR LIJAS LIJEN LIJES LILAC LILAO LILAS LILIO LILOS LIMAD LIMAN LIMAR LIMAS LIMBO LIMEN LIMES LIMON LIMOS LINAO LINAR LINCE LINDA LINDE LINDO LINEA LINEE LINEO LINFA LINIO LINON LINOS LIOSA LIOSO LIPAS LIPES LIPIS LIPON LIRAS LIRIA LIRIO LIRON LISAS LISES LISIA LISIE LISIO LISIS LISOL LISOS LISTA LISTE LISTO LITAD LITAN LITAR LITAS LITEN LITES LITIO LITIS LITRE LITRO LITUO LIUDA LIUDE LIUDO LIVOR LIZAS LIZOS LLACA LLAGA LLAGO LLAMA LLAME LLAMO LLANA LLANO LLAPA LLAPE LLAPO LLAVE LLECA LLECO LLEGA LLEGO LLENA LLENE LLENO LLERA LLEVA LLEVE LLEVO LLORA LLORE LLORO LLOSA LLOVE LLOVI LOABA LOADA LOADO LOAIS LOARA LOARE LOASE LOBAS LOBBY LOBEA LOBEE LOBEO LOBOS LOCAL LOCAS LOCEA LOCEE LOCEO LOCHA LOCHE LOCOS LOCRO LODON LODOS LODRA LOEIS LOGAR LOGIA LOGIS LOGOS LOGRA LOGRE LOGRO LOICA LOINA LOINO LOLAS LOLEA LOLEE LOLEO LOLIO LOLIS LOLOS LOMAS LOMBA LOMBO LOMEA LOMEE LOMEO LOMOS LONAS LONCO LONGA LONGO LONJA LORAS LOREA LOREE LOREO LORES LOROS LORZA LOSAD LOSAN LOSAR LOSAS LOSEN LOSES LOTAS LOTEA LOTEE LOTEO LOTES LOTIN LOTOS LOZAS LUCAS LUCEN LUCES LUCHA LUCHE LUCHO LUCIA LUCID LUCIO LUCIR LUCIS LUCRA LUCRE LUCRO LUDAN LUDAS LUDEN LUDES LUDIA LUDID LUDIE LUDIO LUDIR LUDIS LUDOS LUEGO LUENE LUGAR LUGRE LUIAN LUIAS LUIDA LUIDO LUIRA LUIRE LUISA LUJAD LUJAN LUJAR LUJAS LUJEN LUJES LUJOS LULOS LULUS LUMAS LUMBO LUMEN LUMIA LUNAR LUNAS LUNCH LUNEA LUNEE LUNEL LUNEO LUNES LUNFA LUPAS LUPIA LUPUS LURTE LUSAS LUSCA LUSCO LUSOS LUTEA LUTEO LUTOS LUVIA LUXAD LUXAN LUXAR LUXAS LUXEN LUXES LUYAN LUYAS LUYEN LUYES LUZCA LUZCO LYCRA MABIS MABLE MACAL MACAN MACAR MACAS MACEA MACEE MACEN MACEO MACES MACHA MACHE MACHI MACHO MACIA MACIO MACIS MACLA MACON MACRO MACUA MADOR MADRE MAESA MAESE MAESO MAENA MAENO MAFIA MAGAS MAGIA MAGIE MAGIN MAGIO MAGMA MAGNA MAGNO MAGOS MAGRA MAGRO MAGUE MAHON MAIDO MAJAD MAJAL MAJAN MAJAR MAJAS MAJEA MAJEE MAJEN MAJEO MAJES MAJOS MALAR MALAS MALEA MALEE MALEO MALES MALIS MALLA MALLE MALLO MALON MALOS MALTA MALVA MALVE MALVO MAMAD MAMAN MAMAR MAMAS MAMBI MAMBO MAMEN MAMES MAMEY MAMIA MAMON MAMUA MAMUT MANAD MANAL MANAN MANAR MANAS MANCA MANCO MANDA MANDE MANDI MANDO MANEA MANEE MANEN MANEO MANES MANGA MANGO MANIA MANID MANIO MANIR MANIS MANOS MANSA MANSO MANTA MANTO MANUS MAOMA MAORI MAPAS MAPEA MAPEE MAPEO MAPOS MAQUE MAQUI MARAS MARCA MARCE MARCI MARCO MAREA MAREE MAREO MARES MARGA MARGO MARIA MARLO MARON MAROS MARRA MARRE MARRO MARSA MARSO MARTA MARTE MARZA MARZO MASAD MASAN MASAR MASAS MASCA MASCO MASEA MASEE MASEN MASEO MASES MASIA MASLO MASON MASTE MASTO MATAD MATAN MATAR MATAS MATEA MATEE MATEN MATEO MATES MATIZ MATON MATOS MATUL MAULA MAULE MAULO MAURA MAURE MAURO MAYAD MAYAL MAYAN MAYAR MAYAS MAYEA MAYEE MAYEN MAYEO MAYES MAYOR MAYOS MAZAD MAZAN MAZAR MAZAS MAZNA MAZNE MAZNO MAZOS MBAYA MEABA MEADA MEADO MEAIS MEAJA MEANO MEARA MEARE MEASE MEATO MECAS MECED MECEN MECER MECES MECHA MECHE MECHO MECIA MECIO MECOS MEDAS MEDIA MEDID MEDIE MEDIO MEDIR MEDIS MEDOS MEDRA MEDRE MEDRO MEEIS MEGAS MEGOS MEIGA MEIGO MEJAN MEJAS MEJED MEJEN MEJER MEJES MEJIA MEJIO MEJOR MELAD MELAR MELAS MELCA MELGA MELGO MELIS MELLA MELLE MELLO MELON MELSA MELVA MEMAS MEMEZ MEMOS MENAD MENAN MENAR MENAS MENDA MENEA MENEE MENEN MENEO MENES MENGE MENOR MENOS MENSA MENSO MENSU MENTA MENTE MENTI MENTO MENUS MEONA MERAD MERAN MERAR MERAS MERCA MERCO MEREN MERES MEREY MERGO MERLA MERLO MERMA MERME MERMO MEROL MEROS MERSA MESAD MESAN MESAR MESAS MESEN MESES MESMA MESMO MESON MESTA MESTO METAD METAL METAN METAS METED METEN METER METES METIA METIO METRA METRO MEYAS MEYOR MEZAN MEZAS MIABA MIADO MIAGA MIAGO MIAIS MIAJA MIARA MIARE MIASE MIAUS MIANA MIANE MIANO MICAS MICER MICES MICHA MICHE MICHO MICOS MICRA MICRO MIDAN MIDAS MIDEN MIDES MIDIO MIEDO MIEIS MIELA MIELE MIELO MIERA MIGAD MIGAN MIGAR MIGAS MIGRA MIGRE MIGRO MIGUE MIJOS MILAN MILES MILIS MILLA MILLO MILPA MIMAD MIMAN MIMAR MIMAS MIMEN MIMES MIMOS MINAD MINAL MINAN MINAR MINAS MINAZ MINEN MINES MINGA MINGO MINIA MINIE MINIO MINUE MIOMA MIONA MIOPE MIRAD MIRAN MIRAR MIRAS MIREN MIRES MIRLA MIRLE MIRLO MIRON MIRRA MIRTO MIRZA MISAD MISAL MISAN MISAR MISAS MISEN MISES MISIA MISIL MISIO MISMA MISMO MISTA MISTE MISTO MITAD MITAN MITAS MITIN MITON MITOS MITRA MITRE MITRO MIURA MIXTA MIXTO MIZAS MIZOS MINON MOAIS MOARE MOBLE MOCAD MOCAN MOCAR MOCAS MOCEA MOCEE MOCEO MOCHA MOCHE MOCHO MOCIL MOCOS MODAL MODAS MODEM MODIO MODOS MOFAD MOFAN MOFAR MOFAS MOFEN MOFES MOGAS MOGOL MOGON MOGOS MOHIN MOHOS MOHUR MOJAD MOJAN MOJAR MOJAS MOJEL MOJEN MOJES MOJIL MOJIS MOJON MOJOS MOLAD MOLAN MOLAR MOLAS MOLDA MOLDE MOLDO MOLED MOLEN MOLER MOLES MOLIA MOLIO MOLLA MOLLE MOLON MOLOS MOLSA MOLSO MOMEA MOMEE MOMEO MOMIA MOMIO MOMOS MONAS MONDA MONDE MONDO MONEA MONEE MONEO MONFI MONGA MONGO MONIS MONJA MONJE MONOS MONRA MONSE MONTA MONTE MONTO MOPAN MOPAS MOQUE MORAD MORAL MORAN MORAR MORAS MORBO MORCA MORCO MORDE MORDI MOREA MOREN MOREO MORES MORFA MORFE MORFO MORGA MORIA MORID MORIR MORIS MORMA MORME MORMO MORON MOROS MORRA MORRO MORSA MORSE MOSCA MOSCO MOSEN MOSTE MOSTO MOTAS MOTEA MOTEE MOTEL MOTEO MOTES MOTIL MOTIN MOTON MOTOR MOTOS MOVED MOVER MOVES MOVIA MOVIL MOVIO MOXAS MOXTE MOYAS MOYOS MOZAS MOZOS MONON MUARE MUBLE MUCAS MUCHA MUCHO MUCOS MUDAD MUDAN MUDAR MUDAS MUDEN MUDES MUDEZ MUDOS MUECA MUELA MUELE MUELO MUERA MUERE MUERO MUESO MUEVA MUEVE MUEVO MUFAS MUFLA MUFTI MUGAD MUGAN MUGAR MUGAS MUGEN MUGES MUGIA MUGID MUGIL MUGIO MUGIR MUGIS MUGLE MUGOR MUGRE MUGUE MUIAN MUIAS MUIDA MUIDO MUIRA MUIRE MUJAN MUJAS MUJER MUJOL MULAR MULAS MULEO MULES MULLA MULLE MULLI MULLO MULOS MULSA MULSO MULTA MULTE MULTO MUNAS MUNDO MURAD MURAL MURAN MURAR MURAS MUREN MURES MURGA MURIA MURIO MUROS MURTA MURTO MUSAN MUSAR MUSAS MUSCA MUSCO MUSEN MUSEO MUSES MUSGA MUSGO MUSIA MUSIO MUSIR MUSIS MUSLO MUSOS MUTAD MUTAN MUTAR MUTAS MUTEN MUTES MUTIS MUTRA MUTRO MUTUA MUTUO MUYAN MUYAS MUYEN MUYES MUNAN MUNEN MUNES MUNIA MUNID MUNIR MUNIS MUNON NABAB NABAL NABAR NABAS NABIS NABLA NABOS NACAR NACAS NACED NACEN NACER NACES NACHA NACHO NACIA NACIO NACOS NACRE NADAD NADAL NADAN NADAR NADAS NADEN NADES NADGA NADIE NADIR NAFRA NAFRE NAFRO NAFTA NAGUA NAHOA NAHUA NAIFE NAIFS NAIPE NAIRE NAJAS NALCA NALGA NANAS NANAY NANCE NANEA NANEE NANEO NANSA NANSU NANTA NANTE NANTO NAPAS NAPEA NAPEO NAQUE NARCO NARDO NARES NARIZ NARRA NARRE NARRO NASAL NASAS NASON NASOS NATAL NATAS NATIA NATIO NATOS NATRI NAUTA NAVAL NAVAS NAVES NAVIO NAZCA NAZCO NAZIS NEBEL NEBIS NEBLI NEBRO NECEA NECEE NECEO NECIA NECIO NEGAD NEGAR NEGAS NEGRA NEGRO NEGUE NEGUS NEJAS NEJOS NELDO NELES NEMAS NEMEA NEMEO NEMES NEMON NENAS NENES NENIA NEPES NERON NESGA NESGO NETAS NETOS NEUMA NEVAD NEVAR NEVAS NEVOS NEVUS NEXOS NIARA NIAZO NICHE NICHO NICLE NICOL NIDAL NIDIA NIDIO NIDOS NIEGA NIEGO NIELA NIELE NIELO NIETA NIETO NIEVA NIEVE NIEVO NIGUA NILAD NILON NIMBA NIMBE NIMBO NIMIA NIMIO NINFA NINFO NINOT NIOTO NIPAS NIPIS NIPON NIPOS NIQUI NISTE NITOR NITOS NITRA NITRE NITRO NIVEA NIVEL NIVEO NIXTE NINAS NINEA NINEE NINEO NINEZ NINOS NOBEL NOBLE NOCAS NOCHE NOCIR NOCLA NODAL NODOS NOEMA NOGAL NOLIS NOLIT NOMAS NOMON NOMOS NONAS NONES NONIO NONOS NOPAL NOQUE NORAY NORIA NORMA NORME NORMO NORTE NOTAD NOTAN NOTAR NOTAS NOTEN NOTES NOTOS NOTRO NOVAD NOVAL NOVAN NOVAR NOVAS NOVEL NOVEN NOVES NOVIA NOVIE NOVIO NOYOS NUBES NUBIA NUBIL NUBIO NUBLA NUBLE NUBLO NUCAS NUCHE NUCIR NUCOS NUDAS NUDOS NUERA NUESA NUESO NUEVA NUEVE NUEVO NUEZA NULAS NULOS NUMEN NUMOS NUNCA NUTRA NUTRE NUTRI NUTRO NUNOS OASIS OBELO OBESA OBESO OBICE OBITO OBLEA OBOES OBOLO OBRAD OBRAN OBRAR OBRAS OBREN OBRES OBSTA OBSTE OBSTO OBTEN OBUES OBVIA OBVIE OBVIO OCAPI OCASO OCELO OCENA OCHOS OCIAD OCIAN OCIAR OCIAS OCIEN OCIES OCIOS OCLES OCLUI OCOTE OCRAS OCRES OCREY OCUJE OCUME OCUMO OCUPA OCUPE OCUPO ODEON ODIAD ODIAN ODIAR ODIAS ODIEN ODIES ODIOS ODRES OESTE OFITA OGANO OGROS OHMIO OIAIS OIBLE OIDAS OIDIO OIDOR OIDOS OIGAN OIGAS OIMOS OIRAN OIRAS OIRIA OISLO OISTE OJALA OJALE OJALO OJEAD OJEAN OJEAR OJEAS OJEEN OJEES OJEOS OJERA OJETE OJITO OJIVA OJOSA OJOSO OJOTA OJUDA OJUDO OLAIS OLAJE OLEAD OLEAN OLEAR OLEAS OLEEN OLEES OLEIS OLEOS OLERA OLERE OLIAN OLIAS OLIDA OLIDO OLIOS OLIVA OLIVE OLIVO OLLAO OLLAR OLLAS OLMAS OLMOS OLOTE OLURA OMANI OMASO OMBUS OMEGA OMERO OMEYA OMINA OMINE OMINO OMISA OMISO OMITA OMITE OMITI OMITO OMOTO ONCEA ONCEE ONCEO ONCES ONDAS ONDEA ONDEE ONDEO ONDRA ONECE ONECI ONICE ONOTO ONZAS OPACA OPACO OPADA OPADO OPALO OPERA OPERE OPERO OPILA OPILE OPILO OPIMA OPIMO OPINA OPINE OPINO OPIOS OPONE OPTAD OPTAN OPTAR OPTAS OPTEN OPTES OPUSE OPUSO ORABA ORADA ORADO ORAIS ORAJE ORALE ORARA ORARE ORASE ORATE ORBES ORCAS ORCEN ORCES ORCOS ORDEN OREAD OREAN OREAR OREAS OREEN OREES OREIS OREJA OREOS ORERO ORFOS ORFRE ORGIA ORIBE ORIES ORINA ORINE ORINO ORIOL ORIVE ORLAD ORLAN ORLAR ORLAS ORLEN ORLES ORLOS ORNAD ORNAN ORNAR ORNAS ORNEA ORNEE ORNEN ORNEO ORNES OROYA ORTOS ORUGA ORUJO ORZAD ORZAN ORZAR ORZAS OSABA OSADA OSADO OSAIS OSARA OSARE OSASE OSCAS OSCOS OSEAD OSEAN OSEAR OSEAS OSEEN OSEES OSEIS OSEOS OSERA OSERO OSETA OSMIO OSOSA OSOSO OSTAS OSTIA OSTRA OSTRO OSUDA OSUDO OSUNA OSUNO OTATE OTEAD OTEAN OTEAR OTEAS OTEEN OTEES OTERO OTILA OTILE OTILO OTOBA OTONA OTONE OTONO OTRAS OTRES OTRIS OTROS OVABA OVADA OVADO OVAIS OVALA OVALE OVALO OVARA OVARE OVASE OVEIS OVEJA OVERA OVERO OVIDO OVINA OVINO OVNIS OVOLO OVOSA OVOSO OVULA OVULE OVULO OXEAD OXEAN OXEAR OXEAS OXEEN OXEES OXIDA OXIDE OXIDO OYERA OYERE OYESE OZENA OZONA OZONO PACAE PACAS PACAY PACED PACEN PACER PACES PACHA PACHO PACIA PACIO PACON PACOS PACTA PACTE PACTO PACUS PADRE PAFIA PAFIO PAGAD PAGAN PAGAR PAGAS PAGEL PAGOS PAGRO PAGUA PAGUE PAHUA PAICO PAILA PAINA PAIRA PAIRE PAIRO PAJAR PAJAS PAJEA PAJEE PAJEL PAJEO PAJES PAJIL PAJLA PAJON PAJOS PAJUZ PALAS PALAY PALCA PALCO PALEA PALEE PALEO PALES PALIA PALIE PALIO PALIS PALLA PALLE PALLO PALMA PALME PALMO PALON PALOR PALOS PALPA PALPE PALPI PALPO PALTA PALTO PAMBA PAMPA PAMUE PANAL PANAS PANCA PANCO PANDA PANDO PANEL PANES PANGA PANJI PANOS PANSA PANTY PANUL PANZA PAPAD PAPAL PAPAN PAPAR PAPAS PAPAZ PAPEA PAPEE PAPEL PAPEN PAPEO PAPES PAPIN PAPON PAPOS PAPUA PAPUS PARAD PARAL PARAN PARAO PARAR PARAS PARCA PARCE PARCO PARDA PARDO PAREA PARED PAREE PAREL PAREN PAREO PARES PARGO PARIA PARID PARIO PARIR PARIS PARLA PARLE PARLO PARNE PAROS PARPA PARPE PARPO PARRA PARRE PARRO PARSI PARTA PARTE PARTI PARTO PARVA PARVO PASAD PASAN PASAR PASAS PASCO PASEA PASEE PASEN PASEO PASES PASIL PASMA PASME PASMO PASOS PASPA PASPE PASPO PASTA PASTE PASTO PATAN PATAO PATAS PATAX PATAY PATEA PATEE PATEO PATER PATES PATIN PATIO PATIS PATON PATOS PAUJI PAULA PAULE PAULO PAUSA PAUSE PAUSO PAUTA PAUTE PAUTO PAVAS PAVES PAVIA PAVON PAVOR PAVOS PAXTE PAYAD PAYAN PAYAR PAYAS PAYEN PAYES PAYOS PAZCA PAZCO PAZOS PANIL PANOL PEAIS PEAJE PEALA PEALE PEALO PEANA PEBRE PECAD PECAN PECAR PECAS PECES PECHA PECHE PECHO PECIO PECTA PECTE PECTO PEDAL PEDIA PEDID PEDIO PEDIR PEDIS PEDOS PEDRO PEEIS PEERA PEERE PEGAD PEGAN PEGAR PEGAS PEGON PEGOS PEGUE PEIAN PEIAS PEIDO PEINA PEINE PEINO PEJES PEJIN PELAD PELAN PELAR PELAS PELDE PELEA PELEE PELEN PELEO PELES PELIS PELLA PELLO PELMA PELON PELOS PELTA PELUS PELVI PEMON PENAD PENAL PENAN PENAR PENAS PENCA PENCO PENDA PENDE PENDI PENDO PENEN PENES PENIS PENOL PENOS PENSA PENSE PENSO PEORA PEPAS PEPES PEPLA PEPLO PEPON PEPUS PEQUE PERAL PERAS PERCA PERDE PERDI PERIS PERLA PERLE PERLO PERNA PERNO PEROL PEROS PERRA PERRO PERSA PERTA PERUS PESAD PESAN PESAR PESAS PESCA PESCE PESCO PESEN PESES PESGA PESGO PESIA PESOL PESOR PESOS PESTE PETAD PETAN PETAR PETAS PETEN PETES PETOS PETRA PEUCO PEUMO PEZON PENON PIABA PIADA PIADO PIAFA PIAFE PIAFO PIAIS PIALA PIALE PIALO PIANO PIARA PIARE PIASE PIBAS PIBES PIBIL PICAD PICAL PICAN PICAR PICAS PICEA PICEO PICHA PICHE PICHI PICHO PICON PICOR PICOS PICUY PIDAN PIDAS PIDEN PIDES PIDIO PIDON PIEIS PIEJO PIEZA PIFAS PIFIA PIFIE PIFIO PIGRA PIGRE PIGRO PIGUA PIHUA PIJAS PIJES PIJIN PIJOS PIJUL PIJUY PILAD PILAN PILAR PILAS PILCA PILEN PILEO PILES PILLA PILLE PILLO PILME PILON PILOS PINAL PINAR PINAS PINCE PINES PINGA PINGO PINNA PINOL PINOS PINTA PINTE PINTO PINZA PINZO PIOJO PIOLA PIOLE PIOLO PIONA PIPAD PIPAN PIPAR PIPAS PIPEN PIPES PIPIA PIPIE PIPIL PIPIO PIPIS PIPON PIPOS PIQUE PIRAD PIRAL PIRAN PIRAR PIRAS PIRCA PIRCO PIREN PIRES PIRLA PIRON PIROS PIRRA PIRRE PIRRI PIRRO PIRUL PIRUS PISAD PISAN PISAR PISAS PISCA PISCO PISEN PISES PISON PISOS PISPA PISPE PISPO PISTA PISTE PISTO PITAD PITAL PITAN PITAO PITAR PITAS PITEA PITEE PITEN PITEO PITES PITIA PITIO PITIS PITON PITOS PIULA PIULE PIULO PIUNE PIURE PIVOT PIXEL PIZCA PIZCO PIZZA PINEN PINON PLACA PLACE PLACI PLACO PLAGA PLAGO PLANA PLANO PLATA PLATO PLAYA PLAYE PLAYO PLAZA PLAZO PLANE PLANI PLEBE PLECA PLEGA PLEGO PLENA PLENO PLEON PLEPA PLEXO PLICA PLISA PLISE PLISO PLOMA PLOME PLOMO PLUGO PLUMA POBLA POBLE POBLO POBOS POBRA POBRE POCAS POCHA POCHO POCOS PODAD PODAL PODAN PODAR PODAS PODED PODEN PODER PODES PODIA PODIO PODON PODRA PODRE POEMA POETA POINO POISA POISE POLAR POLCA POLCO POLEA POLEN POLEO POLEX POLIN POLIO POLIR POLIS POLLA POLLO POLOS POLVO POMAR POMAS POMEZ POMOS POMPA POMPO PONCI PONED PONEN PONER PONES PONEY PONGA PONGO PONIA PONIS PONTO POPAD POPAN POPAR POPAS POPEL POPEN POPES POPOS POPTI PORCO PORGA PORGO PORNO POROS PORRA PORRO PORTA PORTE PORTO POSAD POSAN POSAR POSAS POSCA POSEA POSEE POSEI POSEN POSEO POSES POSMA POSMO POSON POSOS POSTA POSTE POTAD POTAN POTAR POTAS POTEA POTEE POTEN POTEO POTES POTOS POTRA POTRO POYAD POYAL POYAN POYAR POYAS POYEN POYES POYOS POZAL POZAS POZOL POZOS PRADO PRAOS PRAVA PRAVO PRAZA PREAR PREAS PREDA PRESA PRESO PREST PREVE PREVI PRENA PRENE PRENO PRIMA PRIME PRIMO PRION PRIOR PRISA PRIVA PRIVE PRIVO PROAL PROAS PROBA PROBE PROBO PROCO PROEL PROFA PROFE PROIS PROIZ PROLE PRONA PRONO PRORA PROSA PRUNA PRUNO PSIES PUABA PUADA PUADO PUAIS PUARA PUARE PUASE PUBER PUBES PUBIS PUCHA PUCHO PUCIA PUDIN PUDIO PUDIR PUDOR PUDRA PUDRE PUDRI PUDRO PUDUS PUEDA PUEDE PUEDO PUEIS PUFOS PUGAS PUGIL PUGNA PUGNE PUGNO PUJAD PUJAN PUJAR PUJAS PUJEN PUJES PUJOS PULAN PULAS PULEN PULES PULGA PULIA PULID PULIO PULIR PULIS PULLA PULLE PULLO PULPA PULPO PULSA PULSE PULSO PUMAS PUMBA PUNAN PUNAR PUNAS PUNCE PUNEN PUNES PUNGA PUNGE PUNGI PUNIA PUNID PUNIO PUNIR PUNIS PUNJA PUNJO PUNTA PUNTE PUNTO PUNZA PUNZO PUPAD PUPAN PUPAR PUPAS PUPEN PUPES PUPOS PUPUS PURAS PUREA PUREE PUREO PURES PURGA PURGO PURIN PUROS PURRA PURRE PURRI PURRO PUSES PUSPA PUSPO PUTAL PUTAS PUTEA PUTEE PUTEO PUTON PUTOS PUYAD PUYAN PUYAR PUYAS PUYEN PUYES PUYON PUYOS PUZLE PUZOL PUNAL PUNOS PYMES QUECO QUEDA QUEDE QUEDO QUEJA QUEJE QUEJO QUEMA QUEME QUEMI QUEMO QUENA QUEPA QUEPI QUEPO QUERA QUERE QUERO QUESO QUIAS QUIEN QUIER QUIFS QUIJO QUILA QUILO QUIMA QUIMO QUINA QUINO QUIOS QUIPA QUIPU QUISA QUISE QUISO QUITA QUITE QUITO QUITU QUIVI QUIZA QUINE RABAL RABAS RABEA RABEE RABEL RABEO RABIA RABIE RABIL RABIO RABIS RABON RABOS RACEA RACEE RACEL RACEO RACHA RACHE RACHO RACOR RACOS RADAL RADAR RADAS RADES RADIA RADIE RADIO RADON RAEIS RAERA RAERE RAFAL RAFAS RAFEA RAFEE RAFEO RAFES RAFEZ RAFIA RAGUA RAGUS RAHEZ RAIAN RAIAS RAICE RAIDA RAIDO RAIGA RAIGO RAIJO RAIZA RAIZO RAJAD RAJAN RAJAR RAJAS RAJEN RAJES RAJON RALAS RALBA RALBE RALBO RALEA RALEE RALEO RALLA RALLE RALLO RALLY RALOS RALVA RALVE RALVO RAMAL RAMAS RAMEA RAMEE RAMEO RAMIO RAMON RAMOS RAMPA RAMPE RAMPO RANAS RANDA RANDS RANGO RANOS RAPAD RAPAN RAPAR RAPAS RAPAZ RAPEN RAPES RAPOS RAPTA RAPTE RAPTO RAQUE RARAS RAREA RAREE RAREO RAROS RASAD RASAN RASAR RASAS RASCA RASCO RASEL RASEN RASES RASGA RASGO RASIS RASOS RASPA RASPE RASPO RATAS RATEA RATEE RATEO RATIO RATON RATOS RAUCA RAUCO RAUDA RAUDO RAULI RAUTA RAYAD RAYAN RAYAR RAYAS RAYEN RAYES RAYON RAYOS RAZAR RAZAS RAZIA RAZON REAJE REALA REAMA REAME REAMO REARA REARE REARO REATA REATE REATO REBLA REBLE REBLO REBOL REBUS RECAE RECAI RECEL RECEN RECES RECIA RECIO RECLE RECRE RECTA RECTE RECTO RECUA REDAD REDAN REDAR REDAS REDEL REDEN REDES REDIL REDOL REDOR REDRO REFEZ REGAD REGAR REGAS REGIA REGID REGIO REGIR REGIS REGLA REGLE REGLO REGUE REHAZ REHEN REHUI REHUS REIAN REIAS REIDA REIDO REILA REILE REILO REINA REINE REINO REIRA REIRE REJAL REJAS REJIN REJON REJOS REJUS RELAX RELEA RELEE RELEI RELEJ RELEO RELES RELOJ RELSA RELSO RELVA RELVE RELVO REMAD REMAN REMAR REMAS REMEN REMES REMOS RENAL RENCA RENCO RENDA RENDE RENDI RENDO RENES RENGA RENGO RENIL RENIO RENOS RENTA RENTE RENTO REOCA REOJO REPON REPOS REPTA REPTE REPTO RESAL RESES RESMA RESOL RESPE RESTA RESTE RESTO RETAD RETAL RETAN RETAR RETAS RETEL RETEN RETES RETIN RETOR RETOS RETRO REUMA REUNA REUNE REUNI REUNO REVEA REVED REVEN REVEO REVER REVES REVIO REYAD REYAN REYAR REYAS REYEN REYES REZAD REZAN REZAR REZAS REZNO REZON REZOS RENIA RENID RENIR RENIS RIADA RIAIS RIATA RIBAS RICAS RICEN RICES RICIA RICIO RICOS RIEGA RIEGO RIELA RIELE RIELO RIERA RIERE RIESE RIFAD RIFAN RIFAR RIFAS RIFEN RIFES RIFLE RIGEN RIGES RIGIL RIGIO RIGOR RIGUA RIGUE RIJAN RIJAS RIJOS RILAD RILAN RILAR RILAS RILEN RILES RIMAD RIMAN RIMAR RIMAS RIMEL RIMEN RIMES RIMUS RINDA RINDE RINDO RINGA RINGO RIOJA RIPIA RIPIE RIPIO RISAS RISCA RISCO RISOS RISPA RISPE RISPO RITMA RITME RITMO RITON RITOS RIVAL RIZAD RIZAL RIZAN RIZAR RIZAS RIZON RIZOS RINAN RINAS RINEN RINES RINON ROAIS ROANA ROANO ROBAD ROBAN ROBAR ROBAS ROBDA ROBEN ROBES ROBIN ROBLA ROBLE ROBLO ROBOS ROBOT ROBRA ROBRE ROCAS ROCEA ROCEE ROCEN ROCEO ROCES ROCHA ROCHE ROCHO ROCIA ROCIE ROCIN ROCIO ROCOS RODAD RODAL RODAO RODAR RODAS RODEA RODEE RODEO RODIA RODIL RODIO RODOS ROEIS ROELA ROERA ROERE ROETE ROGAD ROGAR ROGAS ROGOS ROGUE ROIAN ROIAS ROIDA ROIDO ROIGA ROIGO ROJAL ROJAS ROJEA ROJEE ROJEO ROJEZ ROJOS ROLAD ROLAN ROLAR ROLAS ROLDA ROLDE ROLDO ROLEN ROLEO ROLES ROLLA ROLLE ROLLO ROLOS ROMAN ROMAS ROMBO ROMEA ROMEO ROMIN ROMIS ROMOS ROMPA ROMPE ROMPI ROMPO RONCA RONCE RONCO RONDA RONDE RONDO RONES RONZA RONZO ROPAS ROPON ROQUE RORAD RORAN RORAR RORAS ROREN RORES RORRO ROSAL ROSAN ROSAR ROSAS ROSCA ROSCO ROSEA ROSEE ROSEN ROSEO ROSES ROSJO ROSON ROSOS ROSTA ROSTE ROSTI ROSTO ROTAD ROTAL ROTAN ROTAR ROTAS ROTEN ROTES ROTOR ROTOS ROUGE ROYAN ROYAS ROYOS ROZAD ROZAN ROZAR ROZAS ROZNA ROZNE ROZNO ROZON ROZOS RONAD RONAL RONAN RONAR RONAS RONEN RONIA RUABA RUADA RUADO RUAIS RUANA RUANO RUARA RUARE RUASE RUBEA RUBEO RUBIA RUBIN RUBIO RUBIS RUBLO RUBOR RUBRA RUBRO RUCAD RUCAN RUCAR RUCAS RUCHA RUCHE RUCHO RUCIA RUCIO RUCOS RUDAS RUDOS RUECA RUEDA RUEDE RUEDO RUEGA RUEGO RUEIS RUEJO RUENO RUFAS RUFON RUFOS RUGAD RUGAN RUGAR RUGAS RUGBY RUGEN RUGES RUGIA RUGID RUGIO RUGIR RUGIS RUGUE RUIDO RUINA RUINE RUINO RUJAN RUJAS RUJIA RUJIE RUJIO RULAD RULAN RULAR RULAS RULEN RULES RULOS RUMBA RUMBE RUMBO RUMIA RUMIE RUMIO RUMIS RUMOR RUMOS RUNAS RUNES RUNGA RUNGO RUNOS RUPIA RUQUE RURAL RURRU RUSAS RUSCO RUSEL RUSES RUSIA RUSOS RUSTA RUSTE RUSTI RUSTO RUTAD RUTAN RUTAR RUTAS RUTEL RUTEN RUTES RUNAD RUNAN RUNAR RUNEN RUNIA RUNID RUNIR RUNIS SABEA SABED SABEN SABEO SABER SABES SABIA SABIO SABIR SABLE SABOR SABRA SABRE SACAD SACAN SACAR SACAS SACES SACHA SACHE SACHO SACIA SACIE SACIO SACON SACOS SACRA SACRE SACRO SAETA SAETE SAETI SAETO SAFIR SAGAS SAGAZ SAGUS SAINA SAINE SAINO SAJAD SAJAN SAJAR SAJAS SAJEN SAJES SAJIA SAJON SALAD SALAN SALAR SALAS SALAZ SALCE SALDA SALDE SALDO SALEA SALEE SALEN SALEO SALEP SALES SALGA SALGO SALIA SALID SALIN SALIO SALIR SALIS SALLA SALLE SALLO SALMA SALME SALMO SALOL SALON SALPA SALSA SALSO SALTA SALTE SALTO SALUD SALVA SALVE SALVO SAMAN SAMAS SAMBA SAMBO SAMIA SAMIO SAMPA SANAD SANAN SANAR SANAS SANCO SANEA SANEE SANEN SANEO SANES SANGO SANIE SANJA SANJE SANJO SANOS SANSA SANSO SANTA SANTO SAPAS SAPEA SAPEE SAPEO SAPOS SAQUE SARAN SARAO SARDA SARDE SARDO SARGA SARGO SARIA SARIS SARNA SARRO SARTA SARZA SARZO SASAL SATAN SATAS SATEN SATIN SATIS SATOS SAUCE SAUCO SAUDI SAUNA SAVIA SAXEA SAXEO SAXOS SAYAL SAYAS SAYON SAYOS SAZON SEAIS SEBES SEBOS SECAD SECAN SECAR SECAS SECON SECOS SECTA SECUA SEDAD SEDAL SEDAN SEDAR SEDAS SEDEA SEDEE SEDEN SEDEO SEDES SEGAD SEGAR SEGAS SEGRI SEGUE SEGUI SEGUN SEGUR SEIBO SEICO SEISE SEJES SELES SELLA SELLE SELLO SELVA SEMAS SEMEN SEMIS SENAS SENDA SENES SENIL SENOS SENTA SENTE SENTI SENTO SEORA SEPAN SEPAS SEPES SEPIA SEPTO SEQUE SERAN SERAS SERBA SERBO SERES SERIA SERIE SERIO SERNA SERON SERPA SERRA SERRE SERRO SERVI SERVO SESEA SESEE SESEN SESEO SESES SESGA SESGO SESIL SESIS SESMA SESMO SESOS SETAL SETAS SETOS SEXAD SEXAN SEXAR SEXAS SEXEN SEXES SEXMA SEXMO SEXOS SEXTA SEXTO SENAL SENOR SHORT SHUAR SIBIL SICLO SICUS SIDAS SIDRA SIEGA SIEGO SIENA SIESO SIETE SIFON SIFUE SIGAN SIGAS SIGLA SIGLO SIGMA SIGNA SIGNE SIGNO SIGUA SIGUE SIJES SIJUS SILBA SILBE SILBO SILES SILEX SILFO SILGA SILGO SILLA SILOS SILVA SIMAS SIMIA SIMIL SIMIO SIMON SIMPA SIMUN SINGA SINGO SINOS SIOUX SIPES SIQUE SIRAS SIRGA SIRGO SIRIA SIRIN SIRIO SIRLE SIROS SIRTE SIRVA SIRVE SIRVO SISAD SISAL SISAN SISAR SISAS SISCA SISEA SISEE SISEN SISEO SISES SISMO SISON SITAS SITIA SITIE SITIO SITOS SITUA SITUE SITUO SOASA SOASE SOASO SOBAD SOBAN SOBAR SOBAS SOBEN SOBEO SOBES SOBON SOBOS SOBRA SOBRE SOBRO SOCAS SOCAZ SOCHE SOCIA SOCIO SOCOL SODAS SODIO SOEZA SOFAS SOFIS SOGAS SOGUN SOJAS SOLAD SOLAR SOLAS SOLAZ SOLDA SOLDE SOLDO SOLEA SOLEE SOLEN SOLEO SOLER SOLES SOLFA SOLIA SOLIO SOLLA SOLLO SOLOS SOLTA SOLTE SOLTO SOMAS SOMOS SONAD SONAR SONAS SONDA SONDE SONDO SONES SONIO SONSA SONSO SONTA SONTO SOPAD SOPAN SOPAR SOPAS SOPEA SOPEE SOPEN SOPEO SOPES SOPIE SOPLA SOPLE SOPLO SOPON SOPOR SORBA SORBE SORBI SORBO SORCE SORDA SORDO SORES SORGO SORNA SORNE SORNO SOROR SOROS SORRA SOSAL SOSAR SOSAS SOSIA SOSOS SOTAD SOTAN SOTAR SOTAS SOTEN SOTES SOTIL SOTOL SOTOS SOVOZ SOYAS SPORT SPRAY STAND SUABA SUABO SUATA SUATO SUAVE SUAZI SUBAN SUBAS SUBEN SUBEO SUBES SUBIA SUBID SUBIO SUBIR SUBIS SUBTE SUCHE SUCIA SUCIO SUCOS SUCRE SUCUS SUDAD SUDAN SUDAR SUDAS SUDEN SUDES SUDOR SUECA SUECO SUELA SUELE SUELO SUENA SUENE SUENO SUERO SUEVA SUEVO SUFIS SUFRA SUFRE SUFRI SUFRO SUIDO SUITA SUITE SUIZA SUIZO SULAS SULCO SULLA SUMAD SUMAN SUMAR SUMAS SUMEN SUMES SUMIA SUMID SUMIO SUMIR SUMIS SUMOS SUMUS SUNCA SUNCO SUPER SUPLA SUPLE SUPLI SUPLO SUPON SUPRA SURAL SURAS SURCA SURCO SURDA SURDE SURDI SURDO SURES SURFS SURGE SURGI SURIS SURJA SURJO SURTA SURTE SURTI SURTO SUSES SUSTO SUTAS SUTES SUTIL SUYAS SUYOS SUZON TABAL TABAS TABEA TABES TABIS TABLA TABLE TABLO TABON TABOR TABOS TABUS TACAR TACAS TACEN TACES TACET TACHA TACHE TACHO TACON TACOS TACTO TAFIA TAFON TAFOS TAFUR TAGUA TAHAS TAHUR TAIFA TAIGA TAIMA TAIME TAIMO TAINA TAINO TAIPA TAIRA TAIRE TAITA TAJAD TAJAN TAJAR TAJAS TAJEA TAJEE TAJEN TAJEO TAJES TAJIN TAJON TAJOS TAJUS TALAD TALAN TALAR TALAS TALCO TALEA TALED TALEN TALES TALGO TALIN TALIO TALLA TALLE TALLO TALMA TALON TALOS TALPA TALUD TAMAL TAMBA TAMBO TAMIL TAMIZ TAMOS TAMUL TANAS TANCA TANCO TANDA TANES TANGA TANGE TANGI TANGO TANJA TANJO TANOR TANOS TANTA TANTO TANZA TAPAD TAPAN TAPAR TAPAS TAPEA TAPEE TAPEN TAPEO TAPES TAPIA TAPIE TAPIN TAPIO TAPIR TAPIS TAPIZ TAPON TAQUE TARAD TARAN TARAR TARAS TARAY TARCA TARCO TARDA TARDE TARDO TAREA TAREN TARES TARIN TARJA TARJE TARJO TARMA TAROT TARRA TARRO TARSO TARTA TASAD TASAN TASAR TASAS TASCA TASCO TASEN TASES TASIA TASIO TASIS TASTO TATAS TATAY TATOS TATUA TATUE TATUO TATUS TAUCA TAUCO TAULA TAURO TAXIS TAXON TAYOS TAYUL TAZAD TAZAN TAZAR TAZAS TAZON TANAD TANAN TANAR TANED TANEN TANER TANIA TEAME TEBEA TEBEO TECAS TECES TECHA TECHE TECHO TECLA TECLE TECLO TECOL TEDAS TEDIO TEFES TEGEA TEGEO TEGUA TEGUE TEHUL TEINA TEJAD TEJAN TEJAR TEJAS TEJED TEJEN TEJER TEJES TEJIA TEJIO TEJON TEJOS TELAR TELAS TELES TELEX TELON TEMAD TEMAN TEMAR TEMAS TEMED TEMEN TEMER TEMES TEMIA TEMIO TEMOR TEMPO TEMUS TENAS TENAZ TENCA TENDE TENDI TENED TENER TENES TENGA TENGO TENIA TENIO TENIS TENOR TENSA TENSE TENSO TENTA TENTE TENTO TENUE TEOSA TEOSO TEPES TEPUS TEPUY TEQUE TERCA TERCO TERMA TERMO TERNA TERNE TERNO TEROS TERSA TERSE TERSO TESAD TESAN TESAR TESAS TESEN TESES TESIS TESLA TESON TESOS TESTA TESTE TESTO TETAD TETAN TETAR TETAS TETEN TETES TETON TETRA TETRO TEXES TEXTO TEYAS TEYOS TEYUS TENID TENIR TIACA TIARA TIBAR TIBES TIBIA TIBIE TIBIO TIBOR TICAS TICOS TIENE TIESA TIESO TIFAS TIFON TIFOS TIFUS TIGRA TIGRE TIGUA TIGUE TIJAS TIJOS TIJUL TILAS TILDA TILDE TILDO TILES TILIA TILIN TILLA TILLE TILLO TILMA TILOS TIMAD TIMAN TIMAR TIMAS TIMBA TIMBO TIMEN TIMES TIMOL TIMON TIMOS TIMPA TINAS TINCA TINCO TINEA TINEO TINGE TINOS TINTA TINTE TINTO TIPAS TIPIS TIPLE TIPOI TIPOS TIPOY TIQUE TIQUI TIRAD TIRAN TIRAR TIRAS TIREN TIRES TIRIA TIRIO TIRON TIROS TIRRO TIRSO TIRTE TISIS TISTE TISUS TITAD TITAN TITAR TITAS TITEA TITEE TITEN TITEO TITES TITIL TITIS TITOS TIZAS TIZNA TIZNE TIZNO TIZON TIZOS TINAN TINEN TINES TLACO TOABA TOADA TOADO TOAIS TOARA TOARE TOASE TOBAR TOBAS TOCAD TOCAN TOCAR TOCAS TOCEN TOCES TOCHA TOCHE TOCHO TOCIA TOCIO TOCON TOCOS TOCTE TODAS TODIA TODOS TOEIS TOESA TOFOS TOGAN TOGAR TOGAS TOGUE TOJAL TOJOS TOLAS TOLDA TOLDE TOLDO TOLES TOLLA TOLLO TOLMO TOLON TOLVA TOMAD TOMAN TOMAR TOMAS TOMEN TOMES TOMIN TOMON TOMOS TONAD TONAL TONAN TONAR TONAS TONCA TONDO TONEL TONEN TONER TONES TONGA TONGO TONOS TONTA TONTO TOPAD TOPAN TOPAR TOPAS TOPEA TOPEE TOPEN TOPEO TOPES TOPIA TOPIL TOPON TOPOS TOQUE TOQUI TORAL TORAS TORAX TORCA TORCE TORCI TORCO TORDA TORDO TOREA TOREE TOREO TORES TORGA TORGO TORIL TORIO TORMO TORNA TORNE TORNO TORON TOROS TORPE TORRA TORRE TORRO TORSO TORTA TORVA TORVO TOSAN TOSAS TOSCA TOSCO TOSED TOSEN TOSER TOSES TOSIA TOSIO TOSTA TOSTE TOSTO TOTAL TOTEM TOTES TOTIS TOTOL TOVAS TOZAD TOZAL TOZAN TOZAR TOZAS TOZOS TONIL TRABA TRABE TRABO TRACA TRACE TRAED TRAEN TRAER TRAES TRAFA TRAGA TRAGO TRAIA TRAJE TRAJO TRAMA TRAME TRAMO TRAPA TRAPE TRAPO TRARO TRATA TRATE TRATO TRAVO TRAZA TRAZO TREBO TRECE TREFE TREJA TREMA TREME TREMI TREMO TRENA TRENO TREOS TREPA TREPE TREPO TRETA TRIAD TRIAL TRIAN TRIAR TRIAS TRIBU TRICE TRIEN TRIES TRIGA TRIGO TRILE TRINA TRINE TRINO TRIOS TRIPA TRIPE TRISA TRISE TRISO TRIZA TRIZO TROCA TROCE TROCO TROJA TROJE TROLA TROLE TRONA TRONE TRONO TROPA TROPO TROTA TROTE TROTO TROVA TROVE TROVO TROZA TROZO TRUCA TRUCO TRUES TRUFA TRUFE TRUFO TRUJA TRUSA TRUST TUANI TUBAS TUBOS TUCAN TUCAS TUCIA TUCOS TUCUN TUDAS TUDEL TUECA TUECO TUERA TUERO TUFEA TUFEE TUFEO TUFOS TUINA TULAR TULES TULIO TULLA TULLE TULLI TULLO TULPA TUMBA TUMBE TUMBO TUMOR TUMOS TUNAD TUNAL TUNAN TUNAR TUNAS TUNCA TUNCO TUNDA TUNDE TUNDI TUNDO TUNEA TUNEE TUNEL TUNEN TUNEO TUNES TUNJO TUNOS TUNTA TUPAN TUPAS TUPEN TUPES TUPIA TUPID TUPIN TUPIO TUPIR TUPIS TUPOS TURAR TURBA TURBE TURBO TURCA TURCO TURMA TURNA TURNE TURNO TURON TURRA TURRE TURRO TUSAD TUSAN TUSAR TUSAS TUSCA TUSCO TUSEN TUSES TUSON TUSOS TUTAS TUTEA TUTEE TUTEO TUTES TUTIA TUTOR TUTOS TUTUS TUYAS TUYOS TUZAS UBICA UBICO UBIES UBIOS UBRES UCASE UCHUS UEBOS UFANA UFANE UFANO UGRES UJIER UJULE ULAGA ULALA ULANO ULEMA ULPOS ULTRA ULUAS ULULA ULULE ULULO UMBRA UMBRO UMERO UNAIS UNCEN UNCES UNCIA UNCID UNCIO UNCIR UNCIS UNGEN UNGES UNGIA UNGID UNGIO UNGIR UNGIS UNIAN UNIAS UNICA UNICO UNIDA UNIDO UNION UNIRA UNIRE UNJAN UNJAS UNTAD UNTAN UNTAR UNTAS UNTEN UNTES UNTOS UNZAN UNZAS UPABA UPADA UPADO UPAIS UPARA UPARE UPASE UPEIS UPUPA URAOS URAPE URATO URBES URCAS URCES URDAN URDAS URDEN URDES URDIA URDID URDIO URDIR URDIS URDUS UREAS URGEN URGES URGIA URGID URGIO URGIR URGIS URICA URICO URJAN URJAS URNAS URTAS URUBU URUCU URUGA USABA USADA USADO USAIS USAJE USARA USARE USASE USEIS USGOS USIAS USIER USINA USTED USUAL USURA USURE USURO UTERO UVADA UVATE UVEAS UVERA UVERO UVIAR UVULA UNADA UNADO UNATE UNERA UNERE UNERO UNESE UNETA UNOSA UNOSO UNUDO VACAD VACAN VACAR VACAS VACIA VACIE VACIO VACOS VACUA VACUO VADEA VADEE VADEO VADES VADOS VAFEA VAFEE VAFEO VAGAD VAGAN VAGAR VAGAS VAGON VAGOS VAGUE VAHAD VAHAN VAHAR VAHAS VAHEA VAHEE VAHEN VAHEO VAHES VAHOS VAIDA VAINA VAJEA VAJEE VAJEO VALAR VALED VALEN VALER VALES VALET VALGA VALGO VALIA VALIO VALIS VALLA VALLE VALLO VALON VALOR VALSA VALSE VALSO VALUA VALUE VALUO VALVA VAMOS VANAS VANEA VANEE VANEO VANOS VAPOR VAQUE VARAD VARAL VARAN VARAR VARAS VAREA VAREE VAREN VAREO VARES VARGA VARIA VARIE VARIO VARIS VARIZ VARON VASAR VASAS VASCA VASCO VASOS VASTA VASTO VATER VATES VATIO VAYAN VAYAS VEAIS VECEN VECES VEDAD VEDAN VEDAR VEDAS VEDEN VEDES VEGAS VEIAN VEIAS VEJAD VEJAN VEJAR VEJAS VEJEN VEJES VEJEZ VELAD VELAN VELAR VELAS VELAY VELEN VELES VELIS VELIZ VELLO VELON VELOS VELOZ VEMOS VENAL VENAS VENCE VENCI VENDA VENDE VENDI VENDO VENGA VENGO VENIA VENID VENIR VENIS VENTA VENTE VENTO VENUS VENZA VENZO VERAN VERAS VERAZ VERBA VERBO VERDE VERES VERGA VERGE VERIA VERIL VERJA VERME VERMU VEROS VERSA VERSE VERSO VERTE VERTI VESTE VESTI VETAD VETAN VETAR VETAS VETEA VETEE VETEN VETEO VETES VETON VETOS VEZAD VEZAN VEZAR VEZAS VIADA VIAJA VIAJE VIAJO VIBRA VIBRE VIBRO VICHA VICHE VICHO VICHY VICIA VICIE VICIO VICOS VICTO VIDAS VIDEO VIDES VIDON VIDRO VIEJA VIEJO VIENE VIERA VIERE VIESA VIESE VIGAS VIGIA VIGIE VIGIO VIGOR VILES VILLA VILOS VIMOS VINAL VINAR VINCA VINCO VINOS VINTA VIOLA VIOLE VIOLO VIRAD VIRAL VIRAN VIRAR VIRAS VIREN VIREO VIRES VIRGO VIRIL VIRIO VIROL VIRON VIRUS VISAD VISAN VISAR VISAS VISCO VISEA VISEE VISEN VISEO VISES VISIR VISON VISOR VISOS VISTA VISTE VISTO VITAD VITAL VITAN VITAR VITAS VITEN VITES VITOR VITOS VITRE VIUDA VIUDO VIVAC VIVAD VIVAN VIVAR VIVAS VIVAZ VIVEN VIVES VIVEZ VIVIA VIVID VIVIO VIVIR VIVIS VIVON VIVOS VINAS VOACE VOCAL VOCEA VOCEE VOCEO VOCES VODCA VODUS VOILA VOLAD VOLAR VOLAS VOLCA VOLCO VOLEA VOLEE VOLEO VOLON VOLTS VOLVE VOLVI VOLVO VOMER VORAZ VOSEA VOSEE VOSEO VOTAD VOTAN VOTAR VOTAS VOTEN VOTES VOTOS VOTRI VOZNA VOZNE VOZNO VUDUS VUELA VUELE VUELO VUESA VUESO VULGO VULTO VULVA VUSCO XECAS XENON XINCA XIOTE XOLAS XOLOS YABAS YACAL YACAS YACED YACEN YACER YACES YACIA YACIO YACON YAGAN YAGAS YAGUA YAITI YALES YAMAO YAMBO YAMPA YANAS YANTA YANTE YANTO YAPAD YAPAN YAPAR YAPAS YAPEN YAPES YAPUS YAQUE YAQUI YARDA YARES YAREY YAROS YATAI YATAY YATES YAYAS YAYOS YAZCA YAZCO YAZGA YAZGO YEBOS YECOS YEDGO YEDRA YEGUA YELGO YELMO YEMAS YENDO YENES YENTE YERAL YERBA YERGA YERGO YERMA YERME YERMO YERNA YERNO YEROS YERRA YERRE YERRO YERSI YERTA YERTO YERVO YESAL YESAR YESCA YESON YESOS YETIS YEYES YEYOS YEZGO YINAS YINES YIRAS YIROS YODAD YODAN YODAR YODAS YODEN YODES YODOS YOGAR YOGAS YOGOS YOGUI YOGUR YOLAS YOQUI YORIS YOSES YOYOS YUCAL YUCAS YUCPA YUDOS YUGOS YUMBA YUMBO YUNGA YUNTA YUNTO YURAS YURES YUTAS YUTES YUYAL YUYOS ZABRA ZABRO ZACAS ZACEA ZACEE ZACEO ZADES ZAFAD ZAFAN ZAFAR ZAFAS ZAFEN ZAFES ZAFIA ZAFIO ZAFIR ZAFON ZAFOS ZAFRA ZAFRE ZAGAL ZAGAS ZAGUA ZAHEN ZAHON ZAIDA ZAINA ZAINO ZAJON ZALAS ZALBA ZALBO ZALEA ZALEE ZALEO ZALLA ZALLE ZALLO ZAMBA ZAMBO ZAMPA ZAMPE ZAMPO ZANAS ZANCA ZANCO ZANGA ZANJA ZANJE ZANJO ZAPAD ZAPAN ZAPAR ZAPAS ZAPEA ZAPEE ZAPEN ZAPEO ZAPES ZAQUE ZARBO ZARCA ZARCO ZARES ZARJA ZARPA ZARPE ZARPO ZARZA ZARZO ZATAS ZAYAS ZAZAS ZAZOS ZEBRA ZEDAS ZEGRI ZEINA ZEJEL ZENDA ZENDO ZENES ZENIT ZETAS ZINCS ZOCAD ZOCAN ZOCAR ZOCAS ZOCLO ZOCOS ZOFRA ZOILO ZOIZO ZOLLE ZOMAS ZOMBI ZOMOS ZOMPA ZOMPO ZONAL ZONAS ZONDA ZONTA ZONTO ZONZA ZONZO ZOPAS ZOPES ZOPOS ZOQUE ZORRA ZORRO ZOTAL ZOTES ZOTOL ZUAVO ZUBIA ZUDAS ZUECA ZUECO ZUELA ZUIZA ZULLA ZULLE ZULLO ZULUS ZUMAS ZUMBA ZUMBE ZUMBO ZUMOS ZUNAS ZUNZA ZUPIA ZURAS ZURBA ZURCE ZURCI ZURDA ZURDE ZURDI ZURDO ZUREA ZUREE ZUREO ZUROS ZURRA ZURRE ZURRI ZURRO ZURZA ZURZO ZUZAR ZUZON ZUNAN ZUNEN ZUNES ZUNIA ZUNID ZUNIR ZUNIS ZUNOS NAJOS NAMES NAMPI NANDU NANGA NANGO NATEA NATEE NATEO NANOS NECAS NECLA NECOS NENGA NENGO NEQUE NINGA NIPES NIQUE NIRES NISCA NIZCA NOCHA NOCLO NOCOS NOLAS NONGA NONGO NOQUI NORAS NORBO NORES NONEZ NURDA NURDO NUTAS NUTOS NUZCO ================================================ FILE: core/src/main/res/raw/wordle_list_fr.txt ================================================ ABATS ABBES ABCES ABETI ABIMA ABIME ABOIE ABOIS ABOLI ABORD ABOTS ABOUT ABOYA ABOYE ABRIS ABUSA ABUSE ACCES ACCOT ACCRU ACCUS ACERA ACERE ACHAT ACIDE ACIER ACINI ACMES ACNES ACONS ACORE ACRES ACTAI ACTAS ACTAT ACTEE ACTER ACTES ACTEZ ACTIF ADAGE ADENT ADIEU ADMET ADMIS ADMIT ADNEE ADNES ADORA ADORE ADRET ADULA ADULE AEDES AEQUO AERAI AERAS AERAT AEREE AERER AERES AEREZ AFFIN AFFUT AGACA AGACE AGAMI AGAPE AGATE AGAVE AGEES AGENT AGHAS AGILE AGIOS AGIRA AGITA AGITE AGNAT AGORA AGREA AGREE AGRES AGUIS AHANA AHANE AHANS AHURI AICHE AIDAI AIDAS AIDAT AIDEE AIDER AIDES AIDEZ AIENT AIEUL AIEUX AIGLE AIGRE AIGRI AIGUE AIGUS AILEE AILES AILLA AILLE AIMAI AIMAS AIMAT AIMEE AIMER AIMES AIMEZ AINEE AINES AINSI AIOLI AIRAI AIRAS AIRAT AIRER AIRES AIREZ AISEE AISES AISYS AJONC AJOUR AJOUT ALBUM ALDIN ALDOL ALEAS ALENE ALEPH ALESA ALESE ALFAS ALGIE ALGOL ALGUE ALIAS ALIBI ALIOS ALISE ALITA ALITE ALIZE ALLAI ALLAS ALLAT ALLEE ALLER ALLES ALLEZ ALLIA ALLIE ALMEE ALOES ALORS ALOSE ALPAX ALPES ALPHA ALPIN ALTOS ALUNA ALUNE ALUNI ALUNS ALVIN ALYTE AMANT AMATI AMBLA AMBLE AMBON AMBRA AMBRE AMENA AMENE AMERE AMERS AMIBE AMICT AMIDE AMIES AMINE AMONT AMOUR AMPHI AMPLE AMPLI AMUIE AMUIS AMURA AMURE AMUSA AMUSE AMYLE ANALE ANAUX ANCHE ANCRA ANCRE ANDIN ANETH ANGES ANGLE ANGON ANGOR ANIER ANIMA ANIME ANION ANISA ANISE ANNAL ANNEE ANODE ANONS ANSEE ANSES ANTAN ANTES ANTRE AORTE AOUTA AOUTE APHTE APIOL APION APLAT APNEE APODE APPAS APPAT APPEL APPUI APRES APTES APURA APURE ARABE ARASA ARASE ARBRE ARCHE ARCON ARDUE ARDUS ARECS ARENE ARETE ARGAS ARGON ARGOT ARGUA ARGUE ARGUS ARIAS ARIDE ARIEN ARISA ARISE ARMAI ARMAS ARMAT ARMEE ARMER ARMES ARMET ARMEZ ARMON AROME ARQUA ARQUE ARRET ARSIN ARTEL ARUMS ARYEN ARYLE ASILE ASPES ASPIC ASQUE ASSAI ASSES ASSEZ ASSIS ASSIT ASTER ASTIS ASTRE ATELE ATHEE ATLAS ATOLL ATOME ATONE ATOUR ATOUT AUBES AUBIN AUCUN AUDIO AUDIT AUGES AUGET AULNE AUNES AURAI AURAS AUREZ AUSSI AUTEL AUTOS AUTRE AVAIS AVAIT AVALA AVALE AVALS AVANT AVARE AVENS AVENU AVERA AVERE AVERS AVEUX AVIDE AVIEZ AVILI AVINA AVINE AVION AVISA AVISE AVISO AVIVA AVIVE AVOIR AVONS AVOUA AVOUE AVRIL AXAIS AXAIT AXANT AXEES AXENT AXERA AXIEZ AXILE AXONE AXONS AYANT AYONS AZOTE AZURA AZURE AZURS AZYME BABAS BABIL BABYS BACHA BACHE BACLA BACLE BACON BACUL BADGE BADIN BAFFA BAFFE BAFRA BAFRE BAGAD BAGNE BAGOU BAGUA BAGUE BAHUT BAIES BAINS BAISA BAISE BALAI BALES BALLA BALLE BALSA BALTE BANAL BANCO BANCS BANDA BANDE BANGS BANJO BANNA BANNE BANNI BARBA BARBE BARBU BARDA BARDE BARDS BARGE BARIL BARNS BARON BARRA BARRE BARRI BARYE BASAI BASAL BASAS BASAT BASEE BASER BASES BASEZ BASIC BASIN BASSE BASTE BATAI BATAS BATAT BATEE BATER BATES BATEZ BATIE BATIK BATIR BATIS BATIT BATON BATTE BATTU BAUDS BAUGE BAUME BAVAI BAVAS BAVAT BAVER BAVES BAVEZ BAYAI BAYAS BAYAT BAYER BAYES BAYEZ BAYOU BAZAR BEAIS BEAIT BEANT BEATE BEATS BEAUF BEAUX BEBES BECHA BECHE BECOT BECTA BECTE BEDON BEENT BEERA BEGUE BEGUM BEIEZ BEIGE BEKES BELAI BELAS BELAT BELEE BELER BELES BELEZ BELGE BELLE BELON BEMOL BENEF BENET BENIE BENIN BENIR BENIS BENIT BENNE BEONS BEQUA BEQUE BERCA BERCE BERET BERGE BERME BERNA BERNE BERYL BESEF BETAS BETEL BETES BETON BETTE BEURS BEVUE BIAIS BIBIS BIBLE BICHA BICHE BICOT BIDES BIDET BIDON BIEFS BIENS BIERE BIFFA BIFFE BIGLA BIGLE BIGOT BIGRE BIGUE BIJOU BILAI BILAN BILAS BILAT BILEE BILER BILES BILEZ BILLE BILLS BINAI BINAS BINAT BINEE BINER BINES BINEZ BINGO BIQUE BIRBE BISAI BISAS BISAT BISEE BISER BISES BISET BISEZ BISON BISOU BISSA BISSE BITAI BITAS BITAT BITEE BITER BITES BITEZ BITOS BITTA BITTE BIZUT BLACK BLAIR BLAMA BLAME BLANC BLAPS BLASA BLASE BLEDS BLEME BLEMI BLESA BLESE BLETS BLEUE BLEUI BLEUS BLOCS BLOND BLUES BLUET BLUFF BLUTA BLUTE BOBOS BOCAL BOCHE BOCKS BOETE BOEUF BOGIE BOGUE BOIRA BOIRE BOISA BOISE BOITA BOITE BOIVE BOLDO BOLEE BOLET BOMBA BOMBE BOMES BONDA BONDE BONDI BONDS BONIS BONNE BONTE BONUS BONZE BOOMS BOOTS BORAS BORAX BORDA BORDE BORDS BORES BORNA BORNE BORTS BOSCO BOSSA BOSSE BOSSU BOTES BOTTA BOTTE BOUCS BOUDA BOUDE BOUEE BOUES BOUGE BOUIF BOULA BOULE BOUME BOURG BOUSE BOUTA BOUTE BOUTS BOVIN BOXAI BOXAS BOXAT BOXEE BOXER BOXES BOXEZ BOYAU BRADA BRADE BRAIE BRAIS BRAIT BRAMA BRAME BRANS BRASA BRASE BRAVA BRAVE BRAVO BRAYA BRAYE BREAK BREFS BRELA BRELE BREME BREVE BRICK BRIDA BRIDE BRIES BRIFA BRIFE BRIMA BRIME BRINS BRIOS BRISA BRISE BROCS BRODA BRODE BROIE BROME BROOK BROUM BROUS BROUT BROYA BROYE BRUIE BRUIR BRUIS BRUIT BRULA BRULE BRUMA BRUME BRUNE BRUNI BRUNS BRUTE BRUTS BUBON BUCHA BUCHE BUEES BUGGY BUGLE BUIRE BULBE BULLE BUMES BURES BURIN BURON BUSCS BUSES BUSSE BUSTE BUTAI BUTAS BUTAT BUTEE BUTER BUTES BUTEZ BUTIN BUTOR BUTTA BUTTE BUVEE BUVEZ CABAN CABAS CABLA CABLE CABOT CABRA CABRE CABRI CABUS CACAO CACAS CACHA CACHE CADDY CADES CADET CADIS CADRA CADRE CADUC CAFES CAFRE CAFTA CAFTE CAGES CAGET CAGNA CAGNE CAGOT CAHOT CAIDS CAIEU CAIRN CAJOU CAJUN CAKES CALAI CALAO CALAS CALAT CALEE CALER CALES CALEZ CALFS CALIN CALMA CALME CALMI CALOS CALOT CALTA CALTE CALVA CAMEE CAMES CAMPA CAMPE CAMPS CAMUS CANAI CANAL CANAS CANAT CANDI CANER CANES CANEZ CANIF CANIN CANNA CANNE CANOE CANON CANOT CANUT CAOUA CAPEA CAPEE CAPES CAPON CAPOT CAPPA CAPRE CAPTA CAPTE CAQUA CAQUE CARAT CARDA CARDE CAREX CARGO CARIA CARIE CARIS CARMA CARME CARNE CARPE CARRA CARRE CARRY CARTE CASAI CASAS CASAT CASEE CASER CASES CASEZ CASSA CASSE CASTE CATCH CATIN CATIS CAURI CAUSA CAUSE CAVAI CAVAS CAVAT CAVEE CAVER CAVES CAVET CAVEZ CEANS CEDAI CEDAS CEDAT CEDEE CEDER CEDES CEDEX CEDEZ CEDRE CEINS CEINT CELAI CELAS CELAT CELEE CELER CELES CELEZ CELLA CELLE CELTE CELUI CENES CENSE CENTS CEPES CERAT CERCE CERFS CERNA CERNE CESAR CESSA CESSE CESTE CETTE CHAHS CHAIR CHAIS CHALE CHAMP CHANT CHAOS CHAPE CHARS CHATS CHAUD CHAUT CHAUX CHEFS CHEIK CHENE CHENU CHERA CHERE CHERI CHERS CHIAI CHIAS CHIAT CHICS CHIEE CHIEN CHIER CHIES CHIEZ CHINA CHINE CHIOT CHIPA CHIPE CHIPS CHOCS CHOIE CHOIR CHOIS CHOIT CHOIX CHOMA CHOME CHOPA CHOPE CHOSE CHOTT CHOUX CHOYA CHOYE CHUES CHUTA CHUTE CHYLE CHYME CIBLE CIDRE CIELS CIEUX CIGUE CILIE CILLA CILLE CIMES CINES CIPPE CIRAI CIRAS CIRAT CIREE CIRER CIRES CIREZ CIRON CIRRE CISTE CITAI CITAS CITAT CITEE CITER CITES CITEZ CIVET CIVIL CLADE CLAIE CLAIR CLAMA CLAME CLAMP CLAMS CLANS CLAPA CLAPE CLAPI CLAVA CLAVE CLEBS CLEFS CLERC CLICS CLINS CLIPS CLIVA CLIVE CLODO CLONE CLOPE CLORA CLORE CLOSE CLOUA CLOUE CLOUS CLOWN CLUBS CLUSE COACH COATI COBEA COBOL COBRA COCAS COCHA COCHE COCON COCOS COCUS CODAI CODAS CODAT CODEE CODER CODES CODEX CODEZ CODON COEUR COGNA COGNE COHUE COING COINS COITA COITE COITS COLIN COLIS COLLA COLLE COLON COLTS COLZA COMAS COMBE COMMA COMME COMTE CONCU CONDE CONES CONGA CONGE CONIE CONIR CONIS CONIT CONNU CONTA CONTE COPAL COPIA COPIE COPRA COPTE COQUA COQUE CORAN CORDA CORDE CORNA CORNE CORNU CORON CORPS CORSA CORSE COSSA COSSE COSSU COSYS COTAI COTAS COTAT COTEE COTER COTES COTEZ COTIE COTIR COTIS COTIT COTON COTTE COUAC COUDA COUDE COUDS COUIC COULA COULE COUPA COUPE COUPS COURE COURS COURT COURU COUSE COUSU COUTA COUTE COUTS COUVA COUVE COXAL COYAU CRABE CRACK CRADO CRAIE CRAMA CRAME CRANA CRANE CRANS CRASE CRASH CRAVE CRAWL CREAI CREAS CREAT CREDO CREEE CREER CREES CREEZ CREMA CREME CRENA CRENE CREPA CREPE CREPI CREPU CRETE CREUX CREVA CREVE CRIAI CRIAS CRIAT CRICS CRIEE CRIER CRIES CRIEZ CRIME CRINS CRISE CROCS CROIE CROIS CROIT CROIX CROSS CRUEL CRUES CUBAI CUBAS CUBAT CUBEE CUBER CUBES CUBEZ CUIRA CUIRE CUIRS CUISE CUITA CUITE CUITS CULAI CULAS CULAT CULEE CULER CULES CULEX CULEZ CULOT CULTE CUMIN CUMUL CURAI CURAS CURAT CUREE CURER CURES CUREZ CURRY CUVAI CUVAS CUVAT CUVEE CUVER CUVES CUVEZ CYANS CYCAS CYCLE CYGNE CZARS DADAS DAGUA DAGUE DAHIR DAIMS DAINE DALLA DALLE DALOT DAMAI DAMAN DAMAS DAMAT DAMEE DAMER DAMES DAMEZ DAMNA DAMNE DANDY DANSA DANSE DARCE DARDA DARDE DARDS DARNE DARSE DATAI DATAS DATAT DATEE DATER DATES DATEZ DATIF DATTE DAUBA DAUBE DEBAT DEBET DEBIT DEBUT DECAN DECAS DECES DECHE DECHU DECIS DECOR DECRI DECRU DECUE DECUS DECUT DEDIA DEDIE DEDIS DEDIT DEFIA DEFIE DEFIS DEFIT DEGAT DEGEL DEGRE DEITE DELAI DELCO DELIA DELIE DELIT DELOT DELTA DEMES DEMET DEMIE DEMIS DEMIT DEMON DENIA DENIE DENIS DENSE DENTE DENTS DENUA DENUE DEPIT DEPLU DEPOT DERBY DERME DERNY DESIR DETTE DEUIL DEVET DEVEZ DEVIA DEVIE DEVIN DEVIS DEVOT DEVRA DIANE DIAPO DICOS DICTA DICTE DIESE DIETE DIEUX DIFFA DIGIT DIGNE DIGON DIGUE DILUA DILUE DIMES DINAI DINAR DINAS DINAT DINDE DINER DINES DINEZ DINGO DIODE DIRAI DIRAS DIREZ DISCO DISES DISSE DITES DIVAN DIVAS DIVIN DIVIS DJAIN DJINN DOCKS DOCTE DODOS DODUE DODUS DOGES DOGME DOGUE DOIGT DOIVE DOLAI DOLAS DOLAT DOLCE DOLEE DOLER DOLES DOLEZ DOLIC DOMES DONNA DONNE DOPAI DOPAS DOPAT DOPEE DOPER DOPES DOPEZ DORAI DORAS DORAT DOREE DORER DORES DOREZ DORIS DORME DORMI DOSAI DOSAS DOSAT DOSEE DOSER DOSES DOSEZ DOSSE DOTAI DOTAL DOTAS DOTAT DOTEE DOTER DOTES DOTEZ DOUAI DOUAR DOUAS DOUAT DOUCE DOUCI DOUEE DOUER DOUES DOUEZ DOUMS DOURO DOUTA DOUTE DOUVE DOUZE DOYEN DRAIE DRAIN DRAME DRAPA DRAPE DRAPS DRAVE DRAYA DRAYE DREGE DRILL DRING DRINK DRIVA DRIVE DROIT DROLE DROPA DROPE DROPS DRUES DRUPE DUALE DUAUX DUCAL DUCAT DUCES DUCHE DUELS DUITE DUITS DULIE DUMES DUNES DUODI DUPAI DUPAS DUPAT DUPEE DUPER DUPES DUPEZ DURAI DURAS DURAT DURCI DUREE DURER DURES DUREZ DURIT DUSSE DUTES DUVET DYADE DZETA EBAHI EBATS EBENE EBOUA EBOUE ECALA ECALE ECANG ECART ECATI ECHEC ECHES ECHOS ECHUE ECHUS ECHUT ECIMA ECIME ECLAT ECLOS ECLOT ECOLE ECOPA ECOPE ECORA ECORE ECOTE ECOTS ECRAN ECRIE ECRIN ECRIS ECRIT ECROU ECRUE ECRUS ECULA ECULE ECUMA ECUME ECURA ECURE EDAMS EDENS EDILE EDITA EDITE EDITS EFFET EGAIE EGALA EGALE EGARA EGARD EGARE EGAUX EGAYA EGAYE EGEEN EGIDE EGOUT EIDER ELANS ELAVE ELBOT ELEGI ELEIS ELEVA ELEVE ELFES ELIDA ELIDE ELIMA ELIME ELIRA ELIRE ELISE ELITE ELLES ELOGE ELUDA ELUDE ELUES EMAIL EMANA EMANE EMAUX EMBAT EMBUA EMBUE EMBUS EMERI EMETS EMEUS EMEUT EMIAI EMIAS EMIAT EMIEE EMIER EMIES EMIEZ EMIRS EMISE EMOIS EMOUD EMOUS EMPAN EMPLI EMUES EMULA EMULE ENCAN ENCAS ENCRA ENCRE ENDOS ENFER ENFEU ENFIN ENFLA ENFLE ENFUI ENGIN ENJEU ENLIA ENLIE ENNUI ENOUA ENOUE ENTAI ENTAS ENTAT ENTEE ENTER ENTES ENTEZ ENTRA ENTRE ENVIA ENVIE ENVOI ENVOL EPAIR EPAIS EPALA EPALE EPAND EPARS EPATA EPATE EPAVE EPEES EPELA EPELE EPHOD EPIAI EPIAS EPIAT EPICA EPICE EPIEE EPIER EPIES EPIEU EPIEZ EPIGE EPILA EPILE EPINA EPINE EPITE EPODE EPOUX EPRIS EPUCA EPUCE EPURA EPURE EQUIN ERAIE ERAYA ERAYE ERGOT ERIGE ERINE ERODA ERODE ERRAI ERRAS ERRAT ERRER ERRES ERREZ ESCHA ESCHE ESCOT ESPAR ESSAI ESSES ESSOR ESTER ESTOC ETAGE ETAIE ETAIN ETAIS ETAIT ETALA ETALE ETALS ETAMA ETAME ETANG ETANT ETAPE ETATS ETAUX ETAYA ETAYE ETEND ETETA ETETE ETEUF ETHER ETIER ETIEZ ETIRA ETIRE ETOCS ETOLE ETRES ETRON ETUDE ETUIS ETUVA ETUVE EUMES EUROS EUSSE EUTES EVADE EVASA EVASE EVEIL EVENT EVIDA EVIDE EVIER EVITA EVITE EVOHE EXACT EXCES EXCLU EXIGE EXIGU EXILA EXILE EXILS EXODE EXPIA EXPIE EXTRA FABLE FACES FACHA FACHE FACHO FACON FACTO FADAI FADAS FADAT FADEE FADER FADES FADEZ FADOS FAGNE FAGOT FAIMS FAINE FAIRE FAITE FAITS FAKIR FALLU FALOT FALUN FAMEE FAMES FANAI FANAL FANAS FANAT FANEE FANER FANES FANEZ FANGE FANON FAONS FARAD FARCE FARCI FARDA FARDE FARDS FAROS FARTA FARTE FARTS FASCE FASSE FASTE FATAL FATMA FATUM FAUNE FAUTA FAUTE FAUVE FAVUS FAXAI FAXAS FAXAT FAXEE FAXER FAXES FAXEZ FAYOT FEALE FEAUX FECAL FECES FEINS FEINT FELAI FELAS FELAT FELEE FELER FELES FELEZ FELIN FELON FEMME FEMUR FENDE FENDS FENDU FENIL FENTE FERAI FERAS FEREZ FERIE FERLA FERLE FERMA FERME FERRA FERRE FERRY FERUE FERUS FESSA FESSE FESSU FETAI FETAS FETAT FETEE FETER FETES FETEZ FETUS FEUES FEUIL FEULA FEULE FEVES FIAIS FIAIT FIANT FIBRE FICHA FICHE FICHU FICUS FIEES FIEFS FIELS FIENT FIERA FIERE FIERS FIEUX FIFRE FIGEA FIGEE FIGER FIGES FIGEZ FIGUE FIIEZ FILAI FILAS FILAT FILEE FILER FILES FILET FILEZ FILIN FILLE FILMA FILME FILMS FILON FILOU FIMES FINAL FINES FINIE FINIR FINIS FINIT FIOLE FIONS FIRME FISCS FISSE FITES FIXAI FIXAS FIXAT FIXEE FIXER FIXES FIXEZ FJELD FJORD FLAIR FLANA FLANC FLANE FLANS FLAPI FLASH FLEAU FLEIN FLEUR FLICS FLINT FLIRT FLOOD FLOPS FLORE FLOTS FLOUA FLOUE FLOUS FLUAI FLUAS FLUAT FLUER FLUES FLUET FLUEZ FLUOR FLUSH FLUTA FLUTE FLUXA FLUXE FOCAL FOEHN FOIES FOINS FOIRA FOIRE FOLIE FOLIO FOLKS FOLLE FONCA FONCE FONDA FONDE FONDS FONDU FONTE FOOTS FORAI FORAS FORAT FORCA FORCE FORCI FOREE FORER FORES FORET FOREZ FORGE FORMA FORME FORTE FORTS FORUM FOSSE FOUEE FOUET FOUGE FOUIE FOUIR FOUIS FOUIT FOULA FOULE FOURS FOUTE FOUTU FOVEA FOXEE FOXES FOYER FRACS FRAIE FRAIS FRANC FRAPE FRAYA FRAYE FREIN FRELE FREMI FRENE FREON FRERE FRETA FRETE FRETS FREUX FRICS FRIGO FRIMA FRIME FRIPA FRIPE FRIRA FRIRE FRISA FRISE FRITE FRITZ FROCS FROID FROLA FROLE FRONT FROUA FROUE FRUIT FUCUS FUELS FUGUA FUGUE FUIES FUIRA FUITE FULLS FUMAI FUMAS FUMAT FUMEE FUMER FUMES FUMET FUMEZ FUNIN FUNKY FURAX FURET FURIA FURIE FUSAI FUSAS FUSAT FUSEE FUSEL FUSER FUSES FUSEZ FUSIL FUSSE FUTEE FUTES FUTUR FUYEZ GABLE GACHA GACHE GADES GADIN GAFFA GAFFE GAGAS GAGEA GAGEE GAGER GAGES GAGEZ GAGNA GAGNE GAIAC GAIES GAINA GAINE GAINS GALAS GALBA GALBE GALES GALET GALLO GALON GALOP GAMBA GAMBE GAMIN GAMME GANGA GANGS GANSA GANSE GANTA GANTE GANTS GARAI GARAS GARAT GARCE GARDA GARDE GAREE GARER GARES GAREZ GARNI GAROU GATAI GATAS GATAT GATEE GATER GATES GATEZ GATTE GAUDE GAULA GAULE GAUPE GAURS GAUSS GAVAI GAVAS GAVAT GAVEE GAVER GAVES GAVEZ GAYAL GAZAI GAZAS GAZAT GAZEE GAZER GAZES GAZEZ GAZON GEAIS GEANT GECKO GEINS GEINT GELAI GELAS GELAT GELEE GELER GELES GELEZ GELIF GEMIE GEMIR GEMIS GEMIT GEMMA GEMME GENAI GENAS GENAT GENEE GENER GENES GENET GENEZ GENIE GENOU GENRE GEODE GEOLE GERAI GERAS GERAT GERBA GERBE GERCA GERCE GEREE GERER GERES GEREZ GERMA GERME GESIR GESSE GESTE GIBET GIBUS GICLA GICLE GIFLA GIFLE GIGOT GIGUE GILDE GILET GILLE GIRIE GIRLS GIRON GISEZ GITAI GITAN GITAS GITAT GITER GITES GITEZ GITON GIVRA GIVRE GLACA GLACE GLANA GLAND GLANE GLAPI GLASS GLATI GLEBE GLENE GLOBE GLOME GLOSA GLOSE GLUAU GLUIS GLUME GNOLE GNOME GNONS GNOSE GNOUS GOALS GOBAI GOBAS GOBAT GOBEE GOBER GOBES GOBEZ GOBIE GODAI GODAS GODAT GODER GODES GODET GODEZ GOGLU GOGOS GOLFE GOLFS GOMBO GOMMA GOMME GONDA GONDE GONDS GONGS GONZE GORDS GORET GORGE GOSSE GOTON GOUDA GOUET GOUGE GOULE GOULU GOUMS GOURA GOURD GOURE GOUTA GOUTE GOUTS GOYIM GRACE GRADE GRAIN GRAND GRAUX GRAVA GRAVE GRAVI GREAI GREAS GREAT GREBE GRECS GREEE GREEN GREER GREES GREEZ GREGE GRELA GRELE GRENA GRENE GRENU GRESA GRESE GREVA GREVE GRIEF GRILL GRILS GRIMA GRIME GRIOT GRISA GRISE GRIVE GROGS GROIN GROLE GROOM GROUP GRUAU GRUES GRUGE GRUME GUAIS GUANO GUEAI GUEAS GUEAT GUEDE GUEEE GUEER GUEES GUEEZ GUEPE GUERE GUERI GUETE GUETS GUEUX GUIDA GUIDE GUIPA GUIPE GUISE GUPPY GURUS GUZLA GYPSE GYRIN HABIT HABLA HABLE HACHA HACHE HADJI HAIES HAIKS HAIKU HAINE HAIRA HAIRE HALAI HALAS HALAT HALBI HALEE HALER HALES HALEZ HALLE HALLS HALOS HALTE HALVA HAMAC HAMPE HANAP HANSE HANTA HANTE HAPAX HAPPA HAPPE HARAS HARDA HARDE HARDI HAREM HARKI HARLE HAROS HARPA HARPE HARTS HASCH HASES HASTE HASTS HATAI HATAS HATAT HATEE HATER HATES HATEZ HATIF HAUTE HAUTS HAVAI HAVAS HAVAT HAVEE HAVER HAVES HAVEZ HAVIE HAVIR HAVIS HAVIT HAVRE HAYON HECTO HELAI HELAS HELAT HELEE HELER HELES HELEZ HELIX HELLO HENNE HENNI HENRY HERBA HERBE HERBU HERES HERON HEROS HERPE HERSA HERSE HERTZ HETRE HEURE HEURS HEURT HEVEA HIBOU HINDI HIPPY HISSA HISSE HIVER HOBBY HOCCO HOCHA HOCHE HOIRS HOMES HOMME HOMOS HONNI HONTE HORDE HORST HOSTO HOTEL HOTES HOTTE HOUAI HOUAS HOUAT HOUEE HOUER HOUES HOUEZ HOUKA HOULE HOURD HOURI HOYAU HUAIS HUAIT HUANT HUARD HUART HUCHA HUCHE HUEES HUENT HUERA HUIEZ HUILA HUILE HUMAI HUMAS HUMAT HUMEE HUMER HUMES HUMEZ HUMUS HUNES HUONS HUPPE HURES HURLA HURLE HURON HUTTE HYDNE HYDRE HYENE HYMEN HYMNE IAMBE IBERE ICHOR ICONE ICTUS IDEAL IDEEL IDEES IDIOT IDOLE IGLOO IGNEE IGNES IGUES ILEAL ILEON ILEUS ILIEN ILION ILOTE ILOTS IMAGE IMAGO IMAMS IMBUE IMBUS IMIDE IMITA IMITE IMMUN IMPER IMPIE IMPOT IMPUR INCAS INDEX INDUE INDUS INFRA INFUS INLAY INNEE INNES INOUI INPUT INTER INTIS INUIT INULE IODAI IODAS IODAT IODEE IODER IODES IODEZ IODLA IODLE IOULA IOULE IPECA IRAIS IRAIT IRIEZ IRISA IRISE IRONE IRONS IRONT ISARD ISBAS ISLAM ISOLA ISOLE ISSUE ISSUS ITEMS ITERA ITERE IULES IVRES IXIAS IXODE JABLA JABLE JABOT JACEE JACKS JACOT JACTA JACTE JADES JADIS JAINA JAINS JALAP JALES JALON JAMBE JANTE JAPON JAPPA JAPPE JARDE JARRE JASAI JASAS JASAT JASER JASES JASEZ JASPA JASPE JATTE JAUGE JAUNE JAUNI JAVAS JAVEL JEANS JEEPS JENNY JEREZ JERKA JERKE JERKS JESUS JETAI JETAS JETAT JETEE JETER JETES JETEZ JETON JETTE JEUDI JEUNA JEUNE JODLA JODLE JOIES JOINS JOINT JOKER JOLIE JOLIS JONCA JONCE JONCS JOTAS JOUAI JOUAL JOUAS JOUAT JOUEE JOUER JOUES JOUET JOUEZ JOUGS JOUIR JOUIS JOUIT JOULE JOURS JOUTA JOUTE JOYAU JUBES JUCHA JUCHE JUDAS JUDOS JUGAL JUGEA JUGEE JUGER JUGES JUGEZ JUIFS JUILL JUIVE JULEP JULES JUMBO JUMEL JUNTE JUPES JUPON JURAI JURAS JURAT JUREE JURER JURES JUREZ JURON JURYS JUSEE JUSQU JUSTE JUTAI JUTAS JUTAT JUTEE JUTER JUTES JUTEZ KACHA KACHE KAKIS KALIS KAMIS KAPOK KAPPA KARMA KARTS KAVAS KAWAS KAYAC KAYAK KEFIR KENDO KEPIS KETCH KHANS KHATS KHMER KHOLS KICKS KIEFS KIKIS KILOS KILTS KIWIS KNOUT KOALA KOINE KOLAS KORES KRAAL KRACH KRAFT KRAKS KRISS KSOUR KURDE KYRIE KYSTE LABEL LABIE LABRE LACAI LACAS LACAT LACEE LACER LACES LACET LACEZ LACHA LACHE LACIS LACTE LADIN LADRE LAGON LAICS LAIDE LAIDS LAIES LAINA LAINE LAIRD LAITE LAITS LAIUS LAIZE LAMAI LAMAS LAMAT LAMEE LAMER LAMES LAMEZ LAMIE LAMPA LAMPE LANCA LANCE LANDE LANGE LAPAI LAPAS LAPAT LAPEE LAPER LAPES LAPEZ LAPIN LAPIS LAQUA LAQUE LARDA LARDE LARDS LARGE LARGO LARME LARVE LASER LASSA LASSE LASSO LATEX LATIN LATTA LATTE LAURE LAVAI LAVAS LAVAT LAVEE LAVER LAVES LAVEZ LAVIS LAYAI LAYAS LAYAT LAYEE LAYER LAYES LAYEZ LAYON LAZZI LEBEL LECHA LECHE LECON LEDIT LEGAL LEGAT LEGER LEGES LEGUA LEGUE LEMME LENTE LENTO LENTS LEPRE LEROT LESAI LESAS LESAT LESEE LESER LESES LESEZ LESTA LESTE LESTS LETAL LEUDE LEURS LEVAI LEVAS LEVAT LEVEE LEVER LEVES LEVEZ LEVRE LEXIE LEXIS LIAGE LIAIS LIAIT LIANE LIANT LIARD LIBER LIBRE LICES LICHA LICHE LICOL LICOU LIDOS LIEDS LIEES LIEGE LIENS LIENT LIERA LIEUE LIEUR LIEUS LIEUX LIFTA LIFTE LIFTS LIGES LIGIE LIGNA LIGNE LIGOT LIGUA LIGUE LIIEZ LILAS LIMAI LIMAN LIMAS LIMAT LIMBE LIMEE LIMER LIMES LIMEZ LIMON LINER LINGA LINGE LINKS LINON LINOS LIONS LIPPE LIPPU LIRAI LIRAS LIREZ LIRON LISES LISEZ LISSA LISSE LISTA LISTE LITAI LITAS LITAT LITEE LITER LITES LITEZ LITHO LITRE LIURE LIVES LIVET LIVRA LIVRE LOBAI LOBAS LOBAT LOBBY LOBEE LOBER LOBES LOBEZ LOCAL LOCHA LOCHE LOCHS LODEN LOESS LOFAI LOFAS LOFAT LOFER LOFES LOFEZ LOFTS LOGEA LOGEE LOGER LOGES LOGEZ LOGIS LOGOS LOIRS LOLOS LONGE LONGS LOOCH LOOFA LOOKS LOPIN LOQUA LOQUE LORAN LORDS LORIS LOTES LOTIE LOTIR LOTIS LOTIT LOTOS LOTTE LOTUS LOUAI LOUAS LOUAT LOUEE LOUER LOUES LOUEZ LOUFA LOUFE LOUIS LOUPA LOUPE LOUPS LOURA LOURD LOURE LOUVA LOUVE LOVAI LOVAS LOVAT LOVEE LOVER LOVES LOVEZ LOYAL LOYER LUBIE LUCRE LUEUR LUFFA LUGEA LUGER LUGES LUGEZ LUIRA LUIRE LUISE LUITE LUITS LULUS LUMEN LUMES LUMPS LUNCH LUNDI LUNEE LUNES LUPIN LUPUS LURON LUSIN LUSSE LUTAI LUTAS LUTAT LUTEE LUTER LUTES LUTEZ LUTHS LUTIN LUTTA LUTTE LUXAI LUXAS LUXAT LUXEE LUXER LUXES LUXEZ LYCEE LYCRA LYRES LYRIC LYSAI LYSAS LYSAT LYSEE LYSER LYSES LYSEZ MACHA MACHE MACHO MACIS MACLA MACLE MACON MACRE MADRE MAFIA MAGES MAGIE MAGMA MAGNA MAGNE MAGOT MAIAS MAIES MAILS MAINS MAINT MAIRE MAJOR MAKIS MALES MALIN MALIS MALLE MALTA MALTE MALTS MALUS MAMAN MAMBO MAMIE MAMMY MANAS MANDA MANDE MANES MANGE MANIA MANIE MANNE MANSE MANTE MAORI MAOUX MAQUA MAQUE MARCS MARDI MAREE MARES MARGE MARIA MARIE MARIN MARIS MARKS MARLI MARNA MARNE MARRA MARRE MARRI MARTE MASER MASSA MASSE MATAF MATAI MATAS MATAT MATCH MATEE MATER MATES MATEZ MATHS MATIE MATIN MATIR MATIS MATIT MATON MATOU MATTE MAURE MAUVE MAYAS MAYEN MAYES MEATS MECHA MECHE MEDES MEDIA MEDIS MEDIT MEDOC MEFIA MEFIE MEFIS MEFIT MEGIE MEGIR MEGIS MEGIT MEGOT MEIJI MELAI MELAS MELAT MELBA MELEE MELER MELES MELEZ MELIA MELON MELOS MEMES MENAI MENAS MENAT MENEE MENER MENES MENEZ MENIN MENSE MENTE MENTI MENUE MENUS MERCI MERDE MERES MERLE MERLU MEROU MESAS MESSE METAL METAS METEO METIS METRA METRE METRO METTE MEULA MEULE MEURE MEURS MEURT MEUTE MEUVE MEZZO MIAOU MICAS MICHE MICRO MIDIS MIELS MIENS MIEUX MIGRA MIGRE MILAN MILES MILLE MIMAI MIMAS MIMAT MIMEE MIMER MIMES MIMEZ MIMIS MINAI MINAS MINAT MINCE MINCI MINEE MINER MINES MINET MINEZ MINIS MINOT MINOU MINUS MIRAI MIRAS MIRAT MIREE MIRER MIRES MIREZ MIROS MISAI MISAS MISAT MISEE MISER MISES MISEZ MISSE MITAI MITAN MITAS MITAT MITEE MITER MITES MITEZ MITON MITRE MIXAI MIXAS MIXAT MIXEE MIXER MIXES MIXEZ MIXTE MOCHE MOCOS MODAL MODEM MODES MODUS MOERE MOINE MOINS MOIRA MOIRE MOISA MOISE MOISI MOITA MOITE MOITI MOKAS MOLES MOLLE MOLLI MOLLO MOLYS MOMES MOMIE MONDA MONDE MONEL MONOS MONTA MONTE MONTS MOQUA MOQUE MORAL MORDE MORDS MORDU MORES MORIO MORNE MORSE MORTE MORTS MORUE MORVE MOSAN MOTEL MOTET MOTIF MOTOS MOTTA MOTTE MOTUS MOUDS MOUES MOULA MOULE MOULT MOULU MOUTS MOUVA MOUVE MOXAS MOYEE MOYEN MOYES MOYEU MUAIS MUAIT MUANT MUCHA MUCHE MUCOR MUCUS MUEES MUENT MUERA MUETS MUFLE MUFTI MUGES MUGIR MUGIS MUGIT MUIDS MUIEZ MULES MULET MULON MULOT MUMES MUNIE MUNIR MUNIS MUNIT MUONS MURAI MURAL MURAS MURAT MUREE MURER MURES MURET MUREX MUREZ MURIE MURIR MURIS MURIT MUSAI MUSAS MUSAT MUSCS MUSEE MUSER MUSES MUSEZ MUSSA MUSSE MUSTS MUTAI MUTAS MUTAT MUTEE MUTER MUTES MUTEZ MUTIN MYOME MYOPE MYRTE MYTHE NABAB NABOT NACRA NACRE NADIR NAFES NAGEA NAGEE NAGER NAGES NAGEZ NAIFS NAINE NAINS NAIVE NAJAS NANAN NANAR NANAS NANTI NAPEE NAPEL NAPPA NAPPE NARDS NARRA NARRE NASAL NASES NASSE NATAL NATIF NATTA NATTE NAVAL NAVET NAVRA NAVRE NAZIE NAZIS NEANT NEBKA NECKS NEFLE NEGRE NEGUS NEIGE NENES NENNI NEONS NERFS NERVI NETTE NEUFS NEUME NEUVE NEVES NEVEU NIAIS NIAIT NIANT NICHA NICHE NICOL NIECE NIEES NIEME NIENT NIERA NIFES NIIEZ NILLE NIMBA NIMBE NINAS NIOLE NIONS NIPPA NIPPE NIQUE NITRA NITRE NIVAL NIXES NOBLE NOCES NOCIF NOELS NOEUD NOIES NOIRE NOIRS NOISE NOLIS NOMES NOMMA NOMME NONCE NONES NONNE NOPAI NOPAL NOPAS NOPAT NOPEE NOPER NOPES NOPEZ NORDI NORIA NORME NOTAI NOTAS NOTAT NOTEE NOTER NOTES NOTEZ NOTRE NOUAI NOUAS NOUAT NOUBA NOUEE NOUER NOUES NOUEZ NOVAI NOVAS NOVAT NOVEE NOVER NOVES NOVEZ NOYAI NOYAS NOYAT NOYAU NOYEE NOYER NOYES NOYEZ NUAGE NUAIS NUAIT NUANT NUEES NUENT NUERA NUIEZ NUIRA NUIRE NUISE NUITE NUITS NULLE NUONS NUQUE NURSE NYLON OASIS OBEIR OBEIS OBEIT OBELE OBELS OBERA OBERE OBESE OBIER OBITS OBJET OBLAT OBOLE OBTUS OBVIA OBVIE OCCLU OCEAN OCRAI OCRAS OCRAT OCREE OCRER OCRES OCREZ OCTET ODEON ODEUR OEUFS OEUVE OFFRE OFLAG OGIVE OGRES OILLE OINGS OINTE OINTS OISIF OISON OKAPI OLEUM OLIVE OMBLE OMBRA OMBRE OMEGA OMETS OMISE ONCES ONCLE ONDEE ONDES ONDIN ONGLE OPALE OPENS OPERA OPERE OPIAT OPINA OPINE OPIUM OPTAI OPTAS OPTAT OPTER OPTES OPTEZ ORAGE ORALE ORANT ORAUX ORBES ORDRE OREES ORGES ORGIE ORGUE ORIEL ORINS ORLES ORLON ORMES ORNAI ORNAS ORNAT ORNEE ORNER ORNES ORNEZ OROBE ORPIN ORQUE ORTIE ORVET OSAIS OSAIT OSANT OSCAR OSEES OSENT OSERA OSIDE OSIER OSIEZ OSONS OSQUE OSSUE OSSUS OTAGE OTAIS OTAIT OTANT OTEES OTENT OTERA OTIEZ OTITE OTONS OUAIS OUATA OUATE OUBLI OUCHE OUEDS OUEST OUIES OUIRA OURDI OURLA OURLE OURSE OUSTE OUTIL OUTRA OUTRE OUVRA OUVRE OVALE OVATE OVINE OVINS OVNIS OVULA OVULE OXYDA OXYDE OYAIS OYATS OZENE OZONE PACHA PACKS PACTE PADDY PADOU PAGEA PAGEE PAGEL PAGER PAGES PAGEZ PAGNE PAGRE PAGUS PAIEN PAIES PAINS PAIRE PAIRS PALAN PALEE PALES PALET PALIE PALIR PALIS PALIT PALMA PALME PALOT PALPA PALPE PALUD PALUS PAMAI PAMAS PAMAT PAMEE PAMER PAMES PAMEZ PAMPA PANAI PANAS PANAT PANAX PANCA PANDA PANEE PANEL PANER PANES PANEZ PANIC PANKA PANNA PANNE PANSA PANSE PANSU PAONS PAPAL PAPAS PAPES PAPIS PAPYS PAQUE PARAI PARAS PARAT PARCE PARCS PARDI PAREE PAREO PARER PARES PAREZ PARIA PARIE PARIS PARKA PARLA PARLE PARME PARMI PAROI PAROS PARSI PARTE PARTI PARTS PARUE PARUS PARUT PASSA PASSE PATAI PATAS PATAT PATEE PATER PATES PATEZ PATIO PATIR PATIS PATIT PATON PATRE PATTA PATTE PATTU PAUMA PAUME PAUSA PAUSE PAVAI PAVAS PAVAT PAVEE PAVER PAVES PAVEZ PAVIE PAVOT PAYAI PAYAS PAYAT PAYEE PAYER PAYES PAYEZ PAYSE PEAGE PEANS PEAUX PECHA PECHE PEDUM PEGRE PEINA PEINE PEINS PEINT PEKAN PEKIN PELAI PELAS PELAT PELEE PELER PELES PELEZ PELLE PELTA PELTE PENAL PENDE PENDS PENDU PENES PENIL PENIS PENNE PENNY PENON PENSA PENSE PENTE PENTU PEONS PEPES PEPIA PEPIE PEPIN PEPON PERCA PERCE PERCU PERDE PERDS PERDU PERES PERIL PERIR PERIS PERIT PERLA PERLE PEROT PERSE PERTE PESAI PESAS PESAT PESEE PESER PESES PESEZ PESON PESOS PESSE PESTA PESTE PETAI PETAS PETAT PETEE PETER PETES PETEZ PETIT PETON PETRE PETRI PETUN PEULS PEURS PEZES PHAGE PHARE PHASE PHILO PHLOX PHONE PHONO PHOTO PIAFS PIANO PIANS PICAS PICOT PIECE PIEDS PIEGE PIETA PIETE PIEUX PIEZE PIFAI PIFAS PIFAT PIFEE PIFER PIFES PIFEZ PIFFA PIFFE PIGEA PIGEE PIGER PIGES PIGEZ PIGNE PILAF PILAI PILAS PILAT PILEE PILER PILES PILET PILEZ PILLA PILLE PILON PILOU PILUM PINCA PINCE PINNE PINOT PINTA PINTE PIONS PIPAI PIPAS PIPAT PIPEE PIPER PIPES PIPEZ PIPIS PIPIT PIQUA PIQUE PIRES PISES PISSA PISSE PISTA PISTE PITES PITIE PITON PITRE PIVES PIVOT PIZZA PLACA PLACE PLAGE PLAID PLAIE PLAIS PLAIT PLANA PLANE PLANS PLANT PLATE PLATS PLEBE PLEIN PLEUR PLEUT PLIAI PLIAS PLIAT PLIEE PLIER PLIES PLIEZ PLOIE PLOMB PLOTS PLOUC PLOUF PLOUK PLOYA PLOYE PLUIE PLUMA PLUME PNEUS POCHA POCHE POELA POELE POEME POETE POGNE POIDS POILA POILE POILS POILU POING POINS POINT POIRE POISE POKER POLAR POLES POLIE POLIO POLIR POLIS POLIT POLKA POLOS POMMA POMME POMPA POMPE PONCA PONCE PONDE PONDS PONDU PONEY PONGE PONTA PONTE PONTS POOLS POPES POQUA POQUE PORCS PORES PORNO PORTA PORTE PORTO PORTS POSAI POSAS POSAT POSEE POSER POSES POSEZ POSTA POSTE POTEE POTES POTIN POUAH POUCE POUFS POULE POULS POUPE PRAME PREAU PRELE PREND PRETA PRETE PRETS PREUX PREVU PRIAI PRIAS PRIAT PRIEE PRIER PRIES PRIEZ PRIMA PRIME PRIMO PRISA PRISE PRIVA PRIVE PROBE PROFS PROIE PROLO PROMO PROMU PRONA PRONE PROSE PROTE PROUE PROVO PRUDE PRUNE PSITT PSOAS PTOSE PUAIS PUAIT PUANT PUBIS PUCES PUCHE PUEES PUENT PUERA PUIEZ PUINE PUISA PUISE PUITS PULLS PULPE PULSA PULSE PUMAS PUMES PUNAS PUNCH PUNIE PUNIR PUNIS PUNIT PUNKS PUONS PUPES PUREE PURES PURGE PURIN PUROT PUSSE PUTES PUTTI PUTTO PYREX QUAIS QUAND QUANT QUARK QUART QUASI QUELS QUETA QUETE QUEUE QUEUX QUIET QUINE QUINT QUIPO QUIPU QUOTA RABAN RABAT RABBI RABLA RABLE RABOT RACEE RACER RACES RACLA RACLE RADAI RADAR RADAS RADAT RADEE RADER RADES RADEZ RADIA RADIE RADIN RADIO RADIS RAFLA RAFLE RAGEA RAGER RAGES RAGEZ RAGOT RAGUA RAGUE RAIAS RAIDE RAIDI RAIDS RAIES RAILS RAINA RAINE RAJAH RAJAS RAKIS RALAI RALAS RALAT RALER RALES RALEZ RAMAI RAMAS RAMAT RAMEE RAMER RAMES RAMEZ RAMIE RAMIS RAMPA RAMPE RANCE RANCH RANCI RANGE RANGS RANIS RAOUT RAPAI RAPAS RAPAT RAPEE RAPER RAPES RAPEZ RAPIN RAPTS RAQUA RAQUE RARES RASAI RASAS RASAT RASEE RASER RASES RASEZ RASHS RASTA RATAI RATAS RATAT RATEE RATEL RATER RATES RATEZ RATIO RATON RAVES RAVIE RAVIN RAVIR RAVIS RAVIT RAYAI RAYAS RAYAT RAYEE RAYER RAYES RAYEZ RAYON REACS REAGI REAIS REAIT REALE REANT REAUX REBAB REBAT REBEC REBUS REBUT RECEL RECES RECEZ RECHE RECIF RECIT RECRU RECTA RECTO RECUE RECUL RECUS RECUT REDAN REDIE REDIS REDIT REDUE REDUS REDUT REELS REELU REENT REERA REFIS REFIT REFUS REGAL REGIE REGIR REGIS REGIT REGLA REGLE REGLO REGNA REGNE REIEZ REINE REINS REJET RELAX RELIA RELIE RELIS RELIT RELUE RELUS RELUT REMET REMIS REMIT REMIZ REMUA REMUE RENAL RENDE RENDS RENDU RENEE RENES RENIA RENIE RENNE RENOM RENTA RENTE REONS REPAS REPIC REPIT REPLI REPLU REPOS REPUE REPUS REPUT RESTA RESTE RETIF RETRO REUNI REVAI REVAS REVAT REVEE REVER REVES REVET REVEZ REVIS REVIT REVUE REVUS RHUMA RHUMB RHUME RHUMS RIAIS RIAIT RIALS RIANT RIBLA RIBLE RICHE RICIN RIDAI RIDAS RIDAT RIDEE RIDER RIDES RIDEZ RIELS RIENS RIENT RIEUR RIFLA RIFLE RIFTS RIIEZ RIMAI RIMAS RIMAT RIMEE RIMER RIMES RIMEZ RINCA RINCE RINGS RIONS RIPAI RIPAS RIPAT RIPEE RIPER RIPES RIPEZ RIRAI RIRAS RIRES RIREZ RISEE RISSE RITAL RITES RIVAI RIVAL RIVAS RIVAT RIVEE RIVER RIVES RIVET RIVEZ RIXES ROBAI ROBAS ROBAT ROBEE ROBER ROBES ROBEZ ROBIN ROBOT ROCHE ROCKS ROCOU RODAI RODAS RODAT RODEE RODEO RODER RODES RODEZ ROGNA ROGNE ROGUE ROIDE ROIDI ROLES ROMAN ROMPE ROMPS ROMPT ROMPU RONCE RONDE RONDI RONDO RONDS RONEO RONGE ROQUA ROQUE ROSAI ROSAS ROSAT ROSEE ROSER ROSES ROSEZ ROSIE ROSIR ROSIS ROSIT ROSSA ROSSE ROTAI ROTAS ROTAT ROTER ROTES ROTEZ ROTIE ROTIN ROTIR ROTIS ROTIT ROTOR ROUAI ROUAN ROUAS ROUAT ROUEE ROUER ROUES ROUET ROUEZ ROUFS ROUGE ROUGI ROUIE ROUIR ROUIS ROUIT ROULA ROULE ROUMI ROUND ROUTA ROUTE ROYAL RUADE RUAIS RUAIT RUANT RUBAN RUBIS RUCHA RUCHE RUDES RUEES RUENT RUERA RUGBY RUGIR RUGIS RUGIT RUIEZ RUILA RUILE RUINA RUINE RUMBA RUMEN RUMEX RUOLZ RUONS RUPIN RURAL RUSAI RUSAS RUSAT RUSEE RUSER RUSES RUSEZ RUSHS RUSSE SABIR SABLA SABLE SABOT SABRA SABRE SACHE SACRA SACRE SAFRE SAGAS SAGES SAGOU SAGUM SAHEL SAIGA SAINE SAINS SAINT SAISI SAITE SAJOU SAKES SAKIS SALAI SALAS SALAT SALEE SALEP SALER SALES SALEZ SALIE SALIN SALIR SALIS SALIT SALLE SALOL SALON SALOP SALSA SALSE SALUA SALUE SALUT SALVE SAMBA SAMIT SAMPI SANAS SANGS SANIE SANTE SANVE SANZA SAOUL SAPAI SAPAS SAPAT SAPEE SAPER SAPES SAPEZ SAPIN SAQUA SAQUE SARDE SARIS SAROS SASSA SASSE SATIN SATIS SAUCA SAUCE SAUGE SAULE SAUNA SAUNE SAURA SAURE SAURI SAURS SAUTA SAUTE SAUTS SAUVA SAUVE SAVEZ SAVON SAXES SAXON SAXOS SAYON SBIRE SCALP SCARE SCATS SCEAU SCENE SCHAH SCIAI SCIAS SCIAT SCIEE SCIER SCIES SCIEZ SCION SCOOP SCORE SCOUT SCULL SCUTA SEANT SEAUX SEBKA SEBUM SECHA SECHE SECTE SEIDE SEIME SEINE SEING SEINS SEIZE SELFS SELLA SELLE SELON SELTZ SELVE SEMAI SEMAS SEMAT SEMEE SEMER SEMES SEMEZ SEMIS SENAT SENAU SENES SENNE SENSE SENTE SENTI SEOIR SEPIA SERAC SERAI SERAS SERBE SEREZ SERFS SERGE SERIA SERIE SERIN SERPE SERRA SERRE SERTE SERTI SERUM SERVE SERVI SETON SEUIL SEULE SEULS SEVES SEVIR SEVIS SEVIT SEVRA SEVRE SEXES SEXTE SEXTO SEXUE SHAHS SHAKO SHOOT SHORT SHOWS SHUNT SICLE SIEGE SIENS SIERA SIEUR SIGLE SIGMA SIGNA SIGNE SILEX SILOS SIMAS SINGE SINON SINUS SIOUX SIRES SIREX SIRLI SIROP SISAL SISES SITAR SITES SITOT SITUA SITUE SIUMS SIXTE SKAIS SKATE SKIAI SKIAS SKIAT SKIER SKIES SKIEZ SKIFF SKIFS SLANG SLAVE SLIPS SLOOP SLOWS SMALA SMALT SMART SMASH SMOGS SMOLT SMURF SNACK SNIFF SNOBA SNOBE SNOBS SOBRE SOCLE SODAS SODEE SODES SOEUR SOFAS SOIES SOIFS SOINS SOIRS SOJAS SOLDA SOLDE SOLEN SOLES SOLEX SOLIN SOLOS SOMAS SOMMA SOMME SONAR SONDA SONDE SONGE SONNA SONNE SONOS SORBE SORES SORTE SORTI SORTS SOSIE SOTCH SOTIE SOTTE SOUCI SOUDA SOUDE SOUES SOUKS SOULA SOULE SOULS SOUPA SOUPE SOURD SOURI SOUTE SOYAS SOYER SOYEZ SPAHI SPART SPATH SPEOS SPHEX SPIRE SPORE SPORT SPOTS SPRAT SPRAY SPRUE SQUAT SQUAW STADE STAFF STAGE STAND STARS STASE STEAK STELE STEMM STEMS STENO STERA STERE STICK STIPE STOCK STOPS STORE STOUT STRAS STRIA STRIE STRIX STUCS STYLA STYLE STYLO SUAGE SUAIS SUAIT SUANT SUAVE SUBER SUBIE SUBIR SUBIS SUBIT SUCAI SUCAS SUCAT SUCEE SUCER SUCES SUCEZ SUCON SUCRA SUCRE SUEDE SUEES SUENT SUERA SUEUR SUIES SUIEZ SUIFA SUIFE SUIFS SUINT SUITE SUIVE SUIVI SUJET SULKY SUMAC SUMES SUNNA SUONS SUPER SUPIN SUPRA SURAH SURES SURET SURFA SURFE SURFS SURGI SURIN SURIR SURIS SURIT SUROS SUSHI SUSSE SUTES SUTRA SWAPS SWING SYLVE SYMPA TABAC TABAR TABES TABLA TABLE TABOR TABOU TACCA TACET TACHA TACHE TACON TACOT TACTS TAFIA TAGAL TAIES TAIGA TAINS TAIRA TAIRE TAISE TALAI TALAS TALAT TALCS TALEE TALER TALES TALEZ TALLA TALLE TALON TALUS TAMIA TAMIL TAMIS TANCA TANCE TANGO TANIN TANKS TANNA TANNE TANTE TAONS TAPAI TAPAS TAPAT TAPEE TAPER TAPES TAPEZ TAPIE TAPIN TAPIR TAPIS TAPIT TAPON TAQUA TAQUE TARAI TARAS TARAT TARDA TARDE TAREE TARER TARES TARET TAREZ TARGE TARIE TARIF TARIN TARIR TARIS TARIT TAROT TARSE TARTE TARTI TASSA TASSE TATAI TATAS TATAT TATEE TATER TATES TATEZ TATOU TAULE TAUPE TAURE TAXAI TAXAS TAXAT TAXEE TAXER TAXES TAXEZ TAXIE TAXIS TAXON TCHAO TECKS TEINS TEINT TELES TELEX TELLE TELLS TEMPE TEMPO TEMPS TENDE TENDS TENDU TENEZ TENIA TENIR TENON TENOR TENTA TENTE TENUE TENUS TERCA TERCE TERME TERNE TERNI TERRA TERRE TERRI TERSA TERSE TESTA TESTE TESTS TETAI TETAS TETAT TETEE TETER TETES TETEZ TETIN TETON TETRA TETTE TETUE TETUS TEXAN TEXTE THAIE THEME THESE THETA THONS THORA THUNE THUYA THYMS TIANS TIARE TIBIA TIEDE TIEDI TIENS TIENT TIERS TIFFE TIGES TIGRA TIGRE TILDE TILLA TILLE TILTS TIMON TINTA TINTE TIQUA TIQUE TIRAI TIRAS TIRAT TIREE TIRER TIRES TIRET TIREZ TISAI TISAS TISAT TISEE TISER TISES TISEZ TISON TISSA TISSE TISSU TITAN TITIS TITRA TITRE TMESE TOAST TOGES TOILA TOILE TOISA TOISE TOITS TOKAI TOKAY TOLEE TOLES TOLET TOLLE TOLUS TOMAI TOMAS TOMAT TOMBA TOMBE TOMEE TOMER TOMES TOMEZ TOMME TOMMY TONAL TONDE TONDS TONDU TONIE TONKA TONNA TONNE TONTE TONUS TOPAI TOPAS TOPAT TOPER TOPES TOPEZ TOPOS TOQUA TOQUE TORAH TORDE TORDS TORDU TOREA TOREE TORII TORIL TORON TORSE TORTS TORVE TOTAL TOTEM TOTON TOTOS TOUAI TOUAS TOUAT TOUEE TOUER TOUES TOUEZ TOURD TOURS TOUTE TOUTS TRABE TRACA TRACE TRACS TRACT TRAHI TRAIE TRAIN TRAIS TRAIT TRAMA TRAME TRAMS TRAPU TREMA TREVE TRIAI TRIAL TRIAS TRIAT TRIBU TRIDI TRIEE TRIER TRIES TRIEZ TRIMA TRIME TRINE TRINS TRIOL TRIOS TRIPE TRIPS TROCS TROIS TROLL TRONA TRONC TRONE TROPE TROTS TROUA TROUE TROUS TRUCS TRUIE TRUST TSARS TUAGE TUAIS TUAIT TUANT TUBAI TUBAS TUBAT TUBEE TUBER TUBES TUBEZ TUEES TUENT TUERA TUEUR TUIEZ TUILA TUILE TULLE TUMES TUNER TUNES TUONS TUQUE TURBE TURCO TURCS TURFS TURNE TUSSE TUTES TUTIE TUTTI TUTUS TUYAU TWEED TWIST TYPAI TYPAS TYPAT TYPEE TYPER TYPES TYPEZ TYPHA TYPON TYPOS TYRAN TZARS UBACS UKASE ULEMA ULTRA ULULA ULULE ULVES UNAUS UNIES UNION UNIRA UNITE URANE URATE UREES URGEA URGER URINA URINE URNES URUBU USAGE USAIS USAIT USANT USEES USENT USERA USIEZ USINA USINE USITE USNEE USONS USUEL USURE UTILE UVALE UVAUX UVEES UVULA UVULE VACHE VAGIN VAGIR VAGIS VAGIT VAGUA VAGUE VAINC VAINE VAINS VAIRE VAIRS VALET VALEZ VALSA VALSE VALUE VALUS VALUT VALVE VAMPA VAMPE VAMPS VANDA VANNA VANNE VANTA VANTE VAPES VAQUA VAQUE VARAN VARIA VARIE VARUS VARVE VASES VASTE VEAUX VECES VECUE VECUS VECUT VEDAS VEINA VEINE VELAI VELAR VELAS VELAT VELDS VELER VELES VELEZ VELIE VELIN VELOS VELOT VELTE VELUE VELUM VELUS VENAL VENDE VENDS VENDU VENET VENEZ VENGE VENIN VENIR VENTA VENTE VENTS VENUE VENUS VERBE VERDI VERGE VERIN VERNI VERRA VERRE VERSA VERSE VERSO VERTE VERTS VERTU VERVE VESCE VESPA VESSA VESSE VESTE VETES VETEZ VETIR VETIS VETIT VETUE VETUS VEUFS VEULE VEUVE VEXAI VEXAS VEXAT VEXEE VEXER VEXES VEXEZ VIBRA VIBRE VICES VICHY VICIA VICIE VIDAI VIDAS VIDAT VIDEE VIDEO VIDER VIDES VIDEZ VIEIL VIENS VIENT VIEUX VIGIE VIGNE VILES VILLA VILLE VIMES VINAI VINAS VINAT VINEE VINER VINES VINEZ VINGT VIOCS VIOLA VIOLE VIOLS VIRAI VIRAL VIRAS VIRAT VIREE VIRER VIRES VIREZ VIRIL VIRUS VISAI VISAS VISAT VISEE VISER VISES VISEZ VISON VISSA VISSE VITAE VITAL VITES VITRA VITRE VIVAT VIVES VIVEZ VIVRA VIVRE VIZIR VOCAL VODKA VOEUX VOGUA VOGUE VOICI VOIES VOILA VOILE VOIRE VOLAI VOLAS VOLAT VOLEE VOLER VOLES VOLET VOLEZ VOLIS VOLTA VOLTE VOLTS VOLVE VOMER VOMIE VOMIR VOMIS VOMIT VOTAI VOTAS VOTAT VOTEE VOTER VOTES VOTEZ VOTIF VOTRE VOUAI VOUAS VOUAT VOUEE VOUER VOUES VOUEZ VOUGE VOULU VOUTA VOUTE VOYER VOYEZ VOYOU VRAIE VRAIS VULGO VULVE WAGON WATTS WEBER WHARF WHIGS WHIST WINCH XENON XERES XERUS XIPHO XYSTE YACHT YACKS YARDS YEBLE YEUSE YOGAS YOGIS YOLES YUCCA ZABRE ZAINS ZANIS ZANNI ZANZI ZAZOU ZEBRA ZEBRE ZEBUS ZELEE ZELES ZENDS ZEROS ZESTA ZESTE ZIBAI ZIBAS ZIBAT ZIBEE ZIBER ZIBES ZIBEZ ZIGUA ZIGUE ZINCS ZIPPA ZIPPE ZIZIS ZLOTY ZOILE ZOMBI ZONAI ZONAL ZONAS ZONAT ZONEE ZONER ZONES ZONEZ ZOOMS ZOZOS ================================================ FILE: core/src/main/res/raw/wordle_list_pt.txt ================================================ ABACA ABACO ABADA ABADE ABALA ABALO ABANE ABANO ABATE ABATI ABAZA ABCAZ ABECE ABECO ABETO ABOIE ABOIO ABOLA ABONO ABOTA ABRAO ABREU ABRIA ABRIL ABRIR ABRIS ABRIU ABUJA ABUSO ACABA ACABE ACABO ACAIR ACAJU ACARA ACARI ACARO ACASO ACATA ACAUA ACCAO ACENO ACESA ACESO ACHAI ACHAM ACHAR ACHAS ACHEI ACHEM ACHOU ACIDO ACIMA ACINO ACOAM ACOAR ACOAS ACODE ACOEI ACOEM ACOES ACTOR ACUAI ACUAM ACUAR ACUAS ACUDE ACUDI ACUEI ACUEM ACUES ACULE ACULO ACUOU ACUSA ACUSE ACUSO ACUTA ADABA ADAES ADAGA ADAMA ADAME ADAMO ADEGA ADEJE ADEJO ADEUS ADIAI ADIAM ADIAR ADIAS ADIDA ADIDO ADIEI ADIEM ADIES ADIOU ADITE ADITO ADOBE ADOBO ADORA ADORE ADORO ADOTE ADOTO ADRIA ADRIO ADUBO ADUFE ADULE ADULO ADVIR AECIA AECIO AERAR AEREA AEREO AFAGO AFALA AFAMA AFANO AFARE AFARO AFEAR AFETE AFETO AFIAR AFILO AFIXO AFLAR AFLUI AFOFE AFOFO AFOGO AFORA AFOXE AFUDE AFULO AFURA AGADE AGAPE AGATA AGATO AGAVE AGEUS AGINA AGINO AGITE AGITO AGNES AGOGO AGONO AGORA AGRAZ AGUAI AGUAR AGUAS AGUDA AGUDO AGUES AGUIA AIACE AIALA AINAO AINDA AIPIM AJAJA AJERU AJUDA AJUDE AJUDO AJUGA AJURU ALABE ALABO ALADA ALADI ALADO ALAES ALAGA ALAGE ALALA ALALC ALANA ALANE ALANO ALAOS ALAVA ALBAS ALBOI ALBOS ALBUM ALCAR ALCAS ALCEA ALCEI ALEIA ALEMA ALEMO ALEPA ALEPE ALERJ ALESC ALESP ALETO ALFAR ALFIM ALGAR ALGAS ALGIA ALGOL ALGOR ALGOZ ALGUA ALGUM ALGUR ALHAR ALIAR ALIAS ALIBI ALICE ALIEN ALIJO ALINA ALINE ALINO ALISE ALMAS ALMOS ALOES ALOJA ALOJO ALPES ALTAI ALTAR ALTAS ALTOS ALUAR ALUCO ALUDE ALUIR ALUNE ALUNO ALVAR ALVAS ALVEO ALVES ALVIM ALVOR ALVOS AMADA AMADO AMAGO AMAIS AMAME AMAPA AMARA AMARO AMAVA AMBAR AMBAS AMBOS AMEAR AMEBA AMEIA AMEIS AMENO AMIAL AMIDO AMIGA AMIGO AMITA AMITO AMOJO AMORA AMPLO AMUAR ANACO ANAGE ANAHY ANAIS ANAMA ANANO ANAOS ANAPU ANATA ANCHO ANCIA ANDAI ANDAM ANDAR ANDAS ANDEI ANDEM ANDES ANDOA ANDOR ANDOU ANDRE ANEIS ANEJO ANELO ANEXE ANEXO ANGLO ANHAR ANIAO ANIMA ANIME ANIMO ANINA ANINE ANINO ANION ANITA ANITO ANIXO ANJOS ANODO ANOES ANOIA ANONA ANORI ANOTE ANOTO ANSIA ANTAO ANTAR ANTAS ANTES ANTEU ANTRO ANUAL ANUIR ANUIU ANULE ANULO ANURO ANZOL AONDE AONIA AORTA APAGE APARA APARE APARO APEAR APEGO APELA APELO APIAI APICE APITO APODE APODI APODO APOIO APOJO APOLO APORA APORE APPLE APUPO APURE APURO AQUEM ARABE ARACA ARACI ARACU ARADO ARAMA ARAME ARAMO ARANA ARARA ARARI ARAUA ARAXA ARCAO ARCAR ARCAZ ARCHA ARCOL ARCOS ARDER ARDIA ARDIL ARDIS ARDOR ARDUO AREAL AREAR AREAS ARECA AREIA AREJE AREJO ARELA ARELE ARELO ARENA AREPA ARERE AREUS ARFAR ARGAN ARGEL ARGON ARGOS ARIAL ARIAS ARIDO ARIEL ARIES ARIGO ARILO ARIMO ARIOS ARJAO ARMAI ARMAM ARMAR ARMAS ARMEI ARMEM ARMES ARMOU ARMUR ARNAL ARNAZ ARNES AROLA AROLO AROMA AROMO AROSI ARPAO ARPAR ARPEU ARRAS ARRAU ARREU ARROZ ARTES ARTUR ARUBA ARUJA ARUJO ARULA ARUME ARXAR ASAIS ASARA ASAVA ASCII ASCUA ASEIS ASILO ASNAL ASNAR ASNOS ASPAR ASPAS ASSAI ASSAM ASSAR ASSAS ASSAZ ASSEI ASSEM ASSES ASSIM ASSIS ASSOU ASSUA ASTRO ASTUR ATADO ATAIS ATARA ATAVA ATEAR ATEIA ATEIS ATENA ATESE ATESO ATEUS ATICA ATICO ATILA ATINO ATIRE ATIRO ATIVA ATIVE ATIVO ATLAS ATOMO ATONI ATONO ATRAS ATREU ATRIL ATRIO ATRIZ ATROA ATROO ATROS ATROZ ATUAI ATUAL ATUAM ATUAR ATUAS ATUEI ATUEM ATUES ATUOU ATXIM AUCAO AUDAZ AUDIO AUETE AUETI AUREA AUREO AURIR AUTOR AUTOS AVAIS AVARA AVARE AVARO AVATI AVEIA AVELA AVENA AVIAO AVIAR AVIDO AVISA AVISE AVISO AWETI AXILA AXIXA AZADO AZARE AZEDO AZERI AZIMO AZOAI AZOAM AZOAR AZOAS AZOEI AZOEM AZOES AZOOU AZOTO AZUIS AZULE AZULO BABAI BABAM BABAO BABAS BABAU BABEI BABEL BABEM BABES BABOU BACAR BACIA BACIO BACON BADIO BAETA BAFAR BAFIO BAGOA BAGRE BAGUA BAHIA BAIAO BAIAR BAILA BAILE BAILO BAITA BAITE BAIXA BAIXE BAIXO BALAO BALAR BALAS BALBO BALDA BALDE BALES BALGA BALHO BALIR BALIU BALOR BALSA BAMBA BAMBE BAMBI BAMBO BAMBU BANAL BANCA BANCO BANDA BANDE BANDO BANGA BANGO BANHA BANHO BANIR BANJO BANTO BANTU BANZA BANZE BANZO BAOBA BARAO BARBA BARBO BARCA BARCO BARDA BARDO BAREM BARGA BARIO BARRA BARRE BARRO BASAA BASAL BASCA BASCO BASES BASSE BASTA BASTE BASTO BATCH BATEL BATER BATOM BATON BAURU BAZAR BEATA BEATO BEBAS BEBEM BEBER BEBEU BEBUM BECAS BECHA BECHE BEDEL BEICO BEIGE BEIJA BEIJE BEIJO BEIJU BEIRA BEIRO BEITA BELAS BELEM BELGA BELOS BEMBA BEMOL BENGA BENIM BENIN BENTO BEQUE BERCA BERCE BERCO BERMA BERNA BERRO BESTA BESTE BESTO BETAO BETAR BETIM BHILI BIANA BIARI BICAL BICAR BICAS BICHA BICHO BICLA BICOS BIDUO BIELA BIFAR BIGLE BIGUE BIJOU BILAC BILAR BILES BILHA BILIS BILRO BIMBA BIMBE BIMBO BINAR BINGA BINGO BIOCO BIOMA BIOTA BIPES BIRAO BIRRA BISAI BISAM BISAO BISAR BISAS BISCA BISEI BISEL BISEM BISES BISOU BISPA BISPO BITAR BIVIO BLASE BLEFE BLINI BLITZ BLOCO BLOGA BLUCO BLUES BLUFF BLUSA BNDES BOATE BOATO BOAVA BOBAR BOBOT BOCAL BOCAO BOCAS BOCHA BOCHE BOCOI BODAO BODAS BOFAS BOIAO BOIAR BOICA BOIEI BOIIL BOINA BOIRA BOITA BOITE BOJAR BOLAR BOLAS BOLBO BOLHA BOLOR BOLSA BOLSO BOMBA BOMBO BONDE BONGO BONUS BONZO BORAS BORAX BORBA BORDA BORDE BORDO BORIA BORLA BORNE BOROA BORRA BORRO BOSAO BOSON BOSSA BOSTA BOSTE BOSTO BOTAI BOTAM BOTAO BOTAR BOTAS BOTEI BOTEM BOTES BOTIM BOTON BOTOU BOTOX BOUBA BOUCA BOXER BOXES BRABO BRACA BRACO BRADA BRADO BRAGA BRAMA BRASA BRAVA BRAVO BRAVU BRECA BREGA BREIA BREJA BREJO BRENA BRENO BRETE BREVE BRIAL BRICA BRIDA BRIGA BRIGO BRIOL BRISA BRIZA BROAR BROAS BROCA BROCO BRODO BROMO BROTA BROTE BROTO BROXA BROXE BROXO BRUAR BRUMA BRUNA BRUNE BRUNI BRUNO BRUTA BRUTO BRUXA BRUXO BUCAL BUCHA BUCHO BUCIL BUCLE BUCOS BUENO BUFAO BUFAR BUGAR BUGIA BUGIE BUGIO BUGRE BUGUE BULAR BULAS BULBO BULHA BULHO BULIR BUMBO BUNDA BUNHO BUQUE BURCA BUREL BURGO BURIL BURLA BURRA BURRO BUSAO BUSCA BUSTO BUTAO BUTEO BUTES BUTIA BUTIM BUTIO BUTUA BUXAO BUZIO CAABA CABAL CABAU CABAZ CABEM CABER CABIS CABOS CABOZ CABRA CABRO CABUM CACAO CACAR CACAU CACHA CACHE CACHO CACRE CACTO CACUA CADEA CAETE CAFES CAFRA CAFRE CAFUA CAGAO CAGAR CAIAR CAIBI CAIBO CAICO CAIDA CAIDO CAINS CAIOS CAIRA CAIRO CAIRU CAIUA CAIXA CALAI CALAM CALAO CALAR CALAS CALCA CALCE CALCO CALDA CALDO CALEI CALEM CALES CALFE CALHA CALHE CALIX CALIZ CALMA CALMO CALOM CALOR CALOS CALOU CALVA CALVE CALVO CAMAL CAMAS CAMBA CAMBE CAMBO CAMIM CAMPA CAMPO CANAA CANAL CANAS CANCA CANDA CANDO CANGA CANGO CANHA CANIL CANJA CANLE CANOA CANON CANOS CANSE CANSO CANTA CANTE CANTO CANZA CAPAI CAPAM CAPAO CAPAR CAPAS CAPAZ CAPEM CAPES CAPIM CAPOA CAPOT CAPOU CAPUT CAPUZ CAQUI CARAA CARAI CARAS CARAY CARDA CARDO CARGA CARGO CARIE CARIL CARIZ CARLA CARLO CARMA CARME CARMO CARNE CAROS CARPA CARPE CARPO CARRO CARTA CARTE CASAI CASAL CASAM CASAO CASAR CASAS CASCA CASCO CASEI CASEM CASES CASOS CASOU CASPA CASSA CASSE CASSO CASTA CASTE CASTO CATAI CATAM CATAR CATAS CATEI CATEM CATES CATOU CATRE CATUA CAUAS CAUDA CAUES CAUIM CAULE CAUPI CAURI CAUSA CAUSO CAUTA CAUTO CAVAI CAVAM CAVAR CAVAS CAVEA CAVEI CAVEM CAVES CAVOU CEARA CEDEM CEDER CEDRO CEGAR CEIBA CEIFA CEIFE CEIFO CEITA CEIVE CELGA CELHA CELIA CELIO CELME CELSA CELSO CELTA CEMBA CENAS CENHO CENSO CENTO CERCA CERCE CERCO CERDA CERDO CERES CERIO CERNE CEROL CERRA CERRE CERRO CERTA CERTO CERVA CERVO CESAR CESIO CESSA CESSE CESSO CESTA CESTO CETIM CETRO CEUTA CEVAR CHABU CHACO CHADA CHADE CHAGA CHALE CHAMA CHANA CHAPA CHAPE CHAPO CHARA CHATA CHATO CHAUL CHAVE CHAVO CHECO CHEFA CHEFE CHEGA CHEIA CHEIO CHEKA CHEVA CHIAO CHIAR CHIBA CHIBE CHIBO CHICA CHICO CHILE CHINA CHIPE CHITA CHITO CHOCA CHOCO CHOER CHOLA CHOLO CHONA CHOPE CHORA CHORE CHORO CHOTO CHUCA CHUCO CHUFA CHULA CHULE CHULO CHUTA CHUTE CHUTO CHUVA CIADA CIANO CIAOS CIBAR CICLO CIDRA CIFRA CIFRE CIFRO CIGAS CILHA CILHO CILIO CIMAO CINCO CINTA CINTO CINZA CIOSO CIPRA CIPRO CIRCO CIRIA CIRIO CIRRO CISAO CISCA CISCO CISMA CISNE CISTO CITAI CITAM CITAR CITAS CITEI CITEM CITES CITOU CITRO CIUME CIVIL CLADO CLAIM CLAME CLAMO CLARA CLARO CLAVA CLAVE CLEAN CLERO CLICA CLICK CLIMA CLINA CLIPE CLONE CLORO CLOSE CLOTO CLOWN CLUBE COAIS COALA COARA COARI COAVA COBOI COBRA COBRE COBRI COBRO COCAL COCAO COCAR COCHA COCHE COCHO COCOS CODEA CODEO CODEX COEIS COESO COEVO COFRE COIAS COICE COIFA COIMA COIME COINA COINE COIRA COIRO COISA COISO COITA COITE COITO COLAI COLAM COLAR COLAS COLEI COLEM COLES COLMA COLMO COLON COLOU COLZA COMAM COMAS COMBE COMBO COMER COMES COMEU COMIA COMUA COMUM CONAR CONAS CONCA CONDE CONGO CONHA CONHO CONTA CONTE CONTO COPAM COPAO COPAR COPAS COPIA COPIE COPIO COPLA COPOM COPRA COPTA COQUE CORAI CORAL CORAM CORAO CORAR CORAS CORCA CORCO CORDA COREI COREM CORES CORGA CORGO CORJA CORNE CORNO COROA COROS COROU CORPO CORRA CORRE CORRI CORRO CORSO CORTA CORTE CORTO CORVA CORVO COSCA COSCO COSER COSEU COSMA COSME COSMO COSTA COSTO COTAO COTAR COTAS COTIA COTRA COUBE COUCE COUPE COURA COURO COUSA COUTO COUVE COVAS COVIL COXIA COXIM COZER COZEU CRACK CRASE CRASH CRATO CRAVE CRAVO CRAWL CRECE CREDE CREDO CREEM CREGA CREGO CREIA CREIO CREME CREPE CRESO CRIAI CRIAM CRIAR CRIAS CRICA CRIEI CRIEM CRIES CRIME CRINA CRIOU CRISE CRIVO CROAC CROCA CROIA CROIO CROMO CRUDE CRUEL CRUSH CUAIS CUATA CUBAR CUCAR CUCHO CUDAR CUECA CUICA CUIDA CUIDO CUITE CUJAS CUJOS CULPA CULPE CULPO CULTO CUMBE CUMEL CUMIM CUMIO CUNCA CUNCO CUNHA CUNHO CUPIM CURAI CURAL CURAM CURAR CURAS CURDO CUREI CUREM CURES CURIA CURIO CUROU CURRA CURRO CURRY CURSO CURTA CURTO CURUA CURVA CURVE CURVO CUSCA CUSCO CUSPO CUSTA CUSTE CUSTO CUTER CUTIA CUTIS CUXIU CUZAO DACAO DACAR DACHA DADAS DADOR DADOS DAIME DAKAR DALEM DALIA DALVA DALVO DAMAO DAMAS DAMBA DAMNO DANAI DANAM DANAR DANCA DANCE DANDI DANDO DANEI DANEM DANES DANOS DANOU DAQUI DARAO DARCI DARDO DAREI DARIA DARIO DARMA DATAR DATAS DAUDE DAVAM DAVID DAVIS DEBIL DEBUT DECAM DECAS DECEI DECEM DECER DECES DECEU DECIA DEDAL DEDAO DEDOS DEISE DEITA DEITE DEITO DEIXA DEIXE DEIXO DELAS DELEI DELES DELIR DELTA DEMOS DENDE DENSA DENSO DENTE DEPOR DEPRE DEQUE DERAM DERBI DERBY DERMA DERME DESAR DESCA DESCE DESCI DESCO DESDE DESDO DESPI DESSA DESSE DESTA DESTE DETEM DETER DETOX DEUSA DEVER DEVES DEVIA DEVIR DIABA DIABO DIADE DIAFA DIANA DICAR DIECO DIEGA DIEGO DIESE DIETA DIGNA DIGNO DILDO DILMA DILMO DIMER DINAS DINDA DINGO DINKA DIODO DIOGA DIOGO DIONE DIQUE DIREI DISCO DISEL DISSE DISSO DISTO DITAR DITAS DITOS DIVAS DIVOS DIZEM DIZER DIZIA DOADA DOADO DOAIS DOARA DOAVA DOBRA DOBRE DOBRO DOCAR DOCEM DOCES DOCIL DODOI DODOS DOEIS DOERA DOGAO DOGMA DOGUE DOIAM DOIAS DOIDA DOIDO DOIRA DOIRO DOLAR DOLOR DOMAR DONAS DONDE DONDO DONGO DONOS DOPAR DORIS DORNA DORSO DORZE DOTAR DOUDA DOUDO DOULA DOURA DOURE DOURO DOUTO DRAFT DRAGA DRAMA DRENE DRENO DRIVE DROGA DROGO DRONE DROPE DROPS DRUPA DRUSA DRUSO DUBIA DUBIO DUBLE DUCAL DUCHA DUCHE DUCHO DUCTO DUELO DUERE DUETO DUMAS DUMBA DUPLA DUPLO DUQUE DURAI DURAM DURAR DURAS DUREI DUREM DURES DUROS DUROU DUTRA DUZIA EBANO EBOLA EBRIA EBRIO ECLER ECOAI ECOAR ECRAN EDEIA EDEMA EDENS EDITE EDITO EDNAS EDNOS EFEBO EFIRE EFODE EGIDE EGITO EIRAS EIVAR EIXOS ELEMI ELEPE ELFOS ELIAS ELISA ELISE ELISO ELITE ELOAS ELVAS ELZAS ELZOS EMAIL EMANE EMANO EMBUA EMERO EMESE EMICO EMOJI EMPAR EMPOS ENCHE ENCHO ENDES ENDEZ ENDRO ENFIA ENFIM ENGOS ENGUA ENJOO ENOJA ENOJO ENTAO ENTRA ENTRE ENTRO ENVIO ENZAS ENZOS EPICA EPICO EPOCA EPOXI ERADO ERAIS ERBIO EREIS ERERE ERETO ERGUE ERICA ERICE ERMAS ERRAI ERRAM ERRAR ERRAS ERREI ERREM ERRES ERROR ERROU ERVAS ERZYA ESCOL ESFIA ESGAR ESMAR ESPIA ESPIE ESPIO ESPIR ESPRU ESQUI ESSAS ESSES ESTAO ESTAR ESTAS ESTER ESTES ESTIE ESTIO ESTOL ESTOU ESTRO ETANO ETAPA ETENO ETHOS ETICA ETICO ETIMO ETNIA ETUDE EVADE EVITE EVITO EVORA EXALO EXAME EXATA EXATO EXIDO EXIME EXITO EXODO EXPOR EXTRA EXUMA EXUME EXUMO FABIA FABIO FACAM FACAO FACES FACHA FACHO FACIL FACTO FADOS FAIAL FAIAR FAINA FAITO FAIXA FALAI FALAM FALAR FALAS FALAZ FALCA FALDA FALEI FALEM FALES FALHA FALHE FALHO FALIR FALOU FALSA FALSO FALTA FALTE FALTO FALUS FANAL FANAR FANGA FANHA FANHO FANTA FARAD FARAO FARDA FARDO FARIA FAROL FARPA FARRA FARSA FARTA FARTE FARTO FARUM FASOR FASTA FASTO FATAL FATAO FATIA FATIE FATIO FATOR FATUO FAUCE FAULA FAUNA FAUNO FAVOR FAZEI FAZEM FAZER FAZIA FEBEU FEBRA FEBRE FECAL FECHA FECHE FECHO FEDER FEDOR FEIAS FEIJO FEIOS FEIRA FEIRE FEIRO FEITA FEITO FEIXE FELAS FELEO FELIX FELIZ FELPA FELPO FEMEA FEMEO FEMUR FENDA FENDE FENIX FENOL FENTO FEOFO FERAS FERAZ FERIA FERIE FERIO FERIR FERMI FEROS FEROZ FERRA FERRO FERVE FERVO FESTA FETAL FEUDO FEZES FIADO FIAMA FIAPO FIBRA FICAR FICHA FICOU FIGLE FIGOS FILAO FILER FILET FILHA FILHO FILIE FILIO FILME FILMO FIMIA FINAL FINAS FINCA FINDA FINDE FINDO FINES FINTA FIOFO FIOTA FIOTE FIQUE FIRMA FIRME FIRMO FISCO FISGA FITAR FITAS FIXAI FIXAM FIXAR FIXAS FIXEI FIXEM FIXES FIXOU FLAMA FLAPE FLASH FLATE FLATO FLAVA FLAVO FLEME FLETE FLIRT FLOCO FLORA FLORE FLORO FLUIR FLUOR FLUSH FLUXO FOBIA FOCAO FOCAR FODAO FODAS FODAZ FODER FOFAI FOFAM FOFAO FOFAR FOFAS FOFEI FOFEM FOFES FOFIS FOFOS FOFOU FOGAO FOGEM FOGES FOICE FOILA FOITO FOLAR FOLGA FOLHA FOLHE FOLHO FOLIA FOLIE FOLIO FOMES FOMOS FONTE FORAL FORAM FORAS FORCA FORCE FOREM FORES FORJA FORJO FORMA FORME FORNO FOROS FORRA FORRE FORRO FORTE FORUM FOSCA FOSCO FOSSA FOSSE FOSSO FOSTE FOTAO FOTON FOUCE FOULA FOUTO FRACA FRACO FRADE FRAGA FRAME FRAPE FRASE FREAR FREIO FRESA FRESO FRETE FRIAS FRILA FRIOS FRISA FRISO FRITA FRITO FRIUL FROCO FRONT FROTA FRUIR FRUIS FRUTA FRUTO FUCAR FUDER FUFIO FUGAR FUGAZ FUGIR FUGIU FULAR FULVA FULVO FUMAI FUMAM FUMAR FUMAS FUMEI FUMEM FUMES FUMOS FUMOU FUNAI FUNAR FUNDA FUNDO FUNGA FUNGO FUNIL FUNIS FUNJE FURAI FURAM FURAO FURAR FURAS FURCA FURCO FUREI FUREM FURES FURIA FURNA FUROR FUROU FURTE FURTO FUSAO FUSCA FUSCO FUSIL FUSTA FUSTE FUTIL FUTON FUTRE FUTUM FUZIL FUZUE GABAO GABAR GABIA GACHA GACHO GAFFE GAGUE GAIAR GAIAS GAIOS GAITA GAIVA GAIZO GAJAO GALAI GALAM GALAO GALAR GALAS GALEI GALEM GALES GALGA GALGO GALHA GALHO GALIA GALIO GALLO GALOU GAMAO GAMAR GAMAS GAMBA GAMBE GAMER GANDA GANDU GANES GANGA GANHA GANHE GANHO GANIR GANSO GARBO GARCA GARFO GAROA GARRA GARUA GASES GASTE GASTO GATAL GATAS GATIL GATOS GAVEA GAZUA GEADA GEBAR GEENA GEISE GELAI GELAM GELAR GELEI GELEM GELES GELHA GELOU GEMAR GEMAS GEMEO GEMER GENES GENIA GENIO GENRO GENTE GEODO GERAI GERAL GERAM GERAR GERAS GEREI GEREM GERES GERIA GERIR GERIS GERME GEROU GESSA GESSO GESTA GESTO GETAS GIBAO GICLE GICLO GINGA GINJA GINOS GIRAR GIRES GIRIA GIRIO GIROS GIRUA GLACE GLEBA GLENA GLIDE GLIFO GLOBO GLOSA GLOSE GLOSO GLOTE GLUON GNOMA GNOMO GNOSE GOELA GOFRE GOIAS GOIVA GOLAO GOLEM GOLES GOLFA GOLFE GOLFO GOLPE GOMAR GONGO GONIS GONZO GORAI GORAM GORAR GORAS GORDA GORDO GOREI GOREM GORES GORGA GORJA GOROU GORRO GOSMA GOSTA GOSTO GOTAS GOULI GOZAI GOZAM GOZAR GOZAS GOZEI GOZEM GOZES GOZOU GRAAL GRACA GRACO GRADE GRADO GRAFO GRAMA GRANA GRATA GRATO GRAUS GRAVA GRAVE GRAVO GRAXA GRAXO GREGA GREGO GREIS GREJO GRELO GRENA GRETA GREVE GRIDE GRIFE GRIFO GRILA GRILO GRIMA GRIOT GRIPE GRITA GRITE GRITO GROLO GROSA GROTA GRUDE GRUDO GRUPO GRUTA GUAJA GUAPE GUAPO GUARA GUDAO GUEBO GUEIS GUEJA GUEOS GUETO GUGLE GUGLO GUIAR GUICO GUIDO GUIGA GUINE GUIOU GUISA GUITA GUITO GUIZO GULAG GULIM GUNGA GURIA GURME GUSLA GUSPE HABIL HADES HADJI HAIKU HAITI HAJAM HAJAS HAKKA HALFE HALUX HANJA HANSA HAPAX HARAS HAREM HARPA HARTO HASHI HASTA HASTE HAUCA HAULE HAVAI HAVEI HAVER HAVIA HEDRA HELIO HEMOS HERDE HERDO HEREU HERMA HEROI HERTZ HIATO HIDRA HIDRO HIENA HIFEN HILAR HIMEN HINDI HINDU HIPER HIRTO HOBBY HOJES HOMEM HOMUM HONOR HONRA HORAR HORAS HORDA HORSA HORTA HORTO HORUS HOSCO HOSTE HOTEL HOUVE HULHA HUMOR HUMUS HURRA HUSAR HUSMA IACRI IAMOS IANSA IAQUE IARAS IBAMA IBATE IBEMA IBERO IBIAI IBIAM IBIRA IBOGA IBOPE ICARA ICARO ICATU ICONE IDADE IDAHO IDEAL IDEAR IDEIA IDOLA IDOLO IDOSA IDOSO IEMEM IEMEN IGACI IGARA IGNEO IGUAI IGUAL IJACI ILESO ILHAR ILHAS ILHEU ILHOA ILHOS ILIBE ILIBO ILUDE IMAGO IMAME IMANE IMBAU IMIGA IMIGO IMITA IMOLE IMOLO IMOTO IMPAR IMPIA IMPIO IMPOR IMUNE INAJA INALA INALE INALO INATO INCAR INCAS INCHA INCHE INCHO INCOA INCOE INCOO INCRA INDEX INDEZ INDIA INDIE INDIO INDOL INFRA INGAI INGUA INICA INICO INUME INUMO INVES IONES IONTE IPABA IPERO IPHAN IPIAU IPIRA IPORA IPUBI IRADA IRADO IRANI IRARA IRATI IRECE IREIS IRENE IRERE IRITE IRMAO IRMAS IROSA IROSO IRUPI ISCAR ISLAO ISMAR ISOLA ISOLE ISOLO ISTMO ITABI ITACA ITACO ITAGI ITAJA ITAJE ITAJU ITALA ITALO ITAPE ITATI ITERA ITERE ITERO ITOBI ITRIO IUANE IURTA IVANA IVANO IVATE IVORA IVOTI IXORA JACIS JACOB JACRE JACTO JACUI JADER JAGOZ JAGRA JAGRE JAIBA JAIME JAINA JAIRA JAIRO JALES JALNE JAMBA JAMBO JAMBU JANAL JANGA JANIO JANTA JANTE JANTO JAPAO JAQUE JARDA JARRA JARRO JASPE JATAI JATEI JAULA JAURU JAVRA JAVRE JAVRO JAZEM JAZER JEANE JEGRE JEGUE JEIRA JEITO JEJUE JEJUM JEJUO JELCO JEOVA JEQUE JERRA JERRO JESUS JETOM JETON JIADE JIGUE JIHAD JINES JIPAO JIQUI JIRAU JOAES JOANA JOANE JOEIS JOGAR JOGOS JOIAS JOICA JOICE JOKER JOLDA JOLHO JONAS JONGO JONIA JONIO JOOES JORGE JORNA JORNE JORRO JOSES JOSTA JOSUE JOTAS JOULE JOVEM JOVES JUARA JUCAS JUCHE JUDAS JUDEU JUDIA JUGAR JUINA JUIZA JUIZO JULHO JULIA JULIO JUMBO JUNCO JUNGO JUNHO JUNIA JUNIO JUNOS JUNTA JUNTE JUNTO JUPIA JURAI JURAM JURAR JURAS JUREI JUREM JURES JURIS JUROS JUROU JURUA JUSAO JUSSA JUSTA JUSTO JUTAI KANJI KARBI KARMA KASHA KAYAK KENDO KHASI KHMER KIRIE KNYAZ KOALA KOBAN KOINE KOMBI KONEL KRILL KUAIT KUDZU KULAK KUMEL KUMYK KYOTO KYRIE KYZYL LABEU LABIA LABIL LABIO LABOR LACAO LACAR LACEI LACHO LACIO LACOS LACRE LADOS LADRA LADRE LADRO LAGAR LAGES LAGOA LAGOS LAIAR LAICO LAIDO LAIVO LAJES LAMAS LAMBE LAMEN LAMIA LAMIM LAMPO LANCA LANCE LANCO LANDE LANGE LANHA LANHO LAPAO LAPAR LAPIA LAPIS LAPSO LAQUE LARDO LARES LAREU LARGA LARGO LARPA LARPE LARPO LARVA LASCA LASER LASSA LASSE LASSO LATAO LATAR LATAS LATER LATEX LATIA LATIM LATIR LAUDA LAUDE LAUDO LAURA LAURO LAUTA LAUTO LAVAI LAVAM LAVAR LAVAS LAVEI LAVEM LAVES LAVOR LAVOU LAVRA LAVRE LAVRO LAZER LEBRE LEDAS LEDOR LEDOS LEGAL LEGAO LEGAR LEGRA LEGUA LEGUE LEIDE LEIGO LEILA LEINO LEIRA LEITE LEITO LEIVA LEIXE LEIXO LEMES LENCO LENDA LENHA LENHO LENIR LENTA LENTE LENTO LEPRA LEPTO LEQUE LERDA LERDO LERIA LERPE LESAI LESAM LESAO LESAR LESAS LESCA LESEI LESEM LESES LESIM LESMA LESME LESOU LESTA LESTE LESTO LETAL LETAO LETRA LEVAI LEVAM LEVAR LEVAS LEVEI LEVEL LEVEM LEVES LEVOU LEXIA LGBTI LHAMA LHANO LIAIS LIAME LIANA LIARA LIAVA LIBAI LIBAM LIBAR LIBAS LIBEI LIBEM LIBER LIBES LIBIA LIBIO LIBOU LIBRA LIBRE LICAO LICIO LICOR LICRA LIDAI LIDAM LIDAR LIDAS LIDEI LIDEM LIDER LIDES LIDIA LIDIO LIDOU LIEIS LIGAR LIGRE LILAS LILIA LILIO LIMAI LIMAM LIMAO LIMAR LIMAS LIMBO LIMEI LIMOU LIMPA LIMPE LIMPO LINCE LINDA LINDO LINHA LINHO LINUX LIRAS LIRIA LIRIO LISAS LISCO LISTA LITIO LITRO LIVIA LIVIO LIVRE LIVRO LIXAR LOBIS LOBOS LOCAL LOCAO LOCAR LOCAS LOCRO LOCUS LODAO LOGAR LOGOS LOGRA LOGRO LOICA LOIOS LOIRA LOIRO LOLIO LOMBA LOMBO LONCA LONGA LONGE LONGO LONJA LOPES LOQUE LORDA LORDE LORFO LORGA LORPA LOSNA LOTAI LOTAR LOTES LOTUS LOUCA LOUCO LOURA LOURO LOUSA LUAIS LUANA LUARA LUAVA LUCAS LUCIA LUCIO LUCRO LUDRA LUDRE LUDRO LUEIS LUGAR LUGES LUGRE LUIGI LUISA LUITA LUMEN LUMES LUMIA LUNAR LUQUE LUSCO LUSOS LUTAI LUTAM LUTAR LUTAS LUTEI LUTEM LUTES LUTIE LUTOU LUVAS LUXAI LUXAM LUXAR LUXAS LUXEI LUXEM LUXES LUXOS LUXOU LUZES LUZIA LUZIO LUZIR LYCRA MACAE MACAO MACAR MACAS MACAU MACHA MACHO MACIO MACOM MACRO MACUA MADRE MADRI MAFIA MAFRA MAFUA MAGAS MAGDA MAGIA MAGMA MAGNA MAGNO MAGOA MAGOS MAGRA MAGRO MAIAS MAILU MAINE MAINO MAIOR MAIOS MAIRA MAIRI MAIRO MAISA MAISO MAJOR MALAR MALES MALHA MALHO MALTA MALTE MALUS MALVA MALVO MAMAE MAMAL MAMAO MAMAR MAMBA MAMBO MAMBU MAMIS MAMOA MANAR MANAS MANCO MANDA MANDE MANDI MANDO MANES MANGA MANGO MANHA MANIA MANIR MANJA MANSA MANSO MANTA MANTO MAOME MAORI MAPLE MARAA MARAO MARAS MARAU MARCA MARCO MARES MARGA MARIA MARIO MAROS MARRA MARTA MARTE MASER MASSA MATAI MATAM MATAO MATAR MATAS MATCH MATEI MATEM MATES MATIZ MATOU MATRI MAUES MAURA MAURO MAUSE MEADA MEADO MECHA MEDIA MEDIO MEDIR MEDOS MEDRA MEDRO MEIAS MEIGA MEIGO MEIOS MEIRE MEKEO MELAO MELAR MELES MELGA MELGO MELRA MELRO MENDA MENDO MENGO MENIR MENOR MENOS MENSO MENTA MENTE MENTO MERAR MERCA MERCE MERDA MERME MERMO MESAO MESAS MESES MESMA MESMO MESON MESSE MESTA MESTO METAL METER METIE METRO MEXER MIADO MIAIS MIAMI MIARA MIAVA MICAR MICHA MICHE MICHO MICRO MIDIA MIEIS MIGAR MIGAS MIGRE MIGRO MIJAI MIJAM MIJAO MIJAR MIJAS MIJEI MIJEM MIJES MIJOU MIKIR MILHA MILHO MIMAR MIMIR MINAI MINAM MINAR MINAS MINEI MINEM MINES MINHA MINIX MINOS MINOU MIOJO MIOLA MIOLO MIOMA MIOPE MIOTO MIRAI MIRAM MIRAR MIRAS MIREI MIREM MIRES MIRIA MIROU MIRRA MIRTO MISSA MISSE MISSO MISTA MISTO MITIA MITIM MITRA MIUDA MIUDO MIXER MOADA MOADO MOAIS MOCAO MOCAR MOCAS MOCHA MOCHE MOCHO MOCOS MODAL MODEM MODOS MODUS MOEDA MOEGA MOEIS MOELA MOEMA MOERA MOFAI MOFAM MOFAR MOFAS MOFEI MOFEM MOFES MOFOU MOGAO MOGER MOGNO MOGOR MOIAM MOIAS MOICO MOIDO MOINA MOIRO MOITA MOITO MOLAR MOLDE MOLES MOLHA MOLHE MOLHO MONCO MONDA MONGE MONHA MONHE MONHO MONIA MONIZ MONJA MONSO MONTE MONTO MORAI MORAL MORAM MORAR MORAS MORCA MORDE MOREI MOREM MORES MORIM MORMO MORNO MOROS MOROU MORRE MORRO MORSA MORSO MORTE MORTO MOSCA MOSSA MOSTO MOTAR MOTEL MOTIM MOTOR MOTTA MOUCO MOURA MOURO MOUSE MOUTA MOVEL MOVEM MOVER MOVEU MOVIA MOXAO MUAFO MUANA MUCIA MUCIO MUCUM MUDAI MUDAM MUDAR MUDAS MUDEI MUDEM MUDES MUDEZ MUDOS MUDOU MUDRA MUFLA MUGIR MUITA MUITO MULSO MULTA MULTE MUMIA MUNDE MUNDO MUNIO MUNIR MUNTO MUNUS MUQUE MUQUI MURAR MURCA MURCO MUROS MURRA MURRO MURTA MUSAS MUSEU MUSGO MUSME MUSSE MUTUA MUTUM MUTUO MUXAO NABAL NACAO NACAR NACEM NADAI NADAM NADAR NADAS NADEI NADEM NADES NADIA NADIR NADOS NADOU NAFTA NAGAR NAIFA NAIPE NAIRA NAITE NAKFA NALGA NALGO NANAR NANCI NANJA NAOMI NAQUE NARIZ NAROM NASAL NASCE NASSA NATAL NATAN NATRO NATTO NAUNS NAURU NAVAL NAVIO NECHO NECRA NEDIA NEDIO NEGAO NEGAR NEGRA NEGRE NEGRO NEGUE NEHAN NELAS NELES NENEM NENIA NENTE NEPAL NEREU NERIO NERVO NESGA NESGO NESSA NESSE NESTA NESTE NETOS NEVAI NEVAM NEVAR NEVAS NEVEI NEVEM NEVES NEVOA NEVOU NHAMA NHOCA NICAR NICHA NICHO NIELO NIGER NIGUA NILAS NILOS NILZA NIMBO NIMIO NINAI NINAM NINAR NINAS NINEI NINEM NINES NINFA NINHO NINJA NINOU NIPLE NIPOA NIQAB NISCO NISSO NISTO NITRO NIVEA NIVEL NIVEO NIVER NOBEL NOBRE NOCAO NODAR NODOA NOEMI NOGAI NOIRO NOITE NOIVA NOIVO NOJAR NOMES NONAS NONDE NONOS NORAS NORMA NORMO NORSA NORTE NOSSA NOSSO NOTAR NOTAS NOTEM NOTOU NOUCA NOUTE NOVAS NOVEL NOVES NOVOS NOXIO NUBEA NUBEO NUBIA NUBIL NUBIO NUCAO NUDES NUDEZ NUELE NUELO NUMAS NUNCA NUVEM NUVEO NUVRE NYLON OASIS OBESO OBICE OBITO OBOES OBOLO OBRAI OBRAM OBRAR OBRAS OBREI OBREM OBRES OBROU OBSTA OBSTE OBSTO OBTER OBVIO OCAPI OCARA OCASO OCTAL OCULO OCUPA ODEAO ODEOM ODEON ODIAR ODIOS OESTE OFURO OGHAM OGIVA OIRAR OITAO OITOS OLEAR OLHAI OLHAO OLHAR OLHOS OLIVA OLIVE OLIVO OMBRO OMEGA OMNIA ONCAS ONDAS ONTEM OPACA OPACO OPALA OPCAO OPERA OPERE OPERO OPIMO OPINE OPINO OPTAI OPTAM OPTAR OPTAS OPTEI OPTEM OPTES OPTOU OQUEI ORACA ORAIS ORARA ORATE ORAVA ORCAR ORDEM OREIS ORFAO ORFAS ORFEU ORGAO ORGIA ORIAO ORIBI ORINA ORION ORIXA ORLAR ORNAI ORNAM ORNAR ORNAS ORNEI ORNEM ORNES ORNIS ORNOU OROBO OROCO OROMO OSACA OSAKA OSCAR OSMAR OSMIO OSRAM OSSEA OSSEO OSSOS OSTRA OSTRO OTAKU OTAVA OTICA OTICO OTIMO OTITE OUGAR OUIJA OURAI OURAM OURAR OURAS OUREI OUREM OURES OUROS OUROU OUSAR OUSAS OUSOU OUTAO OUTRA OUTRO OUVIA OUVIR OUVIU OVINO OVULO OXALA OXIDE OXIDO PABLA PABLO PACAS PACER PACOS PACTO PADEL PADRE PAEJA PAFIA PAFIO PAFOS PAGAO PAGAR PAGER PAGUE PAIAL PAIBA PAICA PAINS PAIOL PAIRE PAIRO PAIVA PAIXA PAJEM PALAO PALAS PALAU PALCO PALHA PALIE PALIO PALMA PALME PALMO PALOP PALOR PALPO PALRA PAMPA PAMPO PANAL PANCA PANDA PANDO PANDU PANGO PANOS PANTA PAOLA PAOLO PAPAI PAPAL PAPAO PAPAR PAPEL PAPER PARAI PARAM PARAR PARAS PARAU PARBA PARCA PARCO PARDO PAREA PAREI PAREM PAREO PARES PARGA PARGO PARIA PARIO PARIR PARIS PARIU PARLA PAROU PARRA PARRO PARTE PARTO PARVA PARVO PASMO PASSA PASSE PASSO PASTE PASTO PATAS PATAU PATCH PATIA PATIM PATIO PATIS PATOA PATOS PATUA PAUIS PAULA PAULO PAUSA PAUTA PAUTE PAUTO PAVAO PAVIA PAVIO PAVOA PAVOR PAZES PCDOB PECAR PECAS PECHA PEDAL PEDES PEDIA PEDIR PEDIU PEDRA PEDRO PEGAR PEGOU PEIDA PEIDO PEINA PEITE PEITO PEIXA PEIXE PEJAR PELAI PELAM PELAR PELAS PELEI PELEM PELES PELEU PELOS PELOU PEMBA PENAI PENAL PENAM PENAR PENAS PENCA PENCE PENDE PENEI PENEM PENES PENHA PENIS PENNY PENOU PENSE PENSO PENTE PEOES PEONA PEQUI PERAS PERCA PERDA PERDE PERDI PERLA PERLE PERLO PERNA PERRO PERSA PERTO PERUA PERUS PESAI PESAM PESAR PESAS PESCA PESCO PESEI PESEM PESES PESOU PESTE PETAR PETIZ PETRA PEUGA PEUVA PIABA PIADA PIALO PIANO PIARA PIATA PIAUI PIAVA PICAO PICAR PICAS PICHA PICHE PICHO PICLE PICOS PICUI PICUM PIEIS PIEMA PIESE PIFAO PIFAR PIFIO PILAI PILAM PILAO PILAR PILAS PILEI PILEM PILES PILHA PILHE PILHO PILIO PILOU PILUM PIMBA PINCA PINEL PINEU PINGA PINGO PINHA PINHO PINTA PINTE PINTO PIOLA PIORA PIORE PIORO PIQUE PIQUI PIRAI PIRAO PIRAR PIRES PIREX PIROU PIRRO PIRUA PISAI PISAM PISAR PISAS PISCA PISCO PISEI PISEM PISES PISOU PISTA PISTO PITAO PITEU PIUMA PIVOT PIXEL PIZZA PLACA PLAGA PLANA PLANE PLANO PLATO PLEBE PLENA PLENO PLICA PLUMA PNEUS POBRE POCAO POCAR POCHA POCOS PODAO PODAR PODEM PODER PODIA PODIO PODOA PODRE POEJO POEMA POETA POIAL POISA POITA POITE POITO POJAI POJAM POJAR POJAS POJEI POJEM POJES POJOU POLAR POLAS POLCA POLEN POLHO POLIA POLIR POLIS POLOS POLPA POLVO POMAR POMBA POMBO POMES POMOS POMPA PONDE PONEI PONHO PONTA PONTE PONTO POPOS PORAO PORAS PORCA PORCO POREM PORNO PORRA PORRE PORRO PORTA PORTE PORTO POSAR POSEM POSSA POSSE POSSO POSTA POSTE POSTO POTIM POTRA POTRO POUCA POUCO POULA POULE POULO POUND POUPA POUPE POUPO POUSA POUSO POUTA POVOA POVOS PRACA PRADO PRAGA PRAIA PRALI PRATA PRATO PRAXA PRAXE PRAZO PREAO PREBE PRECE PRECO PREGA PREGO PREIA PREJU PRELO PRESA PRESE PRESO PRETA PRETO PREZA PREZE PREZO PRIMA PRIMO PRIOR PROAS PROBO PROCE PROER PROFE PROIS PROLE PRONA PRONO PROSA PROTO PROVA PROVE PROVO PRUIR PRUMO PSOAS PTDOB PTOSE PUARA PUAVA PUBIS PUCHA PUDER PUDIM PUDOR PUELA PUERA PUGIL PUJAI PUJAM PUJAR PUJAS PUJEI PUJEM PUJES PUJOU PULAR PULGA PULGO PULHA PULSE PULSO PUNAS PUNGA PUNHA PUNHO PUNIR PURAS PURGA PUTAO PUTAS PUTOS PUXAI PUXAM PUXAO PUXAR PUXAS PUXEI PUXEM PUXES PUXOU QATAR QUAIS QUARE QUARK QUARO QUASE QUASI QUATA QUATI QUECA QUEDA QUEDE QUEDO QUEPE QUERO QUETO QUIBE QUICA QUICO QUILO QUINA QUIPA QUITE QUITO QUIUI QUIVI QUIXO QUOTA RABAO RABAT RABAZ RABIL RACAO RACAS RACHA RACHE RACHO RACUM RADAO RADAR RADIO RADON RAFAI RAFAM RAFAR RAFAS RAFEI RAFEM RAFES RAFOU RAIAL RAIAR RAIDE RAION RAIOS RAIVA RAJAR RALAI RALAM RALAR RALAS RALEI RALEM RALES RALHO RALLY RALOU RAMAI RAMAL RAMAM RAMAO RAMAR RAMAS RAMEI RAMEM RAMES RAMOS RAMOU RAMPA RANCA RANCO RANGO RANHO RAPAI RAPAM RAPAR RAPAS RAPAZ RAPEI RAPEL RAPEM RAPES RAPOU RAPTO RAQUE RASAR RASAS RASCA RASGA RASGO RASOS RASPA RASPE RASPO RASTO RATAI RATAM RATAO RATAR RATAS RATEI RATEM RATES RATOS RATOU RAZAO RAZIA REACA REAIS REBAR REBOO RECEM RECHO RECTA RECTO RECUA RECUO REDEA REDES REDIL REDOR REFEM REFIL REFRI REGAI REGAL REGAR REGAS REGER REGIA REGIO REGIS REGRA REGRE REGRO REGUA REIDE REIMA REINE REINO REJAO RELAO RELAR RELER RELES RELHA RELHO RELUZ RELVA REMAI REMAM REMAR REMAS REMEI REMEM REMES REMIR REMIX REMOA REMOI REMOO REMOS REMOU RENAL RENAN RENCA RENDA RENDE RENGO RENIO RENTE REPOR REPOS REPTO RESMA RESTE RESTO RETAS RETER RETOS RETRO REUSO REVEL REVER REVES REXIO REZAI REZAM REZAR REZAS REZEI REZEM REZES REZOU RIADE RIANA RIBAS RICAR RICAS RICOS RIFAO RIFAR RIFLE RIFTE RIGOR RIJAO RIJAR RIJOS RILEX RIMAI RIMAM RIMAR RIMAS RIMEI RIMEL RIMEM RIMES RIMOU RINDO RINHA RINSO RIPAR RISCA RISCO RISSE RISTE RITAO RITAS RITMO RITOS RIUTA RIVAL ROBER ROBOT ROCAR ROCAS ROCAZ ROCHA ROCIA ROCIE ROCIM ROCIO RODAI RODAM RODAR RODAS RODEI RODEM RODES RODIO RODOU ROGAR ROGUE ROIAM ROIDO ROJAI ROJAM ROJAO ROJAR ROJAS ROJEI ROJEM ROJES ROJOS ROJOU ROLAI ROLAM ROLAO ROLAR ROLAS ROLDA ROLDE ROLDO ROLEI ROLEM ROLES ROLHA ROLHE ROLHO ROLOU ROMAI ROMAM ROMAO ROMAR ROMAS ROMBO ROMEI ROMEM ROMES ROMEU ROMOU RONCO RONDA RONDE RONDO RONHA ROQUE ROSAI ROSAL ROSAM ROSAR ROSAS ROSCA ROSEA ROSEI ROSEM ROSEO ROSES ROSNA ROSNE ROSNO ROSOS ROSOU ROSTO ROTAR ROUBA ROUBE ROUBO ROUCA ROUCO ROUPA ROXAS ROXOS RUADA RUBIA RUBIM RUBIS RUBLO RUBOR RUBRA RUBRO RUDOS RUELA RUFAR RUFIE RUFIO RUFOS RUGBI RUGBY RUGIR RUIDE RUIDO RUINA RUIVO RUMAI RUMAM RUMAR RUMAS RUMBA RUMEI RUMEM RUMES RUMOR RUMOU RUPIA RURAL RUSGA RUSSA RUSSO RUTAR RUTES SAAMI SABAO SABEI SABER SABIA SABIO SABLE SABOR SABRA SABRE SACAL SACAR SACHO SACIE SACIO SACRA SACRO SACUE SADRA SAETA SAFAR SAFOS SAFRA SAGAZ SAGRA SAGRE SAGRO SAGUI SAHEL SAIAM SAIAS SAIBA SAIBO SAIDA SAIDO SAIRE SAITE SALAI SALAO SALAR SALAS SALAZ SALDO SALEI SALEM SALES SALGA SALHO SALOA SALOU SALSA SALSO SALTA SALTE SALTO SALVA SALVE SALVO SAMBA SAMIO SAMOA SAMPA SANCA SANDE SANDO SANEI SANGA SANHA SANIE SANJA SANTA SANTO SAPAO SAPOS SAQUE SARAI SARAM SARAO SARAR SARAS SARAU SARCA SARDA SARDO SAREI SAREM SARES SARGA SARJA SARNA SAROS SAROU SARRA SARRO SARTA SATAO SAUCO SAUDE SAUIS SAULA SAULO SAUNA SAUVA SAVEL SAVIA SAVIO SAXAO SAZAO SCHWA SCIFI SCOUT SEARA SECAO SECAR SECOS SEDAN SEDAS SEDES SEDEX SEDIA SEGAR SEGRE SEGUE SEIOS SEIRA SEITA SEIVA SEIXO SEJAM SEJAS SELAR SELEM SELHA SELIC SELIM SELVA SEMEN SEMIS SENAO SENAS SENDA SENDO SENHA SENIL SENIO SENNA SENRA SENSO SENTE SENTO SEPIA SERAO SERAS SEREI SEREM SERES SERIA SERIE SERIO SERPA SERRA SERRE SERRO SERTA SERVA SERVE SERVI SERVO SESGO SESMA SESSO SESTA SETAR SETAS SETES SETOR SEVAI SEVAM SEVAR SEVAS SEVEI SEVEM SEVES SEVOU SEXOS SEXTA SEXTO SHAPE SHEIK SHOJO SHORT SHOYU SICLO SIDAS SIDOS SIDRA SIENE SIFAO SIGLA SIGMA SIGNO SILAS SILEX SILFO SILHA SILTE SILVA SIMAO SIMIL SIMIO SINAL SINAO SINDI SINES SINHA SINOP SINTA SINTO SIOUX SIPAM SIQUE SIRGA SIRGO SIRIA SIRIM SIRIO SIRTE SIRVO SISMO SISTA SISTO SITIO SIVAM SKATE SKIFF SLACK SLANG SLICK SLIDE SNACK SOADA SOADO SOAIS SOARA SOAVA SOBEM SOBES SOBEU SOBPE SOBRA SOBRE SOCAR SOCIO SODIO SOEIS SOERA SOFAS SOFIA SOFIO SOFRE SOGRA SOGRO SOIAM SOIAS SOITO SOLAO SOLAR SOLAZ SOLDA SOLDE SOLDO SOLES SOLHA SOLHO SOLIO SOLTA SOLTE SOLTO SOMAI SOMAM SOMAR SOMAS SOMEI SOMEM SOMES SOMOS SOMOU SONAR SONDA SONHA SONHE SONHO SONSA SONSO SOPAS SOPOR SOPRE SOPRO SORCA SORGO SORNA SOROR SOROS SORTE SORVA SORVO SOSIA SOSSO SOTAO SOUBE SOURE SOUSA SOUTA SOUTO SOUZA SOVAI SOVAM SOVAR SOVAS SOVEI SOVEM SOVES SOVOU SPORT SPRAY SPRUE STAFF STAND STICK STOCK STOLL STOUT SUABE SUADO SUAVE SUAZI SUBAM SUBAS SUBIA SUBIR SUBIS SUBIU SUCHO SUCIA SUCRE SUDAM SUDAO SUDRO SUECO SUELI SUETO SUEVO SUFLE SUGAR SUICA SUICO SUINO SUITE SUJAI SUJAM SUJAR SUJAS SUJEI SUJEM SUJES SUJOS SUJOU SULCO SUMIR SUNAB SUNDA SUPER SUPOR SUPRE SURCA SURDA SURDO SURFE SURFO SURRA SURRO SURTO SUSAO SUSAS SUSHI SUSSA SUSTA SUSTE SUSTO SUTIA SUTIL SUTIS SWING TABAI TABAO TABUA TABUS TACAR TACHA TACHE TACHO TAFUL TAIER TAIGA TAIKO TAINA TAIPA TAIPU TAISE TAIUA TALAI TALAM TALAO TALAR TALAS TALCO TALEI TALEM TALES TALHA TALHE TALHO TALIA TALIM TALIO TALOU TAMAO TAMEM TAMIL TAMOS TAMPA TAMPE TAMPO TANAS TANCA TANGA TANGE TANGO TANHA TANHO TANSA TANSO TANTA TANTO TAOCA TAPAI TAPAM TAPAR TAPAS TAPEI TAPEM TAPES TAPIR TAPOU TAPUA TARAR TARDA TARDE TARDO TARJA TAROT TARRO TARSO TASCA TASCO TASTO TATIL TATUI TAVAO TAXAI TAXAM TAXAR TAXAS TAXEI TAXEM TAXES TAXIS TAXON TAXOU TCHAU TEBAS TECER TECLA TECLE TECLO TECNO TECTO TEDIO TEERA TEIGA TEIMA TEIME TEIMO TEIPE TEITO TEIXO TELAI TELAM TELAO TELAR TELAS TELEI TELEM TELES TELEX TELHA TELHO TELOS TELOU TEMAO TEMAS TEMBA TEMER TEMIA TEMOR TEMOS TEMPO TENAZ TENCA TENDA TENDE TENDO TENHA TENHO TENIA TENIS TENOR TENRA TENRO TENSA TENSO TENTA TENTE TENTO TENUE TERAO TERAS TERCA TERCO TEREI TERGO TERIA TERMA TERMO TERNA TERNO TERRA TERSO TESAI TESAM TESAO TESAR TESAS TESEI TESEM TESES TESEU TESLA TESOU TESSE TESTA TESTE TESTO TETAS TETIS TETRA TETRO TETUM TEUDO TEUTO TEXAS TEXTO TIAGA TIAGO TIARA TIBAR TIBAU TIBIO TIBUM TICAO TICAR TIDAS TIDOS TIETE TIFEU TIGRE TILAR TILIA TILTE TIMAO TIMBA TIMBO TIMER TIMON TINER TINGE TINHA TINIR TINTA TINTO TIPLE TIPOI TIQUE TIRAI TIRAM TIRAR TIRAS TIREI TIREM TIRES TIROS TIROU TIRSO TISNE TISSO TISSU TITIA TITIO TIVER TMESE TOADA TOARI TOCAM TOCAR TOCHA TOCHO TOCOU TODAS TODOS TOGAR TOICA TOIRA TOIRO TOJAL TOKEN TOKYO TOLAR TOLDA TOLDO TOLHO TOMAI TOMAM TOMAR TOMAS TOMBA TOMBE TOMBO TOMEI TOMEM TOMES TOMOS TOMOU TONAL TONAR TONAS TONEL TONER TONGA TONHO TONTA TONTO TOPAI TOPAM TOPAR TOPAS TOPEI TOPEM TOPES TOPOU TOQUE TORAL TORAR TORAX TORCO TORDA TORDO TORGA TORGO TORIO TORNA TORNE TORNO TORPE TORRE TORSO TORTA TORTO TORVO TOSAR TOSCO TOSSE TOSTA TOSTE TOTAL TOTOS TOUCA TOUCO TOUPA TOURA TOURO TOUTA TOXIA TRACA TRACE TRACO TRAEM TRAGO TRAIR TRAJE TRAJO TRAMA TRAME TRAPO TRARA TRASH TRATA TRATE TRATO TRAVE TRAVO TRECO TRELA TREMA TREME TREMI TRENA TRENO TRENS TREPA TREPE TREPO TRESA TRETA TREUS TREVA TREVO TREZE TRIAL TRIBO TRICA TRICK TRIGA TRIGO TRILO TRINA TRINO TRIOS TRIPA TRIPE TROAR TROBA TROCA TROCO TROFA TROIA TROLE TROLL TRONO TROPA TROPO TROTE TROVA TRUAO TRUCO TRUFA TRUTA TUACA TUBAS TUBOS TUFAO TUGIR TUGUE TUIDE TUITE TULHA TULIA TULIO TUMBA TUMOR TUNAR TUNAS TUNDA TUNEL TUNES TUNGA TUNIS TUPAS TUPIS TURBA TURBE TURBO TURCO TURFA TURMA TURNE TURNO TURUQ TURVE TURVO TUSCO TUTAI TUTAM TUTAR TUTAS TUTEI TUTEM TUTES TUTIA TUTOR TUTOU TUTUM TWEED TWIST UAICA UAMBE UAURA UBAIA UBATA UBERE UCCLA UCHOA UFANO UFRGS UIBAI UISTE UIVAI UIVAM UIVAR UIVAS UIVEI UIVEM UIVES UIVOS UIVOU ULEAR ULPIA ULPIO ULULA ULULE ULULO UMAMI UMARI UMBRO UMERO UMIDO UNCAO UNGIR UNHAR UNHAS UNHOS UNIAO UNICA UNICO UNIDA UNIDO UNTAR UOLOF URANO URDIA URDIR UREIA URGIR URIBE URICO URINA URINE URINO URNAS URRAR URRUL URSAS URSOS URUBU URUPA URUPE URUTU URZAL USADA USADO USAIS USARA USAVA USEIS USINA USMAR USTRA USUAL USURA UTERO UVAIA UVIAR UVULA UYEZD VACAO VACAR VACUA VACUM VACUO VADIA VADIO VADUZ VAGAO VAGAR VAGEM VAGIR VAGOS VAIAR VAIBE VALAO VALAR VALAS VALDA VALDO VALER VALES VALHA VALIA VALOR VALSA VALSE VALSO VALVA VAMOS VAMPE VANDA VANDO VANIA VANIO VAPOR VARAI VARAM VARAO VARAR VARAS VAREI VAREM VARES VARGA VARGE VARIA VARIO VARIZ VARJA VAROU VASCO VASOS VASTO VATEL VATES VATIO VAZAI VAZAM VAZAO VAZAR VAZAS VAZEI VAZEM VAZES VAZIA VAZIO VAZOU VEADA VEADO VEDAR VEDES VEDOR VEDRA VEDRO VEGAN VEIAS VEIGA VEIRO VEJAM VELAI VELAM VELAR VELAS VELEI VELEM VELES VELHA VELHO VELOU VELOZ VENAL VENCE VENCI VENDA VENDE VENDO VENHA VENHO VENIA VENTA VENTO VENUS VEPSA VEPSO VERAO VERAS VERAZ VERBA VERBO VERCA VERDE VEREI VERGA VERGE VERIA VERME VEROS VERSO VERTE VESGO VESPA VESTA VESTE VESTI VETAO VETAR VETOR VEXAR VEZES VIADA VIADO VIAJA VIAJE VIAJO VIANA VIBRA VICIO VIDAR VIDAS VIDEO VIDES VIDRA VIDRO VIELA VIENA VIERA VIGAR VIGER VIGIA VIGIE VIGIL VIGIO VIGOR VILAO VILAS VILOA VIMOS VINCA VINCO VINDA VINDE VINDO VINGA VINHA VINHO VINIL VINIS VINTE VIOLA VIRAI VIRAL VIRAM VIRAO VIRAR VIRAS VIREI VIREM VIRES VIRGO VIRIA VIRIL VIRIS VIROL VIROU VIRUS VISAO VISAR VISCO VISEU VISGO VISOM VISON VISSE VISTA VISTE VISTO VITAL VITOR VIUVA VIUVO VIVAS VIVAZ VIVER VIVIA VIVOS VOAVA VOCAL VOCES VOCLP VODCA VODKA VOEJA VOGAL VOGAM VOGAR VOGAS VOILE VOLEI VOLPE VOLPS VOLTA VOLTE VOLTO VOLTS VOMER VORAZ VOSCO VOSSA VOSSO VOTAI VOTAM VOTAR VOTAS VOTEI VOTEM VOTES VOTOS VOTOU VOUGO VOZES VULGO VULTO VULVA VURMO WAIKA WOLOF XABRE XACHO XACRA XADOR XAILE XAMAS XAMPU XANGO XARDA XARDO XAREL XAREM XARIA XAUAL XAXAR XAXIM XEBRA XEBRE XEICA XELIM XENAO XENIO XENON XEQUE XEREM XEREZ XEROX XERPA XEXEU XHOSA XIITA XIMBE XINGA XINTO XISTA XISTO XOCAR XOFAR XOFRE XOGUM XOPIM XORCA XORDO XORTE XOTAI XOTAM XOTAR XOTAS XOTEI XOTEM XOTES XOTOU XOUVA XUATE XUCRO XURRO YACHT ZAGAL ZAGRE ZAINO ZAIRA ZAIRO ZAMBA ZAMBI ZAMBO ZANGA ZANZE ZANZO ZAPAR ZAVAR ZAZAO ZEBRA ZEBRO ZEILA ZELAM ZELAR ZELIA ZELIO ZELOS ZERAR ZEROS ZEUGO ZICHA ZICHO ZINCO ZINGA ZINIR ZIPAI ZIPAM ZIPAR ZIPAS ZIPEI ZIPEM ZIPER ZIPES ZIPOU ZLOTI ZLOTY ZOEIA ZOIAO ZOICO ZOILO ZOIRA ZOMBA ZOMBE ZOMBO ZONAR ZONCA ZORRA ZORRO ZOUPO ZOURA ZUATE ZUCAR ZUELA ZUMBI ZUNGE ZUNIA ZUNIR ZUPAR ZURRA ZURRE ZURRO ZURUO ZURZA ================================================ FILE: core/src/main/res/values/ic_launcher_background.xml ================================================ #6040D7 ================================================ FILE: core/src/main/res/values/strings.xml ================================================ NewQuiz Quick Quiz Back More options Saved questions Wordle Wordle infinite Daily wordle Verify Play again Close Game Over Item empty Item %1$s none Item %1$s present Item %1$s correct 4 Letters 5 Letters 6 Letters Back month Next month Settings General Quiz Quiz language English Portuguese Spanish French Color blind mode High contrast colors Info Letter hints Hint above the letter that it appears twice or more in the hidden word Hard mode Any revealed hints must be used in subsequent guesses Rows limited Wordle infinite row limited. Wordle infinite row limit value. Clean calendar data Cleans all saved calendar wins/losses. Clear Preferences Remove all saved settings Home Today game Today random game Multi choice quiz Flag quiz Logo quiz Other General knowledge Books Film Music Musicals & Theatres Television Video Games Board Games Science & Nature Computers Mathematics Mythology Sports Geography History Politics Art Celebrities Animals Vehicles Comics Gadgets Japanese Anime & Manga Cartoon & Animations Categories No questions available %1$s question available %1$s questions available See all Difficulty " is not in the target word." " is in the word but in the wrong spot." " is in the word and in the correct spot." Results screen %1$s correct questions Quiz question size Translation Translation enabled Download translation model Download translation model for on device translation. Downloading translation model Downloading translation model, this may take a while. Delete translation model Online Profile Correct answers Total questions Last quiz times Time Last questions Correct words Total words User XP Level Current XP Required xp to next level Photo of %1$s Good morning Good afternoon Good evening Good night No diamonds Skip question? You have one diamond, do you want to use the diamond to skip this question? You have %1$d diamonds, do you want to use %2$d diamonds to skip this question? You don\'t have diamonds to skip this question! Skip Save Dismiss Guess the Word Guess the Number Guess math formula Guess solution Math quiz Maze Easy Medium Hard Open drawer Copy maze seed Restart maze Generate maze Maze completed! Generate offline maze Generate maze with all game modes that don\'t need internet connection. Random maze Generate maze with random questions, all game modes will be included. Custom maze Generate maze with custom options Expand custom options When using custom seed, multi choice questions will not be random generated by this seed. Generate Seed User Number trivia About and Help Animations Animations Enabled Global animations enabled Wordle animations enabled Enable animations for wordle game mode. Multi choice animations enabled Enable animations for multi choice game mode. Sort questions Play quiz with random saved questions Play Play with selected questions Unselect All Select All Delete Selected Download questions Sort by Default Sort by Description Sort by Category Country capital flags Comparison quiz or Position: %1$d Highest: %1$d Your score Highest score Select a comparison mode for the first item Image category of %1$s Icon of %1$s Greater Lesser Email Password Name Next Random Quiz Random Quiz with random categories See all categories See less categories Clear recent categories Remove all recent categories from the list screen Loading questions… Quiz steps container Question %1$d - Correct Question %1$d - Incorrect Analytics Analytics collection enabled Data Collection Consent Thank you for using NewQuiz! To help us improve the app and fix any issues that may arise, we collect certain data from your device. The data we collect includes:\n\n \t• Analytics data: This includes information about how you use the app, such as which features you use most frequently, how long you spend on each page, and which quizzes you complete.\n\n \t• Crash data: If the app crashes while you\'re using it, we collect data about the crash to help us identify and fix the issue.\n\n \t• Performance data: We collect information about how the app performs on your device, such as how quickly pages load and how long it takes to complete quizzes.\n\n We take your privacy seriously and only use this data for app improvement purposes. Your data will never be sold or shared with third parties for marketing purposes.\n\n By using the NewQuiz, you consent to the collection of this data. If you ever change your mind, you can disable data collection in the app settings.\n\n Thank you for your understanding and support! Yes, I consent No, I do not consent General analytics enabled Enables general analytics such as events, example: game starts, etc. Crash analytics enabled Enables crash data analytics such as app crashes and exceptions. Performance monitoring enabled Enables performance monitoring such as app startup time and other performance metrics. Daily Challenge Play one random multi choice quiz game. Play %d random multi choice quiz games. End one multi choice quiz game. End %d multi choice quiz games. Answer one question in multi choice quiz game. Answer %d questions in multi choice quiz game. Get one answer correct in multi choice quiz game. Get %d answers correct in multi choice quiz game. Play one game with category of %2$s in multi choice quiz. Play %1$d games with category of %2$s in multi choice quiz. Get one word correct in wordle game. Get %d words correct in wordle game. Play one game with category of %2$s in wordle game. Play %1$d games with category of %2$s in wordle game. Play one game with category of %2$s in comparison quiz. Play %1$d games with category of %2$s in comparison quiz. Play one game and get a score of %2$d in comparison quiz. Play %1$d games and get a score of %2$d in comparison quiz. Play one game with %2$s mode in comparison quiz. Play %1$d games with %2$s mode in comparison quiz. Hide online categories Hide categories that require internet connection, when device is offline. No categories available Target Language Select the language you want to translate to Download Settings Require Wi-Fi Only download the translation model when connected to WiFi. Require Charging Only download the translation model when the device is charging. Confirm Others Requires internet connection Don\'t require internet connection None Both Show category connection info XP Total XP Today This Week Categories, Animations Question count Hard mode, Quiz language, Letter hints Translation language Version, Contact, Open source licences Select only Offline Auto scroll to current question Automatically scroll to current question when playing maze. Remaining: %1$s Claim %1$d \uD83D\uDC8E Regional Preferences Temperature Unit System Default Celsius Fahrenheit Distance Unit Metric Imperial No data No questions saved yet! Save or download questions to start playing offline. Level completed! Next Level Main Menu Try Again Level failed! Don\'t give up! Try again to beat this level. Error verifying word! You need to use all hints from last row! Empty word The word must contain only letters The word must contain only digits Left formula is not equal to right solution Invalid questions There are %1$d questions with invalid categories. Remove the questions to continue playing or restart the maze. Remove them Category information Cancel Restart Are you sure you want to restart the maze? ================================================ FILE: core/src/main/res/values/themes.xml ================================================ ================================================ FILE: core/src/normal/AndroidManifest.xml ================================================ ================================================ FILE: core/src/normal/kotlin/com/infinitepower/newquiz/core/initializer/CoreFirebaseInitializer.kt ================================================ package com.infinitepower.newquiz.core.initializer import android.content.Context import android.util.Log import androidx.startup.Initializer import com.google.firebase.FirebaseApp import com.google.firebase.Firebase import com.google.firebase.initialize class CoreFirebaseInitializer : Initializer { private companion object { private const val TAG = "CoreFirebaseInitializer" } override fun create(context: Context): FirebaseApp { Log.d(TAG, "Initializing Firebase") return Firebase.initialize(context) ?: throw IllegalStateException("FirebaseApp is null") } override fun dependencies(): List>> = emptyList() } ================================================ FILE: core/src/test/java/com/infinitepower/newquiz/core/NumberFormatterTest.kt ================================================ package com.infinitepower.newquiz.core import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.NumberFormatter.Distance.DistanceUnit 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 org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource import java.text.DateFormat import java.text.NumberFormat import java.util.Locale /** * Tests for [NumberFormatter]. */ internal class NumberFormatterTest { private val numberFormat = NumberFormat.getNumberInstance(Locale.getDefault()) private val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()) private val timeFormat = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()) private val dateTimeFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, Locale.getDefault()) // Number type tests @Test fun `test Number formatValueToString without helperValueSuffix`() { val number = 12345.6789 val expected = numberFormat.format(number) val formatter = NumberFormatter.from(NumberFormatType.DEFAULT) assertThat(formatter.formatValueToString(number)).isEqualTo(expected) } @Test fun `test Number formatValueToString with helperValueSuffix`() { val number = 12345.6789 val suffix = "m" val expected = "${numberFormat.format(number)} $suffix" val formatter = NumberFormatter.from(NumberFormatType.DEFAULT) assertThat(formatter.formatValueToString(number, suffix)).isEqualTo(expected) } // Date type tests @Test fun `test Date formatValueToString without helperValueSuffix`() { val timestamp = 1627196400000.0 // July 25, 2021 val expected = dateFormat.format(timestamp.toLong()) val formatter = NumberFormatter.from(NumberFormatType.DATE) assertThat(formatter.formatValueToString(timestamp)).isEqualTo(expected) } @Test fun `test Date formatValueToString with helperValueSuffix`() { val timestamp = 1627196400000.0 // July 25, 2021 val suffix = "UTC" val expected = "${dateFormat.format(timestamp.toLong())} $suffix" val formatter = NumberFormatter.from(NumberFormatType.DATE) assertThat(formatter.formatValueToString(timestamp, suffix)).isEqualTo(expected) } // Time type tests @Test fun `test Time formatValueToString without helperValueSuffix`() { val timestamp = 1627196400000.0 // July 25, 2021 12:00:00 AM GMT val expected = timeFormat.format(timestamp.toLong()) val formatter = NumberFormatter.from(NumberFormatType.TIME) assertThat(formatter.formatValueToString(timestamp)).isEqualTo(expected) } @Test fun `test Time formatValueToString with helperValueSuffix`() { val timestamp = 1627196400000.0 // July 25, 2021 12:00:00 AM GMT val suffix = "UTC" val expected = "${timeFormat.format(timestamp.toLong())} $suffix" val formatter = NumberFormatter.from(NumberFormatType.TIME) assertThat(formatter.formatValueToString(timestamp, suffix)).isEqualTo(expected) } // DateTime type tests @Test fun `test DateTime formatValueToString without helperValueSuffix`() { val timestamp = 1627196400000.0 // July 25, 2021 12:00:00 AM GMT val expected = dateTimeFormat.format(timestamp.toLong()) val formatter = NumberFormatter.from(NumberFormatType.DATETIME) assertThat(formatter.formatValueToString(timestamp)).isEqualTo(expected) } @Test fun `test DateTime formatValueToString with helperValueSuffix`() { val timestamp = 1627196400000.0 // July 25, 2021 12:00:00 AM GMT val suffix = "UTC" val expected = "${dateTimeFormat.format(timestamp.toLong())} $suffix" val formatter = NumberFormatter.from(NumberFormatType.DATETIME) assertThat(formatter.formatValueToString(timestamp, suffix)).isEqualTo(expected) } // Percentage type tests @Test fun `test percentage formatValueToString should format percentage value to string`() { val value = 0.75 val expected = "75%" val formatter = NumberFormatter.from(NumberFormatType.PERCENTAGE) assertThat(formatter.formatValueToString(value)).isEqualTo(expected) } @Test fun `test percentage formatValueToString should include helperValueSuffix if not null`() { val value = 0.75 val suffix = "of the time" val expected = "75% of the time" val formatter = NumberFormatter.from(NumberFormatType.PERCENTAGE) assertThat(formatter.formatValueToString(value, suffix)).isEqualTo(expected) } @Test fun `test percentage formatValueToString should format percentage higher than 100 percent`() { val formatter = NumberFormatter.from(NumberFormatType.PERCENTAGE) assertThat(formatter.formatValueToString(1.3)).isEqualTo("130%") } // Temperature tests @CsvSource( "0.0, celsius, de, 0.0 °C", // German: Celsius to Celsius "0.0, celsius, us, 32 °F", // US: Celsius to Fahrenheit "0.0, fahrenhe, de, -17.78 °C", // German: Fahrenheit to Celsius "0.0, fahrenhe, us, 0.0 °F", // US: Fahrenheit to Fahrenheit ) @ParameterizedTest(name = "{0} in {1} should be formatted to {3} in {2} locale") fun `test temperature formatValueToString`( valueToFormat: Double, temperatureUnitStr: String, convertCountry: String, expected: String ) { val formatter = NumberFormatter.from(NumberFormatType.TEMPERATURE) val locale = Locale("en", convertCountry) val formattedValue = formatter.formatValueToString( value = valueToFormat, helperValueSuffix = temperatureUnitStr, regionalPreferences = RegionalPreferences(locale = locale) ) assertThat(formattedValue).isEqualTo(expected) } @CsvSource( "0.0, CELSIUS, CELSIUS, 0.0 °C", // German: Celsius to Celsius "0.0, CELSIUS, FAHRENHEIT, 32 °F", // US: Celsius to Fahrenheit "0.0, FAHRENHEIT, CELSIUS, -17.78 °C", // German: Fahrenheit to Celsius "0.0, FAHRENHEIT, FAHRENHEIT, 0.0 °F", // US: Fahrenheit to Fahrenheit ) @ParameterizedTest(name = "{0} in {1} should be formatted to {3} in {2} locale") fun `test temperature formatValueToString when regionalPreferences temperatureUnit is configured`( valueToFormat: Double, valueTemperatureUnit: TemperatureUnit, convertTemperatureUnit: TemperatureUnit, expected: String ) { val formatter = NumberFormatter.from(NumberFormatType.TEMPERATURE) val formattedValue = formatter.formatValueToString( value = valueToFormat, helperValueSuffix = valueTemperatureUnit.key, regionalPreferences = RegionalPreferences( temperatureUnit = convertTemperatureUnit, // This should override the default locale ) ) assertThat(formattedValue).isEqualTo(expected) } // Distance tests @CsvSource( "1.0, kilometer, de, 1 km", // German: Meter to Meter "1.0, kilometer, us, 0.62 mi", // US: Meter to Mile ) @ParameterizedTest(name = "{0} in {1} should be formatted to {3} in {2} locale") fun `test distance formatValueToString when regionalPreferences distanceUnit is not configured`( valueToFormat: Double, distanceUnit: String, convertCountry: String, expected: String ) { val formatter = NumberFormatter.from(NumberFormatType.DISTANCE) val locale = Locale("en", convertCountry) val formattedValue = formatter.formatValueToString( value = valueToFormat, helperValueSuffix = distanceUnit, regionalPreferences = RegionalPreferences( locale = locale, ) ) assertThat(formattedValue).isEqualTo(expected) } @CsvSource( "1.0, KILOMETER, METRIC, 1 km", // Kilometer to Kilometer (Metric) system "1.0, KILOMETER, IMPERIAL, 0.62 mi", // Kilometer to Mile (Imperial) system "1.0, METER, METRIC, 1 m", // Meter to Meter (Metric) system "1.0, METER, IMPERIAL, 3.28 ft", // Meter to Foot (Imperial) system "1.0, MILE, METRIC, 1.61 km", // Mile to Kilometer (Metric) system "1.0, MILE, IMPERIAL, 1 mi", // Mile to Mile (Imperial) system "1.0, FOOT, METRIC, 0.3 m", // Foot to Meter (Metric) system "1.0, FOOT, IMPERIAL, 1 ft", // Foot to Foot (Imperial) system ) @ParameterizedTest(name = "{0} in {1} should be formatted to {3} in {2} locale") fun `test distance formatValueToString when regionalPreferences distanceUnit is configured`( valueToFormat: Double, valueDistanceUnit: DistanceUnit, convertDistanceUnitType: DistanceUnitType, expected: String ) { val formatter = NumberFormatter.from(NumberFormatType.DISTANCE) val formattedValue = formatter.formatValueToString( value = valueToFormat, helperValueSuffix = valueDistanceUnit.key, regionalPreferences = RegionalPreferences( distanceUnitType = convertDistanceUnitType, // This should override the default locale ) ) assertThat(formattedValue).isEqualTo(expected) } } ================================================ FILE: core/src/test/java/com/infinitepower/newquiz/core/game/ComparisonQuizDataTest.kt ================================================ package com.infinitepower.newquiz.core.game import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.testing.data.fake.FakeComparisonQuizData import com.infinitepower.newquiz.core.testing.utils.mockAndroidLog import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItem import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizQuestion import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import java.net.URI import kotlin.test.BeforeTest class ComparisonQuizDataTest { @BeforeTest fun setUp() { mockAndroidLog() } @Test fun `nextQuestion should return a new ComparisonQuizData object with a new current question`() { val quizItem1 = ComparisonQuizItem( title = "A", imgUri = URI(""), value = 10.0 ) val quizItem2 = ComparisonQuizItem( title = "B", imgUri = URI(""), value = 5.0 ) val quizData = ComparisonQuizCore.QuizData( questions = listOf(quizItem1, quizItem2), currentQuestion = null, questionDescription = "Which country has more population?", comparisonMode = ComparisonMode.GREATER, category = FakeComparisonQuizData.generateCategory() ) assertThat(quizData.questions).containsExactly(quizItem1, quizItem2) assertThat(quizData.currentQuestion).isNull() val nextQuizData = quizData.getNextQuestion() assertThat(nextQuizData.questions).containsNoneOf(quizItem1, quizItem2) assertThat(nextQuizData.questions).isEmpty() assertThat(nextQuizData.currentQuestion).isNotNull() } @Test fun `nextQuestion should return throw IllegalStateException when questions list is empty`() { val quizData = ComparisonQuizCore.QuizData( questions = emptyList(), currentQuestion = null, questionDescription = "Which country has more population?", comparisonMode = ComparisonMode.GREATER, category = FakeComparisonQuizData.generateCategory() ) assertThat(quizData.questions).isEmpty() assertThat(quizData.currentQuestion).isNull() val exception = assertThrows { quizData.getNextQuestion() } assertThat(exception).hasMessageThat().isEqualTo("Questions list is empty") } @Test fun `test nextQuestion when questions has size of 3`() { val quizItem1 = ComparisonQuizItem( title = "A", imgUri = URI(""), value = 10.0 ) val quizItem2 = ComparisonQuizItem( title = "B", imgUri = URI(""), value = 5.0 ) val quizItem3 = ComparisonQuizItem( title = "C", imgUri = URI(""), value = 8.0 ) val quizData = ComparisonQuizCore.QuizData( questions = listOf(quizItem1, quizItem2, quizItem3), currentQuestion = null, questionDescription = "Which country has more population?", comparisonMode = ComparisonMode.GREATER, category = FakeComparisonQuizData.generateCategory() ) assertThat(quizData.questions).containsExactly(quizItem1, quizItem2, quizItem3) assertThat(quizData.currentQuestion).isNull() val nextQuizData = quizData.getNextQuestion() assertThat(nextQuizData.questions).containsExactly(quizItem3) assertThat(nextQuizData.questions).containsNoneOf(quizItem1, quizItem2) assertThat(nextQuizData.currentQuestion).isNotNull() } @Test fun `test nextQuestion when questions has size of 1 and current question is not null`() { val quizItem1 = ComparisonQuizItem( title = "A", imgUri = URI(""), value = 10.0 ) val quizItem2 = ComparisonQuizItem( title = "B", imgUri = URI(""), value = 5.0 ) val quizItem3 = ComparisonQuizItem( title = "C", imgUri = URI(""), value = 8.0 ) val quizData = ComparisonQuizCore.QuizData( questions = listOf(quizItem3), currentQuestion = ComparisonQuizQuestion( questions = quizItem1 to quizItem2, categoryId = "", comparisonMode = ComparisonMode.GREATER ), questionDescription = "Which country has more population?", comparisonMode = ComparisonMode.GREATER, category = FakeComparisonQuizData.generateCategory() ) assertThat(quizData.questions).containsExactly(quizItem3) assertThat(quizData.currentQuestion).isNotNull() assertThat(quizData.currentQuestion?.questions?.first).isEqualTo(quizItem1) assertThat(quizData.currentQuestion?.questions?.second).isEqualTo(quizItem2) val nextQuizData = quizData.getNextQuestion() assertThat(nextQuizData.questions).isEmpty() assertThat(nextQuizData.questions).containsNoneOf(quizItem1, quizItem2, quizItem3) assertThat(nextQuizData.currentQuestion).isNotNull() assertThat(nextQuizData.currentQuestion?.questions?.first).isEqualTo(quizItem2) assertThat(nextQuizData.currentQuestion?.questions?.second).isEqualTo(quizItem3) } } ================================================ FILE: core/src/test/java/com/infinitepower/newquiz/core/util/UiTextTests.kt ================================================ package com.infinitepower.newquiz.core.util import android.content.Context import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.toUiText import io.mockk.mockk import org.junit.jupiter.api.Test /** * Tests for [UiText]. */ internal class UiTextTests { @Test fun `String#toUiText() should return a UiText`() { val uiText = "Play %d Questions".toUiText(5) val context = mockk() assertThat(uiText).isInstanceOf(UiText.DynamicString::class.java) assertThat(uiText.asString(context)).isEqualTo("Play 5 Questions") } @Test fun `String#toUiText() should return a UiText with multiple arguments`() { val uiText = "Play %d Questions in %s".toUiText(5, "English") val context = mockk() assertThat(uiText).isInstanceOf(UiText.DynamicString::class.java) assertThat(uiText.asString(context)).isEqualTo("Play 5 Questions in English") } @Test fun `String#toUiText() should return a UiText with no arguments`() { val uiText = "Play Questions".toUiText() val context = mockk() assertThat(uiText).isInstanceOf(UiText.DynamicString::class.java) assertThat(uiText.asString(context)).isEqualTo("Play Questions") } } ================================================ FILE: core/src/test/java/com/infinitepower/newquiz/core/util/collections/CollectionsTest.kt ================================================ package com.infinitepower.newquiz.core.util.collections import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test class IndexOfFirstOrNullTest { @Test @DisplayName("Should return the index of the first element that satisfies the predicate") fun indexOfFirstOrNullWhenPredicateIsSatisfiedThenReturnTheIndex() { val list = listOf(1, 2, 3, 4, 5) val result = list.indexOfFirstOrNull { it == 2 } assertThat(result).isEqualTo(1) } @Test @DisplayName("Should return null when the predicate is not satisfied") fun indexOfFirstOrNullWhenPredicateIsNotSatisfiedThenReturnNull() { val list = listOf(1, 2, 3, 4, 5) val result = list.indexOfFirstOrNull { it == 6 } assertThat(result).isNull() } } ================================================ FILE: core/src/test/java/com/infinitepower/newquiz/core/util/kotlin/BooleanUtilsTest.kt ================================================ package com.infinitepower.newquiz.core.util.kotlin import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test class BooleanExtensionsTest { @Test @DisplayName("Should return 1 when the boolean is true") fun toIntWhenBooleanIsTrue() { val boolean = true val expected = 1 val actual = boolean.toInt() assertThat(expected).isEqualTo(actual) } @Test @DisplayName("Should return 0 when the boolean is false") fun toIntWhenBooleanIsFalse() { val boolean = false val expected = 0 val actual = boolean.toInt() assertThat(expected).isEqualTo(actual) } @Test @DisplayName("Should return 1L when the boolean is true") fun toLongWhenBooleanIsTrue() { val boolean = true val expected = 1L val actual = boolean.toLong() assertThat(expected).isEqualTo(actual) } @Test @DisplayName("Should return 0L when the boolean is false") fun toLongWhenBooleanIsFalse() { val boolean = false val expected = 0L val actual = boolean.toLong() assertThat(expected).isEqualTo(actual) } } ================================================ FILE: core/src/test/java/com/infinitepower/newquiz/core/util/kotlin/CollectionsUtilsTest.kt ================================================ package com.infinitepower.newquiz.core.util.kotlin import com.google.common.truth.Truth.assertThat import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import kotlin.random.Random @OptIn(ExperimentalCoroutinesApi::class) internal class CollectionUtilsTest { @Test fun `sum of empty intRanges returns 0 to 0`() { val intRanges = emptyList() val sum = intRanges.sum() assertThat(sum).isEqualTo(0..0) } @Test @DisplayName("Should return the sum of two ranges") fun sumOfTwoRanges() { val range1 = 1..10 val range2 = 11..20 val expected = 12..30 val result = listOf(range1, range2).sum() assertThat(result).isEqualTo(expected) } @Test @DisplayName("Should return the sum of three ranges") fun sumOfThreeRanges() { val range1 = 1..10 val range2 = 11..20 val range3 = 21..30 val result = listOf(range1, range2, range3).sum() assertThat(result).isEqualTo(33..60) } @Test fun testGenerateRandomUniqueItems_correctSize() = runTest { val questionSize = 5 val generator: () -> Int = { Random.nextInt() } val items = generateRandomUniqueItems(questionSize, generator) assertThat(items).hasSize(questionSize) assertThat(items).containsNoDuplicates() } @Test fun testGenerateRandomUniqueItems_generatedItems() = runTest { val questionSize = 5 val expectedItems = listOf(1, 2, 3, 4, 5) var index = 0 val generator: () -> Int = { val item = expectedItems[index] index++ item } val items = generateRandomUniqueItems(questionSize, generator) assertThat(items).containsExactlyElementsIn(expectedItems) } @Test fun testGenerateRandomUniqueItems_sameGenerated_returnsOneSize() = runTest { val questionSize = 5 val generator: () -> Int = { 0 } val items = generateRandomUniqueItems(questionSize, generator, 1000) assertThat(items).hasSize(1) assertThat(items).containsNoDuplicates() } @Test fun `Test ClosedFloatingPointRange increaseEndBy`() { val range = 1f..3f val result = range increaseEndBy 2f assertThat(result).isEqualTo(3f..5f) } @Test fun `test generateIncorrectNumberAnswers`() = runTest { val mockRandom = mockk() every { mockRandom.nextInt(0, 20) } returnsMany listOf(11, 15, 13, 8) val result = generateIncorrectNumberAnswers( answerCount = 3, correctSolution = 10, fromRange = 10, toRange = 10, random = mockRandom ) assertThat(result).containsExactly(11, 15, 13) } @Test fun `test generateIncorrectNumberAnswers from 0 to 20`() = runTest { val mockRandom = mockk() every { mockRandom.nextInt(10, 30) } returnsMany listOf(23, 11, 14, 28) val result = generateIncorrectNumberAnswers( answerCount = 3, correctSolution = 10, fromRange = 0, toRange = 20, random = mockRandom ) assertThat(result).containsExactly(23, 11, 14) } } ================================================ FILE: core/src/test/java/com/infinitepower/newquiz/core/util/kotlin/MathTest.kt ================================================ package com.infinitepower.newquiz.core.util.kotlin import com.google.common.truth.Truth.assertThat import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource import kotlin.random.Random import kotlin.random.nextULong import kotlin.test.Test internal class MathTest { @ParameterizedTest(name = "roundToUInt({0}) = {1}") @CsvSource("0.0, 0", "0.1, 0", "0.5, 1", "0.9, 1", "1.0, 1", "-1.0, -1") fun testRoundToUInt(value: Double, expected: UInt) { assertThat(value.roundToUInt()).isEqualTo(expected) } @ParameterizedTest(name = "{0} pow {1} = {2}") @CsvSource("0, 0, 1", "0, 1, 0", "1, 0, 1", "1, 1, 1", "2, 2, 4", "2, 3, 8", "2, 4, 16") fun testUIntPow(value: UInt, n: Int, expected: UInt) { assertThat(value pow n).isEqualTo(expected) } @Test fun `test ULong div Float`() { val randomDividend = Random.nextULong() val randomDivisor = Random.nextFloat() val expected = randomDividend.toLong() / randomDivisor assertThat(randomDividend / randomDivisor).isEqualTo(expected) } @Test fun `test ULong div Double`() { val randomDividend = Random.nextULong() val randomDivisor = Random.nextDouble() val expected = randomDividend.toLong() / randomDivisor assertThat(randomDividend / randomDivisor).isEqualTo(expected) } } ================================================ FILE: core/src/test/java/com/infinitepower/newquiz/core/util/kotlin/NumberUtilsTest.kt ================================================ package com.infinitepower.newquiz.core.util.kotlin import com.google.common.truth.Truth.assertThat import kotlin.test.Test internal class NumberUtilsTest { @Test fun `converts single digit to double digit`() { val digit = 5 val result = digit.toDoubleDigit() assertThat(result).isEqualTo("05") } @Test fun `does not convert double digit`() { val digit = 15 val result = digit.toDoubleDigit() assertThat(result).isEqualTo("15") } @Test fun `multiplies uInt by float`() { val uInt = 10u val multiplierFactor = 1.5f val result = uInt * multiplierFactor assertThat(result).isEqualTo(15u) } @Test fun `multiplies uIntRange by float`() { val uIntRange = 10u..20u val multiplierFactor = 1.5f val result = uIntRange * multiplierFactor assertThat(result).isEqualTo(15u..30u) } } ================================================ FILE: core/src/test/java/com/infinitepower/newquiz/core/util/kotlin/SetUtilsTest.kt ================================================ package com.infinitepower.newquiz.core.util.kotlin import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test internal class SetUtilsTest { @Test fun `Test Set removeFirst`() { val set = setOf("apple", "banana", "cherry") val result = set.removeFirst() assertThat(result).containsExactly("banana", "cherry") } @Test fun `Test Set removeLast`() { val set = setOf("apple", "banana", "cherry") val result = set.removeLast() assertThat(result).containsExactly("apple", "banana") } } ================================================ FILE: core/testing/.gitignore ================================================ /build ================================================ FILE: core/testing/README.md ================================================ # Core Testing Module (:core:testing) This module provides a set of utilities to help with testing. ================================================ FILE: core/testing/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.testing" } dependencies { api(libs.androidx.activity.compose) api(libs.androidx.compose.ui.test) api(libs.androidx.test.rules) api(libs.androidx.test.runner) api(libs.hilt.android.testing) api(libs.kotlinx.coroutines.test) api(libs.androidx.compose.material3) api(libs.ktor.client.mock) api(libs.room.testing) api(libs.androidx.work.testing) debugApi(libs.androidx.compose.ui.testManifest) implementation(libs.kotlinx.datetime) api(libs.mockk) implementation(projects.model) implementation(projects.core) implementation(projects.core.analytics) api(projects.core.database) api(projects.core.datastore) implementation(projects.core.remoteConfig) implementation(projects.data) implementation(projects.domain) } ================================================ FILE: core/testing/src/foss/kotlin/com/infinitepower/newquiz/core/testing/di/TestRemoteConfigModule.kt ================================================ package com.infinitepower.newquiz.core.testing.di import android.content.Context import com.infinitepower.newquiz.core.remote_config.LocalDefaultsRemoteConfig import com.infinitepower.newquiz.core.remote_config.RemoteConfig import com.infinitepower.newquiz.core.remote_config.initializer.RemoteConfigInitializer import dagger.Module import dagger.Provides import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn import javax.inject.Singleton @Module @TestInstallIn( components = [SingletonComponent::class], replaces = [RemoteConfigInitializer::class] ) object TestRemoteConfigModule { @Provides @Singleton fun provideRemoteConfig( @ApplicationContext context: Context ): RemoteConfig = LocalDefaultsRemoteConfig(context).also { it.initialize() } } ================================================ FILE: core/testing/src/main/AndroidManifest.xml ================================================ ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/NewQuizTestRunner.kt ================================================ package com.infinitepower.newquiz.core.testing import android.app.Application import android.content.Context import androidx.test.runner.AndroidJUnitRunner import dagger.hilt.android.testing.HiltTestApplication class NewQuizTestRunner : AndroidJUnitRunner() { override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application { return super.newApplication(cl, HiltTestApplication::class.java.name, context) } } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/ScreenshotComparator.kt ================================================ package com.infinitepower.newquiz.core.testing import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Build import androidx.annotation.RequiresApi import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.captureToImage import androidx.test.platform.app.InstrumentationRegistry import java.io.File import java.io.FileOutputStream /** * Simple on-device screenshot comparator that uses golden images present in * `androidTest/assets`, * * Minimum SDK is O. Densities between devices must match. * * Screenshots are saved on device in `/data/data/{package}/files`. */ @RequiresApi(Build.VERSION_CODES.O) fun SemanticsNodeInteraction.assertMatchesGolden( goldenName: String, folderPath: String? = null ) { val bitmap = captureToImage().asAndroidBitmap() // Save screenshot to file for debugging saveScreenshot(goldenName + System.currentTimeMillis().toString(), bitmap) val fileName = if (folderPath == null) "$goldenName.png" else "$folderPath/$goldenName.png" val golden = InstrumentationRegistry .getInstrumentation() .context .resources .assets .open(fileName) .use { BitmapFactory.decodeStream(it) } golden.compare(bitmap) } private const val SCREENSHOT_QUALITY = 100 private fun saveScreenshot(filename: String, bmp: Bitmap) { val path = InstrumentationRegistry.getInstrumentation().targetContext.filesDir.canonicalPath FileOutputStream("$path/$filename.png").use { out -> bmp.compress(Bitmap.CompressFormat.PNG, SCREENSHOT_QUALITY, out) } println("Saved screenshot to $path/$filename.png") } private fun Bitmap.compare(other: Bitmap) { if (this.width != other.width || this.height != other.height) { throw AssertionError("Size of screenshot does not match golden file (check device density)") } // Compare row by row to save memory on device val row1 = IntArray(width) val row2 = IntArray(width) for (column in 0 until height) { // Read one row per bitmap and compare this.getRow(row1, column) other.getRow(row2, column) if (!row1.contentEquals(row2)) { throw AssertionError("Sizes match but bitmap content has differences") } } } private fun Bitmap.getRow(pixels: IntArray, column: Int) { this.getPixels(pixels, 0, width, 0, column, width, 1) } fun clearExistingImages(folderName: String) { val path = File(InstrumentationRegistry.getInstrumentation().targetContext.filesDir, folderName) path.deleteRecursively() } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/data/fake/FakeComparisonQuizData.kt ================================================ package com.infinitepower.newquiz.core.testing.data.fake import com.infinitepower.newquiz.model.NumberFormatType import com.infinitepower.newquiz.model.UiText 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.comparison_quiz.ComparisonQuizQuestion import java.net.URI import kotlin.random.Random object FakeComparisonQuizData { fun generateCategories(count: Int = 10): List { return List(count, ::generateCategory) } fun generateCategory(id: Int = 1): ComparisonQuizCategory = ComparisonQuizCategory( id = id.toString(), name = UiText.DynamicString("Category $id"), image = "image_url_$id", description = "Description $id", questionDescription = ComparisonQuizCategory.QuestionDescription( greater = "Greater $id", less = "Less $id" ), formatType = NumberFormatType.DEFAULT, ) fun generateQuestion( categoryId: String = "1", comparisonMode: ComparisonMode = ComparisonMode.GREATER ): ComparisonQuizQuestion { val question1 = generateQuestionItem() val question2 = generateQuestionItem() return ComparisonQuizQuestion( questions = question1 to question2, categoryId = categoryId, comparisonMode = comparisonMode ) } fun generateQuestionItem(): ComparisonQuizItem = ComparisonQuizItem( title = "Title", value = Random.nextDouble(), imgUri = URI.create("") ) } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/data/fake/FakeData.kt ================================================ package com.infinitepower.newquiz.core.testing.data.fake import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.daily_challenge.DailyChallengeTask import com.infinitepower.newquiz.model.global_event.GameEvent import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlin.time.Duration import kotlin.time.Duration.Companion.days object FakeData { fun generateTasks( count: Int = 10, dayDuration: ClosedRange = 0.days..1.days ): List { val now = Clock.System.now() val dateRange = (now + dayDuration.start)..(now + dayDuration.endInclusive) return List(count) { id -> generateTask(id + 1, dateRange) } } fun generateTask(id: Int, dateRange: ClosedRange): DailyChallengeTask { return DailyChallengeTask( id = id, title = UiText.DynamicString("Task $id"), diamondsReward = 10u, experienceReward = 10u, isClaimed = false, dateRange = dateRange, currentValue = 0u, maxValue = 10u, event = GameEvent.MultiChoice.PlayRandomQuiz ) } fun generateTasksWithOffset( size: Int = 10, instant: Instant = Clock.System.now(), offset: Duration = DEFAULT_TASKS_OFFSET.days // Offset to ensure tasks are in the 4 days in the past ): List { return List(size) { day -> val startDate = instant + day.days + offset val endDate = instant + (day + 1).days + offset val dateRange = startDate..endDate generateTask(id = day + 1, dateRange) } } } private const val DEFAULT_TASKS_OFFSET = -4 ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/data/repository/comparison_quiz/FakeComparisonQuizRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.core.testing.data.repository.comparison_quiz import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.model.NumberFormatType import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItem import com.infinitepower.newquiz.model.toUiText import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import java.net.URI import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random private const val CATEGORIES_TO_GENERATE = 1 @Singleton class FakeComparisonQuizRepositoryImpl @Inject constructor() : ComparisonQuizRepository { private val highestPosition = MutableStateFlow>(emptyMap()) override fun getCategories(): List { return List(CATEGORIES_TO_GENERATE) { id -> ComparisonQuizCategory( id = id.toString(), name = "Category $id".toUiText(), description = "Description $id", image = "", questionDescription = ComparisonQuizCategory.QuestionDescription( greater = "Greater", less = "Less" ), formatType = NumberFormatType.DEFAULT ) } } override fun getCategoryById(id: String): ComparisonQuizCategory? { return getCategories().find { it.id == id } } override suspend fun getQuestions( category: ComparisonQuizCategory, size: Int, random: Random ): List = List(size) { id -> ComparisonQuizItem( title = "Question $id", value = random.nextDouble(), imgUri = URI("") ) } override suspend fun getHighestPosition(categoryId: String): Int { return highestPosition.value.getOrDefault(categoryId, 0) } override fun getHighestPositionFlow(categoryId: String): Flow { return highestPosition.map { it.getOrDefault(categoryId, 0)} } } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/data/repository/multi_choice_quiz/TestMultiChoiceQuestionRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.core.testing.data.repository.multi_choice_quiz import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.MultiChoiceQuestionRepository 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 javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @Singleton class TestMultiChoiceQuestionRepositoryImpl @Inject constructor() : MultiChoiceQuestionRepository { companion object { private const val ANSWER_COUNT = 4 } override suspend fun getRandomQuestions( amount: Int, category: MultiChoiceBaseCategory.Normal, difficulty: String?, random: Random ): List = List(amount) { val questionDifficulty = difficulty?.let { difficultyStr -> QuestionDifficulty.from(difficultyStr) } ?: QuestionDifficulty.random(random) val answers = List(ANSWER_COUNT) { answerNum -> "Answer $answerNum" } MultiChoiceQuestion( description = "Question $it", answers = answers, lang = QuestionLanguage.EN, category = category, correctAns = random.nextInt(ANSWER_COUNT), type = MultiChoiceQuestionType.MULTIPLE, difficulty = questionDifficulty ) } } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/data/repository/numbers/FakeNumberTriviaQuestionApiImpl.kt ================================================ package com.infinitepower.newquiz.core.testing.data.repository.numbers import com.infinitepower.newquiz.domain.repository.numbers.NumberTriviaQuestionApi import com.infinitepower.newquiz.model.number.NumberTriviaQuestionEntity import com.infinitepower.newquiz.model.number.NumberTriviaQuestionsEntity import javax.inject.Inject import javax.inject.Singleton @Singleton class FakeNumberTriviaQuestionApiImpl @Inject constructor() : NumberTriviaQuestionApi { override suspend fun getRandomQuestion( size: Int, minNumber: Int, maxNumber: Int ): NumberTriviaQuestionsEntity { val questions = List(size) { val randomNumber = (minNumber..maxNumber).random() NumberTriviaQuestionEntity( number = randomNumber, question = "What is $randomNumber?" ) } return NumberTriviaQuestionsEntity(questions = questions) } } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/di/TestDatabaseModule.kt ================================================ package com.infinitepower.newquiz.core.testing.di import android.content.Context import androidx.room.Room import com.infinitepower.newquiz.core.database.AppDatabase import com.infinitepower.newquiz.core.database.di.DatabaseModule import dagger.Module import dagger.Provides import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn import javax.inject.Singleton @Module @TestInstallIn( components = [SingletonComponent::class], replaces = [DatabaseModule::class] ) object TestDatabaseModule { @Provides @Singleton fun provideAppDatabase( @ApplicationContext applicationContext: Context ): AppDatabase = Room.inMemoryDatabaseBuilder( applicationContext, AppDatabase::class.java ).build() } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/di/TestKtorModule.kt ================================================ package com.infinitepower.newquiz.core.testing.di import com.infinitepower.newquiz.core.common.BaseApiUrls import com.infinitepower.newquiz.core.di.KtorModule import dagger.Module import dagger.Provides import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.http.headersOf import javax.inject.Singleton @Module @TestInstallIn( components = [SingletonComponent::class], replaces = [KtorModule::class] ) object TestKtorModule { @Provides @Singleton fun providerKtorClient(): HttpClient = HttpClient(MockEngine) { engine { addHandler { request -> when (request.url.encodedPath) { "${BaseApiUrls.NEWQUIZ}/api/comparisonquiz/1" -> { respond( content = """ [ { "title": "title1", "value": 1.0, "imgUrl": "imgUrl1" }, { "title": "title2", "value": 2.0, "imgUrl": "imgUrl2" } ] """.trimIndent(), headers = headersOf("Content-Type", "application/json") ) } else -> error("Unhandled ${request.url.encodedPath}") } } } } } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/di/TestRepositoryModule.kt ================================================ package com.infinitepower.newquiz.core.testing.di import com.infinitepower.newquiz.core.testing.data.repository.comparison_quiz.FakeComparisonQuizRepositoryImpl import com.infinitepower.newquiz.core.testing.data.repository.multi_choice_quiz.TestMultiChoiceQuestionRepositoryImpl import com.infinitepower.newquiz.core.testing.data.repository.numbers.FakeNumberTriviaQuestionApiImpl import com.infinitepower.newquiz.data.di.RepositoryModule import com.infinitepower.newquiz.data.repository.comparison_quiz.ComparisonQuizApi import com.infinitepower.newquiz.data.repository.comparison_quiz.ComparisonQuizApiImpl import com.infinitepower.newquiz.data.repository.country.CountryRepositoryImpl import com.infinitepower.newquiz.data.repository.daily_challenge.DailyChallengeRepositoryImpl import com.infinitepower.newquiz.data.repository.home.RecentCategoriesRepositoryImpl import com.infinitepower.newquiz.data.repository.math_quiz.MathQuizCoreRepositoryImpl import com.infinitepower.newquiz.data.repository.maze_quiz.MazeQuizRepositoryImpl import com.infinitepower.newquiz.data.repository.multi_choice_quiz.CountryCapitalFlagsQuizRepositoryImpl import com.infinitepower.newquiz.data.repository.multi_choice_quiz.FlagQuizRepositoryImpl import com.infinitepower.newquiz.data.repository.multi_choice_quiz.GuessMathSolutionRepositoryImpl import com.infinitepower.newquiz.data.repository.multi_choice_quiz.LogoQuizRepositoryImpl import com.infinitepower.newquiz.data.repository.multi_choice_quiz.saved_questions.SavedMultiChoiceQuestionsRepositoryImpl import com.infinitepower.newquiz.data.repository.numbers.NumberTriviaQuestionRepositoryImpl import com.infinitepower.newquiz.data.repository.wordle.WordleRepositoryImpl import com.infinitepower.newquiz.domain.repository.CountryRepository import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.domain.repository.daily_challenge.DailyChallengeRepository import com.infinitepower.newquiz.domain.repository.home.RecentCategoriesRepository import com.infinitepower.newquiz.domain.repository.math_quiz.MathQuizCoreRepository import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.CountryCapitalFlagsQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.FlagQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.GuessMathSolutionRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.LogoQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.MultiChoiceQuestionRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.saved_questions.SavedMultiChoiceQuestionsRepository import com.infinitepower.newquiz.domain.repository.numbers.NumberTriviaQuestionApi import com.infinitepower.newquiz.domain.repository.numbers.NumberTriviaQuestionRepository import com.infinitepower.newquiz.domain.repository.wordle.WordleRepository import dagger.Binds import dagger.Module import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn @Module @TestInstallIn( components = [SingletonComponent::class], replaces = [RepositoryModule::class] ) abstract class TestRepositoryModule { @Binds abstract fun bindMultiChoiceQuestionRepository(impl: TestMultiChoiceQuestionRepositoryImpl): MultiChoiceQuestionRepository @Binds abstract fun bindSavedMultiChoiceQuestionsRepository(impl: SavedMultiChoiceQuestionsRepositoryImpl): SavedMultiChoiceQuestionsRepository @Binds abstract fun bindWordleRepository(impl: WordleRepositoryImpl): WordleRepository @Binds abstract fun bindFlagQuizRepository(impl: FlagQuizRepositoryImpl): FlagQuizRepository @Binds abstract fun bindLogoQuizRepository(impl: LogoQuizRepositoryImpl): LogoQuizRepository @Binds abstract fun bindMathQuizCoreRepository(impl: MathQuizCoreRepositoryImpl): MathQuizCoreRepository @Binds abstract fun bindMazeMathQuizRepository(impl: MazeQuizRepositoryImpl): MazeQuizRepository @Binds abstract fun bindGuessMathSolutionRepository(impl: GuessMathSolutionRepositoryImpl): GuessMathSolutionRepository @Binds abstract fun bindNumberTriviaQuestionApi(impl: FakeNumberTriviaQuestionApiImpl): NumberTriviaQuestionApi @Binds abstract fun bindNumberTriviaQuestionRepository(impl: NumberTriviaQuestionRepositoryImpl): NumberTriviaQuestionRepository @Binds abstract fun bindCountryCapitalFlagsQuizRepository(impl: CountryCapitalFlagsQuizRepositoryImpl): CountryCapitalFlagsQuizRepository @Binds abstract fun bindComparisonQuizRepository(impl: FakeComparisonQuizRepositoryImpl): ComparisonQuizRepository @Binds abstract fun bindDailyChallengeRepository(impl: DailyChallengeRepositoryImpl): DailyChallengeRepository @Binds abstract fun bindRecentCategoriesRepository(impl: RecentCategoriesRepositoryImpl): RecentCategoriesRepository @Binds abstract fun bindCountryRepository(impl: CountryRepositoryImpl): CountryRepository @Binds abstract fun bindComparisonQuizApi(impl: ComparisonQuizApiImpl): ComparisonQuizApi } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/di/WorkManagerModule.kt ================================================ package com.infinitepower.newquiz.core.testing.di import android.content.Context import androidx.work.WorkManager 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 WorkManagerModule { @Singleton @Provides fun provideWorkManager( @ApplicationContext context: Context ): WorkManager = WorkManager.getInstance(context) } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/domain/FakeDailyChallengeDao.kt ================================================ package com.infinitepower.newquiz.core.testing.domain import com.infinitepower.newquiz.core.database.dao.DailyChallengeDao import com.infinitepower.newquiz.core.database.model.DailyChallengeTaskEntity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update class FakeDailyChallengeDao : DailyChallengeDao { private val savedTasks = MutableStateFlow>(emptyList()) override fun getAllTasksFlow(): Flow> = savedTasks override suspend fun getAllTasks(): List { return savedTasks.first() } override suspend fun getAvailableTasks(currentTime: Long): List { return savedTasks.first() .filter { task -> task.startDate <= currentTime && task.endDate >= currentTime } } override suspend fun tasksAreAvailable(currentTime: Long): Boolean { return savedTasks.first() .any { task -> task.startDate <= currentTime && task.endDate >= currentTime } } override suspend fun getTaskByType(type: String): DailyChallengeTaskEntity? { return savedTasks.first().find { task -> task.type == type } } override suspend fun insertAll(vararg tasks: DailyChallengeTaskEntity) { insertAll(tasks.toList()) } override suspend fun insertAll(tasks: List) { savedTasks.emit( savedTasks.first() + tasks ) } override suspend fun update(vararg tasks: DailyChallengeTaskEntity) { updateAll(tasks.toList()) } override suspend fun updateAll(tasks: List) { savedTasks.update { currentTasks -> currentTasks.toMutableList().apply { tasks.forEach { task -> val index = indexOfFirst { it.id == task.id } if (index != -1) { set(index, task) } } } } } override suspend fun deleteAll() { savedTasks.update { emptyList() } } } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/domain/FakeGameResultDao.kt ================================================ package com.infinitepower.newquiz.core.testing.domain import com.infinitepower.newquiz.core.database.dao.GameResultDao 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 import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlin.random.Random class FakeGameResultDao : GameResultDao { private val multiChoiceResults = MutableStateFlow>(emptyList()) override suspend fun insertMultiChoiceResult(vararg result: MultiChoiceGameResultEntity) { multiChoiceResults.update { currentList -> currentList.toMutableList().apply { addAll(result.map { it.copy(gameId = Random.nextInt()) }) } } } override suspend fun getMultiChoiceResults(): List = multiChoiceResults.first() private val wordleResults = MutableStateFlow>(emptyList()) override suspend fun insertWordleResult(vararg result: WordleGameResultEntity) { wordleResults.update { currentList -> currentList.toMutableList().apply { addAll(result.map { it.copy(gameId = Random.nextInt()) }) } } } override suspend fun getWordleResults(): List = wordleResults.first() private val comparisonQuizResults = MutableStateFlow>(emptyList()) override suspend fun insertComparisonQuizResult(vararg result: ComparisonQuizGameResultEntity) { comparisonQuizResults.update { currentList -> currentList.toMutableList().apply { addAll(result.map { it.copy(gameId = Random.nextInt()) }) } } } override suspend fun getComparisonQuizResults(): List { return comparisonQuizResults.first() } override suspend fun getComparisonQuizHighestPosition(categoryId: String): Int { return comparisonQuizResults.first() .filter { it.categoryId == categoryId } .maxOfOrNull { it.endPosition } ?: 0 } override fun getComparisonQuizHighestPositionFlow(categoryId: String): Flow { return comparisonQuizResults.map { results -> results.filter { it.categoryId == categoryId } .maxOfOrNull { it.endPosition } ?: 0 } } override suspend fun getXpForDateRange( startDate: Long, endDate: Long ): List { return (multiChoiceResults.first() + wordleResults.first() + comparisonQuizResults.first()) .filter { it.playedAt in startDate..endDate } .map { GameResultDao.XpForPlayedAt( earnedXp = it.earnedXp, playedAt = it.playedAt ) } } override fun getXpForDateRangeFlow( startDate: Long, endDate: Long ): Flow> = combine( multiChoiceResults, wordleResults, comparisonQuizResults ) { multiChoiceResults, wordleResults, comparisonQuizResults -> (multiChoiceResults + wordleResults + comparisonQuizResults) .filter { it.playedAt in startDate..endDate } .map { GameResultDao.XpForPlayedAt( earnedXp = it.earnedXp, playedAt = it.playedAt ) } } } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/ui/theme/TestTheme.kt ================================================ package com.infinitepower.newquiz.core.testing.ui.theme import androidx.compose.runtime.Composable import com.infinitepower.newquiz.core.theme.NewQuizTheme @Composable fun NewQuizTestTheme( darkTheme: Boolean = false, dynamicColor: Boolean = false, content: @Composable () -> Unit ) { NewQuizTheme( darkTheme = darkTheme, dynamicColor = dynamicColor, content = content ) } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/utils/ComposeRule.kt ================================================ package com.infinitepower.newquiz.core.testing.utils import androidx.annotation.VisibleForTesting import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.test.junit4.ComposeContentTestRule import com.infinitepower.newquiz.core.testing.ui.theme.NewQuizTestTheme import org.jetbrains.annotations.TestOnly import java.util.Locale /** * Sets the content of the [ComposeContentTestRule] to the given [composable] wrapped in a [NewQuizTestTheme]. * The [locale] can be used to set the device locale. * The [darkTheme] and [dynamicColor] can be used to set the theme. * This function is only available in tests. * * @param locale The locale to set the device to. * @param darkTheme Whether to use the dark theme. * @param dynamicColor Whether to use dynamic colors. * @param composable The composable to set as content. */ @TestOnly @VisibleForTesting fun ComposeContentTestRule.setTestContent( locale: Locale = Locale.ENGLISH, darkTheme: Boolean = false, dynamicColor: Boolean = false, composable: @Composable () -> Unit ) { setContent { NewQuizTestTheme( darkTheme = darkTheme, dynamicColor = dynamicColor ) { setTestDeviceLocale(locale = locale) Surface { composable() } } } } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/utils/LocaleUtils.kt ================================================ package com.infinitepower.newquiz.core.testing.utils import android.annotation.SuppressLint import android.content.Context import android.content.res.Configuration import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import org.jetbrains.annotations.TestOnly import java.util.Locale @TestOnly @VisibleForTesting @Suppress("DEPRECATION") @SuppressLint("AppBundleLocaleChanges") fun setTestDeviceLocale( context: Context, locale: Locale = Locale.ENGLISH ) { context.resources.apply { val config = Configuration(configuration) context.createConfigurationContext(configuration) Locale.setDefault(locale) config.setLocale(locale) context.resources.updateConfiguration(config, displayMetrics) } } @TestOnly @Composable @VisibleForTesting @Suppress("ComposableNaming") fun setTestDeviceLocale( locale: Locale = Locale.ENGLISH ) { setTestDeviceLocale(LocalContext.current, locale) } ================================================ FILE: core/testing/src/main/kotlin/com/infinitepower/newquiz/core/testing/utils/LogUtils.kt ================================================ package com.infinitepower.newquiz.core.testing.utils import android.util.Log import io.mockk.every import io.mockk.mockkStatic import kotlinx.datetime.Clock fun mockAndroidLog() { mockkStatic(Log::class) every { Log.d(any(), any()) } answers { println("${Clock.System.now()} DEBUG: ${args[0]} - ${args[1]}") 0 } every { Log.e(any(), any()) } answers { println("${Clock.System.now()} ERROR: ${args[0]} - ${args[1]}") 0 } every { Log.i(any(), any()) } answers { println("${Clock.System.now()} INFO: ${args[0]} - ${args[1]}") 0 } every { Log.v(any(), any()) } answers { println("${Clock.System.now()} VERBOSE: ${args[0]} - ${args[1]}") 0 } every { Log.w(any(), any()) } answers { println("${Clock.System.now()} WARN: ${args[0]} - ${args[1]}") 0 } } ================================================ FILE: core/testing/src/normal/kotlin/com/infinitepower/newquiz/core/testing/di/RemoteConfigModule.kt ================================================ package com.infinitepower.newquiz.core.testing.di import android.content.Context import com.infinitepower.newquiz.core.remote_config.LocalDefaultsRemoteConfig import com.infinitepower.newquiz.core.remote_config.RemoteConfig import com.infinitepower.newquiz.core.remote_config.initializer.RemoteConfigInitializer import dagger.Module import dagger.Provides import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn import javax.inject.Singleton @Module @TestInstallIn( components = [SingletonComponent::class], replaces = [RemoteConfigInitializer::class] ) object RemoteConfigModule { @Provides @Singleton fun provideRemoteConfig( @ApplicationContext context: Context ): RemoteConfig = LocalDefaultsRemoteConfig(context).apply { initialize() } } ================================================ FILE: core/testing/src/normal/kotlin/com/infinitepower/newquiz/core/testing/di/TestAnalyticsModule.kt ================================================ package com.infinitepower.newquiz.core.testing.di import com.infinitepower.newquiz.core.analytics.AnalyticsHelper import com.infinitepower.newquiz.core.analytics.LocalDebugAnalyticsHelper import com.infinitepower.newquiz.core.analytics.NormalAnalyticsModule import dagger.Binds import dagger.Module import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn @Module @TestInstallIn( components = [SingletonComponent::class], replaces = [NormalAnalyticsModule::class] ) abstract class TestAnalyticsModule { @Binds abstract fun bindAnalyticsHelper(impl: LocalDebugAnalyticsHelper): AnalyticsHelper } ================================================ FILE: core/translation/.gitignore ================================================ /build ================================================ FILE: core/translation/README.md ================================================ # Translation Module (:core:translation) This module provides a translation service for the game. ## Normal Flavor The normal flavor of the translation uses the [Ml Kit](https://developers.google.com/ml-kit/language/translation) from Google. ## FOSS Flavor Currently, the translation service is not available in the FOSS flavor. ================================================ FILE: core/translation/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.translation" } dependencies { implementation(projects.model) implementation(projects.core) implementation(projects.core.datastore) normalImplementation(libs.google.mlKit.translate) normalImplementation(libs.kotlinx.coroutines.playServices) } ================================================ FILE: core/translation/consumer-rules.pro ================================================ ================================================ FILE: core/translation/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 -dontwarn java.lang.invoke.StringConcatFactory ================================================ FILE: core/translation/src/androidTestNormal/kotlin/com/infinitepower/newquiz/translation/GoogleTranslatorUtilTest.kt ================================================ package com.infinitepower.newquiz.translation import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth import com.google.mlkit.common.sdkinternal.MlKitContext import com.google.mlkit.nl.translate.TranslateLanguage import com.infinitepower.newquiz.core.common.dataStore.SettingsCommon import com.infinitepower.newquiz.core.dataStore.manager.DataStoreManager import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import kotlin.test.BeforeTest /** * Instrumentation tests for [GoogleTranslatorUtil]. */ @RunWith(AndroidJunit4::class) internal class GoogleTranslatorUtilTest { private val settingsDataStoreManager = mockk() private lateinit var translatorUtil: GoogleTranslatorUtil @BeforeTest fun setUp() { val context = InstrumentationRegistry.getInstrumentation().context MlKitContext.initializeIfNeeded(context) translatorUtil = GoogleTranslatorUtil( settingsDataStoreManager = settingsDataStoreManager ) } @Test fun getTranslator_shouldReturnTranslator() = runTest { coEvery { settingsDataStoreManager.getPreference(SettingsCommon.Translation.TargetLanguage) } returns TranslateLanguage.FRENCH Truth.assertThat(translatorUtil.getTranslator()).isNotNull() } } ================================================ FILE: core/translation/src/foss/kotlin/com/infinitepower/newquiz/core/translation/NoTranslatorUtil.kt ================================================ package com.infinitepower.newquiz.core.translation import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton class NoTranslatorAvailableException : RuntimeException("No translator available") /** * A [TranslatorUtil] implementation that does nothing. * This is used when the translator is not available. * The functions will throw [NoTranslatorAvailableException] when called. */ @Singleton class NoTranslatorUtil @Inject constructor() : TranslatorUtil { override suspend fun isReadyToTranslate(): Boolean = false override suspend fun isModelDownloaded(): Boolean = false override val availableTargetLanguageCodes: List = emptyList() override val availableTargetLanguages: TranslatorTargetLanguages = emptyMap() override suspend fun getTargetLanguageCode(): String { throw NoTranslatorAvailableException() } override suspend fun downloadModel( targetLanguage: String, requireWifi: Boolean, requireCharging: Boolean ): Flow { throw NoTranslatorAvailableException() } override suspend fun deleteModel() { throw NoTranslatorAvailableException() } override suspend fun translate(text: String): String { throw NoTranslatorAvailableException() } override suspend fun translate(items: List): List { throw NoTranslatorAvailableException() } } ================================================ FILE: core/translation/src/foss/kotlin/com/infinitepower/newquiz/core/translation/di/TranslatorModule.kt ================================================ package com.infinitepower.newquiz.core.translation.di import com.infinitepower.newquiz.core.translation.NoTranslatorUtil import com.infinitepower.newquiz.core.translation.TranslatorUtil import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) abstract class NoTranslatorModule { @Binds abstract fun bindTranslatorUtil(impl: NoTranslatorUtil): TranslatorUtil } ================================================ FILE: core/translation/src/main/AndroidManifest.xml ================================================ ================================================ FILE: core/translation/src/main/kotlin/com/infinitepower/newquiz/core/translation/TranslatorLanguageSettings.kt ================================================ package com.infinitepower.newquiz.core.translation /** * Language settings for the translator. * * key - language code * * value - language name */ typealias TranslatorTargetLanguages = Map ================================================ FILE: core/translation/src/main/kotlin/com/infinitepower/newquiz/core/translation/TranslatorModelState.kt ================================================ package com.infinitepower.newquiz.core.translation enum class TranslatorModelState { None, Downloaded, Downloading } ================================================ FILE: core/translation/src/main/kotlin/com/infinitepower/newquiz/core/translation/TranslatorUtil.kt ================================================ package com.infinitepower.newquiz.core.translation import kotlinx.coroutines.flow.Flow interface TranslatorUtil { /** * @return true if the translator is ready to translate, when the model is downloaded * and the target language is set and the translation is enabled, false otherwise */ suspend fun isReadyToTranslate(): Boolean /** * @return true if the model is downloaded, false otherwise */ suspend fun isModelDownloaded(): Boolean /** * @return the list of available language codes */ val availableTargetLanguageCodes: List /** * @return the list of available [TranslatorTargetLanguages] for the target languages. * The key is the language code, the value is the language name. */ val availableTargetLanguages: TranslatorTargetLanguages /** * @return the current target language code */ suspend fun getTargetLanguageCode(): String /** * Downloads the translation model for the current target language. */ suspend fun downloadModel( targetLanguage: String, requireWifi: Boolean, requireCharging: Boolean ): Flow /** * Deletes the current translation model */ suspend fun deleteModel() /** * Translates the given [text] to the current target language. */ suspend fun translate(text: String): String /** * Translates the given [items] to the current target language. */ suspend fun translate(items: List): List } ================================================ FILE: core/translation/src/normal/kotlin/com/infinitepower/newquiz/core/translation/GoogleTranslatorUtil.kt ================================================ package com.infinitepower.newquiz.core.translation import android.os.Build import com.google.mlkit.common.model.DownloadConditions import com.google.mlkit.common.model.RemoteModelManager import com.google.mlkit.nl.translate.TranslateLanguage import com.google.mlkit.nl.translate.TranslateRemoteModel import com.google.mlkit.nl.translate.Translation import com.google.mlkit.nl.translate.Translator import com.google.mlkit.nl.translate.TranslatorOptions import com.infinitepower.newquiz.core.datastore.common.TranslationCommon import com.infinitepower.newquiz.core.datastore.di.SettingsDataStoreManager import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.tasks.await import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @Singleton class GoogleTranslatorUtil @Inject constructor( @SettingsDataStoreManager private val settingsDataStoreManager: DataStoreManager ) : TranslatorUtil { override suspend fun isReadyToTranslate(): Boolean { val translationEnabled = settingsDataStoreManager.getPreference(TranslationCommon.Enabled) return translationEnabled && isModelDownloaded() } override suspend fun isModelDownloaded(): Boolean { val localeLanguage = getTargetLanguageCode() // If the locale language is empty, then the model is not downloaded if (localeLanguage.isEmpty()) { return false } val localeModel = TranslateRemoteModel .Builder(localeLanguage) .build() return RemoteModelManager .getInstance() .isModelDownloaded(localeModel) .await() } override val availableTargetLanguageCodes: List by lazy { TranslateLanguage.getAllLanguages().filter { languageCode -> // Remove English from the list of available languages // because we don't want to translate to English languageCode != TranslateLanguage.ENGLISH } } override val availableTargetLanguages: TranslatorTargetLanguages by lazy { // Associate the language code with the language name availableTargetLanguageCodes.associateWith { languageCode -> // Get the locale for the given language code val locale = Locale(languageCode) locale.getDisplayName(locale) } } override suspend fun getTargetLanguageCode(): String { return settingsDataStoreManager.getPreference(TranslationCommon.TargetLanguage) } suspend fun getTranslator(): Translator { val targetLanguage = getTargetLanguageCode() if (targetLanguage.isEmpty()) { throw IllegalStateException("Target language is empty") } if (targetLanguage == TranslateLanguage.ENGLISH) { throw IllegalStateException("Target language cannot be English") } return getTranslator(targetLanguage) } private fun getTranslator(targetLanguage: String): Translator { val options = TranslatorOptions .Builder() .setSourceLanguage(TranslateLanguage.ENGLISH) .setTargetLanguage(targetLanguage) .build() return Translation.getClient(options) } override suspend fun downloadModel( targetLanguage: String, requireWifi: Boolean, requireCharging: Boolean ): Flow = flow { emit(TranslatorModelState.Downloading) // Get the translator val translator = getTranslator(targetLanguage) val conditions = DownloadConditions .Builder() .apply { if (requireWifi) requireWifi() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && requireCharging){ requireCharging() } }.build() // Download the model translator .downloadModelIfNeeded(conditions) .await() emit(TranslatorModelState.Downloaded) } override suspend fun deleteModel() { // Get the current target language val localeLanguage = getTargetLanguageCode() val localeModel = TranslateRemoteModel .Builder(localeLanguage) .build() // Delete the model RemoteModelManager .getInstance() .deleteDownloadedModel(localeModel) .await() } override suspend fun translate(text: String): String { val translator = getTranslator() return translate(text, translator) } override suspend fun translate(items: List): List { val translator = getTranslator() return items.map { item -> translate(item, translator) } } /** * Translate the given [text] using the given [translator]. * * @return the translated text */ private suspend fun translate(text: String, translator: Translator): String { return translator.translate(text).await() } } ================================================ FILE: core/translation/src/normal/kotlin/com/infinitepower/newquiz/core/translation/di/GoogleTranslatorModule.kt ================================================ package com.infinitepower.newquiz.core.translation.di import com.infinitepower.newquiz.core.translation.GoogleTranslatorUtil import com.infinitepower.newquiz.core.translation.TranslatorUtil import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) abstract class GoogleTranslatorModule { @Binds abstract fun bindTranslatorUtil(impl: GoogleTranslatorUtil): TranslatorUtil } ================================================ FILE: core/translation/src/testFoss/kotlin/com/infinitepower/newquiz/core/translation/NoTranslatorUtilTest.kt ================================================ package com.infinitepower.newquiz.core.translation import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.assertThrows import kotlin.test.BeforeTest import kotlin.test.Test /** * Tests for [NoTranslatorUtil]. */ internal class NoTranslatorUtilTest { private lateinit var translatorUtil: TranslatorUtil @BeforeTest fun setUp() { translatorUtil = NoTranslatorUtil() } @Test fun `translator should not be available and downloaded`() { runTest { assertThat(translatorUtil.isModelDownloaded()).isFalse() assertThat(translatorUtil.isReadyToTranslate()).isFalse() } } @Test fun `should return empty list of available target language codes and languages`() { assertThat(translatorUtil.availableTargetLanguageCodes).isEmpty() assertThat(translatorUtil.availableTargetLanguages).isEmpty() } @Test fun `should throw exception when getting target language code`() { assertThrows { runTest { translatorUtil.getTargetLanguageCode() } } } @Test fun `should throw exception when downloading model`() { assertThrows { runTest { translatorUtil.downloadModel( targetLanguage = "en", requireWifi = false, requireCharging = false ) } } } @Test fun `should throw exception when deleting model`() { assertThrows { runTest { translatorUtil.deleteModel() } } } @Test fun `should throw exception when translating text`() { assertThrows { runTest { translatorUtil.translate("Hello") } } } @Test fun `should throw exception when translating list of items`() { assertThrows { runTest { translatorUtil.translate(listOf("Hello", "World")) } } } } ================================================ FILE: core/translation/src/testNormal/kotlin/com/infinitepower/newquiz/core/translation/GoogleTranslatorUtilTest.kt ================================================ package com.infinitepower.newquiz.core.translation import com.google.common.truth.Truth.assertThat import com.google.mlkit.nl.translate.TranslateLanguage import com.infinitepower.newquiz.core.datastore.common.TranslationCommon import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource import java.util.Locale import kotlin.test.BeforeTest import kotlin.test.Test /** * Tests for [GoogleTranslatorUtil]. */ internal class GoogleTranslatorUtilTest { private val settingsDataStoreManager = mockk() private lateinit var translatorUtil: GoogleTranslatorUtil @BeforeTest fun setUp() { translatorUtil = GoogleTranslatorUtil( settingsDataStoreManager = settingsDataStoreManager ) } @ParameterizedTest @ValueSource(strings = ["", "en", "fr", "de"]) fun `getTargetLanguageCode should return the target language`( targetLanguage: String ) = runTest { coEvery { settingsDataStoreManager.getPreference(TranslationCommon.TargetLanguage) } returns targetLanguage assertThat(translatorUtil.getTargetLanguageCode()).isEqualTo(targetLanguage) } @Test fun `availableTargetLanguageCodes should return all available languages`() { val expectLanguages = TranslateLanguage.getAllLanguages().filter { languageCode -> // Remove English from the list of available languages // because we don't want to translate to English languageCode != TranslateLanguage.ENGLISH } assertThat(translatorUtil.availableTargetLanguageCodes).containsExactlyElementsIn( expectLanguages ) } @Test fun `availableTargetLanguages should return all available languages`() { val expectLanguages = TranslateLanguage.getAllLanguages().filter { languageCode -> // Remove English from the list of available languages // because we don't want to translate to English languageCode != TranslateLanguage.ENGLISH }.associateWith { languageCode -> // Get the locale for the given language code val locale = Locale(languageCode) locale.getDisplayName(locale) } assertThat(translatorUtil.availableTargetLanguages).containsExactlyEntriesIn(expectLanguages) } @Test fun `getTranslator should throw IllegalStateException when target language is empty`() = runTest { coEvery { settingsDataStoreManager.getPreference(TranslationCommon.TargetLanguage) } returns "" assertThrows { translatorUtil.getTranslator() } } @Test fun `getTranslator should throw IllegalStateException when target language is English`() = runTest { coEvery { settingsDataStoreManager.getPreference(TranslationCommon.TargetLanguage) } returns TranslateLanguage.ENGLISH assertThrows { translatorUtil.getTranslator() } } } ================================================ FILE: core/user-services/.gitignore ================================================ /build ================================================ FILE: core/user-services/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.user_services" } dependencies { implementation(projects.model) implementation(projects.core) implementation(projects.core.analytics) implementation(projects.core.database) implementation(projects.core.datastore) implementation(projects.core.remoteConfig) androidTestImplementation(projects.core.testing) implementation(libs.hilt.ext.work) implementation(libs.androidx.work.ktx) ksp(libs.hilt.ext.compiler) androidTestImplementation(libs.androidx.work.testing) implementation(libs.kotlinx.datetime) testImplementation(projects.core.testing) } ================================================ FILE: core/user-services/src/androidTest/AndroidManifest.xml ================================================ ================================================ FILE: core/user-services/src/androidTest/kotlin/com/infinitepower/newquiz/core/user_services/LocalUserServiceImplTest.kt ================================================ package com.infinitepower.newquiz.core.user_services import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.database.AppDatabase import com.infinitepower.newquiz.core.database.dao.GameResultDao import com.infinitepower.newquiz.core.datastore.common.LocalUserCommon import com.infinitepower.newquiz.core.datastore.di.LocalUserDataStoreManager import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager 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.domain.xp.ComparisonQuizXpGenerator import com.infinitepower.newquiz.core.user_services.domain.xp.MultiChoiceQuizXpGenerator import com.infinitepower.newquiz.core.user_services.domain.xp.WordleXpGenerator import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestionStep import com.infinitepower.newquiz.model.multi_choice_quiz.getBasicMultiChoiceQuestion import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.runner.RunWith import javax.inject.Inject import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test /** * Tests for [LocalUserServiceImpl] */ @HiltAndroidTest @RunWith(AndroidJUnit4::class) internal class LocalUserServiceImplTest { @get:Rule val hiltRule = HiltAndroidRule(this) private lateinit var localUserServiceImpl: LocalUserServiceImpl private val remoteConfig: RemoteConfig = mockk() @Inject @LocalUserDataStoreManager lateinit var dataStoreManager: DataStoreManager @Inject lateinit var appDatabase: AppDatabase @Inject lateinit var gameResultDao: GameResultDao @Inject lateinit var multiChoiceQuizXpGenerator: MultiChoiceQuizXpGenerator @Inject lateinit var wordleXpGenerator: WordleXpGenerator @Inject lateinit var comparisonQuizXpGenerator: ComparisonQuizXpGenerator companion object { private const val INITIAL_DIAMONDS = 10 private const val NEW_LEVEL_DIAMONDS = 10 } @BeforeTest fun setUp() { hiltRule.inject() runTest { dataStoreManager.clearPreferences() dataStoreManager.editPreference(LocalUserCommon.UserUid.key, "uid") dataStoreManager.editPreference(LocalUserCommon.UserTotalXp.key, 0) dataStoreManager.editPreference( LocalUserCommon.UserDiamonds(INITIAL_DIAMONDS).key, INITIAL_DIAMONDS ) } localUserServiceImpl = LocalUserServiceImpl( dataStoreManager = dataStoreManager, remoteConfig = remoteConfig, gameResultDao = gameResultDao, multiChoiceXpGenerator = multiChoiceQuizXpGenerator, wordleXpGenerator = wordleXpGenerator, comparisonQuizXpGenerator = comparisonQuizXpGenerator ) } @AfterTest fun tearDown() { appDatabase.close() runTest { dataStoreManager.clearPreferences() } } @Test fun test_saveMultiChoiceGame_whenGenerateXpIsEnabled() = runTest { coEvery { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns INITIAL_DIAMONDS coEvery { remoteConfig.get(RemoteConfigValue.NEW_LEVEL_DIAMONDS) } returns NEW_LEVEL_DIAMONDS // Average answer time is 8 seconds val questionSteps = listOf( MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true, questionTime = 10 ), MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = false, questionTime = 8 ), MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true, questionTime = 6 ), ) val initialUser = localUserServiceImpl.getUser() require(initialUser != null) localUserServiceImpl.saveMultiChoiceGame( questionSteps = questionSteps, generateXp = true ) // Check that the user's total xp has been updated val updatedUser = localUserServiceImpl.getUser() require(updatedUser != null) val expectedXp = multiChoiceQuizXpGenerator.generateXp(questionSteps) // Check if the new xp is in the range of the generated xp val newXp = updatedUser.totalXp - initialUser.totalXp assertThat(newXp.toInt()).isEqualTo(expectedXp.toInt()) // If the user is in new level, check if the diamonds have been updated if (initialUser.isNewLevel(newXp)) { assertThat(updatedUser.diamonds).isEqualTo(updatedUser.diamonds + NEW_LEVEL_DIAMONDS.toUInt()) } else { assertThat(updatedUser.diamonds).isEqualTo(initialUser.diamonds) } // Check if the game result has been saved val gameResults = gameResultDao.getMultiChoiceResults() assertThat(gameResults).hasSize(1) // Because there is only one game result, we can assume that the first one is the one we want gameResults.first().apply { assertThat(correctAnswers).isEqualTo(questionSteps.count { it.correct }) assertThat(questionCount).isEqualTo(questionSteps.count()) assertThat(averageAnswerTime).isEqualTo( questionSteps.map { it.questionTime }.average() ) assertThat(earnedXp).isEqualTo(newXp.toInt()) } } @Test fun test_saveMultiChoiceGame_whenGenerateXpIsDisabled() = runTest { coEvery { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns INITIAL_DIAMONDS coEvery { remoteConfig.get(RemoteConfigValue.NEW_LEVEL_DIAMONDS) } returns NEW_LEVEL_DIAMONDS // Average answer time is 8 seconds val questionSteps = listOf( MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true, questionTime = 10 ), MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = false, questionTime = 8 ), MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true, questionTime = 6 ), ) val initialUser = localUserServiceImpl.getUser() require(initialUser != null) localUserServiceImpl.saveMultiChoiceGame( questionSteps = questionSteps, generateXp = false ) val updatedUser = localUserServiceImpl.getUser() require(updatedUser != null) // because the xp generation is disabled, the new xp should be 0 val newXp = updatedUser.totalXp - initialUser.totalXp assertThat(newXp).isEqualTo(0uL) // Check if the game result has been saved val gameResults = gameResultDao.getMultiChoiceResults() assertThat(gameResults).hasSize(1) } } ================================================ FILE: core/user-services/src/main/AndroidManifest.xml ================================================ ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/DateTimeRangeFormatter.kt ================================================ package com.infinitepower.newquiz.core.user_services import com.infinitepower.newquiz.model.TimestampWithXP import com.infinitepower.newquiz.model.XP import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalTime import kotlinx.datetime.toLocalDateTime import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import kotlin.time.Duration.Companion.days typealias GroupingKey = Int typealias XPByGroupingKey = Map typealias TimeRange = Pair sealed interface DateTimeRangeFormatter { fun groupingKey( timestamp: Long, tz: TimeZone = TimeZone.currentSystemDefault() ): GroupingKey val formatter: DateTimeFormatter fun formatValueToString(value: GroupingKey): String fun aggregateResults( results: List, tz: TimeZone = TimeZone.currentSystemDefault() ): XPByGroupingKey { return results .groupBy { groupingKey(it.timestamp, tz) } .mapValues { (_, values) -> values.sumOf { it.value } }.toSortedMap() } fun getNowDateTimeRange( now: Instant = Clock.System.now(), tz: TimeZone = TimeZone.currentSystemDefault() ): TimeRange data object Day : DateTimeRangeFormatter { override fun groupingKey( timestamp: Long, tz: TimeZone ): GroupingKey { return Instant.fromEpochMilliseconds(timestamp).toLocalDateTime(tz).hour } override val formatter: DateTimeFormatter by lazy { DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) } override fun formatValueToString(value: GroupingKey): String { return LocalTime(value, 0).toJavaLocalTime().format(formatter) } override fun getNowDateTimeRange( now: Instant, tz: TimeZone ): TimeRange { val start = now.toLocalDateTime(tz).date.atStartOfDayIn(tz) return start to now } } data object Week : DateTimeRangeFormatter { override fun groupingKey( timestamp: Long, tz: TimeZone ): GroupingKey { return Instant.fromEpochMilliseconds(timestamp).toLocalDateTime(tz).date.toEpochDays() } override val formatter: DateTimeFormatter by lazy { DateTimeFormatter.ofPattern("d MMM") } override fun formatValueToString(value: GroupingKey): String { return LocalDate.fromEpochDays(value).toJavaLocalDate().format(formatter) } override fun getNowDateTimeRange( now: Instant, tz: TimeZone ): TimeRange { val start = now - 7.days return start to now } } } ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/GameResultTracker.kt ================================================ package com.infinitepower.newquiz.core.user_services import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestionStep interface GameResultTracker { suspend fun saveMultiChoiceGame( questionSteps: List, generateXp: Boolean ) suspend fun saveWordleGame( wordLength: UInt, rowsUsed: UInt, maxRows: Int, categoryId: String, generateXp: Boolean ) suspend fun saveComparisonQuizGame( categoryId: String, comparisonMode: String, endPosition: UInt, skippedAnswers: UInt, generateXp: Boolean ) } ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/InsufficientDiamondsException.kt ================================================ package com.infinitepower.newquiz.core.user_services class InsufficientDiamondsException(diamondsNeeded: UInt, diamondsAvailable: UInt) : Exception( "Not enough diamonds. Need $diamondsNeeded, but only $diamondsAvailable are available." ) ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/LocalUserService.kt ================================================ package com.infinitepower.newquiz.core.user_services interface LocalUserService : UserService ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/LocalUserServiceImpl.kt ================================================ package com.infinitepower.newquiz.core.user_services import com.infinitepower.newquiz.core.database.dao.GameResultDao 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.datastore.common.LocalUserCommon import com.infinitepower.newquiz.core.datastore.di.LocalUserDataStoreManager import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager 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.domain.xp.ComparisonQuizXpGenerator import com.infinitepower.newquiz.core.user_services.domain.xp.MultiChoiceQuizXpGenerator import com.infinitepower.newquiz.core.user_services.domain.xp.WordleXpGenerator import com.infinitepower.newquiz.core.user_services.model.User import com.infinitepower.newquiz.model.TimestampWithXP import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestionStep import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.Instant import javax.inject.Inject import javax.inject.Singleton @Singleton class LocalUserServiceImpl @Inject constructor( @LocalUserDataStoreManager private val dataStoreManager: DataStoreManager, private val remoteConfig: RemoteConfig, private val gameResultDao: GameResultDao, private val multiChoiceXpGenerator: MultiChoiceQuizXpGenerator, private val wordleXpGenerator: WordleXpGenerator, private val comparisonQuizXpGenerator: ComparisonQuizXpGenerator ) : LocalUserService { override suspend fun userAvailable(): Boolean { val uid = dataStoreManager.getPreference(LocalUserCommon.UserUid) return uid.isNotBlank() } override suspend fun getUser(): User? { val uid = dataStoreManager.getPreference(LocalUserCommon.UserUid) if (uid.isBlank()) return null val totalXp = dataStoreManager.getPreference(LocalUserCommon.UserTotalXp) val diamonds = getUserDiamonds() return User( uid = uid, totalXp = totalXp.toULong(), diamonds = diamonds ) } override suspend fun getXpEarnedBy(start: Instant, end: Instant): List { val xpForDateRange = gameResultDao.getXpForDateRange( startDate = start.toEpochMilliseconds(), endDate = end.toEpochMilliseconds() ) return xpForDateRange.map { TimestampWithXP( timestamp = it.playedAt, value = it.earnedXp ) } } override suspend fun getXpEarnedBy(timeRange: TimeRange): List { return getXpEarnedBy( start = timeRange.first, end = timeRange.second, ) } override fun getXpEarnedByFlow(timeRange: TimeRange): Flow> { return gameResultDao.getXpForDateRangeFlow( startDate = timeRange.first.toEpochMilliseconds(), endDate = timeRange.second.toEpochMilliseconds() ).map { xpForDateRange -> xpForDateRange.map { TimestampWithXP( timestamp = it.playedAt, value = it.earnedXp ) } } } override suspend fun getUserDiamonds(): UInt { val initialDiamonds = remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) val diamonds = dataStoreManager.getPreference(LocalUserCommon.UserDiamonds(initialDiamonds)) return diamonds.toUInt() } override fun getUserDiamondsFlow(): Flow { val initialDiamonds = remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) return dataStoreManager .getPreferenceFlow(LocalUserCommon.UserDiamonds(initialDiamonds)) .map(Int::toUInt) } override suspend fun addRemoveDiamonds(diamonds: Int) { val initialDiamonds = remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) val currentDiamonds = dataStoreManager.getPreference(LocalUserCommon.UserDiamonds(initialDiamonds)) val newDiamonds = currentDiamonds + diamonds dataStoreManager.editPreference( key = LocalUserCommon.UserDiamonds(initialDiamonds).key, newValue = newDiamonds ) } suspend fun updateNewLevelDiamonds() { val newLevelDiamonds = remoteConfig.get(RemoteConfigValue.NEW_LEVEL_DIAMONDS) addRemoveDiamonds(newLevelDiamonds) } private fun List.getAverageQuizTime(): Double { return map(MultiChoiceQuestionStep.Completed::questionTime).average() } private suspend fun saveNewXP(newXp: UInt) { val currentUser = getUser() checkNotNull(currentUser) { "User not found" } val newTotalXp = currentUser.totalXp + newXp // Save the new total xp dataStoreManager.editPreference( key = LocalUserCommon.UserTotalXp.key, newValue = newTotalXp.toLong() ) // Check if the user is in a new level val isNewLevel = currentUser.isNewLevel(newXp = newXp.toULong()) // If is new level, update the user diamonds if (isNewLevel) { updateNewLevelDiamonds() } } override suspend fun saveMultiChoiceGame( questionSteps: List, generateXp: Boolean ) { var newXp = 0u // Generate xp if needed if (generateXp) { // Generate and get the new xp newXp = multiChoiceXpGenerator.generateXp(questionSteps) saveNewXP(newXp) } // Save the game result val correctAnswers = questionSteps.count { it.correct } val skippedQuestions = questionSteps.count { it.skipped } val averageAnswerTime = questionSteps.getAverageQuizTime() gameResultDao.insertMultiChoiceResult( MultiChoiceGameResultEntity( correctAnswers = correctAnswers, questionCount = questionSteps.size, skippedQuestions = skippedQuestions, averageAnswerTime = averageAnswerTime, earnedXp = newXp.toInt(), playedAt = System.currentTimeMillis() ) ) } override suspend fun saveWordleGame( wordLength: UInt, rowsUsed: UInt, maxRows: Int, categoryId: String, generateXp: Boolean ) { var newXp = 0u // Generate xp if needed if (generateXp) { // Generate and get the new xp newXp = wordleXpGenerator.generateXp(rowsUsed) saveNewXP(newXp) } // Save the game result gameResultDao.insertWordleResult( WordleGameResultEntity( earnedXp = newXp.toInt(), playedAt = System.currentTimeMillis(), wordLength = wordLength.toInt(), rowsUsed = rowsUsed.toInt(), maxRows = maxRows, categoryId = categoryId ) ) } override suspend fun saveComparisonQuizGame( categoryId: String, comparisonMode: String, endPosition: UInt, skippedAnswers: UInt, generateXp: Boolean ) { var newXp = 0u // Generate xp if needed if (generateXp) { // Generate and get the new xp newXp = comparisonQuizXpGenerator.generateXp( endPosition = endPosition, skippedAnswers = skippedAnswers ) saveNewXP(newXp) } // Save the game result gameResultDao.insertComparisonQuizResult( ComparisonQuizGameResultEntity( earnedXp = newXp.toInt(), playedAt = System.currentTimeMillis(), categoryId = categoryId, comparisonMode = comparisonMode, endPosition = endPosition.toInt(), skippedAnswers = skippedAnswers.toInt() ) ) } } ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/UserService.kt ================================================ package com.infinitepower.newquiz.core.user_services import com.infinitepower.newquiz.core.user_services.model.User import kotlinx.coroutines.flow.Flow interface UserService : GameResultTracker, XpManager { /** * @return true if the user is available, false otherwise */ suspend fun userAvailable(): Boolean suspend fun getUser(): User? suspend fun getUserDiamonds(): UInt fun getUserDiamondsFlow(): Flow /** * @param diamonds the amount of diamonds to add/remove */ suspend fun addRemoveDiamonds(diamonds: Int) } ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/XpManager.kt ================================================ package com.infinitepower.newquiz.core.user_services import com.infinitepower.newquiz.model.TimestampWithXP import kotlinx.coroutines.flow.Flow import kotlinx.datetime.Instant interface XpManager { suspend fun getXpEarnedBy(start: Instant, end: Instant): List suspend fun getXpEarnedBy(timeRange: TimeRange): List fun getXpEarnedByFlow(timeRange: TimeRange): Flow> } ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/data/xp/ComparisonQuizXpGeneratorImpl.kt ================================================ package com.infinitepower.newquiz.core.user_services.data.xp 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.domain.xp.ComparisonQuizXpGenerator import javax.inject.Inject import javax.inject.Singleton @Singleton class ComparisonQuizXpGeneratorImpl @Inject constructor( private val remoteConfig: RemoteConfig ) : ComparisonQuizXpGenerator { override fun getDefaultXpForAnswer(): UInt { return remoteConfig.get(RemoteConfigValue.COMPARISON_QUIZ_DEFAULT_XP_REWARD).toUInt() } override fun generateXp(endPosition: UInt, skippedAnswers: UInt): UInt { // If the user answered incorrectly, then no XP is awarded if (endPosition == 1.toUInt()) return 0u // Calculate the number of answers the user answered without skipping // Example: End position is 4, skipped answers is 1, then the user answered 2 questions // without skipping val answersNotSkipped = endPosition - skippedAnswers - 1u return getDefaultXpForAnswer() * answersNotSkipped } } ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/data/xp/MultiChoiceQuizXpGeneratorImpl.kt ================================================ package com.infinitepower.newquiz.core.user_services.data.xp 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.domain.xp.MultiChoiceQuizXpGenerator import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestionStep import com.infinitepower.newquiz.model.question.QuestionDifficulty import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton @Singleton class MultiChoiceQuizXpGeneratorImpl @Inject constructor( private val remoteConfig: RemoteConfig ) : MultiChoiceQuizXpGenerator { override fun getDefaultXpReward(): Map { val xpRewardStr = remoteConfig.get(RemoteConfigValue.MULTICHOICE_QUIZ_DEFAULT_XP_REWARD) val xpReward: Map = Json.decodeFromString(xpRewardStr) return xpReward.map { (difficultyStr, reward) -> QuestionDifficulty.from(difficultyStr) to reward.toUInt() }.toMap() } override fun generateXp( questionSteps: List ): UInt { val defaultXpReward = getDefaultXpReward() return questionSteps .filter { step -> step.correct && !step.skipped }.sumOf { step -> val difficulty = step.question.difficulty defaultXpReward[difficulty] ?: 0u } } } ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/data/xp/WordleXpGeneratorImpl.kt ================================================ package com.infinitepower.newquiz.core.user_services.data.xp 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.domain.xp.WordleXpGenerator import com.infinitepower.newquiz.core.util.kotlin.roundToUInt import javax.inject.Inject import javax.inject.Singleton import kotlin.math.sqrt @Singleton class WordleXpGeneratorImpl @Inject constructor( private val remoteConfig: RemoteConfig ) : WordleXpGenerator { override fun getDefaultXp(): UInt { return remoteConfig.get(RemoteConfigValue.WORDLE_DEFAULT_XP_REWARD).toUInt() } override fun generateXp(rowsUsed: UInt): UInt { val defaultXp = getDefaultXp() // Calculate the XP multiplier based on the number of rows used. // The XP multiplier is inversely proportional to the square root of the number of rows used. // This means that the XP awarded decreases as the number of rows used increases. val xpMultiplier = (2 / sqrt(rowsUsed.toDouble())) return (defaultXp.toInt() * xpMultiplier).roundToUInt() } } ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/di/UserModule.kt ================================================ package com.infinitepower.newquiz.core.user_services.di import com.infinitepower.newquiz.core.user_services.LocalUserServiceImpl import com.infinitepower.newquiz.core.user_services.UserService import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class UserModule { @Binds @Singleton abstract fun bindUserService( impl: LocalUserServiceImpl ): UserService } ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/di/XpGeneratorsModule.kt ================================================ package com.infinitepower.newquiz.core.user_services.di 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.core.user_services.domain.xp.ComparisonQuizXpGenerator import com.infinitepower.newquiz.core.user_services.domain.xp.MultiChoiceQuizXpGenerator import com.infinitepower.newquiz.core.user_services.domain.xp.WordleXpGenerator import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class XpGeneratorsModule { @Binds @Singleton abstract fun bindMultiChoiceQuizXpGenerator(impl: MultiChoiceQuizXpGeneratorImpl): MultiChoiceQuizXpGenerator @Binds @Singleton abstract fun bindWordleXpGenerator(impl: WordleXpGeneratorImpl): WordleXpGenerator @Binds @Singleton abstract fun bindComparisonQuizXpGenerator(impl: ComparisonQuizXpGeneratorImpl): ComparisonQuizXpGenerator } ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/domain/xp/ComparisonQuizXpGenerator.kt ================================================ package com.infinitepower.newquiz.core.user_services.domain.xp interface ComparisonQuizXpGenerator : XpGenerator { fun getDefaultXpForAnswer(): UInt /** * Generates the XP for the user based on the [endPosition] they reached in the quiz * and the number of [skippedAnswers], this number of [skippedAnswers] * will be deducted from the [endPosition]. * * If the [endPosition] is 1, the user has answered incorrectly, so no XP is awarded. * * @param endPosition The position the user reached in the quiz * @param skippedAnswers The number of questions the user skipped */ fun generateXp( endPosition: UInt, skippedAnswers: UInt, ): UInt } ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/domain/xp/MultiChoiceQuizXpGenerator.kt ================================================ package com.infinitepower.newquiz.core.user_services.domain.xp import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestionStep import com.infinitepower.newquiz.model.question.QuestionDifficulty interface MultiChoiceQuizXpGenerator : XpGenerator { fun getDefaultXpReward(): Map /** * Generates a amount of XP based on the given question steps. */ fun generateXp( questionSteps: List ): UInt } ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/domain/xp/WordleXpGenerator.kt ================================================ package com.infinitepower.newquiz.core.user_services.domain.xp interface WordleXpGenerator : XpGenerator { fun getDefaultXp(): UInt /** * Calculates the XP awarded to the player for completing a game of Wordle. * * Currently it does not take into account the difficulty of the game, because * the difficulty is not yet implemented in the game. * * The formula is: * * xp = xpForDifficulty * (2 / sqrt(rowsUsed)) * * @param rowsUsed The number of rows used in the game. * @return The XP awarded to the player. */ fun generateXp(rowsUsed: UInt): UInt } ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/domain/xp/XpGenerator.kt ================================================ package com.infinitepower.newquiz.core.user_services.domain.xp interface XpGenerator ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/model/User.kt ================================================ package com.infinitepower.newquiz.core.user_services.model import androidx.annotation.Keep import com.infinitepower.newquiz.core.common.DEFAULT_USER_PHOTO import com.infinitepower.newquiz.core.util.kotlin.div import com.infinitepower.newquiz.core.util.kotlin.pow import com.infinitepower.newquiz.core.util.kotlin.roundToUInt import kotlin.math.floor import kotlin.math.sqrt @Keep data class User( val uid: String, val fullName: String = "NewQuiz User", val imageUrl: String = DEFAULT_USER_PHOTO, val totalXp: ULong = 0u, val diamonds: UInt = 0u, ) { companion object { private const val XP_FACTOR = 100 } val level: UInt get() = floor(sqrt(totalXp / XP_FACTOR.toDouble())).roundToUInt() fun getNextLevelXp(): UInt = getRequiredXpForLevel(level + 1u) private fun getRequiredXpForLevel(level: UInt): UInt = level.pow(2) * XP_FACTOR.toUInt() fun getLevelProgress(): Float { val currentLevelXp = getRequiredXpForLevel(level) val nextLevelXp = getNextLevelXp() val requiredXp = nextLevelXp - currentLevelXp val currentXp = totalXp - currentLevelXp return currentXp.toFloat() / requiredXp.toFloat() } fun getRequiredXP(): ULong = getNextLevelXp() - totalXp fun isNewLevel(newXp: ULong): Boolean = getLevelAfter(newXp) > level fun getLevelAfter(newXp: ULong): UInt = copy(totalXp = totalXp + newXp).level } ================================================ FILE: core/user-services/src/main/kotlin/com/infinitepower/newquiz/core/user_services/workers/MultiChoiceQuizEndGameWorker.kt ================================================ package com.infinitepower.newquiz.core.user_services.workers import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters 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.multi_choice_quiz.MultiChoiceQuestionStep import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.serialization.json.Json @HiltWorker class MultiChoiceQuizEndGameWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted workerParams: WorkerParameters, private val analyticsHelper: AnalyticsHelper, private val userService: UserService ) : CoroutineWorker(appContext, workerParams) { companion object { const val INPUT_QUESTION_STEPS = "QUESTION_STEPS" const val INPUT_GENERATE_XP = "GENERATE_XP" } override suspend fun doWork(): Result { val questionStepsStr = inputData.getString(INPUT_QUESTION_STEPS) ?: return Result.failure() val questionSteps: List = Json.decodeFromString(questionStepsStr) val generateXp = inputData.getBoolean(INPUT_GENERATE_XP, true) analyticsHelper.logEvent( AnalyticsEvent.MultiChoiceGameEnd( questionsSize = questionSteps.size, correctAnswers = questionSteps.count { it.correct } ) ) userService.saveMultiChoiceGame( questionSteps = questionSteps, generateXp = generateXp ) return Result.success() } } ================================================ FILE: core/user-services/src/test/kotlin/com/infinitepower/newquiz/core/user_services/DateTimeRangeFormatterTest.kt ================================================ package com.infinitepower.newquiz.core.user_services import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.model.TimestampWithXP import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.toLocalDateTime import kotlin.test.Test import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours internal class DateTimeRangeFormatterTest { @Test fun `test day formatter aggregateResults`() { val now = Clock.System.now() val results = listOf( TimestampWithXP(timestamp = now.toEpochMilliseconds(), value = 10), TimestampWithXP(timestamp = now.toEpochMilliseconds(), value = 5), TimestampWithXP(timestamp = (now - 1.hours).toEpochMilliseconds(), value = 20), TimestampWithXP(timestamp = (now - 6.hours).toEpochMilliseconds(), value = 30), TimestampWithXP(timestamp = (now - 3.hours).toEpochMilliseconds(), value = 40), TimestampWithXP(timestamp = (now - 1.days - 3.hours).toEpochMilliseconds(), value = 40), ) val formatter = DateTimeRangeFormatter.Day val resultsAggregated = formatter.aggregateResults(results) assertThat(resultsAggregated).hasSize(4) } @Test fun `test week formatter aggregateResults`() { val now = Clock.System.now() val results = listOf( TimestampWithXP(timestamp = now.toEpochMilliseconds(), value = 10), TimestampWithXP(timestamp = now.toEpochMilliseconds(), value = 5), TimestampWithXP(timestamp = (now - 1.hours).toEpochMilliseconds(), value = 20), TimestampWithXP(timestamp = (now - 32.hours).toEpochMilliseconds(), value = 30), TimestampWithXP(timestamp = (now - 6.days).toEpochMilliseconds(), value = 40), ) val formatter = DateTimeRangeFormatter.Week val resultsAggregated = formatter.aggregateResults(results) assertThat(resultsAggregated).hasSize(3) } @Test fun `test getNowDateTimeRange`() { val now = Clock.System.now() val tz = TimeZone.currentSystemDefault() val today = DateTimeRangeFormatter.Day.getNowDateTimeRange(now, tz) val todayStart = today.first.toLocalDateTime(tz).date.atStartOfDayIn(tz) assertThat(today.first).isEqualTo(todayStart) assertThat(today.second).isEqualTo(now) val thisWeek = DateTimeRangeFormatter.Week.getNowDateTimeRange(now, tz) val thisWeekStart = now - 7.days assertThat(thisWeek.first).isEqualTo(thisWeekStart) assertThat(thisWeek.second).isEqualTo(now) } } ================================================ FILE: core/user-services/src/test/kotlin/com/infinitepower/newquiz/core/user_services/LocalUserServiceImplUnitTest.kt ================================================ package com.infinitepower.newquiz.core.user_services 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.database.dao.GameResultDao 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.datastore.common.LocalUserCommon import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import com.infinitepower.newquiz.core.datastore.manager.PreferencesDatastoreManager 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.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.core.user_services.domain.xp.ComparisonQuizXpGenerator import com.infinitepower.newquiz.core.user_services.domain.xp.MultiChoiceQuizXpGenerator import com.infinitepower.newquiz.core.user_services.domain.xp.WordleXpGenerator import com.infinitepower.newquiz.core.user_services.model.User import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestionStep import com.infinitepower.newquiz.model.multi_choice_quiz.getBasicMultiChoiceQuestion import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.minutes import kotlin.time.measureTimedValue /** * Tests for [LocalUserServiceImpl] */ internal class LocalUserServiceImplUnitTest { @TempDir lateinit var tmpDir: File private lateinit var dataStoreManager: DataStoreManager private val remoteConfig: RemoteConfig = mockk() private lateinit var gameResultDao: GameResultDao private val multiChoiceQuizXpGenerator: MultiChoiceQuizXpGenerator = MultiChoiceQuizXpGeneratorImpl(remoteConfig) private val wordleXpGenerator: WordleXpGenerator = WordleXpGeneratorImpl(remoteConfig) private val comparisonQuizXpGenerator: ComparisonQuizXpGenerator = ComparisonQuizXpGeneratorImpl(remoteConfig) private lateinit var localUserServiceImpl: LocalUserServiceImpl companion object { private const val INITIAL_DIAMONDS = 10 private const val NEW_LEVEL_DIAMONDS = 10 } @BeforeTest fun setUp() { val testDataStore: DataStore = PreferenceDataStoreFactory.create( produceFile = { File(tmpDir, "user.preferences_pb") } ) dataStoreManager = PreferencesDatastoreManager(testDataStore) gameResultDao = FakeGameResultDao() every { remoteConfig.get(RemoteConfigValue.WORDLE_DEFAULT_XP_REWARD) } returns 10 every { remoteConfig.get(RemoteConfigValue.COMPARISON_QUIZ_DEFAULT_XP_REWARD) } returns 10 every { remoteConfig.get(RemoteConfigValue.MULTICHOICE_QUIZ_DEFAULT_XP_REWARD) } returns """ { "easy": 10, "medium": 20, "hard": 30 } """.trimIndent() localUserServiceImpl = LocalUserServiceImpl( dataStoreManager = dataStoreManager, remoteConfig = remoteConfig, gameResultDao = gameResultDao, multiChoiceXpGenerator = multiChoiceQuizXpGenerator, wordleXpGenerator = wordleXpGenerator, comparisonQuizXpGenerator = comparisonQuizXpGenerator ) } @Test fun `userAvailable() should return true when uid is not blank`() = runTest { dataStoreManager.editPreference(LocalUserCommon.UserUid.key, "uid") val result = localUserServiceImpl.userAvailable() assertThat(result).isTrue() } @Test fun `getUserDiamonds() should return the user diamonds`() = runTest { coEvery { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns INITIAL_DIAMONDS dataStoreManager.editPreference( LocalUserCommon.UserDiamonds(INITIAL_DIAMONDS).key, INITIAL_DIAMONDS ) val result = localUserServiceImpl.getUserDiamonds() assertThat(result).isEqualTo(INITIAL_DIAMONDS.toUInt()) } @Test fun `getUserDiamonds() should return the initial diamonds when the user diamonds are not set`() = runTest { coEvery { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns INITIAL_DIAMONDS val result = localUserServiceImpl.getUserDiamonds() assertThat(result).isEqualTo(INITIAL_DIAMONDS.toUInt()) } @Test fun `getUserDiamondsFlow() should return the user diamonds`() = runTest { coEvery { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns INITIAL_DIAMONDS dataStoreManager.editPreference( LocalUserCommon.UserDiamonds(INITIAL_DIAMONDS).key, INITIAL_DIAMONDS ) localUserServiceImpl.getUserDiamondsFlow().test { assertThat(awaitItem()).isEqualTo(INITIAL_DIAMONDS.toUInt()) } } @Test fun `addRemoveDiamonds() should add diamonds to the user`() = runTest { coEvery { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns INITIAL_DIAMONDS dataStoreManager.editPreference( LocalUserCommon.UserDiamonds(INITIAL_DIAMONDS).key, INITIAL_DIAMONDS ) val diamondsToAdd = 5 localUserServiceImpl.addRemoveDiamonds(diamondsToAdd) // Check that the diamonds were added val expectedDiamonds = INITIAL_DIAMONDS + diamondsToAdd val result = dataStoreManager.getPreference(LocalUserCommon.UserDiamonds(expectedDiamonds)) assertThat(result).isEqualTo(expectedDiamonds) } @Test fun `updateNewLevelDiamonds() should update the user diamonds`() = runTest { coEvery { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns INITIAL_DIAMONDS dataStoreManager.editPreference( LocalUserCommon.UserDiamonds(INITIAL_DIAMONDS).key, INITIAL_DIAMONDS ) coEvery { remoteConfig.get(RemoteConfigValue.NEW_LEVEL_DIAMONDS) } returns NEW_LEVEL_DIAMONDS localUserServiceImpl.updateNewLevelDiamonds() // Check that the diamonds were added val expectedDiamonds = INITIAL_DIAMONDS + NEW_LEVEL_DIAMONDS val result = dataStoreManager.getPreference(LocalUserCommon.UserDiamonds(expectedDiamonds)) assertThat(result).isEqualTo(expectedDiamonds) } @Test fun `getUser() should return null when uid is blank`() = runTest { dataStoreManager.editPreference(LocalUserCommon.UserUid.key, "") val result = localUserServiceImpl.getUser() assertThat(result).isNull() } @Test fun `getUser() should return User when uid is not blank`() = runTest { dataStoreManager.editPreference(LocalUserCommon.UserUid.key, "uid") dataStoreManager.editPreference(LocalUserCommon.UserTotalXp.key, 0) coEvery { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns INITIAL_DIAMONDS dataStoreManager.editPreference( LocalUserCommon.UserDiamonds(INITIAL_DIAMONDS).key, INITIAL_DIAMONDS ) val result = localUserServiceImpl.getUser() assertThat(result).isNotNull() val expectedUser = User( uid = "uid", totalXp = 0u, diamonds = INITIAL_DIAMONDS.toUInt() ) assertThat(result).isEqualTo(expectedUser) } @CsvSource( "0", "100", "399", "1000" ) @ParameterizedTest(name = "test getNextLevelXp() when totalXp is {0}") fun `test saveMultiChoiceGame when generate xp is enabled`( initialXp: Long ) = runTest { coEvery { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns INITIAL_DIAMONDS coEvery { remoteConfig.get(RemoteConfigValue.NEW_LEVEL_DIAMONDS) } returns NEW_LEVEL_DIAMONDS dataStoreManager.editPreference(LocalUserCommon.UserTotalXp.key, initialXp) // Average answer time is 8 seconds val questionSteps = listOf( MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true, questionTime = 10 ), MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = false, questionTime = 8 ), MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true, questionTime = 6 ), ) val initialUser = localUserServiceImpl.getUser() require(initialUser != null) localUserServiceImpl.saveMultiChoiceGame( questionSteps = questionSteps, generateXp = true ) // Check that the user's total xp has been updated val updatedUser = localUserServiceImpl.getUser() require(updatedUser != null) val expectedXp = multiChoiceQuizXpGenerator.generateXp(questionSteps) // Check if the new xp is in the range of the generated xp val newXp = updatedUser.totalXp - initialUser.totalXp assertThat(newXp.toInt()).isEqualTo(expectedXp.toInt()) // If the user is in new level, check if the diamonds have been updated if (initialUser.isNewLevel(newXp)) { assertThat(updatedUser.diamonds).isEqualTo(initialUser.diamonds + NEW_LEVEL_DIAMONDS.toUInt()) } else { assertThat(updatedUser.diamonds).isEqualTo(initialUser.diamonds) } // Check if the game result has been saved val gameResults = gameResultDao.getMultiChoiceResults() assertThat(gameResults).hasSize(1) // Because there is only one game result, we can assume that the first one is the one we want gameResults.first().apply { assertThat(correctAnswers).isEqualTo(questionSteps.count { it.correct }) assertThat(questionCount).isEqualTo(questionSteps.count()) assertThat(averageAnswerTime).isEqualTo( questionSteps.map { it.questionTime }.average() ) assertThat(earnedXp).isEqualTo(newXp.toInt()) } } @Test fun `test saveMultiChoiceGame when generate xp is disabled`() = runTest { coEvery { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns INITIAL_DIAMONDS coEvery { remoteConfig.get(RemoteConfigValue.NEW_LEVEL_DIAMONDS) } returns NEW_LEVEL_DIAMONDS // Average answer time is 8 seconds val questionSteps = listOf( MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true, questionTime = 10 ), MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = false, questionTime = 8 ), MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true, questionTime = 6 ), ) val initialUser = localUserServiceImpl.getUser() require(initialUser != null) localUserServiceImpl.saveMultiChoiceGame( questionSteps = questionSteps, generateXp = false ) val updatedUser = localUserServiceImpl.getUser() require(updatedUser != null) // because the xp generation is disabled, the new xp should be 0 val newXp = updatedUser.totalXp - initialUser.totalXp assertThat(newXp).isEqualTo(0uL) // Check if the game result has been saved val gameResults = gameResultDao.getMultiChoiceResults() assertThat(gameResults).hasSize(1) } @CsvSource( "0", "100", "399", "1000" ) @ParameterizedTest(name = "test saveWordleGame when generate xp is enabled and totalXp is {0}") fun `test saveWordleGame when generate xp is enabled`( initialXp: Long ) = runTest { coEvery { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns INITIAL_DIAMONDS coEvery { remoteConfig.get(RemoteConfigValue.NEW_LEVEL_DIAMONDS) } returns NEW_LEVEL_DIAMONDS dataStoreManager.editPreference(LocalUserCommon.UserTotalXp.key, initialXp) val initialUser = localUserServiceImpl.getUser() require(initialUser != null) localUserServiceImpl.saveWordleGame( wordLength = 5u, rowsUsed = 3u, maxRows = Int.MAX_VALUE, categoryId = "category", generateXp = true ) // Check that the user's total xp has been updated val updatedUser = localUserServiceImpl.getUser() require(updatedUser != null) val expectedXp = wordleXpGenerator.generateXp(rowsUsed = 3u) // Check if the new xp is equal to the generated xp val newXp = updatedUser.totalXp - initialUser.totalXp assertThat(newXp.toInt()).isEqualTo(expectedXp.toInt()) // If the user is in new level, check if the diamonds have been updated if (initialUser.isNewLevel(newXp)) { assertThat(updatedUser.diamonds).isEqualTo(initialUser.diamonds + NEW_LEVEL_DIAMONDS.toUInt()) } else { assertThat(updatedUser.diamonds).isEqualTo(initialUser.diamonds) } // Check if the game result has been saved val gameResults = gameResultDao.getWordleResults() assertThat(gameResults).hasSize(1) // Because there is only one game result, we can assume that the first one is the one we want gameResults.first().apply { assertThat(wordLength).isEqualTo(5) assertThat(rowsUsed).isEqualTo(3) assertThat(maxRows).isEqualTo(Int.MAX_VALUE) assertThat(earnedXp).isEqualTo(newXp.toInt()) } } @Test fun `test saveWordleGame when generate xp is disabled`() = runTest { coEvery { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns INITIAL_DIAMONDS coEvery { remoteConfig.get(RemoteConfigValue.NEW_LEVEL_DIAMONDS) } returns NEW_LEVEL_DIAMONDS val initialUser = localUserServiceImpl.getUser() require(initialUser != null) localUserServiceImpl.saveWordleGame( wordLength = 5u, rowsUsed = 3u, maxRows = Int.MAX_VALUE, categoryId = "category", generateXp = false ) // Check that the user's total xp has been updated val updatedUser = localUserServiceImpl.getUser() require(updatedUser != null) // because the xp generation is disabled, the new xp should be 0 val newXp = updatedUser.totalXp - initialUser.totalXp assertThat(newXp).isEqualTo(0uL) // Check if the game result has been saved val gameResults = gameResultDao.getWordleResults() assertThat(gameResults).hasSize(1) } @CsvSource( "0", "100", "399", "1000" ) @ParameterizedTest(name = "test saveComparisonQuizGame when generate xp is enabled and totalXp is {0}") fun `test saveComparisonQuizGame when generate xp is enabled`( initialXp: Long ) = runTest { coEvery { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns INITIAL_DIAMONDS coEvery { remoteConfig.get(RemoteConfigValue.NEW_LEVEL_DIAMONDS) } returns NEW_LEVEL_DIAMONDS dataStoreManager.editPreference(LocalUserCommon.UserTotalXp.key, initialXp) val initialUser = localUserServiceImpl.getUser() require(initialUser != null) val endPosition = 5u localUserServiceImpl.saveComparisonQuizGame( categoryId = "category", comparisonMode = ComparisonMode.GREATER.name, endPosition = endPosition, skippedAnswers = 1u, generateXp = true ) // Check that the user's total xp has been updated val updatedUser = localUserServiceImpl.getUser() require(updatedUser != null) val expectedXp = comparisonQuizXpGenerator.generateXp( endPosition = endPosition, skippedAnswers = 1u, ) // Check if the new xp is equal to the generated xp val newXp = updatedUser.totalXp - initialUser.totalXp assertThat(newXp.toInt()).isEqualTo(expectedXp.toInt()) // If the user is in new level, check if the diamonds have been updated if (initialUser.isNewLevel(newXp)) { assertThat(updatedUser.diamonds).isEqualTo(initialUser.diamonds + NEW_LEVEL_DIAMONDS.toUInt()) } else { assertThat(updatedUser.diamonds).isEqualTo(initialUser.diamonds) } // Check if the game result has been saved val gameResults = gameResultDao.getComparisonQuizResults() assertThat(gameResults).hasSize(1) // Because there is only one game result, we can assume that the first one is the one we want gameResults.first().apply { assertThat(comparisonMode).isEqualTo(ComparisonMode.GREATER.name) assertThat(this.endPosition).isEqualTo(endPosition.toInt()) assertThat(earnedXp).isEqualTo(newXp.toInt()) } } @Test fun `test saveComparisonQuizGame when generate xp is disabled`() = runTest { coEvery { remoteConfig.get(RemoteConfigValue.USER_INITIAL_DIAMONDS) } returns INITIAL_DIAMONDS coEvery { remoteConfig.get(RemoteConfigValue.NEW_LEVEL_DIAMONDS) } returns NEW_LEVEL_DIAMONDS val initialUser = localUserServiceImpl.getUser() require(initialUser != null) val endPosition = 5u localUserServiceImpl.saveComparisonQuizGame( categoryId = "category", comparisonMode = ComparisonMode.GREATER.name, endPosition = endPosition, skippedAnswers = 0u, generateXp = false ) // Check that the user's total xp has been updated val updatedUser = localUserServiceImpl.getUser() require(updatedUser != null) // because the xp generation is disabled, the new xp should be 0 val newXp = updatedUser.totalXp - initialUser.totalXp assertThat(newXp).isEqualTo(0uL) // Check if the game result has been saved val gameResults = gameResultDao.getComparisonQuizResults() assertThat(gameResults).hasSize(1) } @Test fun `test getXpEarnedByRange() and getXpEarnedInLastDays()`() = runTest { val now = Clock.System.now() val startInstant = now - 7.days // 7 days ago // Insert multi choice results gameResultDao.insertMultiChoiceResult( MultiChoiceGameResultEntity( correctAnswers = 0, skippedQuestions = 0, questionCount = 0, averageAnswerTime = 0.0, earnedXp = 5, playedAt = (now - 1.minutes).toEpochMilliseconds() // today ), MultiChoiceGameResultEntity( correctAnswers = 0, skippedQuestions = 0, questionCount = 0, averageAnswerTime = 0.0, earnedXp = 10, playedAt = (now - 2.minutes).toEpochMilliseconds() // today ), MultiChoiceGameResultEntity( correctAnswers = 0, skippedQuestions = 0, questionCount = 0, averageAnswerTime = 0.0, earnedXp = 20, playedAt = (now - 1.days).toEpochMilliseconds() // yesterday ), // Insert a result that is not in the current week MultiChoiceGameResultEntity( correctAnswers = 0, skippedQuestions = 0, questionCount = 0, averageAnswerTime = 0.0, earnedXp = 20, playedAt = (now - 10.days).toEpochMilliseconds() // 10 days ago ), ) // Insert wordle results gameResultDao.insertWordleResult( WordleGameResultEntity( earnedXp = 10, playedAt = (now - 2.days).toEpochMilliseconds(), // before yesterday wordLength = 5, rowsUsed = 3, maxRows = Int.MAX_VALUE, categoryId = "category" ), ) // Insert comparison quiz results gameResultDao.insertComparisonQuizResult( ComparisonQuizGameResultEntity( earnedXp = 10, playedAt = (now - 4.minutes).toEpochMilliseconds(), // today comparisonMode = ComparisonMode.GREATER.name, endPosition = 5, categoryId = "category", skippedAnswers = 0 ) ) // Results: // 3 results today // 1 result yesterday // 1 result before yesterday // 1 result 10 days ago (not in the current week, should not be returned) // 5 in total to be returned // XP for days: // today: 25 // yesterday: 20 // before yesterday: 10 // Check if the results are returned val resultTimed = measureTimedValue { localUserServiceImpl.getXpEarnedBy( start = startInstant, end = now ) } val lastWeek = DateTimeRangeFormatter.Week.getNowDateTimeRange(now) val resultLast7Days = localUserServiceImpl.getXpEarnedBy(lastWeek) val result = resultTimed.value assertThat(result).isEqualTo(resultLast7Days) assertThat(result).hasSize(5) result.forEach { (date, _) -> // Check if the result from the other week is not returned assertThat(date).isAtLeast(startInstant.toEpochMilliseconds()) assertThat(date).isAtMost(now.toEpochMilliseconds()) } // Test getXpEarnedByRangeFlow() because it is same as getXpEarnedByRange() localUserServiceImpl.getXpEarnedByFlow(lastWeek).test { assertThat(awaitItem()).isEqualTo(result) } } } ================================================ FILE: core/user-services/src/test/kotlin/com/infinitepower/newquiz/core/user_services/data/xp/ComparisonQuizXpGeneratorImplTest.kt ================================================ package com.infinitepower.newquiz.core.user_services.data.xp import com.google.common.truth.Truth.assertThat 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 io.mockk.every import io.mockk.mockk import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource import kotlin.test.BeforeTest /** * Tests for [ComparisonQuizXpGeneratorImpl]. */ internal class ComparisonQuizXpGeneratorImplTest { private lateinit var comparisonQuizXpGeneratorImpl: ComparisonQuizXpGeneratorImpl private val remoteConfig: RemoteConfig = mockk() @BeforeTest fun setUp() { comparisonQuizXpGeneratorImpl = ComparisonQuizXpGeneratorImpl(remoteConfig) every { remoteConfig.get(RemoteConfigValue.COMPARISON_QUIZ_DEFAULT_XP_REWARD) } returns 10 } @ParameterizedTest(name = "test getXpForPosition with endPosition = {0} and skippedAnswers = {1}") @CsvSource(""" 1, 0 """) fun `test getXpForPosition`( endPosition: Int, skippedAnswers: Int ) { val xp = comparisonQuizXpGeneratorImpl.generateXp( endPosition = endPosition.toUInt(), skippedAnswers = 0u ) val answersNotSkipped = endPosition - skippedAnswers - 1 val expectedXp = comparisonQuizXpGeneratorImpl.getDefaultXpForAnswer() * answersNotSkipped.toUInt() assertThat(xp).isEqualTo(expectedXp) } } ================================================ FILE: core/user-services/src/test/kotlin/com/infinitepower/newquiz/core/user_services/data/xp/MultiChoiceQuizXpGeneratorImplTest.kt ================================================ package com.infinitepower.newquiz.core.user_services.data.xp import com.google.common.truth.Truth.assertThat 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.model.multi_choice_quiz.MultiChoiceQuestionStep import com.infinitepower.newquiz.model.multi_choice_quiz.SelectedAnswer import com.infinitepower.newquiz.model.multi_choice_quiz.getBasicMultiChoiceQuestion import com.infinitepower.newquiz.model.question.QuestionDifficulty import io.mockk.every import io.mockk.mockk import kotlin.random.Random import kotlin.test.BeforeTest import kotlin.test.Test /** * Tests for [MultiChoiceQuizXpGeneratorImpl]. */ internal class MultiChoiceQuizXpGeneratorImplTest { private lateinit var multiChoiceQuizXpGeneratorImpl: MultiChoiceQuizXpGeneratorImpl private val remoteConfig: RemoteConfig = mockk() @BeforeTest fun setUp() { every { remoteConfig.get(RemoteConfigValue.MULTICHOICE_QUIZ_DEFAULT_XP_REWARD) } returns """ { "easy": 10, "medium": 20, "hard": 30 } """.trimIndent() multiChoiceQuizXpGeneratorImpl = MultiChoiceQuizXpGeneratorImpl( remoteConfig = remoteConfig ) } @Test fun `getDefaultXpReward should return the default XP reward`() { val defaultXpReward = multiChoiceQuizXpGeneratorImpl.getDefaultXpReward() assertThat(defaultXpReward).isEqualTo( mapOf( QuestionDifficulty.Easy to 10u, QuestionDifficulty.Medium to 20u, QuestionDifficulty.Hard to 30u ) ) } @Test fun `generateXp when all question steps are incorrect, should return 0`() { val questionSteps = List(5) { MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = false, selectedAnswer = SelectedAnswer.NONE ) } val randomXp = multiChoiceQuizXpGeneratorImpl.generateXp(questionSteps) assertThat(randomXp).isEqualTo(0u) } @Test fun `test generateXp`() { val questionSteps = List(5) { MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = Random.nextBoolean(), selectedAnswer = SelectedAnswer.NONE ) // Add a skipped question step to test that it is not counted } + MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true, selectedAnswer = SelectedAnswer.NONE, skipped = true ) val generatedXp = multiChoiceQuizXpGeneratorImpl.generateXp(questionSteps) val defaultXpReward = multiChoiceQuizXpGeneratorImpl.getDefaultXpReward() val expectedXp = questionSteps .filter { step -> step.correct && !step.skipped }.sumOf { step -> val difficulty = step.question.difficulty defaultXpReward[difficulty] ?: 0u } assertThat(generatedXp).isEqualTo(expectedXp) } } ================================================ FILE: core/user-services/src/test/kotlin/com/infinitepower/newquiz/core/user_services/data/xp/WordleXpGeneratorImplTest.kt ================================================ package com.infinitepower.newquiz.core.user_services.data.xp import com.google.common.truth.Truth.assertThat 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.util.kotlin.roundToUInt import io.mockk.every import io.mockk.mockk import kotlin.math.sqrt import kotlin.test.BeforeTest import kotlin.test.Test /** * Tests for [WordleXpGeneratorImpl]. */ internal class WordleXpGeneratorImplTest { private lateinit var wordleXpGeneratorImpl: WordleXpGeneratorImpl private val remoteConfig: RemoteConfig = mockk() @BeforeTest fun setUp() { wordleXpGeneratorImpl = WordleXpGeneratorImpl(remoteConfig) every { remoteConfig.get(RemoteConfigValue.WORDLE_DEFAULT_XP_REWARD) } returns 10 } @Test fun `test generateXp`() { val rowsUsed = 5u val generatedXp = wordleXpGeneratorImpl.generateXp(rowsUsed) val defaultXp = wordleXpGeneratorImpl.getDefaultXp() val expectedXp = (defaultXp.toInt() * (2 / sqrt(rowsUsed.toDouble()))).roundToUInt() assertThat(generatedXp).isEqualTo(expectedXp) } } ================================================ FILE: core/user-services/src/test/kotlin/com/infinitepower/newquiz/core/user_services/model/UserTest.kt ================================================ package com.infinitepower.newquiz.core.user_services.model import com.google.common.truth.Truth.assertThat import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource import kotlin.test.Test /** * Tests for [User]. */ internal class UserTest { private fun getTestUser( uid: String = "testUid", fullName: String = "testFullName", imageUrl: String = "testImageUrl", totalXp: ULong = 0u, diamonds: UInt = 0u, ) = User( uid = uid, fullName = fullName, imageUrl = imageUrl, totalXp = totalXp, diamonds = diamonds, ) @CsvSource( "0, 0", "99, 0", "100, 1", "101, 1", "9999, 9", "10000, 10", "10001, 10", "1000000, 100", "1000001, 100", ) @ParameterizedTest(name = "totalXp = {0} -> level = {1}") fun `test user level`( totalXp: Long, expectedLevel: Int, ) { val user = getTestUser(totalXp = totalXp.toULong()) assertThat(user.level).isEqualTo(expectedLevel.toUInt()) } @Test fun `test get next level xp`() { val user = getTestUser(totalXp = 0u) // For level 0, next level xp is 100 assertThat(user.getNextLevelXp()).isEqualTo(100u) } @CsvSource( "0, 0.0", "50, 0.5", "99, 0.99", "100, 0.0", // New level 1 "399, 0.99", "400, 0.0" // New level 2 ) @ParameterizedTest(name = "totalXp = {0} should have progress = {1}") fun `test get level progress`( totalXp: Long, expectedProgress: Float, ) { val user = getTestUser(totalXp = totalXp.toULong()) assertThat(user.getLevelProgress()).isWithin(0.01f).of(expectedProgress) } @Test fun `test get required xp`() { val user = getTestUser(totalXp = 0u) // For level 0, next level xp is 100 assertThat(user.getRequiredXP()).isEqualTo(100uL) val user2 = getTestUser(totalXp = 100u) // For level 1, next level xp is 400, so required xp is 300 assertThat(user2.getRequiredXP()).isEqualTo(300uL) } @Test fun `test is new level`() { val user = getTestUser(totalXp = 500u) // Level 2 assertThat(user.isNewLevel(100u)).isFalse() assertThat(user.isNewLevel(400u)).isTrue() assertThat(user.isNewLevel(500u)).isTrue() } @Test fun `test get level after`() { val user = getTestUser(totalXp = 500u) // Level 2 assertThat(user.getLevelAfter(100u)).isEqualTo(2u) // No level up assertThat(user.getLevelAfter(400u)).isEqualTo(3u) assertThat(user.getLevelAfter(500u)).isEqualTo(3u) } } ================================================ FILE: data/.gitignore ================================================ /build ================================================ FILE: data/build.gradle.kts ================================================ plugins { alias(libs.plugins.newquiz.android.library.compose) alias(libs.plugins.newquiz.android.hilt) alias(libs.plugins.newquiz.kotlin.serialization) id("com.google.devtools.ksp") } android { namespace = "com.infinitepower.newquiz.data" } dependencies { implementation(libs.androidx.core.ktx) implementation(libs.kotlinx.coroutines.playServices) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.compose.ui.test) implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.serialization) implementation(libs.hilt.navigationCompose) ksp(libs.hilt.ext.compiler) implementation(libs.hilt.ext.work) implementation(libs.androidx.work.ktx) androidTestImplementation(libs.androidx.work.testing) implementation(libs.kotlinx.datetime) implementation(projects.core) implementation(projects.core.analytics) implementation(projects.core.database) implementation(projects.core.datastore) implementation(projects.core.remoteConfig) implementation(projects.core.userServices) implementation(projects.domain) implementation(projects.model) testImplementation(projects.core.testing) androidTestImplementation(projects.core.testing) } ================================================ FILE: data/consumer-rules.pro ================================================ ================================================ FILE: data/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.** { *; } # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. -keepattributes RuntimeVisibleAnnotations,AnnotationDefault -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.** { *; } ================================================ FILE: data/src/androidTest/AndroidManifest.xml ================================================ ================================================ FILE: data/src/androidTest/java/com/infinitepower/newquiz/data/daily_challenge/DailyChallengeRepositoryImplTest.kt ================================================ package com.infinitepower.newquiz.data.daily_challenge import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.database.dao.DailyChallengeDao import com.infinitepower.newquiz.data.repository.daily_challenge.util.getTitle import com.infinitepower.newquiz.data.util.mappers.comparisonquiz.toEntity import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.domain.repository.daily_challenge.DailyChallengeRepository import com.infinitepower.newquiz.model.daily_challenge.DailyChallengeTask import com.infinitepower.newquiz.model.global_event.GameEvent import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest import kotlinx.datetime.Clock import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import javax.inject.Inject import kotlin.time.Duration.Companion.days @HiltAndroidTest @RunWith(AndroidJUnit4::class) class DailyChallengeRepositoryImplTest { @get:Rule val hiltRule = HiltAndroidRule(this) private lateinit var context: Context @Inject lateinit var dailyChallengeDao: DailyChallengeDao @Inject lateinit var dailyChallengeRepository: DailyChallengeRepository @Inject lateinit var comparisonQuizRepository: ComparisonQuizRepository @Before fun setup() { context = InstrumentationRegistry.getInstrumentation().context hiltRule.inject() } @Test fun test_getDailyChallengeTasks_returnsCorrectTasks() = runTest { val now = Clock.System.now() val type1 = GameEvent.MultiChoice.PlayQuestions val initialTasks = listOf( DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = false, dateRange = (now - 1.days).rangeTo(now + 1.days), // Not expired currentValue = 0u, // Not completed maxValue = 1u, event = type1, title = type1.getTitle(1, comparisonQuizRepository.getCategories()) ) ) dailyChallengeDao.insertAll(initialTasks.map(DailyChallengeTask::toEntity)) val tasks = dailyChallengeRepository.getAvailableTasks() assertThat(tasks).containsExactlyElementsIn(initialTasks) } @Test fun test_checkAndGenerateTasks_shouldGenerateNewTasks() = runTest { val now = Clock.System.now() val type1 = GameEvent.MultiChoice.PlayQuestions val task1 = DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = false, dateRange = (now - 2.days).rangeTo(now - 1.days), // Expired currentValue = 0u, // Not completed maxValue = 1u, event = type1, title = type1.getTitle(1, comparisonQuizRepository.getCategories()) ) val initialTasks = setOf(task1) dailyChallengeDao.insertAll(initialTasks.map(DailyChallengeTask::toEntity)) dailyChallengeRepository.checkAndGenerateTasksIfNeeded(tasksToGenerate = 2) val tasks = dailyChallengeRepository.getAllTasks().toSet() assertThat(tasks).isNotEmpty() assertThat(tasks).hasSize(3) val newTasks = tasks - initialTasks assertThat(newTasks).isNotEmpty() assertThat(newTasks).hasSize(2) val nowVerify = Clock.System.now() // Check that the new tasks are not expired newTasks.forEach { task -> assertThat(nowVerify).isAtLeast(task.dateRange.start) assertThat(nowVerify).isAtMost(task.dateRange.endInclusive) println(task) } } @Test fun test_checkAndGenerateTasks_shouldGenerateNewTasks_whenInitialTasksAreEmpty() = runTest { val initialTasks = emptySet() dailyChallengeRepository.checkAndGenerateTasksIfNeeded(tasksToGenerate = 2) val tasks = dailyChallengeRepository.getAvailableTasks() assertThat(tasks).isNotEmpty() assertThat(tasks).hasSize(2) val newTasks = tasks - initialTasks assertThat(newTasks).isNotEmpty() assertThat(newTasks).hasSize(2) val now = Clock.System.now() // Check that the new tasks are not expired newTasks.forEach { task -> assertThat(task.dateRange.contains(now)).isTrue() assertThat(task.isActive()).isTrue() assertThat(task.isCompleted()).isFalse() assertThat(task.isExpired()).isFalse() assertThat(task.isClaimed).isFalse() } } @Test fun test_completeTaskStep_shouldCompleteTheTask_ifTheCurrentValueIsEqualToTheMaxValue() = runTest { val now = Clock.System.now() val initialTasks = setOf( DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = false, dateRange = (now - 1.days).rangeTo(now + 1.days), // Not expired currentValue = 0u, // Not completed maxValue = 1u, event = GameEvent.MultiChoice.PlayQuestions, title = GameEvent.MultiChoice.PlayQuestions.getTitle(1, comparisonQuizRepository.getCategories()) ), DailyChallengeTask( id = 2, diamondsReward = 1u, experienceReward = 1u, isClaimed = false, dateRange = (now - 1.days).rangeTo(now + 1.days), // Not expired currentValue = 0u, // Not completed maxValue = 1u, event = GameEvent.MultiChoice.GetAnswersCorrect, title = GameEvent.MultiChoice.GetAnswersCorrect.getTitle(1, comparisonQuizRepository.getCategories()) ) ) dailyChallengeDao.insertAll(initialTasks.map(DailyChallengeTask::toEntity)) // Check that the initial tasks are not completed assertThat(dailyChallengeRepository.getAvailableTasks()).containsExactlyElementsIn(initialTasks) // Complete the task dailyChallengeRepository.completeTaskStep(GameEvent.MultiChoice.PlayQuestions) val tasks = dailyChallengeRepository.getAvailableTasks() assertThat(tasks).isNotEmpty() assertThat(tasks).hasSize(2) val completedTask = tasks.find { it.event == GameEvent.MultiChoice.PlayQuestions } // Check that the task is not null assertThat(completedTask).isNotNull() if (completedTask == null) throw AssertionError("Completed task is null") assertThat(initialTasks).doesNotContain(completedTask) assertThat(completedTask.currentValue).isEqualTo(completedTask.maxValue) assertThat(completedTask.isCompleted()).isTrue() // Check that the others task are not changed val otherTasks = tasks - completedTask assertThat(otherTasks).containsExactlyElementsIn(initialTasks.filter { it.id != completedTask.id }) } @Test fun completeTaskStep_shouldThrowException_ifTaskIsExpired() { val now = Clock.System.now() val task1 = DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = false, dateRange = (now - 2.days).rangeTo(now - 1.days), // Expired currentValue = 0u, // Not completed maxValue = 1u, event = GameEvent.MultiChoice.PlayQuestions, title = GameEvent.MultiChoice.PlayQuestions.getTitle(1, comparisonQuizRepository.getCategories()) ) runTest { dailyChallengeDao.insertAll(task1.toEntity()) } val e = assertThrows(IllegalStateException::class.java) { runTest { dailyChallengeRepository.completeTaskStep(GameEvent.MultiChoice.PlayQuestions) } } assertThat(e) .hasMessageThat() .isEqualTo("Task (${task1.title}) is expired.") } @Test fun completeTaskStep_shouldThrowException_ifTaskIsClaimed() { val now = Clock.System.now() val task1 = DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = true, dateRange = (now - 1.days).rangeTo(now + 1.days), // Not expired currentValue = 0u, // Not completed maxValue = 1u, event = GameEvent.MultiChoice.PlayQuestions, title = GameEvent.MultiChoice.PlayQuestions.getTitle(1, comparisonQuizRepository.getCategories()) ) runTest { dailyChallengeDao.insertAll(task1.toEntity()) } val e = assertThrows(IllegalStateException::class.java) { runTest { dailyChallengeRepository.completeTaskStep(GameEvent.MultiChoice.PlayQuestions) } } assertThat(e) .hasMessageThat() .isEqualTo("Task (${task1.title}) is already claimed.") } @Test fun completeTaskStep_shouldThrowException_ifTaskIsNotFound() { val e = assertThrows(NullPointerException::class.java) { runTest { dailyChallengeRepository.completeTaskStep(GameEvent.MultiChoice.GetAnswersCorrect) } } assertThat(e) .hasMessageThat() .isEqualTo("Task not found.") } } ================================================ FILE: data/src/androidTest/java/com/infinitepower/newquiz/data/repository/comparison_quiz/ComparisonQuizRepositoryImplTest.kt ================================================ package com.infinitepower.newquiz.data.repository.comparison_quiz import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.database.dao.GameResultDao import com.infinitepower.newquiz.core.database.model.user.ComparisonQuizGameResultEntity import com.infinitepower.newquiz.core.remote_config.RemoteConfig import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest import kotlinx.datetime.Clock import org.junit.Rule import org.junit.runner.RunWith import javax.inject.Inject import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.time.Duration.Companion.minutes /** * Tests for [ComparisonQuizRepositoryImpl] */ @HiltAndroidTest @RunWith(AndroidJUnit4::class) internal class ComparisonQuizRepositoryImplTest { @get:Rule val hiltRule = HiltAndroidRule(this) @Inject lateinit var remoteConfig: RemoteConfig @Inject lateinit var gameResultDao: GameResultDao @Inject lateinit var comparisonQuizApi: ComparisonQuizApi private lateinit var repository: ComparisonQuizRepositoryImpl @BeforeTest fun setUp() { hiltRule.inject() repository = ComparisonQuizRepositoryImpl( remoteConfig = remoteConfig, gameResultDao = gameResultDao, comparisonQuizApi = comparisonQuizApi, ) } @Test fun getHighestPosition_returnsHighestPosition() = runTest { // The highest position is not stored in the database // So it should return 0 val noHighestPosition = repository.getHighestPosition(categoryId = "category") assertThat(noHighestPosition).isEqualTo(0) val now = Clock.System.now() gameResultDao.insertComparisonQuizResult( ComparisonQuizGameResultEntity( earnedXp = 10, playedAt = (now - 4.minutes).toEpochMilliseconds(), // today comparisonMode = ComparisonMode.GREATER.name, endPosition = 5, categoryId = "category", skippedAnswers = 0 ) ) val highestPosition = repository.getHighestPosition(categoryId = "category") assertThat(highestPosition).isEqualTo(5) } } ================================================ FILE: data/src/androidTest/java/com/infinitepower/newquiz/data/repository/country/CountryRepositoryImplTest.kt ================================================ package com.infinitepower.newquiz.data.repository.country import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat 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 io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.runner.RunWith import kotlin.test.Test @SmallTest @RunWith(AndroidJUnit4::class) internal class CountryRepositoryImplTest { @Test fun testGetCountries() = runTest { val context = InstrumentationRegistry.getInstrumentation().targetContext val remoteConfig = mockk() every { remoteConfig.get(RemoteConfigValue.FLAG_BASE_URL) } returns "local" val repository = CountryRepositoryImpl( context = context, remoteConfig = remoteConfig ) val countries = repository.getAllCountries().onEach { println(it) } assertThat(countries).isNotEmpty() } } ================================================ FILE: data/src/androidTest/java/com/infinitepower/newquiz/data/worker/daily_challenge/VerifyDailyChallengeWorkerTest.kt ================================================ package com.infinitepower.newquiz.data.worker.daily_challenge import android.content.Context import androidx.hilt.work.HiltWorkerFactory import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry import androidx.work.ListenableWorker import androidx.work.testing.TestListenableWorkerBuilder import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.database.AppDatabase import com.infinitepower.newquiz.domain.repository.daily_challenge.DailyChallengeRepository import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import javax.inject.Inject @SmallTest @HiltAndroidTest @RunWith(AndroidJUnit4::class) internal class VerifyDailyChallengeWorkerTest { @get:Rule val hiltRule = HiltAndroidRule(this) @Inject lateinit var appDatabase: AppDatabase @Inject lateinit var workerFactory: HiltWorkerFactory @Inject lateinit var dailyChallengeRepository: DailyChallengeRepository private lateinit var context: Context @Before fun setup() { hiltRule.inject() context = InstrumentationRegistry.getInstrumentation().context } @After fun tearDown() { appDatabase.close() } @Test fun testPeriodicWork() = runTest { // Clear the tasks. dailyChallengeRepository.resetTasks() val verifyDailyChallengeWorker = TestListenableWorkerBuilder(context) .setWorkerFactory(workerFactory) .build() val result = verifyDailyChallengeWorker.doWork() assertThat(result).isNotNull() assertThat(result).isEqualTo(ListenableWorker.Result.success()) val tasks = dailyChallengeRepository.getAvailableTasks() assertThat(tasks).isNotEmpty() } } ================================================ FILE: data/src/androidTest/java/com/infinitepower/newquiz/data/worker/maze/CleanMazeQuizWorkerTest.kt ================================================ package com.infinitepower.newquiz.data.worker.maze import android.content.Context import androidx.hilt.work.HiltWorkerFactory import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.work.ListenableWorker import androidx.work.testing.TestListenableWorkerBuilder import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.database.dao.MazeQuizDao import com.infinitepower.newquiz.core.database.model.MazeQuizItemEntity import com.infinitepower.newquiz.model.question.QuestionDifficulty import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.runner.RunWith import javax.inject.Inject import kotlin.test.BeforeTest import kotlin.test.Test @HiltAndroidTest @RunWith(AndroidJUnit4::class) internal class CleanMazeQuizWorkerTest { @get:Rule val hiltRule = HiltAndroidRule(this) @Inject lateinit var workerFactory: HiltWorkerFactory @Inject lateinit var mazeQuizDao: MazeQuizDao private lateinit var context: Context @BeforeTest fun setup() { hiltRule.inject() context = ApplicationProvider.getApplicationContext() } @Test fun testCleanMazeQuizWorker() = runTest { val mazeQuizItems = List(10) { MazeQuizItemEntity( difficulty = QuestionDifficulty.Easy, played = false, type = MazeQuizItemEntity.Type.MULTI_CHOICE, mazeSeed = 0 ) } mazeQuizDao.insertItems(mazeQuizItems) // Check if the items were inserted correctly. val mazeQuizItemsBeforeClean = mazeQuizDao.getAllMazeItems() assertThat(mazeQuizItemsBeforeClean).hasSize(10) // Clean the maze quiz items. val cleanSavedMazeRequest = TestListenableWorkerBuilder(context) .setWorkerFactory(workerFactory) .build() val result = cleanSavedMazeRequest.doWork() assertThat(result).isNotNull() assertThat(result).isEqualTo(ListenableWorker.Result.success()) // Check if the items were deleted correctly. val mazeQuizItemsAfterClean = mazeQuizDao.getAllMazeItems() assertThat(mazeQuizItemsAfterClean).isEmpty() } } ================================================ FILE: data/src/androidTest/java/com/infinitepower/newquiz/data/worker/maze/GenerateMazeQuizWorkerTest.kt ================================================ package com.infinitepower.newquiz.data.worker.maze import android.content.Context import androidx.hilt.work.HiltWorkerFactory import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.work.ListenableWorker import androidx.work.testing.TestListenableWorkerBuilder import androidx.work.workDataOf import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.database.AppDatabase import com.infinitepower.newquiz.core.database.dao.MazeQuizDao import com.infinitepower.newquiz.domain.repository.wordle.WordleRepository import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.runner.RunWith import javax.inject.Inject import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test /** * Test for [GenerateMazeQuizWorker]. */ @HiltAndroidTest @RunWith(AndroidJUnit4::class) internal class GenerateMazeQuizWorkerTest { @get:Rule val hiltRule = HiltAndroidRule(this) @Inject lateinit var workerFactory: HiltWorkerFactory @Inject lateinit var mazeQuizDao: MazeQuizDao @Inject lateinit var appDatabase: AppDatabase @Inject lateinit var wordleRepository: WordleRepository private lateinit var context: Context @BeforeTest fun setup() { hiltRule.inject() context = ApplicationProvider.getApplicationContext() } @AfterTest fun tearDown() { appDatabase.close() } @Test fun testGenerateMazeQuizWorker() = runTest { val seed = 0 val questionSize = 50 val multiChoiceCategories = GenerateMazeQuizWorker.GameModes.MultiChoice.categories val multiChoiceCategoriesId = multiChoiceCategories.map { category -> category.id }.toTypedArray() val wordleCategories = GenerateMazeQuizWorker.GameModes.Wordle.categories val wordleCategoriesIds = wordleCategories.map { category -> category.id }.toTypedArray() val generateMazeQuizRequest = TestListenableWorkerBuilder(context) .setWorkerFactory(workerFactory) .setInputData( workDataOf( GenerateMazeQuizWorker.INPUT_SEED to seed, GenerateMazeQuizWorker.INPUT_MULTI_CHOICE_CATEGORIES to multiChoiceCategoriesId, GenerateMazeQuizWorker.INPUT_WORDLE_QUIZ_TYPES to wordleCategoriesIds, GenerateMazeQuizWorker.INPUT_QUESTION_SIZE to questionSize ) ).build() val result = generateMazeQuizRequest.doWork() // Check if the worker finished successfully. assertThat(result).isNotNull() assertThat(result).isEqualTo(ListenableWorker.Result.success()) // Check if the items were inserted correctly. val mazeQuizItems = mazeQuizDao.getAllMazeItems() val allCategoryCount = multiChoiceCategories.count() + wordleCategories.count() val questionSizePerMode = questionSize / allCategoryCount // Get the real generated question size. // The real generated question size may be different from the input question size // Because the size is equally divided by the number of categories and is rounded down to the nearest integer. val realGeneratedQuestionSize = questionSizePerMode * allCategoryCount assertThat(mazeQuizItems).hasSize(realGeneratedQuestionSize) } } ================================================ FILE: data/src/main/AndroidManifest.xml ================================================ ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/di/MathModule.kt ================================================ package com.infinitepower.newquiz.data.di import com.infinitepower.newquiz.core.math.evaluator.Expressions import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object MathModule { @Provides @Singleton fun provideExpressions(): Expressions = Expressions() } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/di/RepositoryModule.kt ================================================ @file:Suppress("unused") package com.infinitepower.newquiz.data.di import com.infinitepower.newquiz.data.repository.UserConfigRepositoryImpl import com.infinitepower.newquiz.data.repository.comparison_quiz.ComparisonQuizApi import com.infinitepower.newquiz.data.repository.comparison_quiz.ComparisonQuizApiImpl import com.infinitepower.newquiz.data.repository.comparison_quiz.ComparisonQuizRepositoryImpl import com.infinitepower.newquiz.data.repository.country.CountryRepositoryImpl import com.infinitepower.newquiz.data.repository.daily_challenge.DailyChallengeRepositoryImpl import com.infinitepower.newquiz.data.repository.home.RecentCategoriesRepositoryImpl import com.infinitepower.newquiz.data.repository.math_quiz.MathQuizCoreRepositoryImpl import com.infinitepower.newquiz.data.repository.maze_quiz.MazeQuizRepositoryImpl import com.infinitepower.newquiz.data.repository.multi_choice_quiz.CountryCapitalFlagsQuizRepositoryImpl import com.infinitepower.newquiz.data.repository.multi_choice_quiz.FlagQuizRepositoryImpl import com.infinitepower.newquiz.data.repository.multi_choice_quiz.GuessMathSolutionRepositoryImpl import com.infinitepower.newquiz.data.repository.multi_choice_quiz.LogoQuizRepositoryImpl import com.infinitepower.newquiz.data.repository.multi_choice_quiz.MultiChoiceQuestionRepositoryImpl import com.infinitepower.newquiz.data.repository.multi_choice_quiz.saved_questions.SavedMultiChoiceQuestionsRepositoryImpl import com.infinitepower.newquiz.data.repository.numbers.NumberTriviaQuestionApiImpl import com.infinitepower.newquiz.data.repository.numbers.NumberTriviaQuestionRepositoryImpl import com.infinitepower.newquiz.data.repository.wordle.WordleRepositoryImpl import com.infinitepower.newquiz.domain.repository.CountryRepository import com.infinitepower.newquiz.domain.repository.UserConfigRepository import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.domain.repository.daily_challenge.DailyChallengeRepository import com.infinitepower.newquiz.domain.repository.home.RecentCategoriesRepository import com.infinitepower.newquiz.domain.repository.math_quiz.MathQuizCoreRepository import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.CountryCapitalFlagsQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.FlagQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.GuessMathSolutionRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.LogoQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.MultiChoiceQuestionRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.saved_questions.SavedMultiChoiceQuestionsRepository import com.infinitepower.newquiz.domain.repository.numbers.NumberTriviaQuestionApi import com.infinitepower.newquiz.domain.repository.numbers.NumberTriviaQuestionRepository import com.infinitepower.newquiz.domain.repository.wordle.WordleRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { @Binds abstract fun bindMultiChoiceQuestionRepository(openTDBRepository: MultiChoiceQuestionRepositoryImpl): MultiChoiceQuestionRepository @Binds abstract fun bindSavedMultiChoiceQuestionsRepository(savedQuestionsRepository: SavedMultiChoiceQuestionsRepositoryImpl): SavedMultiChoiceQuestionsRepository @Binds abstract fun bindWordleRepository(wordleRepositoryImpl: WordleRepositoryImpl): WordleRepository @Binds abstract fun bindFlagQuizRepository(flagQuizRepositoryImpl: FlagQuizRepositoryImpl): FlagQuizRepository @Binds abstract fun bindLogoQuizRepository(logoQuizRepositoryImpl: LogoQuizRepositoryImpl): LogoQuizRepository @Binds abstract fun bindMathQuizCoreRepository(mathQuizCoreRepositoryImpl: MathQuizCoreRepositoryImpl): MathQuizCoreRepository @Binds abstract fun bindMazeMathQuizRepository(mazeMathQuizRepository: MazeQuizRepositoryImpl): MazeQuizRepository @Binds abstract fun bindGuessMathSolutionRepository(guessMathSolutionRepository: GuessMathSolutionRepositoryImpl): GuessMathSolutionRepository @Binds abstract fun bindNumberTriviaQuestionApi(numbersTriviaQuestionApiImpl: NumberTriviaQuestionApiImpl): NumberTriviaQuestionApi @Binds abstract fun bindNumberTriviaQuestionRepository(numberTriviaQuestionRepositoryImpl: NumberTriviaQuestionRepositoryImpl): NumberTriviaQuestionRepository @Binds abstract fun bindCountryCapitalFlagsQuizRepository(countryCapitalFlagsQuizRepository: CountryCapitalFlagsQuizRepositoryImpl): CountryCapitalFlagsQuizRepository @Binds abstract fun bindComparisonQuizRepository(comparisonQuizRepository: ComparisonQuizRepositoryImpl): ComparisonQuizRepository @Binds abstract fun bindDailyChallengeRepository(dailyChallengeRepository: DailyChallengeRepositoryImpl): DailyChallengeRepository @Binds abstract fun bindRecentCategoriesRepository(impl: RecentCategoriesRepositoryImpl): RecentCategoriesRepository @Binds abstract fun bindCountryRepository(impl: CountryRepositoryImpl): CountryRepository @Binds abstract fun bindComparisonQuizApi(impl: ComparisonQuizApiImpl): ComparisonQuizApi @Binds abstract fun bindUserConfigRepository(impl: UserConfigRepositoryImpl): UserConfigRepository } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/local/multi_choice_quiz/category/MultiChoiceQuestionCategories.kt ================================================ package com.infinitepower.newquiz.data.local.multi_choice_quiz.category import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory import com.infinitepower.newquiz.core.R as CoreR val multiChoiceQuestionCategories = listOf( MultiChoiceCategory( id = MultiChoiceBaseCategory.Logo.id, name = UiText.StringResource(CoreR.string.logo_quiz), image = "https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/Illustrations%2Flogo_quiz_illustration.jpg?alt=media&token=cd9e54a2-a5d1-45f1-a285-cc490cc44cad" ), MultiChoiceCategory( id = MultiChoiceBaseCategory.Flag.id, name = UiText.StringResource(CoreR.string.flag_quiz), 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 ), MultiChoiceCategory( id = MultiChoiceBaseCategory.CountryCapitalFlags.id, name = UiText.StringResource(CoreR.string.country_capital_flags), 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 ), MultiChoiceCategory( id = MultiChoiceBaseCategory.GuessMathSolution.id, name = UiText.StringResource(CoreR.string.guess_solution), image = "https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/Illustrations%2Fnumber_illustration.jpg?alt=media&token=68faf243-2b0e-4a13-aa9c-223743e263fd", requireInternetConnection = false ), MultiChoiceCategory( id = MultiChoiceBaseCategory.NumberTrivia.id, name = UiText.StringResource(CoreR.string.number_trivia), image = "https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/Illustrations%2Fnumber_12_in_beach.jpg?alt=media&token=9b888c81-c51c-49ac-a376-0b3bde45db36" ), MultiChoiceCategory( id = "9", name = UiText.StringResource(CoreR.string.general_knowledge), image = CoreR.drawable.general_knowledge ), MultiChoiceCategory( id = "10", name = UiText.StringResource(CoreR.string.entertainment_books), image = CoreR.drawable.books ), MultiChoiceCategory( id = "11", name = UiText.StringResource(CoreR.string.entertainment_film), image = CoreR.drawable.films ), MultiChoiceCategory( id = "12", name = UiText.StringResource(CoreR.string.entertainment_music), image = CoreR.drawable.music ), MultiChoiceCategory( id = "13", name = UiText.StringResource(CoreR.string.entertainment_musicals_and_theatres), image = CoreR.drawable.musicals_and_theatres ), MultiChoiceCategory( id = "14", name = UiText.StringResource(CoreR.string.entertainment_television), image = CoreR.drawable.entertainment_television ), MultiChoiceCategory( id = "15", name = UiText.StringResource(CoreR.string.entertainment_video_games), image = CoreR.drawable.entertainment_video_games ), MultiChoiceCategory( id = "16", name = UiText.StringResource(CoreR.string.entertainment_board_games), image = CoreR.drawable.entertainment_board_games ), MultiChoiceCategory( id = "17", name = UiText.StringResource(CoreR.string.science_and_nature), image = CoreR.drawable.science_and_nature ), MultiChoiceCategory( id = "18", name = UiText.StringResource(CoreR.string.science_computers), image = CoreR.drawable.science_computers ), MultiChoiceCategory( id = "19", name = UiText.StringResource(CoreR.string.science_mathematics), image = CoreR.drawable.science_mathematics ), MultiChoiceCategory( id = "20", name = UiText.StringResource(CoreR.string.mythology), image = CoreR.drawable.mythology ), MultiChoiceCategory( id = "21", name = UiText.StringResource(CoreR.string.sports), image = CoreR.drawable.sports ), MultiChoiceCategory( id = "22", name = UiText.StringResource(CoreR.string.geography), image = CoreR.drawable.geography ), MultiChoiceCategory( id = "23", name = UiText.StringResource(CoreR.string.history), image = CoreR.drawable.history ), MultiChoiceCategory( id = "24", name = UiText.StringResource(CoreR.string.politics), image = CoreR.drawable.politics ), MultiChoiceCategory( id = "25", name = UiText.StringResource(CoreR.string.art), image = CoreR.drawable.art ), MultiChoiceCategory( id = "26", name = UiText.StringResource(CoreR.string.celebrities), image = CoreR.drawable.celebrities ), MultiChoiceCategory( id = "27", name = UiText.StringResource(CoreR.string.animals), image = CoreR.drawable.animals ), MultiChoiceCategory( id = "28", name = UiText.StringResource(CoreR.string.vehicles), image = CoreR.drawable.vehicles ), MultiChoiceCategory( id = "29", name = UiText.StringResource(CoreR.string.entertainment_comics), image = CoreR.drawable.entertainment_comics ), MultiChoiceCategory( id = "30", name = UiText.StringResource(CoreR.string.science_gadgets), image = CoreR.drawable.science_gadgets ), MultiChoiceCategory( id = "31", name = UiText.StringResource(CoreR.string.entertainment_japanese_anime_and_manga), image = CoreR.drawable.entertainment_japanese_anime_and_manga ), MultiChoiceCategory( id = "32", name = UiText.StringResource(CoreR.string.entertainment_cartoon_and_animations), image = CoreR.drawable.entertainment_cartoon_and_animations ) ) ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/local/wordle/WordleCategories.kt ================================================ package com.infinitepower.newquiz.data.local.wordle import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.wordle.WordleCategory import com.infinitepower.newquiz.model.wordle.WordleQuizType import kotlin.random.Random import com.infinitepower.newquiz.core.R as CoreR object WordleCategories { /** * Returns a random [WordleCategory] from the list of [allCategories]. * If [isInternetAvailable] is false, it will only return categories that don't require internet connection. * * @param isInternetAvailable Whether the internet is available or not. * @param random The random instance to use. * @see [WordleCategory] */ fun random( isInternetAvailable: Boolean, random: Random = Random ): WordleCategory = if (isInternetAvailable) { allCategories.random(random) } else { allCategories.filter { !it.requireInternetConnection }.random(random) } val allCategories = listOf( WordleCategory( name = UiText.StringResource(CoreR.string.guess_the_word), image = "https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/Illustrations%2Fwordle_illustration.jpg?alt=media&token=69019438-4904-4656-8b1c-18678c537d6b", wordleQuizType = WordleQuizType.TEXT ), WordleCategory( name = UiText.StringResource(CoreR.string.guess_the_number), image = "https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/Illustrations%2Fnumbers_12345_illustration.jpg?alt=media&token=f170e7ca-02a3-4dae-87f0-63b0f1205bc5", wordleQuizType = WordleQuizType.NUMBER ), WordleCategory( name = UiText.StringResource(CoreR.string.guess_math_formula), image = "https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/Illustrations%2Fnumber_illustration.jpg?alt=media&token=68faf243-2b0e-4a13-aa9c-223743e263fd", wordleQuizType = WordleQuizType.MATH_FORMULA ), WordleCategory( name = UiText.StringResource(CoreR.string.number_trivia), image = "https://firebasestorage.googleapis.com/v0/b/newquiz-app.appspot.com/o/Illustrations%2Fnumber_12_in_beach.jpg?alt=media&token=9b888c81-c51c-49ac-a376-0b3bde45db36", wordleQuizType = WordleQuizType.NUMBER_TRIVIA, requireInternetConnection = true ) ) } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/UserConfigRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository 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.domain.repository.UserConfigRepository 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 javax.inject.Inject import javax.inject.Singleton @Singleton class UserConfigRepositoryImpl @Inject constructor( @SettingsDataStoreManager private val settingsDataStoreManager: DataStoreManager, ) : UserConfigRepository { override suspend fun getRegionalPreferences(): RegionalPreferences { val temperatureUnitStr = settingsDataStoreManager.getPreference(SettingsCommon.TemperatureUnit) val temperatureUnit = if (temperatureUnitStr.isBlank()) { null // use default } else { TemperatureUnit.valueOf(temperatureUnitStr) } val distanceUnitTypeStr = settingsDataStoreManager.getPreference(SettingsCommon.DistanceUnitType) val distanceUnitType = if (distanceUnitTypeStr.isBlank()) { null // use default } else { DistanceUnitType.valueOf(distanceUnitTypeStr) } return RegionalPreferences( temperatureUnit = temperatureUnit, distanceUnitType = distanceUnitType ) } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/comparison_quiz/ComparisonQuizApi.kt ================================================ package com.infinitepower.newquiz.data.repository.comparison_quiz import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItemEntity import kotlin.random.Random interface ComparisonQuizApi { suspend fun generateQuestions( category: ComparisonQuizCategory, size: Int, random: Random = Random ): List } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/comparison_quiz/ComparisonQuizApiImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.comparison_quiz import android.util.Log import com.infinitepower.newquiz.core.common.BaseApiUrls import com.infinitepower.newquiz.domain.repository.CountryRepository import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItemEntity import io.ktor.client.HttpClient import io.ktor.client.request.headers import io.ktor.client.request.parameter import io.ktor.client.request.request import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @Singleton class ComparisonQuizApiImpl @Inject constructor( private val client: HttpClient, private val countryRepository: CountryRepository ) : ComparisonQuizApi { companion object { private const val TAG = "ComparisonQuizApiImpl" private const val COUNTRY_POPULATION_CATEGORY_ID = "country-population" private const val COUNTRY_AREA_CATEGORY_ID = "country-area" internal val supportedLocalCategories = setOf( COUNTRY_POPULATION_CATEGORY_ID, COUNTRY_AREA_CATEGORY_ID ) } override suspend fun generateQuestions( category: ComparisonQuizCategory, size: Int, random: Random ): List { return if (category.generateQuestionsLocally && category.id in supportedLocalCategories) { generateQuestionsLocally( category = category, size = size, random = random ) } else { getQuestionsFromRemoteApi( category = category, size = size ) } } private suspend fun getQuestionsFromRemoteApi( category: ComparisonQuizCategory, size: Int ): List { Log.d(TAG, "Getting questions from remote API with category: ${category.id}") val apiUrl = "${BaseApiUrls.NEWQUIZ}/api/comparisonquiz/${category.id}" val response: HttpResponse = client.request(apiUrl) { headers { append(HttpHeaders.Accept, "application/json") } method = HttpMethod.Get parameter("size", size) } val textResponse = response.bodyAsText() return Json.decodeFromString(textResponse) } private suspend fun generateQuestionsLocally( category: ComparisonQuizCategory, size: Int, random: Random ): List { return when (category.id) { COUNTRY_POPULATION_CATEGORY_ID, COUNTRY_AREA_CATEGORY_ID -> generateCountryQuestions( category = category, size = size, random = random ) else -> throw UnsupportedOperationException("Category not supported: ${category.id}") } } private suspend fun generateCountryQuestions( category: ComparisonQuizCategory, size: Int, random: Random ): List { Log.d(TAG, "Generating country questions locally with category: ${category.id}") val countries = countryRepository .getAllCountries() .shuffled(random) .take(size) return countries.map { country -> val value = when (category.id) { COUNTRY_POPULATION_CATEGORY_ID -> country.population.toDouble() COUNTRY_AREA_CATEGORY_ID -> country.area else -> throw UnsupportedOperationException("Category not supported: ${category.id}") } ComparisonQuizItemEntity( title = country.countryName, value = value, imgUrl = country.flagImage.toASCIIString() ) } } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/comparison_quiz/ComparisonQuizRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.comparison_quiz import com.infinitepower.newquiz.core.database.dao.GameResultDao 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.toModel import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategoryEntity import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItem import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItemEntity import kotlinx.coroutines.flow.Flow import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @Singleton class ComparisonQuizRepositoryImpl @Inject constructor( private val remoteConfig: RemoteConfig, private val gameResultDao: GameResultDao, private val comparisonQuizApi: ComparisonQuizApi ) : ComparisonQuizRepository { private val categoriesCache: MutableList = mutableListOf() override fun getCategories(): List { if (categoriesCache.isEmpty()) { val categoriesStr = remoteConfig.get(RemoteConfigValue.COMPARISON_QUIZ_CATEGORIES) val categoriesEntity: List = Json.decodeFromString(categoriesStr) val categories = categoriesEntity.map(ComparisonQuizCategoryEntity::toModel) categoriesCache.addAll(categories) } return categoriesCache } override fun getCategoryById(id: String): ComparisonQuizCategory? { return getCategories().find { it.id == id } } override suspend fun getQuestions( category: ComparisonQuizCategory, size: Int, random: Random ): List { val entityQuestions = comparisonQuizApi.generateQuestions( category = category, size = size, random = random ) return entityQuestions.map(ComparisonQuizItemEntity::toModel) } override suspend fun getHighestPosition(categoryId: String): Int { return gameResultDao.getComparisonQuizHighestPosition(categoryId) } override fun getHighestPositionFlow(categoryId: String): Flow { return gameResultDao.getComparisonQuizHighestPositionFlow(categoryId) } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/country/CountryEntity.kt ================================================ package com.infinitepower.newquiz.data.repository.country import androidx.annotation.Keep import com.infinitepower.newquiz.model.country.Continent import com.infinitepower.newquiz.model.country.Country import com.infinitepower.newquiz.model.question.QuestionDifficulty import kotlinx.serialization.Serializable import java.net.URI @Keep @Serializable internal data class CountryEntity( val countryCode: String, val countryName: String, val capital: String, val continent: String, val difficulty: String, val population: Long, val area: Double, ) : java.io.Serializable internal fun CountryEntity.toModel(flagBaseUrl: String): Country { // Format the url like https://flagapi.example/svg/%code%.svg val flagUrl = flagBaseUrl.replace("%code%", countryCode.lowercase()) return Country( countryCode = countryCode, countryName = countryName, capital = capital, population = population, area = area, continent = Continent.from(continent), difficulty = QuestionDifficulty.from(difficulty), flagImage = URI.create(flagUrl) ) } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/country/CountryRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.country import android.content.Context 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.util.android.resources.readRawJson import com.infinitepower.newquiz.data.R import com.infinitepower.newquiz.domain.repository.CountryRepository import com.infinitepower.newquiz.model.country.Continent import com.infinitepower.newquiz.model.country.Country import com.infinitepower.newquiz.model.question.QuestionDifficulty import dagger.hilt.android.qualifiers.ApplicationContext import java.net.URI import javax.inject.Inject import javax.inject.Singleton @Singleton class CountryRepositoryImpl @Inject constructor( @ApplicationContext private val context: Context, private val remoteConfig: RemoteConfig ) : CountryRepository { companion object { /** * The string that is used to indicate that the flag should be loaded from the local * resources. */ private const val LOCAL_FLAG_BASE_URL = "local" } private fun getCountryFlag( countryCode: String, flagBaseUrl: String ): URI { // Check if the flag loads from the local resource or from the remote server if (flagBaseUrl == LOCAL_FLAG_BASE_URL) { // Get the flag uri from the assets folder return URI.create("file:///android_asset/flags/${countryCode.lowercase()}.svg") } // Load the flag from the url val flagUrl = flagBaseUrl.replace("%code%", countryCode.lowercase()) return URI.create(flagUrl) } override suspend fun getAllCountries(): List { val flagBaseUrl = remoteConfig.get(RemoteConfigValue.FLAG_BASE_URL) return getCountryFromJson().map { entity -> val flagImage = getCountryFlag(entity.countryCode, flagBaseUrl) Country( countryCode = entity.countryCode, countryName = entity.countryName, capital = entity.capital, population = entity.population, area = entity.area, continent = Continent.from(entity.continent), difficulty = QuestionDifficulty.from(entity.difficulty), flagImage = flagImage ) } } private suspend fun getCountryFromJson(): List { return context .resources .readRawJson>(R.raw.all_countries) } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/daily_challenge/DailyChallengeRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.daily_challenge import com.infinitepower.newquiz.core.database.dao.DailyChallengeDao import com.infinitepower.newquiz.core.database.model.DailyChallengeTaskEntity 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.UserService import com.infinitepower.newquiz.data.local.multi_choice_quiz.category.multiChoiceQuestionCategories import com.infinitepower.newquiz.data.util.mappers.daily_challenge.toModel import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.domain.repository.daily_challenge.DailyChallengeRepository import com.infinitepower.newquiz.model.daily_challenge.DailyChallengeTask import com.infinitepower.newquiz.model.global_event.GameEvent import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.Clock import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random import kotlin.time.Duration.Companion.days @Singleton class DailyChallengeRepositoryImpl @Inject constructor( private val dailyChallengeDao: DailyChallengeDao, private val comparisonQuizRepository: ComparisonQuizRepository, private val remoteConfig: RemoteConfig, private val userService: UserService ) : DailyChallengeRepository { override fun getAvailableTasksFlow(): Flow> { val comparisonQuizCategories = comparisonQuizRepository.getCategories() return dailyChallengeDao .getAllTasksFlow() .map { tasks -> val now = Clock.System.now().toEpochMilliseconds() tasks .filter { task -> task.startDate <= now && task.endDate >= now } .map { task -> task.toModel(comparisonQuizCategories) } } } override suspend fun getAllTasks(): List { val comparisonQuizCategories = comparisonQuizRepository.getCategories() return dailyChallengeDao .getAllTasks() .map { task -> task.toModel(comparisonQuizCategories) } } override suspend fun getAvailableTasks(): List { val comparisonQuizCategories = comparisonQuizRepository.getCategories() val now = Clock.System.now().toEpochMilliseconds() return dailyChallengeDao .getAllTasks() .filter { task -> now in task.startDate..task.endDate } .map { task -> task.toModel(comparisonQuizCategories) } } override fun getClaimableTasksCountFlow(): Flow = getAvailableTasksFlow().map { tasks -> tasks.count { task -> task.isClaimable() } } override suspend fun checkAndGenerateTasksIfNeeded( tasksToGenerate: Int, random: Random ) { val now = Clock.System.now().toEpochMilliseconds() val tasksAreExpired = !dailyChallengeDao.tasksAreAvailable(now) // Check if the daily tasks are expired if (tasksAreExpired) { generateDailyTasks( tasksToGenerate = tasksToGenerate, random = random ) } } private suspend fun generateDailyTasks( tasksToGenerate: Int, random: Random = Random ) { val now = Clock.System.now() val dateRange = now.rangeTo(now + 1.days) val types = GameEvent.getRandomEvents( count = tasksToGenerate, multiChoiceCategories = multiChoiceQuestionCategories, comparisonQuizCategories = comparisonQuizRepository.getCategories(), random = random ) val diamondsReward = remoteConfig.get(RemoteConfigValue.DAILY_CHALLENGE_ITEM_REWARD).toUInt() val newTasks = types.map { type -> val maxValue = type.valueRange.toList().random(random) DailyChallengeTaskEntity( id = random.nextInt(), diamondsReward = diamondsReward.toInt(), experienceReward = (10..100).random(random), isClaimed = false, currentValue = 0, maxValue = maxValue.toInt(), type = type.key, startDate = dateRange.start.toEpochMilliseconds(), endDate = dateRange.endInclusive.toEpochMilliseconds() ) } dailyChallengeDao.insertAll(newTasks) } override suspend fun completeTaskStep(taskType: GameEvent) { val task = dailyChallengeDao.getTaskByType(taskType.key) ?: throw NullPointerException("Task not found.") val now = Clock.System.now() // Check if the task is expired val taskIsAvailable = now.toEpochMilliseconds() in task.startDate..task.endDate check(taskIsAvailable) { "Task (${task.id}) is expired." } // Check if the task is already claimed check(!task.isClaimed) { "Task (${task.id}) is already claimed." } // Update the task val newTask = task.copy(currentValue = task.currentValue + 1) // Update the tasks set dailyChallengeDao.update(newTask) } override suspend fun claimTask(taskType: GameEvent) { val task = dailyChallengeDao.getTaskByType(taskType.key) ?: throw NullPointerException("Task not found.") val now = Clock.System.now() // Check if the task is expired val taskIsAvailable = now.toEpochMilliseconds() in task.startDate..task.endDate check(taskIsAvailable) { "Task (${task.id}) is expired." } // Check if the task is already claimed check(!task.isClaimed) { "Task (${task.id}) is already claimed." } check(task.currentValue >= task.maxValue) { "Task (${task.id}) is not completed." } // Update the task val newTask = task.copy(isClaimed = true) // Update the tasks set dailyChallengeDao.update(newTask) // Give the user the reward userService.addRemoveDiamonds(newTask.diamondsReward) } override suspend fun resetTasks() { dailyChallengeDao.deleteAll() } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/daily_challenge/util/DailyChallengeTypeTitleUtil.kt ================================================ package com.infinitepower.newquiz.data.repository.daily_challenge.util import com.infinitepower.newquiz.data.local.multi_choice_quiz.category.multiChoiceQuestionCategories import com.infinitepower.newquiz.data.util.translation.getWordleTitle import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.global_event.GameEvent import com.infinitepower.newquiz.core.R as CoreR fun GameEvent.getTitle( maxValue: Int, comparisonQuizCategories: List ): UiText { return when (this) { // Multi choice quiz GameEvent.MultiChoice.PlayRandomQuiz -> UiText.PluralStringResource( resId = CoreR.plurals.play_random_multi_choice_quiz_game, quantity = maxValue, maxValue ) GameEvent.MultiChoice.EndQuiz -> UiText.PluralStringResource( resId = CoreR.plurals.end_multi_choice_quiz_game, quantity = maxValue, maxValue ) GameEvent.MultiChoice.PlayQuestions -> UiText.PluralStringResource( resId = CoreR.plurals.play_multi_choice_quiz_questions, quantity = maxValue, maxValue ) GameEvent.MultiChoice.GetAnswersCorrect -> UiText.PluralStringResource( resId = CoreR.plurals.get_multi_choice_quiz_answer_correct, quantity = maxValue, maxValue ) is GameEvent.MultiChoice.PlayQuizWithCategory -> { val category = multiChoiceQuestionCategories.first { it.id == this.categoryId } UiText.PluralStringResource( resId = CoreR.plurals.play_multi_choice_quiz_game_in_category, quantity = maxValue, maxValue, category.name ) } // Wordle GameEvent.Wordle.GetWordCorrect -> UiText.PluralStringResource( resId = CoreR.plurals.get_wordle_word_correct, quantity = maxValue, maxValue ) is GameEvent.Wordle.PlayWordWithCategory -> { val categoryName = wordleCategory.getWordleTitle() UiText.PluralStringResource( resId = CoreR.plurals.play_wordle_game_in_category, quantity = maxValue, maxValue, categoryName ) } // Comparison quiz is GameEvent.ComparisonQuiz.PlayQuizWithCategory -> { val categoryName = comparisonQuizCategories .find { it.id == this.categoryId } ?.name ?: "" UiText.PluralStringResource( resId = CoreR.plurals.play_comparison_quiz_game_in_category, quantity = maxValue, maxValue, categoryName ) } is GameEvent.ComparisonQuiz.PlayAndGetScore -> UiText.PluralStringResource( resId = CoreR.plurals.play_comparison_quiz_game_and_get_score, quantity = maxValue, maxValue, score ) is GameEvent.ComparisonQuiz.PlayWithComparisonMode -> { val modeName = if (mode == ComparisonMode.GREATER) { UiText.StringResource(CoreR.string.greater) } else { UiText.StringResource(CoreR.string.lesser) } UiText.PluralStringResource( resId = CoreR.plurals.play_comparison_quiz_game_in_comparison_mode, quantity = maxValue, maxValue, modeName ) } } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/home/RecentCategoriesRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.home import com.infinitepower.newquiz.core.datastore.PreferenceRequest import com.infinitepower.newquiz.core.datastore.common.RecentCategoryDataStoreCommon import com.infinitepower.newquiz.core.datastore.common.SettingsCommon import com.infinitepower.newquiz.core.datastore.di.RecentCategoriesDataStoreManager import com.infinitepower.newquiz.core.datastore.di.SettingsDataStoreManager import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager 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.local.multi_choice_quiz.category.multiChoiceQuestionCategories import com.infinitepower.newquiz.data.local.wordle.WordleCategories import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.domain.repository.home.HomeCategories import com.infinitepower.newquiz.domain.repository.home.HomeCategoriesFlow import com.infinitepower.newquiz.domain.repository.home.RecentCategoriesRepository import com.infinitepower.newquiz.model.BaseCategory import com.infinitepower.newquiz.model.category.ShowCategoryConnectionInfo import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory import com.infinitepower.newquiz.model.wordle.WordleCategory import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @Singleton class RecentCategoriesRepositoryImpl @Inject constructor( @RecentCategoriesDataStoreManager private val recentCategoriesDataStoreManager: DataStoreManager, @SettingsDataStoreManager private val settingsDataStoreManager: DataStoreManager, private val comparisonQuizRepository: ComparisonQuizRepository, private val remoteConfig: RemoteConfig ) : RecentCategoriesRepository { override fun getMultiChoiceCategories( isInternetAvailable: Boolean ): HomeCategoriesFlow = getHomeCategories( allCategories = multiChoiceQuestionCategories, request = RecentCategoryDataStoreCommon.MultiChoice, isInternetAvailable = isInternetAvailable ) override fun getWordleCategories( isInternetAvailable: Boolean ): HomeCategoriesFlow = getHomeCategories( allCategories = WordleCategories.allCategories, request = RecentCategoryDataStoreCommon.Wordle, isInternetAvailable = isInternetAvailable ) override fun getComparisonCategories( isInternetAvailable: Boolean ): HomeCategoriesFlow = getHomeCategories( allCategories = comparisonQuizRepository.getCategories(), request = RecentCategoryDataStoreCommon.ComparisonQuiz, isInternetAvailable = isInternetAvailable ) private fun getHomeCategories( allCategories: List, request: PreferenceRequest>, isInternetAvailable: Boolean ): Flow> = getHomeCategories( allCategories = allCategories, recentCategoriesFlow = recentCategoriesDataStoreManager.getPreferenceFlow(request), hideOnlineCategoriesFlow = settingsDataStoreManager.getPreferenceFlow(SettingsCommon.HideOnlineCategories), isInternetAvailable = isInternetAvailable ) internal fun getHomeCategories( allCategories: List, recentCategoriesFlow: Flow>, hideOnlineCategoriesFlow: Flow, isInternetAvailable: Boolean ) = combine( recentCategoriesFlow, hideOnlineCategoriesFlow ) { recentCategoriesIds, hideOnlineCategories -> val shouldHideCategories = hideOnlineCategories && !isInternetAvailable val allCategoriesFiltered = if (shouldHideCategories) { allCategories.filter { !it.requireInternetConnection } } else { allCategories } getHomeBaseCategories( savedRecentCategoriesIds = recentCategoriesIds, allCategories = allCategoriesFiltered, isInternetAvailable = isInternetAvailable ) } private fun getHomeBaseCategories( savedRecentCategoriesIds: Set, allCategories: List, isInternetAvailable: Boolean ): HomeCategories { val savedRecentCategories = savedRecentCategoriesIds.mapNotNull { id -> allCategories.find { it.id == id } } val recentCategories = getRecentCategories( recentCategories = savedRecentCategories, allCategories = allCategories, isInternetAvailable = isInternetAvailable ) val otherCategories = allCategories - recentCategories.toSet() return HomeCategories( recentCategories = recentCategories.toImmutableList(), otherCategories = otherCategories.sortByInternetConnection(isInternetAvailable).toImmutableList() ) } /** * If there is internet available, we return all the categories normally, * but if there is no internet, we make the categories that don't require internet connection * in the top of the list. */ private fun List.sortByInternetConnection( isInternetAvailable: Boolean ): List = if (isInternetAvailable) this else sortedBy { it.requireInternetConnection } private fun getRecentCategories( recentCategories: List, allCategories: List, isInternetAvailable: Boolean ): List { // When there are recent categories, we return them return recentCategories.ifEmpty { // If there are no recent categories, we take 3 random ones, // So we don't show all categories initially allCategories // If there is no internet, we only show the categories that don't require internet connection .filter { !it.requireInternetConnection || isInternetAvailable } // If there are no categories that don't require internet connection, we use all categories .ifEmpty { allCategories } .shuffled() .take(3) } } /** * Get the default value for the [ShowCategoryConnectionInfo] settings preference * from the remote config. */ override fun getDefaultShowCategoryConnectionInfo(): ShowCategoryConnectionInfo { return remoteConfig.get(RemoteConfigValue.DEFAULT_SHOW_CATEGORY_CONNECTION_INFO) } override fun getShowCategoryConnectionInfoFlow(): Flow { val default = getDefaultShowCategoryConnectionInfo() return settingsDataStoreManager .getPreferenceFlow(SettingsCommon.CategoryConnectionInfoBadge(default)) .map(ShowCategoryConnectionInfo::valueOf) } override suspend fun addMultiChoiceCategory(categoryId: String) { addCategory(categoryId, RecentCategoryDataStoreCommon.MultiChoice) } override suspend fun addWordleCategory(categoryId: String) { addCategory(categoryId, RecentCategoryDataStoreCommon.Wordle) } override suspend fun addComparisonCategory(categoryId: String) { addCategory(categoryId, RecentCategoryDataStoreCommon.ComparisonQuiz) } private suspend fun addCategory( id: String, preferenceRequest: PreferenceRequest> ) { val recentCategories = recentCategoriesDataStoreManager.getPreference(preferenceRequest) val newCategoriesIds = recentCategories .toMutableSet() .apply { // If the category to add is in the recent it's not necessary // to add the category, so return if (id in this) return if (size >= 3) remove(last()) add(id) }.toSet() recentCategoriesDataStoreManager.editPreference( key = preferenceRequest.key, newValue = newCategoriesIds ) } override suspend fun cleanAllSavedCategories() { recentCategoriesDataStoreManager.editPreference( key = RecentCategoryDataStoreCommon.MultiChoice.key, newValue = emptySet() ) recentCategoriesDataStoreManager.editPreference( key = RecentCategoryDataStoreCommon.Wordle.key, newValue = emptySet() ) recentCategoriesDataStoreManager.editPreference( key = RecentCategoryDataStoreCommon.ComparisonQuiz.key, newValue = emptySet() ) } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/math_quiz/MathQuizCoreRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.math_quiz import android.os.Build import com.infinitepower.newquiz.core.math.evaluator.Expressions import com.infinitepower.newquiz.domain.repository.math_quiz.MathQuizCoreRepository import com.infinitepower.newquiz.domain.repository.math_quiz.MathQuizCoreRepository.Companion.numbers import com.infinitepower.newquiz.domain.repository.math_quiz.MathQuizCoreRepository.Companion.operators import com.infinitepower.newquiz.model.math_quiz.MathFormula import com.infinitepower.newquiz.model.question.QuestionDifficulty import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @Singleton class MathQuizCoreRepositoryImpl @Inject constructor( private val expressions: Expressions ) : MathQuizCoreRepository { private val allNumbers by lazy { 0..9 } private val allOperators by lazy { "+-*/=" } override fun generateMathFormula( operatorSize: Int, difficulty: QuestionDifficulty, random: Random ): MathFormula { val formula = StringBuilder() var operatorCount = 0 // Add the first number formula.append(getRandomNumber(difficulty, random)) while (operatorCount < operatorSize && formula.length < MathQuizCoreRepository.MAX_FORMULA_LENGTH) { // Get the operator val operator = getRandomOperator(difficulty, random) // Get the number val number = getRandomNumber(difficulty, random) if (operator == '/' && number == 0) { // Skip division by zero continue } val newFormula = StringBuilder(formula).apply { append(operator) append(number) } // Check if the formula is in range if (getSolution(newFormula.toString()) !in MathQuizCoreRepository.SOLUTION_RANGE) { // Skip if the formula is not in range continue } formula.append(operator) formula.append(number) operatorCount++ } return MathFormula(formula.toString(), getSolution(formula.toString())) } private fun getRandomNumber( difficulty: QuestionDifficulty, random: Random = Random ): Int = difficulty.numbers.random(random) private fun getRandomOperator( difficulty: QuestionDifficulty, random: Random = Random ): Char = difficulty.operators.random(random) private fun getSolution(formula: String): Int { val solutionBigInt = expressions.eval(formula).toBigInteger() return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { solutionBigInt.intValueExact() } else { solutionBigInt.toInt() } } override fun validateFormula(formula: String): Boolean { val allNumbersStr = allNumbers.map(Int::digitToChar) val allItemsNumberOrOperator = formula.all { it in allNumbersStr || it in allOperators } if (!allItemsNumberOrOperator) return false // Checks if formula contains one equals if (formula.count { it == '=' } != 1) return false val leftExpression = formula.takeWhile { it != '=' } val rightSolution = formula .takeLastWhile { it != '=' } .toDoubleOrNull() ?: return false // Evaluate left-hand side expression and compare it with right-hand side solution return try { val solution = expressions.eval(leftExpression).toDouble() solution == rightSolution } catch (e: Exception) { false } } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/maze_quiz/MazeQuizRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.maze_quiz import com.infinitepower.newquiz.core.analytics.AnalyticsEvent import com.infinitepower.newquiz.core.analytics.AnalyticsHelper import com.infinitepower.newquiz.core.database.dao.MazeQuizDao import com.infinitepower.newquiz.core.database.model.MazeQuizItemEntity import com.infinitepower.newquiz.data.util.mappers.maze.toEntity import com.infinitepower.newquiz.data.util.mappers.maze.toMazeQuizItem import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository import com.infinitepower.newquiz.model.maze.MazeQuiz import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @Singleton class MazeQuizRepositoryImpl @Inject constructor( private val mazeQuizDao: MazeQuizDao, private val analyticsHelper: AnalyticsHelper ) : MazeQuizRepository { override fun getSavedMazeQuizFlow(): Flow = mazeQuizDao .getAllMazeItemsFlow() .map { entities -> entities.map(MazeQuizItemEntity::toMazeQuizItem) } .map { mazeItems -> MazeQuiz(items = mazeItems.toPersistentList()) } override suspend fun countAllItems(): Int = mazeQuizDao.countAllItems() override suspend fun insertItems(items: List) { val entities = items.map(MazeQuiz.MazeItem::toEntity) mazeQuizDao.insertItems(entities) } override suspend fun removeItems(items: List) { val entities = items.map(MazeQuiz.MazeItem::toEntity) mazeQuizDao.removeItems(entities) } override suspend fun getMazeItemById(id: Int): MazeQuiz.MazeItem? { return mazeQuizDao.getMazeItemById(id)?.toMazeQuizItem() } override suspend fun getNextAvailableMazeItem(): MazeQuiz.MazeItem? { return mazeQuizDao.getFirstAvailableMazeItem()?.toMazeQuizItem() } override suspend fun completeMazeItem(id: Int) { val allMazeItems = mazeQuizDao.getAllMazeItems() val mazeItem = allMazeItems.find { item -> item.id == id } ?: throw NullPointerException("Maze item with id $id not found") val updatedMazeItem = mazeItem.copy(played = true) mazeQuizDao.updateItem(updatedMazeItem) // Checks if is maze completed val isMazeCompleted = allMazeItems.all { it.played } if (isMazeCompleted) analyticsHelper.logEvent(AnalyticsEvent.MazeCompleted(allMazeItems.size)) } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/multi_choice_quiz/CountryCapitalFlagsQuizRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.multi_choice_quiz import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.CountryCapitalFlagsQuizRepository 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.domain.repository.CountryRepository import com.infinitepower.newquiz.model.country.Country import com.infinitepower.newquiz.model.question.QuestionDifficulty import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @Singleton class CountryCapitalFlagsQuizRepositoryImpl @Inject constructor( private val countryRepository: CountryRepository ) : CountryCapitalFlagsQuizRepository { override suspend fun getRandomQuestions( amount: Int, category: MultiChoiceBaseCategory.CountryCapitalFlags, difficulty: String?, random: Random ): List { val questionDifficulty = difficulty?.let { QuestionDifficulty.from(it) } val allCountries = countryRepository.getAllCountries() val allCountriesCapitalNames = allCountries.map(Country::capital) // Filter countries by difficulty val countriesFiltered = questionDifficulty?.let { allCountries.filter { country -> country.difficulty == it } } ?: allCountries // if difficulty is null, then return all countries return countriesFiltered .sortedBy { it.countryName } .shuffled(random) .take(amount) .map { country -> country.toQuestion( allCountriesCapitalNames = allCountriesCapitalNames, random = random ) } } private fun Country.toQuestion( allCountriesCapitalNames: List, random: Random = Random ): MultiChoiceQuestion { val answerCountries = allCountriesCapitalNames.shuffled(random).take(3) + capital val answers = answerCountries.shuffled(random) return MultiChoiceQuestion( description = "What is the capital of $countryName?", image = flagImage, answers = answers, correctAns = answers.indexOf(capital), category = MultiChoiceBaseCategory.CountryCapitalFlags, difficulty = difficulty, lang = QuestionLanguage.EN, type = MultiChoiceQuestionType.MULTIPLE ) } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/multi_choice_quiz/FlagQuizRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.multi_choice_quiz import com.infinitepower.newquiz.domain.repository.CountryRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.FlagQuizRepository import com.infinitepower.newquiz.model.country.Country 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 javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @Singleton class FlagQuizRepositoryImpl @Inject constructor( private val countryRepository: CountryRepository ) : FlagQuizRepository { override suspend fun getRandomQuestions( amount: Int, category: MultiChoiceBaseCategory.Flag, difficulty: String?, random: Random ): List { val questionDifficulty = difficulty?.let { QuestionDifficulty.from(it) } val allCountries = countryRepository.getAllCountries() val allCountriesNames = allCountries.map(Country::countryName) // Filter countries by difficulty val countriesFiltered = questionDifficulty?.let { allCountries.filter { country -> country.difficulty == it } } ?: allCountries // if difficulty is null, then return all countries return countriesFiltered .sortedBy { it.countryName } .shuffled(random) .take(amount) .map { country -> country.toQuestion( allCountriesNames = allCountriesNames, random = random ) } } private fun Country.toQuestion( allCountriesNames: List, random: Random = Random ): MultiChoiceQuestion { val answerCountriesNames = allCountriesNames.shuffled(random).take(3) + countryName val answers = answerCountriesNames.shuffled(random) return MultiChoiceQuestion( description = "What is the country of this flag?", image = flagImage, answers = answers, correctAns = answers.indexOf(countryName), category = MultiChoiceBaseCategory.Flag, difficulty = difficulty, lang = QuestionLanguage.EN, type = MultiChoiceQuestionType.MULTIPLE ) } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/multi_choice_quiz/GuessMathSolutionRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.multi_choice_quiz import com.infinitepower.newquiz.core.util.kotlin.generateIncorrectNumberAnswers import com.infinitepower.newquiz.core.util.kotlin.generateRandomUniqueItems import com.infinitepower.newquiz.domain.repository.math_quiz.MathQuizCoreRepository import com.infinitepower.newquiz.domain.repository.math_quiz.MathQuizCoreRepository.Companion.operatorSizeRange import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.GuessMathSolutionRepository 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 javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @Singleton class GuessMathSolutionRepositoryImpl @Inject constructor( private val mathQuizCoreRepository: MathQuizCoreRepository ) : GuessMathSolutionRepository { override suspend fun getRandomQuestions( amount: Int, category: MultiChoiceBaseCategory.GuessMathSolution, difficulty: String?, random: Random ): List { val questionDifficulty = if (difficulty == null) { QuestionDifficulty.random(random) } else { QuestionDifficulty.from(difficulty) } return generateRandomUniqueItems( itemCount = amount, generator = { mathQuizCoreRepository.generateMathFormula( difficulty = questionDifficulty, operatorSize = questionDifficulty.operatorSizeRange.random(random), random = random ) } ).map { formula -> val incorrectAnswers = generateIncorrectNumberAnswers(3, formula.solution) val answers = incorrectAnswers + formula.solution val answersShuffled = answers.shuffled(random) MultiChoiceQuestion( description = "What is the solution of ${formula.leftFormula} ?", answers = answersShuffled.map(Int::toString), category = MultiChoiceBaseCategory.GuessMathSolution, correctAns = answersShuffled.indexOf(formula.solution), difficulty = questionDifficulty, lang = QuestionLanguage.EN, type = MultiChoiceQuestionType.MULTIPLE ) } } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/multi_choice_quiz/LogoQuizRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.multi_choice_quiz 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.domain.repository.multi_choice_quiz.LogoQuizRepository 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.multi_choice_quiz.logo_quiz.LogoQuizBaseItem import com.infinitepower.newquiz.model.question.QuestionDifficulty import kotlinx.serialization.json.Json import java.net.URI import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @Singleton class LogoQuizRepositoryImpl @Inject constructor( private val remoteConfig: RemoteConfig ) : LogoQuizRepository { override suspend fun getRandomQuestions( amount: Int, category: MultiChoiceBaseCategory.Logo, difficulty: String?, random: Random ): List { val allLogos = getRemoteConfigAllLogos() val filteredByDifficulty = if (difficulty != null) { allLogos.filter { item -> item.difficulty == difficulty } } else allLogos return filteredByDifficulty .sortedBy { it.name } .shuffled(random) .take(amount) .map { item -> item.toQuestion() } } private fun getRemoteConfigAllLogos(): List { val allLogosQuizStr = remoteConfig.get(RemoteConfigValue.ALL_LOGOS_QUIZ) return Json.decodeFromString(allLogosQuizStr) } private fun LogoQuizBaseItem.toQuestion( random: Random = Random ): MultiChoiceQuestion { val answerCountries = incorrectAnswers.shuffled(random) + name val answers = answerCountries.shuffled(random) return MultiChoiceQuestion( description = description, image = URI.create(imgUrl), answers = answers, correctAns = answers.indexOf(name), category = MultiChoiceBaseCategory.Logo, difficulty = QuestionDifficulty.Medium, lang = QuestionLanguage.EN, type = MultiChoiceQuestionType.MULTIPLE ) } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/multi_choice_quiz/MultiChoiceQuestionRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.multi_choice_quiz import com.infinitepower.newquiz.data.repository.multi_choice_quiz.dto.OpenTDBQuestionResponse import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.MultiChoiceQuestionRepository import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion import io.ktor.client.HttpClient import io.ktor.client.request.headers import io.ktor.client.request.parameter import io.ktor.client.request.request import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random private const val OPENTDB_API_URL = "https://opentdb.com/api.php" @Singleton class MultiChoiceQuestionRepositoryImpl @Inject constructor( private val client: HttpClient ) : MultiChoiceQuestionRepository { override suspend fun getRandomQuestions( amount: Int, category: MultiChoiceBaseCategory.Normal, difficulty: String?, random: Random ): List = withContext(Dispatchers.IO) { val openTDBResults = getOpenTDBResponse(amount, category, difficulty).results val questions = openTDBResults.map { result -> async(Dispatchers.IO) { result.decodeResultToQuestion() } } questions.awaitAll() } private suspend fun getOpenTDBResponse( amount: Int, category: MultiChoiceBaseCategory.Normal, difficulty: String? ): OpenTDBQuestionResponse { val response: HttpResponse = client.request(OPENTDB_API_URL) { method = HttpMethod.Get parameter("encode", "base64") parameter("amount", amount) if (category.hasCategory) { parameter("category", category.categoryId) } if (difficulty != null) parameter("difficulty", difficulty) headers { append(HttpHeaders.Accept, "application/json") } } val textResponse = response.bodyAsText() return Json.decodeFromString(textResponse) } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/multi_choice_quiz/dto/OpenTDBQuestionResponse.kt ================================================ package com.infinitepower.newquiz.data.repository.multi_choice_quiz.dto import androidx.annotation.Keep 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 com.infinitepower.newquiz.model.util.base64.base64Decoded import kotlinx.serialization.Serializable @Keep @Serializable data class OpenTDBQuestionResponse( val response_code: Int, val results: List ) : java.io.Serializable { @Keep @Serializable data class OpenTDBResult( val category: String, val type: String, val difficulty: String, val question: String, val correct_answer: String, val incorrect_answers: List ) : java.io.Serializable { private fun decodeBase64OpenTDBResult(): OpenTDBResult = copy( category = category.base64Decoded, type = type.base64Decoded, difficulty = difficulty.base64Decoded, question = question.base64Decoded, correct_answer = correct_answer.base64Decoded, incorrect_answers = incorrect_answers.map { answer -> answer.base64Decoded } ) private fun toQuestion(): MultiChoiceQuestion { val answers = incorrect_answers.plus(correct_answer).shuffled() val correctAnswerIndex = answers.indexOf(correct_answer) return MultiChoiceQuestion( description = question, answers = answers, category = MultiChoiceBaseCategory.fromId(category), correctAns = correctAnswerIndex, type = MultiChoiceQuestionType.valueOf(type.uppercase()), difficulty = QuestionDifficulty.from(difficulty), lang = QuestionLanguage.EN ) } fun decodeResultToQuestion(): MultiChoiceQuestion = decodeBase64OpenTDBResult().toQuestion() } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/multi_choice_quiz/saved_questions/SavedMultiChoiceQuestionsRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.multi_choice_quiz.saved_questions import com.infinitepower.newquiz.core.database.dao.SavedMultiChoiceQuestionsDao import com.infinitepower.newquiz.core.database.model.MultiChoiceQuestionEntity import com.infinitepower.newquiz.core.database.util.mappers.toEntity import com.infinitepower.newquiz.core.database.util.mappers.toModel import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.saved_questions.SavedMultiChoiceQuestionsRepository import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion import com.infinitepower.newquiz.model.multi_choice_quiz.saved.SortSavedQuestionsBy import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @Singleton class SavedMultiChoiceQuestionsRepositoryImpl @Inject constructor( private val savedQuestionsDao: SavedMultiChoiceQuestionsDao ) : SavedMultiChoiceQuestionsRepository { override suspend fun insertQuestions(questions: List) { val questionsEntity = questions.map(MultiChoiceQuestion::toEntity) savedQuestionsDao.insertQuestions(questionsEntity) } override suspend fun insertQuestions(vararg questions: MultiChoiceQuestion) { val questionsEntity = questions.map(MultiChoiceQuestion::toEntity) savedQuestionsDao.insertQuestions(questionsEntity) } override fun getFlowQuestions( sortBy: SortSavedQuestionsBy ): Flow> { val questionsFlow = when (sortBy) { SortSavedQuestionsBy.BY_DEFAULT -> savedQuestionsDao.getFlowQuestions() SortSavedQuestionsBy.BY_DESCRIPTION -> savedQuestionsDao.getFlowQuestionsSortedByDescription() SortSavedQuestionsBy.BY_CATEGORY -> savedQuestionsDao.getFlowQuestionsSortedByCategory() } return questionsFlow.map { flowQuestions -> flowQuestions.map(MultiChoiceQuestionEntity::toModel) } } override suspend fun getQuestions(): List = savedQuestionsDao .getQuestions() .map(MultiChoiceQuestionEntity::toModel) override fun getCount(): Flow = savedQuestionsDao.getCount() override suspend fun deleteAllSelected(questions: List) { val questionsEntity = questions.map(MultiChoiceQuestion::toEntity) savedQuestionsDao.deleteAll(questionsEntity) } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/numbers/NumberTriviaQuestionApiImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.numbers import com.infinitepower.newquiz.domain.repository.numbers.NumberTriviaQuestionApi import com.infinitepower.newquiz.model.number.NumberTriviaQuestionsEntity import io.ktor.client.HttpClient import io.ktor.client.request.headers import io.ktor.client.request.parameter import io.ktor.client.request.request import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpHeaders import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton private const val NUMBERS_API_URL = "https://newquiz-app.vercel.app/api/numberTrivia/random" @Singleton class NumberTriviaQuestionApiImpl @Inject constructor( private val client: HttpClient ) : NumberTriviaQuestionApi { override suspend fun getRandomQuestion( size: Int, minNumber: Int, maxNumber: Int ): NumberTriviaQuestionsEntity { val response: HttpResponse = client.request(NUMBERS_API_URL) { parameter("size", size) parameter("min", minNumber) parameter("max", maxNumber) headers { append(HttpHeaders.Accept, "application/json") } } val textResponse = response.bodyAsText() return Json.decodeFromString(textResponse) } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/numbers/NumberTriviaQuestionRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.numbers import com.infinitepower.newquiz.core.util.kotlin.generateIncorrectNumberAnswers import com.infinitepower.newquiz.domain.repository.numbers.NumberTriviaQuestionApi import com.infinitepower.newquiz.domain.repository.numbers.NumberTriviaQuestionRepository 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.number.NumberTriviaQuestion import com.infinitepower.newquiz.model.question.QuestionDifficulty import com.infinitepower.newquiz.model.wordle.WordleWord import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @Singleton class NumberTriviaQuestionRepositoryImpl @Inject constructor( private val numberTriviaQuestionApi: NumberTriviaQuestionApi ) : NumberTriviaQuestionRepository { override suspend fun generateRandomQuestions( size: Int, minNumber: Int, maxNumber: Int, random: Random ): List { val questionEntity = numberTriviaQuestionApi.getRandomQuestion(size, minNumber, maxNumber) return questionEntity.toNumberTriviaQuestions() } override suspend fun generateWordleQuestion(random: Random): WordleWord { val randomQuestion = generateRandomQuestions(1, 100, 10000, random).first() return WordleWord( word = randomQuestion.number.toString(), textHelper = randomQuestion.question ) } override suspend fun generateMultiChoiceQuestion(size: Int, random: Random): List { val randomQuestion = generateRandomQuestions(size, 100, 10000, random) return randomQuestion.map { question -> val incorrectAnswers = generateIncorrectNumberAnswers(3, question.number) val answers = (incorrectAnswers + question.number).shuffled(random) MultiChoiceQuestion( description = question.question, answers = answers.map(Int::toString), correctAns = answers.indexOf(question.number), lang = QuestionLanguage.EN, category = MultiChoiceBaseCategory.NumberTrivia, type = MultiChoiceQuestionType.MULTIPLE, difficulty = QuestionDifficulty.Easy ) } } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/wordle/InvalidWordError.kt ================================================ package com.infinitepower.newquiz.data.repository.wordle sealed class InvalidWordError : IllegalArgumentException() { data object Empty : InvalidWordError() data object NotOnlyLetters : InvalidWordError() data object NotOnlyDigits : InvalidWordError() data object InvalidMathFormula : InvalidWordError() } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/repository/wordle/WordleRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.wordle import android.content.Context import com.infinitepower.newquiz.core.datastore.common.SettingsCommon import com.infinitepower.newquiz.core.datastore.common.textWordleSupportedLang import com.infinitepower.newquiz.core.datastore.di.SettingsDataStoreManager import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import com.infinitepower.newquiz.domain.repository.math_quiz.MathQuizCoreRepository import com.infinitepower.newquiz.domain.repository.numbers.NumberTriviaQuestionRepository import com.infinitepower.newquiz.domain.repository.wordle.WordleRepository import com.infinitepower.newquiz.model.FlowResource import com.infinitepower.newquiz.model.Resource import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.model.wordle.WordleWord import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @Singleton class WordleRepositoryImpl @Inject constructor( @ApplicationContext private val context: Context, @SettingsDataStoreManager private val settingsDataStoreManager: DataStoreManager, private val mathQuizCoreRepository: MathQuizCoreRepository, private val numberTriviaQuestionRepository: NumberTriviaQuestionRepository ) : WordleRepository { private val baseNumbers by lazy { 0..9 } override suspend fun getAllWords(): Set = withContext(Dispatchers.IO) { val quizLanguage = settingsDataStoreManager.getPreference(SettingsCommon.InfiniteWordleQuizLanguage) val listRawId = textWordleSupportedLang.find { lang -> lang.key == quizLanguage }?.rawListId ?: throw NullPointerException("Wordle language not found") val wordleListInputStream = context.resources.openRawResource(listRawId) try { wordleListInputStream .readBytes() .decodeToString() .split("\r\n", "\n") .filter { it.length == 5 } .map { it.uppercase().trim() } .toSet() } catch (e: Exception) { throw e } finally { wordleListInputStream.close() } } override fun generateRandomWord( quizType: WordleQuizType, random: Random ): FlowResource = flow { try { emit(Resource.Loading()) val randomWord = when (quizType) { WordleQuizType.TEXT -> generateRandomTextWord(random = random) WordleQuizType.NUMBER -> generateRandomNumberWord(random = random) WordleQuizType.MATH_FORMULA -> { val formula = mathQuizCoreRepository.generateMathFormula(random = random) WordleWord(formula.fullFormula) } WordleQuizType.NUMBER_TRIVIA -> numberTriviaQuestionRepository.generateWordleQuestion( random = random ) } emit(Resource.Success(randomWord)) } catch (e: Exception) { e.printStackTrace() emit(Resource.Error(e.localizedMessage ?: "A error occurred while getting word.")) } } override suspend fun generateRandomTextWord(random: Random): WordleWord { val allWords = getAllWords().shuffled(random) return WordleWord(allWords.random(random)) } override suspend fun generateRandomTextWords(count: Int, random: Random): List { val allWords = getAllWords().shuffled(random) return allWords .take(count) .map { word -> WordleWord(word) } } override suspend fun generateRandomNumberWord( wordSize: Int, random: Random ): WordleWord { val randomNumbers = List(wordSize) { baseNumbers.random(random) } return WordleWord(randomNumbers.joinToString("")) } override suspend fun isColorBlindEnabled(): Boolean { return settingsDataStoreManager.getPreference(SettingsCommon.WordleColorBlindMode) } override suspend fun isLetterHintEnabled(): Boolean { return settingsDataStoreManager.getPreference(SettingsCommon.WordleLetterHints) } override suspend fun isHardModeEnabled(): Boolean { return settingsDataStoreManager.getPreference(SettingsCommon.WordleHardMode) } override suspend fun getWordleMaxRows(defaultMaxRow: Int?): Int { if (defaultMaxRow == null) { // If is row limited return row limit value else return int max value val isRowLimited = settingsDataStoreManager.getPreference(SettingsCommon.WordleInfiniteRowsLimited) if (isRowLimited) return settingsDataStoreManager.getPreference(SettingsCommon.WordleInfiniteRowsLimit) return Int.MAX_VALUE } return defaultMaxRow } @Suppress("ReturnCount") override fun validateWord(word: String, quizType: WordleQuizType): Result { if (word.isBlank()) return Result.failure(InvalidWordError.Empty) when (quizType) { WordleQuizType.TEXT -> { if (!word.all(Char::isLetter)) { return Result.failure(InvalidWordError.NotOnlyLetters) } } WordleQuizType.NUMBER, WordleQuizType.NUMBER_TRIVIA -> { if (!word.all(Char::isDigit)) { return Result.failure(InvalidWordError.NotOnlyDigits) } } WordleQuizType.MATH_FORMULA -> { if (!mathQuizCoreRepository.validateFormula(word)) { return Result.failure(InvalidWordError.InvalidMathFormula) } } } return Result.success(Unit) } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/util/mappers/comparisonquiz/ComparisonQuizMapper.kt ================================================ package com.infinitepower.newquiz.data.util.mappers.comparisonquiz import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItem import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItemEntity import java.net.URI fun ComparisonQuizItem.toEntity() = ComparisonQuizItemEntity( title = title, value = value, imgUrl = imgUri.toASCIIString(), ) fun ComparisonQuizItemEntity.toModel() = ComparisonQuizItem( title = title, value = value, imgUri = URI.create(imgUrl) ) ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/util/mappers/daily_challenge/DailyChallengeTaskMapper.kt ================================================ package com.infinitepower.newquiz.data.util.mappers.daily_challenge import com.infinitepower.newquiz.core.database.model.DailyChallengeTaskEntity import com.infinitepower.newquiz.data.repository.daily_challenge.util.getTitle import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.daily_challenge.DailyChallengeTask import com.infinitepower.newquiz.model.global_event.GameEvent import kotlinx.datetime.Instant fun DailyChallengeTask.toEntity(): DailyChallengeTaskEntity { return 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() ) } fun DailyChallengeTaskEntity.toModel( comparisonQuizCategories: List ): DailyChallengeTask { val startInstant = Instant.fromEpochMilliseconds(startDate) val endInstant = Instant.fromEpochMilliseconds(endDate) val dateRange = startInstant..endInstant val type = GameEvent.fromKey(type) return DailyChallengeTask( id = id, diamondsReward = diamondsReward.toUInt(), experienceReward = experienceReward.toUInt(), isClaimed = isClaimed, dateRange = dateRange, currentValue = currentValue.toUInt(), maxValue = maxValue.toUInt(), event = type, title = type.getTitle(maxValue, comparisonQuizCategories) ) } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/util/mappers/maze/MazeQuizMappers.kt ================================================ package com.infinitepower.newquiz.data.util.mappers.maze import com.infinitepower.newquiz.core.database.model.MazeQuizItemEntity import com.infinitepower.newquiz.core.database.util.mappers.toEntity import com.infinitepower.newquiz.core.database.util.mappers.toModel import com.infinitepower.newquiz.data.util.mappers.comparisonquiz.toEntity import com.infinitepower.newquiz.data.util.mappers.comparisonquiz.toModel import com.infinitepower.newquiz.model.GameMode import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizQuestion import com.infinitepower.newquiz.model.maze.MazeQuiz import com.infinitepower.newquiz.model.wordle.WordleWord fun MazeQuiz.MazeItem.toEntity(): MazeQuizItemEntity = when (this) { is MazeQuiz.MazeItem.Wordle -> MazeQuizItemEntity( id = id, mazeSeed = mazeSeed, difficulty = difficulty, played = played, type = GameMode.WORDLE, wordleItem = MazeQuizItemEntity.WordleEntity( wordleWord = wordleWord.word, wordleQuizType = wordleQuizType, textHelper = wordleWord.textHelper ) ) is MazeQuiz.MazeItem.MultiChoice -> MazeQuizItemEntity( id = id, mazeSeed = mazeSeed, difficulty = difficulty, played = played, type = GameMode.MULTI_CHOICE, multiChoiceQuestion = question.toEntity() ) is MazeQuiz.MazeItem.ComparisonQuiz -> MazeQuizItemEntity( id = id, mazeSeed = mazeSeed, difficulty = difficulty, played = played, type = GameMode.COMPARISON_QUIZ, comparisonQuizQuestion = MazeQuizItemEntity.ComparisonQuizEntity( category = question.categoryId, comparisonMode = question.comparisonMode, firstQuestion = question.questions.first.toEntity(), secondQuestion = question.questions.second.toEntity() ) ) } fun MazeQuizItemEntity.toMazeQuizItem(): MazeQuiz.MazeItem = when (type) { GameMode.WORDLE -> { val wordleItem = this.wordleItem ?: throw NullPointerException("Wordle word is null") MazeQuiz.MazeItem.Wordle( wordleWord = WordleWord( word = wordleItem.wordleWord ), wordleQuizType = wordleItem.wordleQuizType, id = id, mazeSeed = mazeSeed, difficulty = difficulty, played = played ) } GameMode.MULTI_CHOICE -> { val questionEntity = this.multiChoiceQuestion ?: throw NullPointerException("Question is null") MazeQuiz.MazeItem.MultiChoice(questionEntity.toModel(), id, mazeSeed, difficulty, played) } GameMode.COMPARISON_QUIZ -> { val questionEntity = this.comparisonQuizQuestion ?: throw NullPointerException("Question is null") val firstQuestion = questionEntity.firstQuestion.toModel() val secondQuestion = questionEntity.secondQuestion.toModel() MazeQuiz.MazeItem.ComparisonQuiz( question = ComparisonQuizQuestion( questions = firstQuestion to secondQuestion, categoryId = questionEntity.category, comparisonMode = questionEntity.comparisonMode ), id = id, mazeSeed = mazeSeed, difficulty = difficulty, played = played ) } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/util/translation/WordleTitleUtil.kt ================================================ package com.infinitepower.newquiz.data.util.translation import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.core.R as CoreR fun WordleQuizType?.getWordleTitle() = when (this) { WordleQuizType.TEXT -> UiText.StringResource(CoreR.string.guess_the_word) WordleQuizType.NUMBER -> UiText.StringResource(CoreR.string.guess_the_number) WordleQuizType.MATH_FORMULA -> UiText.StringResource(CoreR.string.guess_math_formula) WordleQuizType.NUMBER_TRIVIA -> UiText.StringResource(CoreR.string.number_trivia) else -> UiText.StringResource(CoreR.string.wordle) } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/worker/UpdateGlobalEventDataWorker.kt ================================================ package com.infinitepower.newquiz.data.worker import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.OneTimeWorkRequestBuilder import androidx.work.Operation import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf import com.infinitepower.newquiz.domain.repository.daily_challenge.DailyChallengeRepository import com.infinitepower.newquiz.model.global_event.GameEvent import dagger.assisted.Assisted import dagger.assisted.AssistedInject /** * This worker is responsible for updating the game event data. * * Updates daily challenge task data. * Update achievements data. */ @HiltWorker class UpdateGlobalEventDataWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted workerParams: WorkerParameters, private val dailyChallengeRepository: DailyChallengeRepository ) : CoroutineWorker(appContext, workerParams) { companion object { private const val EVENTS_KEY = "events_key" fun enqueueWork( workManager: WorkManager, vararg event: GameEvent ): Operation { val eventsKey = event.map { it.key }.toTypedArray() val workRequest = OneTimeWorkRequestBuilder() .setInputData(workDataOf(EVENTS_KEY to eventsKey)) .build() return workManager.enqueue(workRequest) } } override suspend fun doWork(): Result { val eventsKey = inputData.getStringArray(EVENTS_KEY) ?: return Result.failure() // Update daily challenge task data. eventsKey.forEach { eventKey -> // Get the event from the key. try { val event = GameEvent.fromKey(eventKey) dailyChallengeRepository.completeTaskStep(event) } catch (e: Exception) { e.printStackTrace() } } return Result.success() } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/worker/daily_challenge/VerifyDailyChallengeWorker.kt ================================================ package com.infinitepower.newquiz.data.worker.daily_challenge import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.Operation import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters 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.domain.repository.daily_challenge.DailyChallengeRepository import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlin.time.Duration.Companion.days import kotlin.time.toJavaDuration @HiltWorker class VerifyDailyChallengeWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted workerParams: WorkerParameters, private val dailyChallengeRepository: DailyChallengeRepository, private val remoteConfig: RemoteConfig ) : CoroutineWorker(appContext, workerParams) { companion object { private const val WORK_NAME = "VerifyDailyChallengeWorker" fun enqueueUniquePeriodicWork(workManager: WorkManager): Operation { val verifyDailyChallengeWorker = PeriodicWorkRequestBuilder( repeatInterval = 1.days.toJavaDuration() ).build() return workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, verifyDailyChallengeWorker) } } override suspend fun doWork(): Result { // Get the number of tasks to generate if the tasks are expired or not generated yet. val tasksToGenerate = getTasksToGenerate() // Check if the tasks are expired or not generated yet. dailyChallengeRepository.checkAndGenerateTasksIfNeeded(tasksToGenerate) return Result.success() } /** * Get the number of tasks to generate in remote config. * Default value is 5. */ private fun getTasksToGenerate(): Int = remoteConfig.get(RemoteConfigValue.DAILY_CHALLENGE_TASKS_TO_GENERATE) } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/worker/maze/CleanMazeQuizWorker.kt ================================================ package com.infinitepower.newquiz.data.worker.maze 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.database.dao.MazeQuizDao import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @HiltWorker class CleanMazeQuizWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted workerParams: WorkerParameters, private val mazeQuizDao: MazeQuizDao ) : CoroutineWorker(appContext, workerParams) { companion object { fun enqueue(workManager: WorkManager) { val cleanSavedMazeRequest = OneTimeWorkRequestBuilder().build() workManager.enqueue(cleanSavedMazeRequest) } } override suspend fun doWork(): Result = withContext(Dispatchers.IO) { mazeQuizDao.deleteAll() Result.success() } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/worker/maze/GenerateMazeQuizWorker.kt ================================================ package com.infinitepower.newquiz.data.worker.maze import android.content.Context import android.util.Log 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.remote_config.RemoteConfig import com.infinitepower.newquiz.core.remote_config.RemoteConfigValue import com.infinitepower.newquiz.core.remote_config.get import com.infinitepower.newquiz.core.util.kotlin.generateRandomUniqueItems import com.infinitepower.newquiz.data.local.multi_choice_quiz.category.multiChoiceQuestionCategories import com.infinitepower.newquiz.data.local.wordle.WordleCategories import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.domain.repository.math_quiz.MathQuizCoreRepository import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.CountryCapitalFlagsQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.FlagQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.GuessMathSolutionRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.LogoQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.MultiChoiceQuestionRepository import com.infinitepower.newquiz.domain.repository.wordle.WordleRepository import com.infinitepower.newquiz.model.BaseCategory import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizQuestion import com.infinitepower.newquiz.model.maze.MazeQuiz import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory import com.infinitepower.newquiz.model.question.QuestionDifficulty import com.infinitepower.newquiz.model.wordle.WordleCategory import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.model.wordle.WordleWord import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.util.UUID import kotlin.random.Random import com.infinitepower.newquiz.core.R as CoreR private val multiChoiceBaseCategoriesIds: List = listOf( MultiChoiceBaseCategory.Random.id, MultiChoiceBaseCategory.Logo.id, MultiChoiceBaseCategory.Flag.id, MultiChoiceBaseCategory.CountryCapitalFlags.id, MultiChoiceBaseCategory.GuessMathSolution.id ) @HiltWorker class GenerateMazeQuizWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted workerParams: WorkerParameters, private val mazeMathQuizRepository: MazeQuizRepository, private val mathQuizCoreRepository: MathQuizCoreRepository, private val flagQuizRepository: FlagQuizRepository, private val logoQuizRepository: LogoQuizRepository, private val wordleRepository: WordleRepository, private val multiChoiceQuestionRepository: MultiChoiceQuestionRepository, private val guessMathSolutionRepository: GuessMathSolutionRepository, private val countryCapitalFlagsQuizRepository: CountryCapitalFlagsQuizRepository, private val comparisonQuizRepository: ComparisonQuizRepository, private val remoteConfig: RemoteConfig, private val analyticsHelper: AnalyticsHelper, ) : CoroutineWorker(appContext, workerParams) { /** * Game modes available to generate the maze quiz. * * Number trivia category is not supported because of api limitations * * @param name Name of the game mode * @param categories Categories available to generate the maze quiz */ sealed class GameModes( val name: UiText, val categories: List ) { /** * Get the offline game mode categories. */ fun getOfflineCategories(): List = categories.filterNot { category -> category.requireInternetConnection } data object MultiChoice : GameModes( name = UiText.StringResource(CoreR.string.multi_choice_quiz), // Get only the categories that are in the multi choice base categories ids categories = multiChoiceQuestionCategories.filter { category -> multiChoiceBaseCategoriesIds.contains(category.id) } + MultiChoiceCategory( // Add the normal categories to the options id = MultiChoiceBaseCategory.Normal().categoryId, name = UiText.StringResource(CoreR.string.others), image = "" ) ) data object Wordle : GameModes( name = UiText.StringResource(CoreR.string.wordle), categories = WordleCategories.allCategories.filter { category -> category.wordleQuizType != WordleQuizType.NUMBER_TRIVIA } ) } companion object { private const val TAG = "GenerateMazeQuizWorker" internal const val INPUT_SEED = "INPUT_SEED" internal const val INPUT_QUESTION_SIZE = "INPUT_QUESTION_SIZE" internal const val INPUT_MULTI_CHOICE_CATEGORIES = "INPUT_MULTI_CHOICE_CATEGORIES" internal const val INPUT_WORDLE_QUIZ_TYPES = "INPUT_WORDLE_QUIZ_TYPES" internal const val INPUT_COMPARISON_QUIZ_CATEGORIES = "INPUT_COMPARISON_QUIZ_CATEGORIES" /** * Enqueue a new work to generate a maze quiz. * * First it will clean the saved maze and then it will generate a new one. * * @param workManager WorkManager instance * @param seed Seed to use in the random generator * @param questionSize Question size to generate the maze * @return The work id */ fun enqueue( workManager: WorkManager, seed: Int?, multiChoiceCategories: List, wordleCategories: List, comparisonQuizCategories: List, questionSize: Int? = null ): UUID { val cleanSavedMazeRequest = OneTimeWorkRequestBuilder().build() val multiChoiceCategoriesId = multiChoiceCategories.map { category -> category.id }.toTypedArray() val wordleCategoriesIds = wordleCategories.map { category -> category.id }.toTypedArray() val comparisonQuizCategoriesIds = comparisonQuizCategories.map { category -> category.id }.toTypedArray() val generateMazeRequest = OneTimeWorkRequestBuilder() .setInputData( workDataOf( INPUT_SEED to seed, INPUT_MULTI_CHOICE_CATEGORIES to multiChoiceCategoriesId, INPUT_WORDLE_QUIZ_TYPES to wordleCategoriesIds, INPUT_COMPARISON_QUIZ_CATEGORIES to comparisonQuizCategoriesIds, INPUT_QUESTION_SIZE to questionSize ) ).build() workManager .beginWith(cleanSavedMazeRequest) .then(generateMazeRequest) .enqueue() return generateMazeRequest.id } } override suspend fun doWork(): Result = withContext(Dispatchers.IO) { val seed = inputData.getInt(INPUT_SEED, Random.nextInt()) val multiChoiceCategoriesIds = inputData.getStringArray(INPUT_MULTI_CHOICE_CATEGORIES) requireNotNull(multiChoiceCategoriesIds) Log.i(TAG, "Multi choice categories: $multiChoiceCategoriesIds") val wordleCategoriesIds = inputData.getStringArray(INPUT_WORDLE_QUIZ_TYPES) requireNotNull(wordleCategoriesIds) Log.i(TAG, "Wordle quiz types: $wordleCategoriesIds") val comparisonQuizCategoriesIds = inputData.getStringArray(INPUT_COMPARISON_QUIZ_CATEGORIES) requireNotNull(comparisonQuizCategoriesIds) Log.i(TAG, "Comparison quiz categories: $comparisonQuizCategoriesIds") val questionSize = inputData.getInt( INPUT_QUESTION_SIZE, remoteConfig.get(RemoteConfigValue.MAZE_QUIZ_GENERATED_QUESTIONS) ) // Random to use in all of the generators val random = Random(seed) // Get the questions size per mode, this is the size of the questions that will be generated per mode val allCategoryCount = multiChoiceCategoriesIds.count() + wordleCategoriesIds.count() + comparisonQuizCategoriesIds.count() val questionSizePerMode = questionSize / allCategoryCount Log.i( TAG, "Generating maze with seed: $seed, question size: $questionSize, question size per mode: $questionSizePerMode" ) val multiChoiceMazeQuestions = multiChoiceCategoriesIds.map { categoryId -> val quizType = MultiChoiceBaseCategory.fromId(categoryId) generateMultiChoiceMazeItems( mazeSeed = seed, questionSize = questionSizePerMode, multiChoiceQuizType = quizType, random = random ) } val wordleMazeQuestions = wordleCategoriesIds.map { categoryId -> val wordleQuizType = WordleQuizType.valueOf(categoryId) generateWordleMazeItems( mazeSeed = seed, questionSize = questionSizePerMode, wordleQuizType = wordleQuizType, random = random ) } val comparisonQuizCategories = comparisonQuizRepository.getCategories() val comparisonMazeQuestions = comparisonQuizCategoriesIds.mapNotNull { categoryId -> val category = comparisonQuizCategories.find { it.id == categoryId } if (category == null) return@mapNotNull null generateComparisonMazeItems( category = category, mazeSeed = seed, questionSize = questionSizePerMode, random = random ) } val allMazeQuestions = (multiChoiceMazeQuestions + wordleMazeQuestions + comparisonMazeQuestions) .flatten() .shuffled(random) mazeMathQuizRepository.insertItems(allMazeQuestions) val questionsCount = mazeMathQuizRepository.countAllItems() check(questionsCount == allMazeQuestions.count()) { "Maze saved questions: $questionsCount is not equal to generated questions: ${allMazeQuestions.count()}" } Log.d(TAG, "Generated $questionsCount questions") analyticsHelper.logEvent(AnalyticsEvent.CreateMaze(seed, questionsCount)) Result.success() } private suspend fun generateMultiChoiceMazeItems( mazeSeed: Int, questionSize: Int, multiChoiceQuizType: MultiChoiceBaseCategory, difficulty: QuestionDifficulty = QuestionDifficulty.Easy, random: Random = Random ): List { val questions = when (multiChoiceQuizType) { is MultiChoiceBaseCategory.Normal -> multiChoiceQuestionRepository.getRandomQuestions( amount = questionSize, random = random, category = multiChoiceQuizType ) is MultiChoiceBaseCategory.Logo -> logoQuizRepository.getRandomQuestions( amount = questionSize, random = random, category = multiChoiceQuizType ) is MultiChoiceBaseCategory.Flag -> flagQuizRepository.getRandomQuestions( amount = questionSize, random = random, category = multiChoiceQuizType ) is MultiChoiceBaseCategory.GuessMathSolution -> guessMathSolutionRepository.getRandomQuestions( amount = questionSize, random = random, category = multiChoiceQuizType ) is MultiChoiceBaseCategory.CountryCapitalFlags -> countryCapitalFlagsQuizRepository.getRandomQuestions( amount = questionSize, random = random, category = multiChoiceQuizType ) // Temporary disabled because of api limitations is MultiChoiceBaseCategory.NumberTrivia -> error("Number trivia is not supported") } return questions.map { question -> MazeQuiz.MazeItem.MultiChoice( mazeSeed = mazeSeed, question = question, difficulty = difficulty ) } } private suspend fun generateWordleMazeItems( mazeSeed: Int, questionSize: Int, wordleQuizType: WordleQuizType, difficulty: QuestionDifficulty = QuestionDifficulty.Easy, random: Random = Random ): List { require(wordleQuizType != WordleQuizType.NUMBER_TRIVIA) { "Number trivia is not supported" } if (wordleQuizType == WordleQuizType.TEXT) { return wordleRepository.generateRandomTextWords( count = questionSize, random = random ).map { word -> MazeQuiz.MazeItem.Wordle( mazeSeed = mazeSeed, wordleWord = word, wordleQuizType = wordleQuizType, difficulty = difficulty ) } } return generateRandomUniqueItems( itemCount = questionSize, generator = { when (wordleQuizType) { WordleQuizType.NUMBER -> wordleRepository.generateRandomNumberWord(random = random) WordleQuizType.MATH_FORMULA -> { val formula = mathQuizCoreRepository.generateMathFormula( difficulty = difficulty, random = random ) WordleWord(formula.fullFormula) } else -> error("Wordle quiz type not supported") } } ).map { word -> MazeQuiz.MazeItem.Wordle( mazeSeed = mazeSeed, wordleWord = word, wordleQuizType = wordleQuizType, difficulty = difficulty ) } } private suspend fun generateComparisonMazeItems( category: ComparisonQuizCategory, mazeSeed: Int, questionSize: Int, random: Random = Random ): List { // Each question has two items, so we need to request twice as many questions val requestSize = questionSize * 2 return comparisonQuizRepository.getQuestions( category = category, size = requestSize, random = random ).chunked(2) { items -> val firstQuestion = items.first() val secondQuestion = items.last() MazeQuiz.MazeItem.ComparisonQuiz( mazeSeed = mazeSeed, question = ComparisonQuizQuestion( questions = firstQuestion to secondQuestion, categoryId = category.id, comparisonMode = ComparisonMode.GREATER // In future we can make this random ), ) } } } ================================================ FILE: data/src/main/java/com/infinitepower/newquiz/data/worker/multichoicequiz/DownloadMultiChoiceQuestionsWorker.kt ================================================ package com.infinitepower.newquiz.data.worker.multichoicequiz import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.MultiChoiceQuestionRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.saved_questions.SavedMultiChoiceQuestionsRepository import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory import dagger.assisted.Assisted import dagger.assisted.AssistedInject @HiltWorker class DownloadMultiChoiceQuestionsWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted workerParams: WorkerParameters, private val savedQuestionsRepository: SavedMultiChoiceQuestionsRepository, private val questionRepository: MultiChoiceQuestionRepository ) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { val allSavedQuestions = savedQuestionsRepository.getQuestions() val questions = questionRepository .getRandomQuestions(amount = 50, category = MultiChoiceBaseCategory.Normal()) .filter { it !in allSavedQuestions } savedQuestionsRepository.insertQuestions(questions) return Result.success() } } ================================================ FILE: data/src/main/res/raw/all_countries.json ================================================ [ { "countryCode": "AF", "capital": "Kabul", "difficulty": "hard", "countryName": "Afghanistan", "continent": "Asia", "population": 40218234, "area": 652230 }, { "countryCode": "AL", "capital": "Tirana", "difficulty": "medium", "countryName": "Albania", "continent": "Europe", "population": 2837743, "area": 28748 }, { "countryCode": "DZ", "capital": "Algiers", "difficulty": "medium", "countryName": "Algeria", "continent": "Africa", "population": 44700000, "area": 2381741 }, { "countryCode": "AD", "capital": "Andorra la Vella", "difficulty": "easy", "countryName": "Andorra", "continent": "Europe", "population": 77265, "area": 468 }, { "countryCode": "AO", "capital": "Luanda", "difficulty": "medium", "countryName": "Angola", "continent": "Africa", "population": 32866268, "area": 1246700 }, { "countryCode": "AG", "capital": "St. John's", "difficulty": "hard", "countryName": "Antigua and Barbuda", "continent": "North America", "population": 97928, "area": 442 }, { "countryCode": "AR", "capital": "Buenos Aires", "difficulty": "easy", "countryName": "Argentina", "continent": "South America", "population": 45376763, "area": 2780400 }, { "countryCode": "AM", "capital": "Yerevan", "difficulty": "hard", "countryName": "Armenia", "continent": "Asia", "population": 2963234, "area": 29743 }, { "countryCode": "AU", "capital": "Canberra", "difficulty": "medium", "countryName": "Australia", "continent": "Oceania", "population": 25687041, "area": 7692024 }, { "countryCode": "AT", "capital": "Vienna", "difficulty": "easy", "countryName": "Austria", "continent": "Europe", "population": 8917205, "area": 83871 }, { "countryCode": "AZ", "capital": "Baku", "difficulty": "medium", "countryName": "Azerbaijan", "continent": "Asia", "population": 10110116, "area": 86600 }, { "countryCode": "BS", "capital": "Nassau", "difficulty": "hard", "countryName": "Bahamas", "continent": "North America", "population": 393248, "area": 13943 }, { "countryCode": "BH", "capital": "Manama", "difficulty": "hard", "countryName": "Bahrain", "continent": "Asia", "population": 1701583, "area": 765 }, { "countryCode": "BD", "capital": "Dhaka", "difficulty": "medium", "countryName": "Bangladesh", "continent": "Asia", "population": 164689383, "area": 147570 }, { "countryCode": "BB", "capital": "Bridgetown", "difficulty": "hard", "countryName": "Barbados", "continent": "North America", "population": 287371, "area": 430 }, { "countryCode": "BY", "capital": "Minsk", "difficulty": "hard", "countryName": "Belarus", "continent": "Europe", "population": 9398861, "area": 207600 }, { "countryCode": "BE", "capital": "Brussels", "difficulty": "easy", "countryName": "Belgium", "continent": "Europe", "population": 11555997, "area": 30528 }, { "countryCode": "BZ", "capital": "Belmopan", "difficulty": "hard", "countryName": "Belize", "continent": "North America", "population": 397621, "area": 22966 }, { "countryCode": "BJ", "capital": "Porto-Novo", "difficulty": "hard", "countryName": "Benin", "continent": "Africa", "population": 12123198, "area": 112622 }, { "countryCode": "BT", "capital": "Thimphu", "difficulty": "hard", "countryName": "Bhutan", "continent": "Asia", "population": 771612, "area": 38394 }, { "countryCode": "BO", "capital": "La Paz", "difficulty": "hard", "countryName": "Bolivia", "continent": "South America", "population": 11673029, "area": 1098581 }, { "countryCode": "BA", "capital": "Sarajevo", "difficulty": "hard", "countryName": "Bosnia and Herzegovina", "continent": "Europe", "population": 3280815, "area": 51209 }, { "countryCode": "BW", "capital": "Gaborone", "difficulty": "medium", "countryName": "Botswana", "continent": "Africa", "population": 2351625, "area": 582000 }, { "countryCode": "BR", "capital": "Brasília", "difficulty": "easy", "countryName": "Brazil", "continent": "South America", "population": 212559409, "area": 8515767 }, { "countryCode": "BN", "capital": "Bandar Seri Begawan", "difficulty": "hard", "countryName": "Brunei", "continent": "Asia", "population": 437483, "area": 5765 }, { "countryCode": "BG", "capital": "Sofia", "difficulty": "easy", "countryName": "Bulgaria", "continent": "Europe", "population": 6927288, "area": 110879 }, { "countryCode": "BF", "capital": "Ouagadougou", "difficulty": "medium", "countryName": "Burkina Faso", "continent": "Africa", "population": 20903278, "area": 272967 }, { "countryCode": "BI", "capital": "Bujumbura", "difficulty": "hard", "countryName": "Burundi", "continent": "Africa", "population": 11890781, "area": 27834 }, { "countryCode": "KH", "capital": "Phnom Penh", "difficulty": "hard", "countryName": "Cambodia", "continent": "Asia", "population": 16718971, "area": 181035 }, { "countryCode": "CM", "capital": "Yaoundé", "difficulty": "medium", "countryName": "Cameroon", "continent": "Africa", "population": 26545864, "area": 475442 }, { "countryCode": "CA", "capital": "Ottawa", "difficulty": "easy", "countryName": "Canada", "continent": "North America", "population": 38005238, "area": 9984670 }, { "countryCode": "CV", "capital": "Praia", "difficulty": "medium", "countryName": "Cape Verde", "continent": "Africa", "population": 555988, "area": 4033 }, { "countryCode": "CF", "capital": "Bangui", "difficulty": "medium", "countryName": "Central African Republic", "continent": "Africa", "population": 4829764, "area": 622984 }, { "countryCode": "TD", "capital": "N'Djamena", "difficulty": "hard", "countryName": "Chad", "continent": "Africa", "population": 16425859, "area": 1284000 }, { "countryCode": "CL", "capital": "Santiago", "difficulty": "medium", "countryName": "Chile", "continent": "South America", "population": 19116209, "area": 756102 }, { "countryCode": "CN", "capital": "Beijing", "difficulty": "easy", "countryName": "China", "continent": "Asia", "population": 1402112000, "area": 9706961 }, { "countryCode": "CO", "capital": "Bogotá", "difficulty": "medium", "countryName": "Colombia", "continent": "South America", "population": 50882884, "area": 1141748 }, { "countryCode": "KM", "capital": "Moroni", "difficulty": "hard", "countryName": "Comoros", "continent": "Africa", "population": 869595, "area": 1862 }, { "countryCode": "CD", "capital": "Kinshasa", "difficulty": "hard", "countryName": "Democratic Republic of the Congo", "continent": "Africa", "population": 108407721, "area": 2344858 }, { "countryCode": "CG", "capital": "Brazzaville", "difficulty": "hard", "countryName": "Republic of the Congo", "continent": "Africa", "population": 5657000, "area": 342000 }, { "countryCode": "CR", "capital": "San José", "difficulty": "medium", "countryName": "Costa Rica", "continent": "North America", "population": 5094114, "area": 51100 }, { "countryCode": "CI", "capital": "Yamoussoukro", "difficulty": "medium", "countryName": "Côte d'Ivoire", "continent": "Africa", "population": 26378275, "area": 322463 }, { "countryCode": "HR", "capital": "Zagreb", "difficulty": "easy", "countryName": "Croatia", "continent": "Europe", "population": 4047200, "area": 56594 }, { "countryCode": "CU", "capital": "Havana", "difficulty": "medium", "countryName": "Cuba", "continent": "North America", "population": 11326616, "area": 109884 }, { "countryCode": "CY", "capital": "Nicosia", "difficulty": "hard", "countryName": "Cyprus", "continent": "Asia", "population": 1207361, "area": 9251 }, { "countryCode": "CZ", "capital": "Prague", "difficulty": "easy", "countryName": "Czech Republic", "continent": "Europe", "population": 10698896, "area": 78865 }, { "countryCode": "DK", "capital": "Copenhagen", "difficulty": "easy", "countryName": "Denmark", "continent": "Europe", "population": 5831404, "area": 43094 }, { "countryCode": "DJ", "capital": "Djibouti", "difficulty": "hard", "countryName": "Djibouti", "continent": "Africa", "population": 988002, "area": 23200 }, { "countryCode": "DM", "capital": "Roseau", "difficulty": "hard", "countryName": "Dominica", "continent": "North America", "population": 71991, "area": 751 }, { "countryCode": "DO", "capital": "Santo Domingo", "difficulty": "medium", "countryName": "Dominican Republic", "continent": "North America", "population": 10847904, "area": 48671 }, { "countryCode": "EC", "capital": "Quito", "difficulty": "medium", "countryName": "Ecuador", "continent": "South America", "population": 17643060, "area": 276841 }, { "countryCode": "EG", "capital": "Cairo", "difficulty": "medium", "countryName": "Egypt", "continent": "Africa", "population": 102334403, "area": 1002450 }, { "countryCode": "SV", "capital": "San Salvador", "difficulty": "medium", "countryName": "El Salvador", "continent": "North America", "population": 6486201, "area": 21041 }, { "countryCode": "GQ", "capital": "Malabo", "difficulty": "hard", "countryName": "Equatorial Guinea", "continent": "Africa", "population": 1402985, "area": 28051 }, { "countryCode": "ER", "capital": "Asmara", "difficulty": "hard", "countryName": "Eritrea", "continent": "Africa", "population": 5352000, "area": 117600 }, { "countryCode": "EE", "capital": "Tallinn", "difficulty": "medium", "countryName": "Estonia", "continent": "Europe", "population": 1331057, "area": 45227 }, { "countryCode": "ET", "capital": "Addis Ababa", "difficulty": "hard", "countryName": "Ethiopia", "continent": "Africa", "population": 114963583, "area": 1104300 }, { "countryCode": "FJ", "capital": "Suva", "difficulty": "hard", "countryName": "Fiji", "continent": "Oceania", "population": 896444, "area": 18272 }, { "countryCode": "FI", "capital": "Helsinki", "difficulty": "medium", "countryName": "Finland", "continent": "Europe", "population": 5530719, "area": 338424 }, { "countryCode": "FR", "capital": "Paris", "difficulty": "easy", "countryName": "France", "continent": "Europe", "population": 67391582, "area": 551695 }, { "countryCode": "GA", "capital": "Libreville", "difficulty": "hard", "countryName": "Gabon", "continent": "Africa", "population": 2225728, "area": 267668 }, { "countryCode": "GM", "capital": "Banjul", "difficulty": "hard", "countryName": "Gambia", "continent": "Africa", "population": 2416664, "area": 10689 }, { "countryCode": "GE", "capital": "Tbilisi", "difficulty": "hard", "countryName": "Georgia", "continent": "Asia", "population": 3714000, "area": 69700 }, { "countryCode": "DE", "capital": "Berlin", "difficulty": "easy", "countryName": "Germany", "continent": "Europe", "population": 83240525, "area": 357114 }, { "countryCode": "GH", "capital": "Accra", "difficulty": "medium", "countryName": "Ghana", "continent": "Africa", "population": 31072945, "area": 238533 }, { "countryCode": "GR", "capital": "Athens", "difficulty": "easy", "countryName": "Greece", "continent": "Europe", "population": 10715549, "area": 131990 }, { "countryCode": "GD", "capital": "St. George's", "difficulty": "hard", "countryName": "Grenada", "continent": "North America", "population": 112519, "area": 344 }, { "countryCode": "GT", "capital": "Guatemala City", "difficulty": "medium", "countryName": "Guatemala", "continent": "North America", "population": 16858333, "area": 108889 }, { "countryCode": "GN", "capital": "Conakry", "difficulty": "medium", "countryName": "Guinea", "continent": "Africa", "population": 13132792, "area": 245857 }, { "countryCode": "GW", "capital": "Bissau", "difficulty": "medium", "countryName": "Guinea-Bissau", "continent": "Africa", "population": 1967998, "area": 36125 }, { "countryCode": "GY", "capital": "Georgetown", "difficulty": "medium", "countryName": "Guyana", "continent": "South America", "population": 786559, "area": 214969 }, { "countryCode": "HT", "capital": "Port-au-Prince", "difficulty": "medium", "countryName": "Haiti", "continent": "North America", "population": 11402533, "area": 27750 }, { "countryCode": "HN", "capital": "Tegucigalpa", "difficulty": "medium", "countryName": "Honduras", "continent": "North America", "population": 9904608, "area": 112492 }, { "countryCode": "HU", "capital": "Budapest", "difficulty": "medium", "countryName": "Hungary", "continent": "Europe", "population": 9749763, "area": 93028 }, { "countryCode": "IS", "capital": "Reykjavik", "difficulty": "medium", "countryName": "Iceland", "continent": "Europe", "population": 366425, "area": 103000 }, { "countryCode": "IN", "capital": "New Delhi", "difficulty": "easy", "countryName": "India", "continent": "Asia", "population": 1380004385, "area": 3287590 }, { "countryCode": "ID", "capital": "Jakarta", "difficulty": "medium", "countryName": "Indonesia", "continent": "Asia", "population": 273523621, "area": 1904569 }, { "countryCode": "IR", "capital": "Tehran", "difficulty": "hard", "countryName": "Iran", "continent": "Asia", "population": 83992953, "area": 1648195 }, { "countryCode": "IQ", "capital": "Baghdad", "difficulty": "hard", "countryName": "Iraq", "continent": "Asia", "population": 40222503, "area": 438317 }, { "countryCode": "IE", "capital": "Dublin", "difficulty": "easy", "countryName": "Ireland", "continent": "Europe", "population": 4994724, "area": 70273 }, { "countryCode": "IL", "capital": "Jerusalem", "difficulty": "medium", "countryName": "Israel", "continent": "Asia", "population": 9216900, "area": 20770 }, { "countryCode": "IT", "capital": "Rome", "difficulty": "easy", "countryName": "Italy", "continent": "Europe", "population": 59554023, "area": 301336 }, { "countryCode": "JM", "capital": "Kingston", "difficulty": "medium", "countryName": "Jamaica", "continent": "North America", "population": 2961161, "area": 10991 }, { "countryCode": "JP", "capital": "Tokyo", "difficulty": "easy", "countryName": "Japan", "continent": "Asia", "population": 125836021, "area": 377930 }, { "countryCode": "JO", "capital": "Amman", "difficulty": "hard", "countryName": "Jordan", "continent": "Asia", "population": 10203140, "area": 89342 }, { "countryCode": "KZ", "capital": "Nur-Sultan", "difficulty": "medium", "countryName": "Kazakhstan", "continent": "Asia", "population": 18754440, "area": 2724900 }, { "countryCode": "KE", "capital": "Nairobi", "difficulty": "medium", "countryName": "Kenya", "continent": "Africa", "population": 53771300, "area": 580367 }, { "countryCode": "KI", "capital": "Tarawa", "difficulty": "hard", "countryName": "Kiribati", "continent": "Oceania", "population": 119446, "area": 811 }, { "countryCode": "KP", "capital": "Pyongyang", "difficulty": "hard", "countryName": "North Korea", "continent": "Asia", "population": 25778815, "area": 120538 }, { "countryCode": "KR", "capital": "Seoul", "difficulty": "easy", "countryName": "South Korea", "continent": "Asia", "population": 51780579, "area": 100210 }, { "countryCode": "KW", "capital": "Kuwait City", "difficulty": "medium", "countryName": "Kuwait", "continent": "Asia", "population": 4270563, "area": 17818 }, { "countryCode": "KG", "capital": "Bishkek", "difficulty": "hard", "countryName": "Kyrgyzstan", "continent": "Asia", "population": 6591600, "area": 199951 }, { "countryCode": "LA", "capital": "Vientiane", "difficulty": "hard", "countryName": "Laos", "continent": "Asia", "population": 7275556, "area": 236800 }, { "countryCode": "LV", "capital": "Riga", "difficulty": "medium", "countryName": "Latvia", "continent": "Europe", "population": 1901548, "area": 64559 }, { "countryCode": "LB", "capital": "Beirut", "difficulty": "medium", "countryName": "Lebanon", "continent": "Asia", "population": 6825442, "area": 10452 }, { "countryCode": "LS", "capital": "Maseru", "difficulty": "medium", "countryName": "Lesotho", "continent": "Africa", "population": 2142252, "area": 30355 }, { "countryCode": "LR", "capital": "Monrovia", "difficulty": "medium", "countryName": "Liberia", "continent": "Africa", "population": 5057677, "area": 111369 }, { "countryCode": "LY", "capital": "Tripoli", "difficulty": "hard", "countryName": "Libya", "continent": "Africa", "population": 6871287, "area": 1759540 }, { "countryCode": "LI", "capital": "Vaduz", "difficulty": "hard", "countryName": "Liechtenstein", "continent": "Europe", "population": 38137, "area": 160 }, { "countryCode": "LT", "capital": "Vilnius", "difficulty": "medium", "countryName": "Lithuania", "continent": "Europe", "population": 2794700, "area": 65300 }, { "countryCode": "LU", "capital": "Luxembourg City", "difficulty": "medium", "countryName": "Luxembourg", "continent": "Europe", "population": 632275, "area": 2586 }, { "countryCode": "MG", "capital": "Antananarivo", "difficulty": "medium", "countryName": "Madagascar", "continent": "Africa", "population": 27691019, "area": 587041 }, { "countryCode": "MW", "capital": "Lilongwe", "difficulty": "hard", "countryName": "Malawi", "continent": "Africa", "population": 19129955, "area": 118484 }, { "countryCode": "MY", "capital": "Kuala Lumpur", "difficulty": "medium", "countryName": "Malaysia", "continent": "Asia", "population": 32365998, "area": 330803 }, { "countryCode": "MV", "capital": "Male", "difficulty": "medium", "countryName": "Maldives", "continent": "Asia", "population": 540542, "area": 300 }, { "countryCode": "ML", "capital": "Bamako", "difficulty": "medium", "countryName": "Mali", "continent": "Africa", "population": 20250834, "area": 1240192 }, { "countryCode": "MT", "capital": "Valletta", "difficulty": "hard", "countryName": "Malta", "continent": "Europe", "population": 525285, "area": 316 }, { "countryCode": "MH", "capital": "Majuro", "difficulty": "hard", "countryName": "Marshall Islands", "continent": "Oceania", "population": 59194, "area": 181 }, { "countryCode": "MR", "capital": "Nouakchott", "difficulty": "medium", "countryName": "Mauritania", "continent": "Africa", "population": 4649660, "area": 1030700 }, { "countryCode": "MU", "capital": "Port Louis", "difficulty": "medium", "countryName": "Mauritius", "continent": "Africa", "population": 1265740, "area": 2040 }, { "countryCode": "MX", "capital": "Mexico City", "difficulty": "easy", "countryName": "Mexico", "continent": "North America", "population": 128932753, "area": 1964375 }, { "countryCode": "FM", "capital": "Palikir", "difficulty": "medium", "countryName": "Micronesia", "continent": "Oceania", "population": 115021, "area": 702 }, { "countryCode": "MD", "capital": "Chisinau", "difficulty": "medium", "countryName": "Moldova", "continent": "Europe", "population": 2617820, "area": 33846 }, { "countryCode": "MC", "capital": "Monaco", "difficulty": "medium", "countryName": "Monaco", "continent": "Europe", "population": 39244, "area": 2.02 }, { "countryCode": "MN", "capital": "Ulaanbaatar", "difficulty": "medium", "countryName": "Mongolia", "continent": "Asia", "population": 3278292, "area": 1564110 }, { "countryCode": "ME", "capital": "Podgorica", "difficulty": "medium", "countryName": "Montenegro", "continent": "Europe", "population": 621718, "area": 13812 }, { "countryCode": "MA", "capital": "Rabat", "difficulty": "medium", "countryName": "Morocco", "continent": "Africa", "population": 36910558, "area": 446550 }, { "countryCode": "MZ", "capital": "Maputo", "difficulty": "medium", "countryName": "Mozambique", "continent": "Africa", "population": 31255435, "area": 801590 }, { "countryCode": "MM", "capital": "Naypyidaw", "difficulty": "hard", "countryName": "Myanmar (Burma)", "continent": "Asia", "population": 54409794, "area": 676578 }, { "countryCode": "NA", "capital": "Windhoek", "difficulty": "hard", "countryName": "Namibia", "continent": "Africa", "population": 2540916, "area": 825615 }, { "countryCode": "NR", "capital": "Yaren", "difficulty": "hard", "countryName": "Nauru", "continent": "Oceania", "population": 10834, "area": 21 }, { "countryCode": "NP", "capital": "Kathmandu", "difficulty": "medium", "countryName": "Nepal", "continent": "Asia", "population": 29136808, "area": 147181 }, { "countryCode": "NL", "capital": "Amsterdam", "difficulty": "easy", "countryName": "Netherlands", "continent": "Europe", "population": 16655799, "area": 41850 }, { "countryCode": "NZ", "capital": "Wellington", "difficulty": "medium", "countryName": "New Zealand", "continent": "Oceania", "population": 5084300, "area": 270467 }, { "countryCode": "NI", "capital": "Managua", "difficulty": "hard", "countryName": "Nicaragua", "continent": "North America", "population": 6624554, "area": 130373 }, { "countryCode": "NE", "capital": "Niamey", "difficulty": "hard", "countryName": "Niger", "continent": "Africa", "population": 24206636, "area": 1267000 }, { "countryCode": "NG", "capital": "Abuja", "difficulty": "medium", "countryName": "Nigeria", "continent": "Africa", "population": 206139587, "area": 923768 }, { "countryCode": "MK", "capital": "Skopje", "difficulty": "medium", "countryName": "North Macedonia", "continent": "Europe", "population": 2077132, "area": 25713 }, { "countryCode": "NO", "capital": "Oslo", "difficulty": "easy", "countryName": "Norway", "continent": "Europe", "population": 5379475, "area": 323802 }, { "countryCode": "OM", "capital": "Muscat", "difficulty": "hard", "countryName": "Oman", "continent": "Asia", "population": 5106622, "area": 309500 }, { "countryCode": "PK", "capital": "Islamabad", "difficulty": "medium", "countryName": "Pakistan", "continent": "Asia", "population": 220892331, "area": 881912 }, { "countryCode": "PW", "capital": "Ngerulmud", "difficulty": "hard", "countryName": "Palau", "continent": "Oceania", "population": 18092, "area": 459 }, { "countryCode": "PA", "capital": "Panama City", "difficulty": "medium", "countryName": "Panama", "continent": "North America", "population": 4314768, "area": 75417 }, { "countryCode": "PG", "capital": "Port Moresby", "difficulty": "hard", "countryName": "Papua New Guinea", "continent": "Oceania", "population": 8947027, "area": 462840 }, { "countryCode": "PY", "capital": "Asunción", "difficulty": "medium", "countryName": "Paraguay", "continent": "South America", "population": 7132530, "area": 406752 }, { "countryCode": "PE", "capital": "Lima", "difficulty": "medium", "countryName": "Peru", "continent": "South America", "population": 32971846, "area": 1285216 }, { "countryCode": "PH", "capital": "Manila", "difficulty": "medium", "countryName": "Philippines", "continent": "Asia", "population": 109581085, "area": 342353 }, { "countryCode": "PL", "capital": "Warsaw", "difficulty": "medium", "countryName": "Poland", "continent": "Europe", "population": 37950802, "area": 312679 }, { "countryCode": "PT", "capital": "Lisbon", "difficulty": "easy", "countryName": "Portugal", "continent": "Europe", "population": 10305564, "area": 92090 }, { "countryCode": "QA", "capital": "Doha", "difficulty": "medium", "countryName": "Qatar", "continent": "Asia", "population": 2881060, "area": 11586 }, { "countryCode": "RO", "capital": "Bucharest", "difficulty": "medium", "countryName": "Romania", "continent": "Europe", "population": 19286123, "area": 238391 }, { "countryCode": "RU", "capital": "Moscow", "difficulty": "easy", "countryName": "Russia", "continent": "Europe", "population": 144104080, "area": 17098242 }, { "countryCode": "RW", "capital": "Kigali", "difficulty": "medium", "countryName": "Rwanda", "continent": "Africa", "population": 12952209, "area": 26338 }, { "countryCode": "KN", "capital": "Basseterre", "difficulty": "hard", "countryName": "Saint Kitts and Nevis", "continent": "North America", "population": 53192, "area": 261 }, { "countryCode": "LC", "capital": "Castries", "difficulty": "hard", "countryName": "Saint Lucia", "continent": "North America", "population": 183629, "area": 616 }, { "countryCode": "VC", "capital": "Kingstown", "difficulty": "hard", "countryName": "Saint Vincent and the Grenadines", "continent": "North America", "population": 110947, "area": 389 }, { "countryCode": "WS", "capital": "Apia", "difficulty": "hard", "countryName": "Samoa", "continent": "Oceania", "population": 198410, "area": 2842 }, { "countryCode": "SM", "capital": "San Marino", "difficulty": "hard", "countryName": "San Marino", "continent": "Europe", "population": 33938, "area": 61 }, { "countryCode": "ST", "capital": "São Tomé", "difficulty": "medium", "countryName": "Sao Tome and Principe", "continent": "Africa", "population": 219161, "area": 964 }, { "countryCode": "SA", "capital": "Riyadh", "difficulty": "medium", "countryName": "Saudi Arabia", "continent": "Asia", "population": 34813867, "area": 2149690 }, { "countryCode": "SN", "capital": "Dakar", "difficulty": "medium", "countryName": "Senegal", "continent": "Africa", "population": 16743930, "area": 196722 }, { "countryCode": "RS", "capital": "Belgrade", "difficulty": "medium", "countryName": "Serbia", "continent": "Europe", "population": 6908224, "area": 88361 }, { "countryCode": "SC", "capital": "Victoria", "difficulty": "hard", "countryName": "Seychelles", "continent": "Africa", "population": 98462, "area": 452 }, { "countryCode": "SL", "capital": "Freetown", "difficulty": "medium", "countryName": "Sierra Leone", "continent": "Africa", "population": 7976985, "area": 71740 }, { "countryCode": "SG", "capital": "Singapore", "difficulty": "easy", "countryName": "Singapore", "continent": "Asia", "population": 5685807, "area": 710 }, { "countryCode": "SK", "capital": "Bratislava", "difficulty": "medium", "countryName": "Slovakia", "continent": "Europe", "population": 5458827, "area": 49037 }, { "countryCode": "SI", "capital": "Ljubljana", "difficulty": "medium", "countryName": "Slovenia", "continent": "Europe", "population": 2100126, "area": 20273 }, { "countryCode": "SB", "capital": "Honiara", "difficulty": "hard", "countryName": "Solomon Islands", "continent": "Oceania", "population": 686878, "area": 28896 }, { "countryCode": "SO", "capital": "Mogadishu", "difficulty": "hard", "countryName": "Somalia", "continent": "Africa", "population": 15893219, "area": 637657 }, { "countryCode": "ZA", "capital": "Pretoria", "difficulty": "medium", "countryName": "South Africa", "continent": "Africa", "population": 59308690, "area": 1221037 }, { "countryCode": "SS", "capital": "Juba", "difficulty": "medium", "countryName": "South Sudan", "continent": "Africa", "population": 11193729, "area": 619745 }, { "countryCode": "ES", "capital": "Madrid", "difficulty": "easy", "countryName": "Spain", "continent": "Europe", "population": 47351567, "area": 505992 }, { "countryCode": "LK", "capital": "Sri Jayawardenepura Kotte", "difficulty": "medium", "countryName": "Sri Lanka", "continent": "Asia", "population": 21919000, "area": 65610 }, { "countryCode": "SD", "capital": "Khartoum", "difficulty": "hard", "countryName": "Sudan", "continent": "Africa", "population": 43849269, "area": 1886068 }, { "countryCode": "SR", "capital": "Paramaribo", "difficulty": "medium", "countryName": "Suriname", "continent": "South America", "population": 586634, "area": 163820 }, { "countryCode": "SZ", "capital": "Mbabane", "difficulty": "hard", "countryName": "Eswatini", "continent": "Africa", "population": 1160164, "area": 17364 }, { "countryCode": "SE", "capital": "Stockholm", "difficulty": "medium", "countryName": "Sweden", "continent": "Europe", "population": 10353442, "area": 450295 }, { "countryCode": "CH", "capital": "Bern", "difficulty": "medium", "countryName": "Switzerland", "continent": "Europe", "population": 8654622, "area": 41284 }, { "countryCode": "SY", "capital": "Damascus", "difficulty": "medium", "countryName": "Syria", "continent": "Asia", "population": 17500657, "area": 185180 }, { "countryCode": "TW", "capital": "Taipei", "difficulty": "medium", "countryName": "Taiwan", "continent": "Asia", "population": 23503349, "area": 36193 }, { "countryCode": "TJ", "capital": "Dushanbe", "difficulty": "hard", "countryName": "Tajikistan", "continent": "Asia", "population": 9537642, "area": 143100 }, { "countryCode": "TZ", "capital": "Dodoma", "difficulty": "hard", "countryName": "Tanzania", "continent": "Africa", "population": 59734213, "area": 945087 }, { "countryCode": "TH", "capital": "Bangkok", "difficulty": "medium", "countryName": "Thailand", "continent": "Asia", "population": 69799978, "area": 513120 }, { "countryCode": "TL", "capital": "Dili", "difficulty": "hard", "countryName": "Timor-Leste", "continent": "Asia", "population": 1318442, "area": 14874 }, { "countryCode": "TG", "capital": "Lomé", "difficulty": "medium", "countryName": "Togo", "continent": "Africa", "population": 8278737, "area": 56785 }, { "countryCode": "TO", "capital": "Nuku'alofa", "difficulty": "medium", "countryName": "Tonga", "continent": "Oceania", "population": 105697, "area": 747 }, { "countryCode": "TT", "capital": "Port of Spain", "difficulty": "medium", "countryName": "Trinidad and Tobago", "continent": "North America", "population": 1399491, "area": 5130 }, { "countryCode": "TN", "capital": "Tunis", "difficulty": "medium", "countryName": "Tunisia", "continent": "Africa", "population": 11818618, "area": 163610 }, { "countryCode": "TR", "capital": "Ankara", "difficulty": "medium", "countryName": "Turkey", "continent": "Asia", "population": 84339067, "area": 783562 }, { "countryCode": "TM", "capital": "Ashgabat", "difficulty": "hard", "countryName": "Turkmenistan", "continent": "Asia", "population": 6031187, "area": 488100 }, { "countryCode": "TV", "capital": "Funafuti", "difficulty": "hard", "countryName": "Tuvalu", "continent": "Oceania", "population": 11792, "area": 26 }, { "countryCode": "UG", "capital": "Kampala", "difficulty": "hard", "countryName": "Uganda", "continent": "Africa", "population": 45741000, "area": 241550 }, { "countryCode": "UA", "capital": "Kyiv", "difficulty": "medium", "countryName": "Ukraine", "continent": "Europe", "population": 44134693, "area": 603500 }, { "countryCode": "AE", "capital": "Abu Dhabi", "difficulty": "medium", "countryName": "United Arab Emirates", "continent": "Asia", "population": 9890400, "area": 83600 }, { "countryCode": "GB", "capital": "London", "difficulty": "easy", "countryName": "United Kingdom", "continent": "Europe", "population": 67215293, "area": 242900 }, { "countryCode": "US", "capital": "Washington D.C.", "difficulty": "easy", "countryName": "United States of America", "continent": "North America", "population": 329484123, "area": 9372610 }, { "countryCode": "UY", "capital": "Montevideo", "difficulty": "medium", "countryName": "Uruguay", "continent": "South America", "population": 3473727, "area": 181034 }, { "countryCode": "UZ", "capital": "Tashkent", "difficulty": "medium", "countryName": "Uzbekistan", "continent": "Asia", "population": 34232050, "area": 447400 }, { "countryCode": "VU", "capital": "Port Vila", "difficulty": "medium", "countryName": "Vanuatu", "continent": "Oceania", "population": 307150, "area": 12189 }, { "countryCode": "VE", "capital": "Caracas", "difficulty": "medium", "countryName": "Venezuela", "continent": "South America", "population": 28435943, "area": 916445 }, { "countryCode": "VN", "capital": "Hanoi", "difficulty": "medium", "countryName": "Vietnam", "continent": "Asia", "population": 97338583, "area": 331212 }, { "countryCode": "EH", "capital": "El Aaiún", "difficulty": "hard", "countryName": "Western Sahara", "continent": "Africa", "population": 510713, "area": 266000 }, { "countryCode": "YE", "capital": "Sana'a", "difficulty": "hard", "countryName": "Yemen", "continent": "Asia", "population": 29825968, "area": 527968 }, { "countryCode": "ZM", "capital": "Lusaka", "difficulty": "hard", "countryName": "Zambia", "continent": "Africa", "population": 18383956, "area": 752612 }, { "countryCode": "ZW", "capital": "Harare", "difficulty": "hard", "countryName": "Zimbabwe", "continent": "Africa", "population": 14862927, "area": 390757 } ] ================================================ FILE: data/src/test/java/com/infinitepower/newquiz/data/local/math_quiz/MathQuizCoreRepositoryImplTest.kt ================================================ package com.infinitepower.newquiz.data.local.math_quiz import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.math.evaluator.Expressions import com.infinitepower.newquiz.data.repository.math_quiz.MathQuizCoreRepositoryImpl import com.infinitepower.newquiz.domain.repository.math_quiz.MathQuizCoreRepository import com.infinitepower.newquiz.model.math_quiz.MathFormula import com.infinitepower.newquiz.model.question.QuestionDifficulty import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import kotlin.time.measureTime import kotlin.time.measureTimedValue internal class MathQuizCoreRepositoryImplTest { private lateinit var mathQuizCoreRepository: MathQuizCoreRepositoryImpl companion object { @JvmStatic private fun getGenerateMathFormulaParams() = listOf( Arguments.of(1, QuestionDifficulty.Easy), Arguments.of(1, QuestionDifficulty.Medium), Arguments.of(1, QuestionDifficulty.Hard), Arguments.of(2, QuestionDifficulty.Easy), Arguments.of(2, QuestionDifficulty.Medium), Arguments.of(2, QuestionDifficulty.Hard), Arguments.of(3, QuestionDifficulty.Easy), Arguments.of(3, QuestionDifficulty.Medium), Arguments.of(3, QuestionDifficulty.Hard) ) } @BeforeEach fun setup() { val expressions = Expressions() mathQuizCoreRepository = MathQuizCoreRepositoryImpl(expressions) } @ParameterizedTest(name = "generate math formula, operator size: {0}, difficulty: {1}") @MethodSource("getGenerateMathFormulaParams") fun `generate math formula`( operatorSize: Int, difficulty: QuestionDifficulty ) { val timedValue = measureTimedValue { mathQuizCoreRepository.generateMathFormula(operatorSize, difficulty) } println("Time: ${timedValue.duration}") val formula = timedValue.value println("Formula: $formula") assertThat(formula.fullFormula).matches(MathFormula.mathFormulaRegex.toPattern()) assertThat(formula.solution).isIn(MathQuizCoreRepository.SOLUTION_RANGE) val solution = Expressions().eval(formula.leftFormula).toInt() assertThat(solution).isEqualTo(formula.solution) } @Test fun `validate correct formulas`() { // Test formulas with not valid operators assertThat(mathQuizCoreRepository.validateFormula("2+2s=4")).isFalse() // Test valid formulas assertThat(mathQuizCoreRepository.validateFormula("2+2=4")).isTrue() assertThat(mathQuizCoreRepository.validateFormula("2*3=6")).isTrue() assertThat(mathQuizCoreRepository.validateFormula("10/5=2")).isTrue() // Test formulas with multiple equals signs assertThat(mathQuizCoreRepository.validateFormula("2+2=4=")).isFalse() assertThat(mathQuizCoreRepository.validateFormula("2+2=4=8")).isFalse() // Test formulas with empty left-hand side expression assertThat(mathQuizCoreRepository.validateFormula("=4")).isFalse() assertThat(mathQuizCoreRepository.validateFormula(" = 4 ")).isFalse() // Test formulas with empty right-hand side solution assertThat(mathQuizCoreRepository.validateFormula("2+2=")).isFalse() assertThat(mathQuizCoreRepository.validateFormula("2+2= ")).isFalse() // Test formulas with invalid right-hand side solution assertThat(mathQuizCoreRepository.validateFormula("2+2=abc")).isFalse() assertThat(mathQuizCoreRepository.validateFormula("2+2=1.2.3")).isFalse() // Test formulas with invalid left-hand side expression assertThat(mathQuizCoreRepository.validateFormula("2+a=4")).isFalse() measureTime { mathQuizCoreRepository.validateFormula("2+2=4") }.also { println(it) } } } ================================================ FILE: data/src/test/java/com/infinitepower/newquiz/data/local/wordle/WordleCategoriesTest.kt ================================================ package com.infinitepower.newquiz.data.local.wordle import com.google.common.truth.Truth import org.junit.jupiter.api.Test internal class WordleCategoriesTest { @Test fun `test getRandomWordleCategory when not internet connection`() { val allCategories = WordleCategories.allCategories val isInternetAvailable = false val categoriesWithoutInternet = allCategories.filter { !it.requireInternetConnection } val randomCategory = WordleCategories.random(isInternetAvailable) Truth.assertThat(randomCategory).isIn(categoriesWithoutInternet) Truth.assertThat(randomCategory.requireInternetConnection).isEqualTo(isInternetAvailable) } @Test fun `test getRandomWordleCategory when internet connection`() { val allCategories = WordleCategories.allCategories val isInternetAvailable = true val randomCategory = WordleCategories.random(isInternetAvailable) Truth.assertThat(randomCategory).isIn(allCategories) // If internet is available, it can return any category Truth.assertThat(randomCategory.requireInternetConnection).isAnyOf(true, false) } } ================================================ FILE: data/src/test/java/com/infinitepower/newquiz/data/repository/comparison_quiz/ComparisonQuizApiImplTest.kt ================================================ package com.infinitepower.newquiz.data.repository.comparison_quiz import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.testing.utils.mockAndroidLog import com.infinitepower.newquiz.domain.repository.CountryRepository import com.infinitepower.newquiz.model.NumberFormatType import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItemEntity import com.infinitepower.newquiz.model.country.Continent import com.infinitepower.newquiz.model.country.Country import com.infinitepower.newquiz.model.question.QuestionDifficulty import com.infinitepower.newquiz.model.toUiText import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.utils.io.ByteReadChannel import io.mockk.coEvery import io.mockk.coVerify import io.mockk.confirmVerified import io.mockk.mockk import kotlinx.coroutines.test.runTest import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.net.URI import kotlin.test.BeforeTest import kotlin.test.Test /** * Tests for [ComparisonQuizApiImpl] */ internal class ComparisonQuizApiImplTest { private lateinit var comparisonQuizApi: ComparisonQuizApiImpl private val countryRepository: CountryRepository = mockk() private val mockEngine = MockEngine { request -> val size = request.url.parameters["size"]?.toIntOrNull() ?: 0 val randomQuestions = List(size) { ComparisonQuizItemEntity( title = "Item $it", value = it.toDouble(), imgUrl = "url$it" ) } respond( content = ByteReadChannel(Json.encodeToString(randomQuestions)), status = HttpStatusCode.OK, headers = headersOf(HttpHeaders.ContentType, "application/json") ) } @BeforeTest fun setup() { mockAndroidLog() mockLocalCountries() comparisonQuizApi = ComparisonQuizApiImpl( client = HttpClient(mockEngine), countryRepository = countryRepository ) } @Test fun `generateQuestions should return questions from remote API when generateQuestionsLocally is false`() = runTest { val category = getCategory(generateQuestionsLocally = false) val size = 10 val questions = comparisonQuizApi.generateQuestions( category = category, size = size ) assertThat(mockEngine.requestHistory).hasSize(1) assertThat(questions).hasSize(size) coVerify(exactly = 0) { countryRepository.getAllCountries() } confirmVerified(countryRepository) } @Test fun `generateQuestions should return questions from local repository when generateQuestionsLocally is true and category is supported`() = runTest { val category = getCategory( id = ComparisonQuizApiImpl.supportedLocalCategories.random(), generateQuestionsLocally = true ) val size = 10 val questions = comparisonQuizApi.generateQuestions( category = category, size = size ) // Check if no calls were made to the remote API assertThat(mockEngine.requestHistory).isEmpty() assertThat(questions).hasSize(size) coVerify(exactly = 1) { countryRepository.getAllCountries() } confirmVerified(countryRepository) } @Test fun `generateQuestions should return questions from remote API when generateQuestionsLocally is true and category is not supported`() = runTest { val category = getCategory( id = "not_supported", generateQuestionsLocally = true ) val size = 10 val questions = comparisonQuizApi.generateQuestions( category = category, size = size ) assertThat(mockEngine.requestHistory).hasSize(1) assertThat(questions).hasSize(size) coVerify(exactly = 0) { countryRepository.getAllCountries() } confirmVerified(countryRepository) } private fun getCategory( id: String = "test", generateQuestionsLocally: Boolean = false ) = ComparisonQuizCategory( id = id, name = "test".toUiText(), image = "", description = "", questionDescription = ComparisonQuizCategory.QuestionDescription( greater = "greater", less = "less" ), formatType = NumberFormatType.DEFAULT, generateQuestionsLocally = generateQuestionsLocally ) private fun mockLocalCountries() { val countries = List(100) { Country( countryCode = "code$it", countryName = "name$it", capital = "capital$it", population = it.toLong(), area = it.toDouble(), continent = Continent.from("Europe"), flagImage = URI.create("flag$it"), difficulty = QuestionDifficulty.random() ) } coEvery { countryRepository.getAllCountries() } returns countries } } ================================================ FILE: data/src/test/java/com/infinitepower/newquiz/data/repository/comparison_quiz/ComparisonQuizRepositoryImplTest.kt ================================================ package com.infinitepower.newquiz.data.repository.comparison_quiz import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.database.dao.GameResultDao 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.model.NumberFormatType import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItemEntity import io.mockk.coEvery import io.mockk.coVerify import io.mockk.confirmVerified import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.test.BeforeTest import kotlin.test.Test /** * Tests for [ComparisonQuizRepositoryImpl] */ internal class ComparisonQuizRepositoryImplTest { private lateinit var repository: ComparisonQuizRepositoryImpl private val remoteConfig: RemoteConfig = mockk() private val gameResultDao: GameResultDao = mockk() private val comparisonQuizApi: ComparisonQuizApi = mockk() @BeforeTest fun setUp() { repository = ComparisonQuizRepositoryImpl( remoteConfig = remoteConfig, gameResultDao = gameResultDao, comparisonQuizApi = comparisonQuizApi ) } @Test fun `getCategories() should return list of categories`() { val categories = List(10) { id -> ComparisonQuizCategory( id = id.toString(), name = UiText.DynamicString("name $id"), image = "image$id", description = "description $id", formatType = NumberFormatType.DEFAULT, questionDescription = ComparisonQuizCategory.QuestionDescription( greater = "greater $id", less = "less $id" ) ) } val categoriesEntity = categories.map { it.toEntity() } every { remoteConfig.get(RemoteConfigValue.COMPARISON_QUIZ_CATEGORIES) } returns Json.encodeToString(categoriesEntity) val result = repository.getCategories() assertThat(result).containsExactlyElementsIn(categories) verify(exactly = 1) { remoteConfig.get(RemoteConfigValue.COMPARISON_QUIZ_CATEGORIES) } // Check if the second call to getCategories() returns the same list of categories cached // and does not call remoteConfig.get() again val result2 = repository.getCategories() assertThat(result2).containsExactlyElementsIn(categories) // Not called again verify(exactly = 1) { remoteConfig.get(RemoteConfigValue.COMPARISON_QUIZ_CATEGORIES) } confirmVerified(remoteConfig) } @Test fun `getHighestPosition() should return highest position`() = runTest { val highestPosition = 10 coEvery { gameResultDao.getComparisonQuizHighestPosition("1") } returns highestPosition val result = repository.getHighestPosition("1") assertThat(result).isEqualTo(highestPosition) coVerify(exactly = 1) { gameResultDao.getComparisonQuizHighestPosition("1") } confirmVerified(gameResultDao) } @Test fun `getHighestPositionFlow() should return highest position`() = runTest { val highestPosition = 10 every { gameResultDao.getComparisonQuizHighestPositionFlow("1") } returns flowOf(highestPosition) val result = repository.getHighestPositionFlow("1") result.test { assertThat(awaitItem()).isEqualTo(highestPosition) awaitComplete() } verify(exactly = 1) { gameResultDao.getComparisonQuizHighestPositionFlow("1") } confirmVerified(gameResultDao) } @Test fun `getQuestions() should return list of questions`() = runTest { val questionsToGenerate = 10 val questionsEntity = List(questionsToGenerate) { id -> ComparisonQuizItemEntity( title = "title $id", value = id.toDouble(), imgUrl = "", ) } val category = ComparisonQuizCategory( id = "1", name = UiText.DynamicString("name"), image = "image", description = "description", formatType = NumberFormatType.DEFAULT, questionDescription = ComparisonQuizCategory.QuestionDescription( greater = "greater", less = "less" ) ) coEvery { comparisonQuizApi.generateQuestions( category = category, size = questionsToGenerate, ) } returns questionsEntity val result = repository.getQuestions( category = category, size = questionsToGenerate, ) result.forEachIndexed { index, question -> assertThat(question.title).isNotEmpty() assertThat(question.value).isEqualTo(index.toDouble()) assertThat(question.imgUri).isNotNull() } coVerify(exactly = 1) { comparisonQuizApi.generateQuestions( category = category, size = questionsToGenerate, ) } confirmVerified(comparisonQuizApi) } } ================================================ FILE: data/src/test/java/com/infinitepower/newquiz/data/repository/country/CountryRepositoryImplTest.kt ================================================ package com.infinitepower.newquiz.data.repository.country import android.content.Context import com.google.common.truth.Truth.assertThat 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 io.mockk.coEvery import io.mockk.coVerify import io.mockk.confirmVerified import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.test.Test /** * Tests for [CountryRepositoryImpl] */ internal class CountryRepositoryImplTest { @Test fun `test get countries`() = runTest { val context = mockk() val countriesEntities = List(10) { CountryEntity( countryCode = "TEST_$it", countryName = "Test Country $it", capital = "Test Capital $it", continent = "Europe", difficulty = "easy", population = 1000, area = 1000.0 ) } val inputStream = Json .encodeToString(countriesEntities) .byteInputStream() coEvery { context.resources.openRawResource(any()) } returns inputStream val remoteConfig = mockk() every { remoteConfig.get(RemoteConfigValue.FLAG_BASE_URL) } returns "https://example.com/%code%.png" val countryRepository = CountryRepositoryImpl( context = context, remoteConfig = remoteConfig ) val allCountries = countryRepository.getAllCountries() assertThat(allCountries).hasSize(countriesEntities.size) coVerify(exactly = 1) { context.resources.openRawResource(any()) } coVerify(exactly = 1) { remoteConfig.get(RemoteConfigValue.FLAG_BASE_URL) } confirmVerified(context, remoteConfig) } } ================================================ FILE: data/src/test/java/com/infinitepower/newquiz/data/repository/country/TestCountryRepositoryImpl.kt ================================================ package com.infinitepower.newquiz.data.repository.country import com.infinitepower.newquiz.domain.repository.CountryRepository import com.infinitepower.newquiz.model.country.Country import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import java.io.File /** * This class is used for loading countries from local json file without needing * to use android context. Only used for testing. */ internal class TestCountryRepositoryImpl : CountryRepository { override suspend fun getAllCountries(): List { val flagBaseUrl = "https://example.com/%code%" return getCountryFromJson().map { it.toModel(flagBaseUrl) } } private suspend fun getCountryFromJson(): List { return withContext(Dispatchers.IO) { val path = "src/main/res/raw" val file = File(path, "all_countries.json") val strRes = file.readText() Json.decodeFromString(strRes) } } } ================================================ FILE: data/src/test/java/com/infinitepower/newquiz/data/repository/daily_challenge/DailyChallengeRepositoryImplTest.kt ================================================ package com.infinitepower.newquiz.data.repository.daily_challenge import app.cash.turbine.test import com.google.common.truth.Truth.assertThat 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.data.fake.FakeComparisonQuizData import com.infinitepower.newquiz.core.testing.data.fake.FakeData import com.infinitepower.newquiz.core.testing.domain.FakeDailyChallengeDao import com.infinitepower.newquiz.core.user_services.UserService import com.infinitepower.newquiz.data.util.mappers.daily_challenge.toEntity import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.model.daily_challenge.DailyChallengeTask import com.infinitepower.newquiz.model.global_event.GameEvent import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.coJustRun import io.mockk.coVerify import io.mockk.confirmVerified import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest import kotlinx.datetime.Clock import org.junit.jupiter.api.assertThrows import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.time.Duration.Companion.days internal class DailyChallengeRepositoryImplTest { private val dailyChallengeDao = FakeDailyChallengeDao() private val comparisonQuizRepository = mockk() private val remoteConfig = mockk() private val userService = mockk() private lateinit var dailyChallengeRepository: DailyChallengeRepositoryImpl @BeforeTest fun setup() { val comparisonQuizCategories = FakeComparisonQuizData.generateCategories() coEvery { comparisonQuizRepository.getCategories() } returns comparisonQuizCategories dailyChallengeRepository = DailyChallengeRepositoryImpl( dailyChallengeDao = dailyChallengeDao, comparisonQuizRepository = comparisonQuizRepository, remoteConfig = remoteConfig, userService = userService ) } @AfterTest fun tearDown() { clearAllMocks() runTest { dailyChallengeDao.deleteAll() } } @Test fun `getAllTasks returns all tasks`() = runTest { val tasks = FakeData.generateTasks() dailyChallengeDao.insertAll(tasks.map(DailyChallengeTask::toEntity)) val resultTasks = dailyChallengeRepository.getAllTasks() assertThat(resultTasks).containsExactlyElementsIn(tasks) coVerify(exactly = 1) { comparisonQuizRepository.getCategories() } confirmVerified() } @Test fun `getAvailableTasks returns available tasks`() = runTest { val tasks = FakeData.generateTasks() val expiredTasks = FakeData.generateTasks( count = 5, dayDuration = (-2).days..(-1).days ) val newTasks = FakeData.generateTasks( count = 5, dayDuration = (1).days..10.days ) val allTasks = tasks + expiredTasks + newTasks dailyChallengeDao.insertAll(allTasks.map(DailyChallengeTask::toEntity)) val resultTasks = dailyChallengeRepository.getAvailableTasks() assertThat(resultTasks).containsExactlyElementsIn(tasks) coVerify(exactly = 1) { comparisonQuizRepository.getCategories() } confirmVerified() } @Test fun `getAvailableTasksFlow returns available tasks`() = runTest { val tasks = FakeData.generateTasks() val expiredTasks = FakeData.generateTasks( count = 5, dayDuration = (-2).days..(-1).days ) val newTasks = FakeData.generateTasks( count = 5, dayDuration = (1).days..10.days ) val allTasks = tasks + expiredTasks + newTasks dailyChallengeRepository.getAvailableTasksFlow().test { dailyChallengeDao.insertAll(allTasks.map(DailyChallengeTask::toEntity)) assertThat(awaitItem()).isEmpty() assertThat(awaitItem()).containsExactlyElementsIn(tasks) } coVerify(exactly = 1) { comparisonQuizRepository.getCategories() } confirmVerified() } @Test fun `getClaimableTasksCountFlow returns correct count`() = runTest { val tasks = FakeData.generateTasks().toMutableList().apply { set(0, get(0).copy(currentValue = 10u, maxValue = 10u)) } dailyChallengeRepository.getClaimableTasksCountFlow().test { dailyChallengeDao.insertAll(tasks.map(DailyChallengeTask::toEntity)) assertThat(awaitItem()).isEqualTo(0) assertThat(awaitItem()).isEqualTo(tasks.count { it.isClaimable() }) } coVerify(exactly = 1) { comparisonQuizRepository.getCategories() } confirmVerified() } @Test fun `resetTasks deletes all tasks`() = runTest { val tasks = FakeData.generateTasks() dailyChallengeDao.insertAll(tasks.map(DailyChallengeTask::toEntity)) dailyChallengeRepository.resetTasks() confirmVerified() // No calls should be made to the mock // Verify that the tasks are deleted assertThat(dailyChallengeRepository.getAllTasks()).isEmpty() } @Test fun `checkAndGenerateTasksIfNeeded does not generate tasks if they are not expired`() = runTest { val tasks = FakeData.generateTasks() dailyChallengeDao.insertAll(tasks.map(DailyChallengeTask::toEntity)) dailyChallengeRepository.checkAndGenerateTasksIfNeeded(tasksToGenerate = 10) coVerify(exactly = 0) { comparisonQuizRepository.getCategories() remoteConfig.get(RemoteConfigValue.DAILY_CHALLENGE_ITEM_REWARD) } confirmVerified() // Verify that the tasks are not changed assertThat(dailyChallengeRepository.getAllTasks()).containsExactlyElementsIn(tasks) } @Test fun `checkAndGenerateTasksIfNeeded generates tasks if they are expired`() = runTest { val tasksToGenerate = 10 val diamondsReward = 10 val expiredTasks = FakeData.generateTasks( count = 5, dayDuration = (-2).days..(-1).days // 2 days ago ) dailyChallengeDao.insertAll(expiredTasks.map(DailyChallengeTask::toEntity)) every { remoteConfig.get(RemoteConfigValue.DAILY_CHALLENGE_ITEM_REWARD) } returns diamondsReward dailyChallengeRepository.checkAndGenerateTasksIfNeeded(tasksToGenerate = tasksToGenerate) coVerify(exactly = 1) { comparisonQuizRepository.getCategories() remoteConfig.get(RemoteConfigValue.DAILY_CHALLENGE_ITEM_REWARD) } confirmVerified() // Check if the tasks are generated dailyChallengeRepository .getAvailableTasks() .also { assertThat(it).hasSize(tasksToGenerate) }.forEach { assertThat(it.isClaimed).isFalse() assertThat(it.currentValue).isEqualTo(0u) assertThat(it.isClaimed).isFalse() val now = Clock.System.now() assertThat(now).isAtLeast(it.dateRange.start) assertThat(now).isAtMost(it.dateRange.endInclusive) } } @Test fun `completeTaskStep throws exception if task is not found`() = runTest { assertThrows { dailyChallengeRepository.completeTaskStep(GameEvent.MultiChoice.PlayQuestions) } } @Test fun `completeTaskStep throws exception if task is expired`() = runTest { val tasks = FakeData.generateTasks( count = 5, dayDuration = (-2).days..(-1).days ) dailyChallengeDao.insertAll(tasks.map(DailyChallengeTask::toEntity)) assertThrows { dailyChallengeRepository.completeTaskStep(tasks.first().event) } } @Test fun `completeTaskStep throws exception if task is already claimed`() = runTest { val now = Clock.System.now() val dateRange = now..now + 1.days val task = FakeData .generateTask(id = 1, dateRange = dateRange) .copy(isClaimed = true) dailyChallengeDao.insertAll(task.toEntity()) assertThrows { dailyChallengeRepository.completeTaskStep(task.event) } } @Test fun `completeTaskStep updates the current value if exists and is available`() = runTest { val now = Clock.System.now() val dateRange = now..now + 1.days val task = FakeData.generateTask(id = 1, dateRange = dateRange) assertThat(task.currentValue).isEqualTo(0u) dailyChallengeDao.insertAll(task.toEntity()) dailyChallengeRepository.completeTaskStep(task.event) val updatedTask = dailyChallengeDao.getTaskByType(task.event.key)!! assertThat(updatedTask.currentValue).isEqualTo(1) } @Test fun `claimTask throws exception if task is not found`() = runTest { assertThrows { dailyChallengeRepository.claimTask(GameEvent.MultiChoice.PlayQuestions) } } @Test fun `claimTask throws exception if task is expired`() = runTest { val tasks = FakeData.generateTasks( count = 5, dayDuration = (-2).days..(-1).days ) dailyChallengeDao.insertAll(tasks.map(DailyChallengeTask::toEntity)) assertThrows { dailyChallengeRepository.claimTask(tasks.first().event) } } @Test fun `claimTask throws exception if task is already claimed`() = runTest { val now = Clock.System.now() val dateRange = now..now + 1.days val task = FakeData .generateTask(id = 1, dateRange = dateRange) .copy(isClaimed = true) dailyChallengeDao.insertAll(task.toEntity()) assertThrows { dailyChallengeRepository.claimTask(task.event) } } @Test fun `claimTask throws exception if task is not completed`() = runTest { val now = Clock.System.now() val dateRange = now..now + 1.days val task = FakeData.generateTask(id = 1, dateRange = dateRange) .copy(currentValue = 5u, maxValue = 10u) dailyChallengeDao.insertAll(task.toEntity()) assertThrows { dailyChallengeRepository.claimTask(task.event) } confirmVerified() } @Test fun `claimTask updates the task if exists and is available`() = runTest { val now = Clock.System.now() val dateRange = now..now + 1.days val task = FakeData .generateTask(id = 1, dateRange = dateRange) .copy(currentValue = 10u, maxValue = 10u) assertThat(task.isClaimable()).isTrue() assertThat(task.isClaimed).isFalse() coJustRun { userService.addRemoveDiamonds(task.diamondsReward.toInt()) } dailyChallengeDao.insertAll(task.toEntity()) dailyChallengeRepository.claimTask(task.event) val updatedTask = dailyChallengeDao.getTaskByType(task.event.key)!! assertThat(updatedTask.isClaimed).isTrue() coVerify(exactly = 1) { userService.addRemoveDiamonds(task.diamondsReward.toInt()) } confirmVerified() } } ================================================ FILE: data/src/test/java/com/infinitepower/newquiz/data/repository/home/RecentCategoriesRepositoryImplTest.kt ================================================ package com.infinitepower.newquiz.data.repository.home import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.datastore.PreferenceRequest import com.infinitepower.newquiz.core.datastore.common.RecentCategoryDataStoreCommon import com.infinitepower.newquiz.core.datastore.common.SettingsCommon import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import com.infinitepower.newquiz.core.remote_config.RemoteConfig import com.infinitepower.newquiz.data.local.multi_choice_quiz.category.multiChoiceQuestionCategories import com.infinitepower.newquiz.data.local.wordle.WordleCategories import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.model.BaseCategory import com.infinitepower.newquiz.model.GameMode import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.NumberFormatType import com.infinitepower.newquiz.model.toUiText import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.coJustRun import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.slot import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource /** * Tests for [RecentCategoriesRepositoryImpl] */ internal class RecentCategoriesRepositoryImplTest { private lateinit var recentCategoriesRepository: RecentCategoriesRepositoryImpl private val recentCategoriesDataStoreManager: DataStoreManager = mockk(relaxed = true) private val settingsDataStoreManager: DataStoreManager = mockk(relaxed = true) private val comparisonQuizRepository: ComparisonQuizRepository = mockk(relaxed = true) private val remoteConfig: RemoteConfig = mockk(relaxed = true) @BeforeEach fun setUp() { every { settingsDataStoreManager.getPreferenceFlow(SettingsCommon.HideOnlineCategories) } returns flowOf(false) // Create an instance of RecentCategoriesRepositoryImpl with mocked dependencies recentCategoriesRepository = RecentCategoriesRepositoryImpl( recentCategoriesDataStoreManager, settingsDataStoreManager, comparisonQuizRepository, remoteConfig ) } @AfterEach fun tearDown() { // Clear any recorded calls and reset mocks after each test clearAllMocks() } private data class TestCategory( override val gameMode: GameMode, override val id: String, override val name: UiText, override val image: String, override val requireInternetConnection: Boolean ) : BaseCategory private val testCategories = List(10) { TestCategory( gameMode = GameMode.MULTI_CHOICE, id = "id$it", name = "name$it".toUiText(), image = "image$it", requireInternetConnection = it % 2 == 0 ) } @Test fun `getCategories should return expected result, when connection available and have recent categories`() = runTest { // Mock the necessary dependencies and data val recentCategories = listOf( testCategories[0], testCategories[1], testCategories[2] ) val recentCategoriesIds = recentCategories .map { it.id } .toSet() // Call the method under test val result = recentCategoriesRepository.getHomeCategories( allCategories = testCategories, recentCategoriesFlow = flowOf(recentCategoriesIds), hideOnlineCategoriesFlow = flowOf(false), isInternetAvailable = true ).first() val otherCategories = testCategories.filterNot { it in recentCategories } // Verify the result assertThat(result.recentCategories.size).isAtMost(3) assertThat(result.recentCategories).containsExactlyElementsIn(recentCategories) assertThat(result.otherCategories).containsExactlyElementsIn(otherCategories) } @Test fun `getCategories should return expected result, when connection available and have no recent categories`() = runTest { // Mock the necessary dependencies and data val recentCategoriesIds = emptySet() // Call the method under test val result = recentCategoriesRepository.getHomeCategories( allCategories = testCategories, recentCategoriesFlow = flowOf(recentCategoriesIds), hideOnlineCategoriesFlow = flowOf(false), isInternetAvailable = true ).first() // Verify the result assertThat(result.recentCategories).isNotEmpty() assertThat(result.recentCategories).hasSize(3) assertThat(result.otherCategories).hasSize(testCategories.size - 3) assertThat(result.otherCategories).containsExactlyElementsIn(testCategories - result.recentCategories.toSet()) } @Test fun `getCategories should return expected result, when connection not available and have recent categories`() = runTest { // Mock the necessary dependencies and data val recentCategories = listOf( testCategories[0], testCategories[1], testCategories[2] ) val recentCategoriesIds = recentCategories .map { it.id } .toSet() // Call the method under test val result = recentCategoriesRepository.getHomeCategories( allCategories = testCategories, recentCategoriesFlow = flowOf(recentCategoriesIds), hideOnlineCategoriesFlow = flowOf(false), isInternetAvailable = false ).first() val otherCategories = testCategories.filterNot { it in recentCategories } // Verify the result assertThat(result.recentCategories.size).isAtMost(3) assertThat(result.recentCategories).containsExactlyElementsIn(recentCategories) assertThat(result.otherCategories).containsExactlyElementsIn(otherCategories) // Check that the other categories with no internet connection are on the top of the list val otherCategoriesSorted = otherCategories.sortedBy { it.requireInternetConnection } assertThat(result.otherCategories).isEqualTo(otherCategoriesSorted) } @Test fun `getCategories should return expected result, when connection not available and have no recent categories`() = runTest { // Mock the necessary dependencies and data val recentCategoriesIds = emptySet() // Call the method under test val result = recentCategoriesRepository.getHomeCategories( allCategories = testCategories, recentCategoriesFlow = flowOf(recentCategoriesIds), hideOnlineCategoriesFlow = flowOf(false), isInternetAvailable = false ).first() // Verify the result assertThat(result.recentCategories).isNotEmpty() assertThat(result.recentCategories.size).isAtMost(3) assertThat(result.otherCategories).hasSize(testCategories.size - result.recentCategories.size) assertThat(result.otherCategories).containsExactlyElementsIn(testCategories - result.recentCategories.toSet()) } @Test fun `getCategories should return expected result, when connection not available and have no recent categories and all categories require internet`() = runTest { val testCategories = List(10) { TestCategory( gameMode = GameMode.MULTI_CHOICE, id = "id$it", name = "name$it".toUiText(), image = "image$it", requireInternetConnection = true ) } // Mock the necessary dependencies and data val recentCategoriesIds = emptySet() // Call the method under test val result = recentCategoriesRepository.getHomeCategories( allCategories = testCategories, recentCategoriesFlow = flowOf(recentCategoriesIds), hideOnlineCategoriesFlow = flowOf(false), isInternetAvailable = false ).first() // Verify the result assertThat(result.recentCategories).isNotEmpty() assertThat(result.recentCategories).hasSize(3) assertThat(result.otherCategories).hasSize(testCategories.size - result.recentCategories.size) } @ParameterizedTest @MethodSource("getMultiChoiceCategoriesParams") fun `getMultiChoiceCategories should return expected result with parameters`( isInternetAvailable: Boolean, recentCategoriesIds: Set ) = runTest { every { recentCategoriesDataStoreManager.getPreferenceFlow>(any()) } returns flowOf(recentCategoriesIds) // Call the method under test val result = recentCategoriesRepository.getMultiChoiceCategories( isInternetAvailable = isInternetAvailable ).first() // Verify the result assertThat(result).isNotNull() assertThat(result.recentCategories).isNotEmpty() assertThat(result.recentCategories.size).isAtMost(3) if (recentCategoriesIds.isNotEmpty()) { val recentCategories = multiChoiceQuestionCategories.filter { it.id in recentCategoriesIds } assertThat(result.recentCategories).containsExactlyElementsIn(recentCategories) } assertThat(result.otherCategories).hasSize(multiChoiceQuestionCategories.size - result.recentCategories.size) assertThat(result.otherCategories).containsExactlyElementsIn(multiChoiceQuestionCategories - result.recentCategories.toSet()) } @ParameterizedTest @MethodSource("getWordleCategoriesParams") fun `getWordCategories should return expected result with parameters`( isInternetAvailable: Boolean, recentCategoriesIds: Set ) = runTest { every { recentCategoriesDataStoreManager.getPreferenceFlow>(any()) } returns flowOf(recentCategoriesIds) // Call the method under test val result = recentCategoriesRepository.getWordleCategories( isInternetAvailable = isInternetAvailable ).first() // Verify the result assertThat(result).isNotNull() assertThat(result.recentCategories).isNotEmpty() assertThat(result.recentCategories.size).isAtMost(3) val allWordleCategories = WordleCategories.allCategories if (recentCategoriesIds.isNotEmpty()) { val recentCategories = allWordleCategories.filter { it.id in recentCategoriesIds } assertThat(result.recentCategories).containsExactlyElementsIn(recentCategories) } assertThat(result.otherCategories).hasSize(allWordleCategories.size - result.recentCategories.size) assertThat(result.otherCategories).containsExactlyElementsIn(allWordleCategories - result.recentCategories.toSet()) } @ParameterizedTest @MethodSource("getComparisonCategoriesParams") fun `getComparisonCategories should return expected result with parameters`( isInternetAvailable: Boolean, recentCategoriesIds: Set ) = runTest { every { comparisonQuizRepository.getCategories() } returns allComparisonQuizCategories every { recentCategoriesDataStoreManager.getPreferenceFlow>(any()) } returns flowOf(recentCategoriesIds) // Call the method under test val result = recentCategoriesRepository.getComparisonCategories( isInternetAvailable = isInternetAvailable ).first() // Verify the result assertThat(result).isNotNull() assertThat(result.recentCategories).isNotEmpty() assertThat(result.recentCategories.size).isAtMost(3) if (recentCategoriesIds.isNotEmpty()) { val recentCategories = allComparisonQuizCategories.filter { it.id in recentCategoriesIds } assertThat(result.recentCategories).containsExactlyElementsIn(recentCategories) } assertThat(result.otherCategories).hasSize(allComparisonQuizCategories.size - result.recentCategories.size) assertThat(result.otherCategories).containsExactlyElementsIn(allComparisonQuizCategories - result.recentCategories.toSet()) } // Tests for addMultiChoiceCategory @ParameterizedTest @MethodSource("addMultiChoiceCategoryParams") fun `addMultiChoiceCategory should add category to recent categories`( categoryIdToAdd: String, initialCategories: Set ) = runTest { // Mock the necessary dependencies and data coEvery { recentCategoriesDataStoreManager.getPreference>(any()) } returns initialCategories coJustRun { recentCategoriesDataStoreManager.editPreference>(any(), any()) } // Act recentCategoriesRepository.addMultiChoiceCategory(categoryIdToAdd) // Assert val slot = slot>>() coVerify { recentCategoriesDataStoreManager.getPreference(capture(slot)) } assertThat(slot.captured.key).isEqualTo(RecentCategoryDataStoreCommon.MultiChoice.key) val addFunctionShouldBeCalled = categoryIdToAdd !in initialCategories val newValueSlot = slot>() coVerify( exactly = if (addFunctionShouldBeCalled) 1 else 0 ) { recentCategoriesDataStoreManager.editPreference(any(), capture(newValueSlot)) } if (addFunctionShouldBeCalled) { assertThat(newValueSlot.captured.size).isAtMost(3) assertThat(newValueSlot.captured).contains(categoryIdToAdd) } else { assertThat(newValueSlot.isCaptured).isFalse() } } // Tests for addWordleCategory @ParameterizedTest @MethodSource("addWordleCategoryParams") fun `addWordleCategory should add category to recent categories`( categoryIdToAdd: String, initialCategories: Set ) = runTest { // Mock the necessary dependencies and data coEvery { recentCategoriesDataStoreManager.getPreference>(any()) } returns initialCategories coJustRun { recentCategoriesDataStoreManager.editPreference>(any(), any()) } // Act recentCategoriesRepository.addWordleCategory(categoryIdToAdd) // Assert val slot = slot>>() coVerify { recentCategoriesDataStoreManager.getPreference(capture(slot)) } assertThat(slot.captured.key).isEqualTo(RecentCategoryDataStoreCommon.Wordle.key) val addFunctionShouldBeCalled = categoryIdToAdd !in initialCategories val newValueSlot = slot>() coVerify( exactly = if (addFunctionShouldBeCalled) 1 else 0 ) { recentCategoriesDataStoreManager.editPreference(any(), capture(newValueSlot)) } if (addFunctionShouldBeCalled) { assertThat(newValueSlot.captured.size).isAtMost(3) assertThat(newValueSlot.captured).contains(categoryIdToAdd) } else { assertThat(newValueSlot.isCaptured).isFalse() } } // Tests for addComparisonCategory @ParameterizedTest @MethodSource("addComparisonCategoryParams") fun `addComparisonCategory should add category to recent categories`( categoryIdToAdd: String, initialCategories: Set ) = runTest { // Mock the necessary dependencies and data coEvery { recentCategoriesDataStoreManager.getPreference>(any()) } returns initialCategories coJustRun { recentCategoriesDataStoreManager.editPreference>(any(), any()) } // Act recentCategoriesRepository.addComparisonCategory(categoryIdToAdd) // Assert val slot = slot>>() coVerify { recentCategoriesDataStoreManager.getPreference(capture(slot)) } assertThat(slot.captured.key).isEqualTo(RecentCategoryDataStoreCommon.ComparisonQuiz.key) val addFunctionShouldBeCalled = categoryIdToAdd !in initialCategories val newValueSlot = slot>() coVerify( exactly = if (addFunctionShouldBeCalled) 1 else 0 ) { recentCategoriesDataStoreManager.editPreference(any(), capture(newValueSlot)) } if (addFunctionShouldBeCalled) { assertThat(newValueSlot.captured.size).isAtMost(3) assertThat(newValueSlot.captured).contains(categoryIdToAdd) } else { assertThat(newValueSlot.isCaptured).isFalse() } } // Tests for cleanAllSavedCategories @Test fun `cleanAllSavedCategories should clean all saved categories`() = runTest { // Mock the necessary dependencies and data coJustRun { recentCategoriesDataStoreManager.editPreference>(any(), any()) } // Act recentCategoriesRepository.cleanAllSavedCategories() coVerify(exactly = 1) { recentCategoriesDataStoreManager.editPreference( key = RecentCategoryDataStoreCommon.MultiChoice.key, newValue = emptySet() ) recentCategoriesDataStoreManager.editPreference( key = RecentCategoryDataStoreCommon.Wordle.key, newValue = emptySet() ) recentCategoriesDataStoreManager.editPreference( key = RecentCategoryDataStoreCommon.ComparisonQuiz.key, newValue = emptySet() ) } } companion object { // Params for getCategories tests @JvmStatic private fun getCategoriesParams( allCategories: List ) = listOf( Arguments.of( true, setOf( allCategories[0].id, allCategories[1].id, allCategories[2].id ) ), Arguments.of( false, setOf( allCategories[0].id, allCategories[1].id, allCategories[2].id ) ), Arguments.of( true, emptySet() ), Arguments.of( false, emptySet() ), Arguments.of( true, setOf( allCategories.first().id ) ) ) @JvmStatic fun getMultiChoiceCategoriesParams() = getCategoriesParams(multiChoiceQuestionCategories) @JvmStatic fun getWordleCategoriesParams() = getCategoriesParams(WordleCategories.allCategories) @JvmStatic fun getComparisonCategoriesParams() = getCategoriesParams(allComparisonQuizCategories) // Params for addCategory tests @JvmStatic private fun addCategoryParams( allCategories: List ) = listOf( Arguments.of( allCategories[0].id, emptySet() ), Arguments.of( allCategories[0].id, setOf( allCategories[1].id ) ), Arguments.of( allCategories[0].id, setOf( allCategories[1].id, allCategories[2].id ) ), Arguments.of( allCategories[0].id, setOf( allCategories[1].id, allCategories[2].id, allCategories[3].id ) ), // Test that adding a category that already exists in the set will not change the set Arguments.of( allCategories[0].id, setOf( allCategories[0].id, ) ), // Test that adding a category that already exists in the set will not change the set Arguments.of( allCategories[1].id, setOf( allCategories[0].id, allCategories[1].id ) ), // Test that adding a category that already exists in the set will not change the set Arguments.of( allCategories[1].id, setOf( allCategories[0].id, allCategories[1].id, allCategories[2].id ) ), ) @JvmStatic fun addMultiChoiceCategoryParams() = addCategoryParams(multiChoiceQuestionCategories) @JvmStatic fun addWordleCategoryParams() = addCategoryParams(WordleCategories.allCategories) @JvmStatic fun addComparisonCategoryParams() = addCategoryParams(allComparisonQuizCategories) private val allComparisonQuizCategories = listOf( 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 = "" ), requireInternetConnection = false ), ComparisonQuizCategory( id = "countries", name = "Countries".toUiText(), description = "Countries description", image = "", questionDescription = ComparisonQuizCategory.QuestionDescription( greater = "Which country is bigger?", less = "Which country is smaller?", ), formatType = NumberFormatType.DEFAULT, dataSourceAttribution = ComparisonQuizCategory.DataSourceAttribution( text = "NewQuiz API", logo = "" ), requireInternetConnection = false ), ComparisonQuizCategory( id = "cities", name = "Cities".toUiText(), description = "Cities description", image = "", questionDescription = ComparisonQuizCategory.QuestionDescription( greater = "Which city is bigger?", less = "Which city is smaller?", ), formatType = NumberFormatType.DEFAULT, dataSourceAttribution = ComparisonQuizCategory.DataSourceAttribution( text = "NewQuiz API", logo = "" ), requireInternetConnection = true ), ComparisonQuizCategory( id = "planets", name = "Planets".toUiText(), description = "Planets description", image = "", questionDescription = ComparisonQuizCategory.QuestionDescription( greater = "Which planet is bigger?", less = "Which planet is smaller?", ), formatType = NumberFormatType.DEFAULT, dataSourceAttribution = ComparisonQuizCategory.DataSourceAttribution( text = "NewQuiz API", logo = "" ), requireInternetConnection = true ), ) } } ================================================ FILE: data/src/test/java/com/infinitepower/newquiz/data/repository/maze_quiz/MazeQuizRepositoryImplTest.kt ================================================ package com.infinitepower.newquiz.data.repository.maze_quiz import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.analytics.LocalDebugAnalyticsHelper import com.infinitepower.newquiz.core.database.dao.MazeQuizDao import com.infinitepower.newquiz.data.util.mappers.maze.toEntity import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository import com.infinitepower.newquiz.model.maze.MazeQuiz import com.infinitepower.newquiz.model.question.QuestionDifficulty import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.model.wordle.WordleWord import io.mockk.coEvery import io.mockk.coJustRun import io.mockk.coVerify import io.mockk.confirmVerified import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest import kotlin.test.Test internal class MazeQuizRepositoryImplTest { private val mazeQuizDao = mockk() private lateinit var mazeQuizRepository: MazeQuizRepository @BeforeTest fun setup() { mazeQuizRepository = MazeQuizRepositoryImpl( mazeQuizDao = mazeQuizDao, analyticsHelper = LocalDebugAnalyticsHelper() ) } @Test fun `get saved maze quiz flow test`() = runTest { val items = createItems() every { mazeQuizDao.getAllMazeItemsFlow() } returns flowOf(items.map(MazeQuiz.MazeItem::toEntity)) mazeQuizRepository.getSavedMazeQuizFlow().test { assertThat(awaitItem().items).hasSize(10) awaitComplete() } } @Test fun `completeMazeItem should complete maze item`() = runTest { val items = createItems() val firstItem = items.first() coEvery { mazeQuizDao.getAllMazeItems() } returns items.map(MazeQuiz.MazeItem::toEntity) coJustRun { mazeQuizDao.updateItem(firstItem.toEntity().copy(played = true)) } mazeQuizRepository.completeMazeItem(firstItem.id) coVerify(exactly = 1) { mazeQuizDao.getAllMazeItems() mazeQuizDao.updateItem(firstItem.toEntity().copy(played = true)) } confirmVerified() } private fun createItems(count: Int = 10): List { return List(count) { MazeQuiz.MazeItem.Wordle( difficulty = QuestionDifficulty.Easy, wordleWord = WordleWord("1+1=2"), wordleQuizType = WordleQuizType.MATH_FORMULA, mazeSeed = 0 ) } } } ================================================ FILE: data/src/test/java/com/infinitepower/newquiz/data/repository/multi_choice_quiz/CountryCapitalFlagsQuizRepositoryImplTest.kt ================================================ package com.infinitepower.newquiz.data.repository.multi_choice_quiz import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.data.repository.country.TestCountryRepositoryImpl import com.infinitepower.newquiz.domain.repository.CountryRepository import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory import com.infinitepower.newquiz.model.question.QuestionDifficulty import kotlinx.coroutines.test.runTest import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource import kotlin.test.BeforeTest import kotlin.test.Test /** * Tests for [CountryCapitalFlagsQuizRepositoryImpl] */ internal class CountryCapitalFlagsQuizRepositoryImplTest { private val countryRepository: CountryRepository = TestCountryRepositoryImpl() private lateinit var repository: CountryCapitalFlagsQuizRepositoryImpl @BeforeTest fun setUp() { repository = CountryCapitalFlagsQuizRepositoryImpl( countryRepository = countryRepository ) } @Test fun `getFlagQuiz() returns a list of questions`() = runTest { val questionSize = 5 val questions = repository.getRandomQuestions( amount = questionSize, category = MultiChoiceBaseCategory.CountryCapitalFlags ) assertThat(questions).hasSize(questionSize) // Use imageUrl because it is unique for each question val uniqueQuestions = questions.distinctBy { it.image } assertThat(uniqueQuestions).hasSize(questionSize) // Check if the questions category is correct assertThat(questions.all { it.category == MultiChoiceBaseCategory.CountryCapitalFlags }).isTrue() val allCountries = countryRepository.getAllCountries() // Check if the answer capital corresponds to the country questions.forEach { question -> val capitalAnswer = question.answers[question.correctAns] val countryName = question .description .removePrefix("What is the capital of ") .removeSuffix("?") val country = allCountries.find { it.countryName == countryName } assertThat(country).isNotNull() require(country != null) assertThat(capitalAnswer).isEqualTo(country.capital) } } @ParameterizedTest(name = "getRandomQuestions returns questions filtered by difficulty: {0}") @ValueSource(strings = ["easy", "medium", "hard"]) fun `getRandomQuestions() returns questions filtered by difficulty`( difficulty: String ) = runTest { val questionSize = 5 val questions = repository.getRandomQuestions( amount = questionSize, category = MultiChoiceBaseCategory.CountryCapitalFlags, difficulty = difficulty ) assertThat(questions).hasSize(questionSize) // Check if the questions difficulty is correct assertThat(questions.all { it.difficulty == QuestionDifficulty.from(difficulty) }).isTrue() } } ================================================ FILE: data/src/test/java/com/infinitepower/newquiz/data/repository/multi_choice_quiz/FlagQuizRepositoryImplTest.kt ================================================ package com.infinitepower.newquiz.data.repository.multi_choice_quiz import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.data.repository.country.TestCountryRepositoryImpl import com.infinitepower.newquiz.domain.repository.CountryRepository import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory import com.infinitepower.newquiz.model.question.QuestionDifficulty import kotlinx.coroutines.test.runTest import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource import kotlin.test.BeforeTest import kotlin.test.Test /** * Tests for [FlagQuizRepositoryImpl] */ internal class FlagQuizRepositoryImplTest { private val countryRepository: CountryRepository = TestCountryRepositoryImpl() private lateinit var repository: FlagQuizRepositoryImpl @BeforeTest fun setUp() { repository = FlagQuizRepositoryImpl( countryRepository = countryRepository ) } @Test fun `getRandomQuestions() returns a list of questions`() = runTest { val questionSize = 5 val questions = repository.getRandomQuestions( amount = questionSize, category = MultiChoiceBaseCategory.Flag ) assertThat(questions).hasSize(questionSize) // Use imageUrl because it is unique for each question val uniqueQuestions = questions.distinctBy { it.image } assertThat(uniqueQuestions).hasSize(questionSize) // Check if the questions category is correct assertThat(questions.all { it.category == MultiChoiceBaseCategory.Flag }).isTrue() } @ParameterizedTest(name = "getRandomQuestions returns questions filtered by difficulty: {0}") @ValueSource(strings = ["easy", "medium", "hard"]) fun `getRandomQuestions() returns questions filtered by difficulty`( difficulty: String ) = runTest { val questionSize = 5 val questions = repository.getRandomQuestions( amount = questionSize, category = MultiChoiceBaseCategory.Flag, difficulty = difficulty ) assertThat(questions).hasSize(questionSize) // Check if the questions difficulty is correct assertThat(questions.all { it.difficulty == QuestionDifficulty.from(difficulty) }).isTrue() } } ================================================ FILE: data/src/test/java/com/infinitepower/newquiz/data/repository/wordle/WordleRepositoryImplTest.kt ================================================ package com.infinitepower.newquiz.data.repository.wordle import android.content.Context import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.datastore.common.SettingsCommon import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import com.infinitepower.newquiz.domain.repository.math_quiz.MathQuizCoreRepository import com.infinitepower.newquiz.domain.repository.numbers.NumberTriviaQuestionRepository import com.infinitepower.newquiz.model.wordle.WordleQuizType import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.coVerify import io.mockk.confirmVerified import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test /** * Tests for [WordleRepositoryImpl] */ internal class WordleRepositoryImplTest { private lateinit var repository: WordleRepositoryImpl private val context = mockk(relaxed = true) private val settingsDataStoreManager = mockk() private val mathQuizCoreRepository = mockk() private val numberTriviaQuestionRepository = mockk() @BeforeTest fun setup() { repository = WordleRepositoryImpl( context = context, settingsDataStoreManager = settingsDataStoreManager, mathQuizCoreRepository = mathQuizCoreRepository, numberTriviaQuestionRepository = numberTriviaQuestionRepository ) } @AfterTest fun tearDown() { clearAllMocks() } @Test fun `getAllWords should return a list of words`() = runTest { coEvery { settingsDataStoreManager.getPreference(SettingsCommon.InfiniteWordleQuizLanguage) } returns "en" mockkWordsResources() val words = repository.getAllWords() assertThat(words).isNotEmpty() assertThat(words).hasSize(3) coVerify(exactly = 1) { settingsDataStoreManager.getPreference(SettingsCommon.InfiniteWordleQuizLanguage) context.resources.openRawResource(any()) } confirmVerified() } @Test fun `getAllWords should throw an exception when language is not supported`() = runTest { coEvery { settingsDataStoreManager.getPreference(SettingsCommon.InfiniteWordleQuizLanguage) } returns "a" mockkWordsResources() assertThrows { repository.getAllWords() } coVerify(exactly = 1) { settingsDataStoreManager.getPreference(SettingsCommon.InfiniteWordleQuizLanguage) } confirmVerified() } @Test fun `generateRandomTextWord should return a random word`() = runTest { coEvery { settingsDataStoreManager.getPreference(SettingsCommon.InfiniteWordleQuizLanguage) } returns "en" mockkWordsResources() val word = repository.generateRandomTextWord() coVerify(exactly = 1) { settingsDataStoreManager.getPreference(SettingsCommon.InfiniteWordleQuizLanguage) context.resources.openRawResource(any()) } confirmVerified() assertThat(word.word).isNotEmpty() assertThat(allWords).contains(word.word.lowercase()) } @CsvSource( "word,TEXT", "123,NUMBER", "123,NUMBER_TRIVIA", "1+2=3,MATH_FORMULA" ) @ParameterizedTest fun `validateWord should return true when word is valid`( word: String, quizType: WordleQuizType ) = runTest { every { mathQuizCoreRepository.validateFormula(any()) } returns true val result = repository.validateWord(word, quizType) assertThat(result.isSuccess).isTrue() } private val allWords = listOf("word1", "word2", "word3", "wordignored") private fun mockkWordsResources() { val words = allWords.joinToString("\n") every { context.resources.openRawResource(any()) } returns words.byteInputStream() } } ================================================ FILE: detekt-compose.yml ================================================ Compose: ComposableAnnotationNaming: active: true ComposableNaming: active: true ComposableParamOrder: active: false # Modifiers are order first CompositionLocalAllowlist: active: true allowedCompositionLocals: LocalAnalyticsHelper CompositionLocalNaming: active: true ContentEmitterReturningValues: active: true ContentTrailingLambda: active: true DefaultsVisibility: active: true LambdaParameterInRestartableEffect: active: true Material2: active: false ModifierClickableOrder: active: true ModifierComposable: active: true ModifierComposed: active: true ModifierMissing: active: true ModifierNaming: active: true ModifierNotUsedAtRoot: active: true ModifierReused: active: true ModifierWithoutDefault: active: true MultipleEmitters: active: true MutableParams: active: true MutableStateAutoboxing: active: true MutableStateParam: active: true ParameterNaming: active: true PreviewAnnotationNaming: active: true PreviewPublic: active: true RememberMissing: active: true RememberContentMissing: active: true UnstableCollections: active: false ViewModelForwarding: active: true ViewModelInjection: active: true ================================================ FILE: detekt.yml ================================================ config: warningsAsErrors: true complexity: active: true LongParameterList: # It is suggested to increase this for Compose: https://detekt.dev/docs/introduction/compose/#longparameterlist functionThreshold: 8 constructorThreshold: 20 ignoreDefaultParameters: true ignoreAnnotated: - "Composable" LongMethod: active: true excludes: [ "*/generated/**" ] threshold: 100 ignoreAnnotated: - "Composable" CyclomaticComplexMethod: active: true threshold: 15 ignoreAnnotated: - "Composable" TooManyFunctions: active: true thresholdInClasses: 20 ignoreAnnotated: - "InstallIn" - "TestInstallIn" naming: active: true FunctionNaming: active: true functionPattern: '([a-z][a-zA-Z0-9]*)|(`.*`)' excludeClassPattern: '$^' ignoreAnnotated: [ 'Composable' ] TopLevelPropertyNaming: active: true constantPattern: '[A-Za-z][_A-Za-z0-9]*' propertyPattern: '[A-Za-z][_A-Za-z0-9]*' privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' PackageNaming: active: true packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9_]*)*' style: MagicNumber: ignoreAnnotated: [ 'Preview', 'PreviewScreenSizes', 'PreviewFontScale', 'PreviewLightDark', 'PreviewDynamicColors' ] excludes: [ '**/test/**', '**/*Test.kt', '**/demos/**', '**/androidUnitTest/**', '**/commonTest/**', '**/desktopTest/**' , '**/jsTest/**' ] MaxLineLength: excludes: [ '**/test/**', '**/*.Test.kt', '**/*.Spec.kt', '**/androidUnitTest/**', '**/commonTest/**', '**/desktopTest/**' , '**/jsTest/**' ] ignoreAnnotated: - "InstallIn" - "TestInstallIn" excludeCommentStatements: true UnusedPrivateMember: # https://detekt.dev/docs/introduction/compose#unusedprivatemember ignoreAnnotated: [ 'Preview', 'PreviewScreenSizes', 'PreviewFontScale', 'PreviewLightDark', 'PreviewDynamicColors' ] WildcardImport: active: true excludeImports: [ ] ReturnCount: active: true max: 3 ignoreAnnotated: [ 'Composable' ] exceptions: # TODO: Add this back when exceptions are caught correctly TooGenericExceptionCaught: active: false ================================================ FILE: domain/.gitignore ================================================ /build ================================================ FILE: domain/build.gradle.kts ================================================ plugins { alias(libs.plugins.newquiz.jvm.library) alias(libs.plugins.newquiz.detekt) } dependencies { implementation(projects.model) api(libs.kotlinx.collections.immutable) implementation(libs.javax.inject) implementation(libs.androidx.annotation) implementation(libs.kotlinx.coroutines.core) } ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/CountryRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository import com.infinitepower.newquiz.model.country.Country interface CountryRepository { suspend fun getAllCountries(): List } ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/UserConfigRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository import com.infinitepower.newquiz.model.regional_preferences.RegionalPreferences interface UserConfigRepository { suspend fun getRegionalPreferences(): RegionalPreferences } ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/comparison_quiz/ComparisonQuizRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository.comparison_quiz import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizItem import kotlinx.coroutines.flow.Flow import kotlin.random.Random interface ComparisonQuizRepository { fun getCategories(): List fun getCategoryById(id: String): ComparisonQuizCategory? suspend fun getQuestions( category: ComparisonQuizCategory, size: Int = 30, random: Random = Random ): List suspend fun getHighestPosition( categoryId: String ): Int /** * Get the highest position of the [category]. */ fun getHighestPositionFlow( categoryId: String ): Flow } ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/daily_challenge/DailyChallengeRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository.daily_challenge import com.infinitepower.newquiz.model.daily_challenge.DailyChallengeTask import com.infinitepower.newquiz.model.global_event.GameEvent import kotlinx.coroutines.flow.Flow import kotlin.random.Random /** * Daily challenge repository interface */ interface DailyChallengeRepository { /** * Returns all the daily challenge tasks */ fun getAvailableTasksFlow(): Flow> suspend fun getAllTasks(): List suspend fun getAvailableTasks(): List fun getClaimableTasksCountFlow(): Flow /** * Checks if the daily tasks are expired and generates new ones if needed. * Will be generated unique types of tasks. * * @param tasksToGenerate The number of tasks to generate */ suspend fun checkAndGenerateTasksIfNeeded( tasksToGenerate: Int = 5, random: Random = Random ) /** * Completes the task step if it exists. * If the task is not found, nothing happens. */ suspend fun completeTaskStep(taskType: GameEvent) suspend fun claimTask(taskType: GameEvent) suspend fun resetTasks() } ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/home/RecentCategoriesRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository.home import androidx.annotation.Keep import com.infinitepower.newquiz.model.category.ShowCategoryConnectionInfo import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory import com.infinitepower.newquiz.model.wordle.WordleCategory import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.Flow @Keep data class HomeCategories ( val recentCategories: ImmutableList, val otherCategories: ImmutableList ) fun emptyHomeCategories() = HomeCategories( recentCategories = persistentListOf(), otherCategories = persistentListOf() ) typealias HomeCategoriesFlow = Flow> interface RecentCategoriesRepository { fun getMultiChoiceCategories( isInternetAvailable: Boolean ): HomeCategoriesFlow fun getWordleCategories( isInternetAvailable: Boolean ): HomeCategoriesFlow fun getComparisonCategories( isInternetAvailable: Boolean ): HomeCategoriesFlow fun getDefaultShowCategoryConnectionInfo(): ShowCategoryConnectionInfo fun getShowCategoryConnectionInfoFlow(): Flow suspend fun addMultiChoiceCategory(categoryId: String) suspend fun addWordleCategory(categoryId: String) suspend fun addComparisonCategory(categoryId: String) suspend fun cleanAllSavedCategories() } ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/math_quiz/MathQuizCoreRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository.math_quiz import com.infinitepower.newquiz.model.math_quiz.MathFormula import com.infinitepower.newquiz.model.question.QuestionDifficulty import kotlin.random.Random interface MathQuizCoreRepository { companion object { /** * The range of numbers that can be generated for the solution of the formula. */ val SOLUTION_RANGE = -999..999 const val MAX_FORMULA_LENGTH = 10 /** * The range of numbers that can be generated for each number of the formula. */ val QuestionDifficulty.numbers get() = when (this) { QuestionDifficulty.Easy -> 0..9 QuestionDifficulty.Medium -> 0..49 QuestionDifficulty.Hard -> 0..99 } /** * The operators that can be generated for each difficulty. */ val QuestionDifficulty.operators get() = when (this) { QuestionDifficulty.Easy -> listOf('+', '-') QuestionDifficulty.Medium -> listOf('+', '-', '*') QuestionDifficulty.Hard -> listOf('+', '-', '*', '/') } val QuestionDifficulty.operatorSizeRange get() = when (this) { QuestionDifficulty.Easy -> 1..1 QuestionDifficulty.Medium -> 1..2 QuestionDifficulty.Hard -> 1..3 } } fun generateMathFormula( operatorSize: Int = 1, difficulty: QuestionDifficulty = QuestionDifficulty.Easy, random: Random = Random ): MathFormula fun validateFormula(formula: String): Boolean } ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/maze/MazeQuizRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository.maze import com.infinitepower.newquiz.model.maze.MazeQuiz import kotlinx.coroutines.flow.Flow interface MazeQuizRepository { fun getSavedMazeQuizFlow(): Flow suspend fun countAllItems(): Int suspend fun insertItems(items: List) suspend fun removeItems(items: List) suspend fun getMazeItemById(id: Int): MazeQuiz.MazeItem? suspend fun getNextAvailableMazeItem(): MazeQuiz.MazeItem? suspend fun completeMazeItem(id: Int) } ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/multi_choice_quiz/CountryCapitalFlagsQuizRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository.multi_choice_quiz import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory interface CountryCapitalFlagsQuizRepository : MultiChoiceQuestionBaseRepository ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/multi_choice_quiz/FlagQuizRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository.multi_choice_quiz import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory interface FlagQuizRepository : MultiChoiceQuestionBaseRepository ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/multi_choice_quiz/GuessMathSolutionRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository.multi_choice_quiz import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory interface GuessMathSolutionRepository : MultiChoiceQuestionBaseRepository ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/multi_choice_quiz/LogoQuizRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository.multi_choice_quiz import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory interface LogoQuizRepository : MultiChoiceQuestionBaseRepository ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/multi_choice_quiz/MultiChoiceQuestionBaseRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository.multi_choice_quiz import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion import kotlin.random.Random sealed interface MultiChoiceQuestionBaseRepository { suspend fun getRandomQuestions( amount: Int = 5, category: T, difficulty: String? = null, random: Random = Random ): List } ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/multi_choice_quiz/MultiChoiceQuestionRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository.multi_choice_quiz import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory interface MultiChoiceQuestionRepository : MultiChoiceQuestionBaseRepository ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/multi_choice_quiz/saved_questions/SavedMultiChoiceQuestionsRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository.multi_choice_quiz.saved_questions import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion import com.infinitepower.newquiz.model.multi_choice_quiz.saved.SortSavedQuestionsBy import kotlinx.coroutines.flow.Flow interface SavedMultiChoiceQuestionsRepository { suspend fun insertQuestions(questions: List) suspend fun insertQuestions(vararg questions: MultiChoiceQuestion) fun getFlowQuestions( sortBy: SortSavedQuestionsBy = SortSavedQuestionsBy.BY_DEFAULT ): Flow> suspend fun getQuestions(): List fun getCount(): Flow suspend fun deleteAllSelected(questions: List) } ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/numbers/NumberTriviaQuestionApi.kt ================================================ package com.infinitepower.newquiz.domain.repository.numbers import androidx.annotation.IntRange import com.infinitepower.newquiz.model.number.NumberTriviaQuestionsEntity interface NumberTriviaQuestionApi { suspend fun getRandomQuestion( size: Int, @IntRange(from = 0) minNumber: Int, @IntRange(from = 0) maxNumber: Int ): NumberTriviaQuestionsEntity } ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/numbers/NumberTriviaQuestionRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository.numbers import androidx.annotation.IntRange import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion import com.infinitepower.newquiz.model.number.NumberTriviaQuestion import com.infinitepower.newquiz.model.wordle.WordleWord import kotlin.random.Random interface NumberTriviaQuestionRepository { suspend fun generateRandomQuestions( size: Int = 1, @IntRange(from = 0) minNumber: Int, @IntRange(from = 0) maxNumber: Int, random: Random = Random ): List suspend fun generateWordleQuestion( random: Random = Random ): WordleWord suspend fun generateMultiChoiceQuestion( size: Int = 1, random: Random = Random ): List } ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/repository/wordle/WordleRepository.kt ================================================ package com.infinitepower.newquiz.domain.repository.wordle import com.infinitepower.newquiz.model.FlowResource import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.model.wordle.WordleWord import kotlin.random.Random interface WordleRepository { suspend fun getAllWords(): Set fun generateRandomWord( quizType: WordleQuizType, random: Random = Random ): FlowResource suspend fun generateRandomTextWord(random: Random = Random): WordleWord suspend fun generateRandomTextWords( count: Int = 5, random: Random = Random ): List suspend fun generateRandomNumberWord( wordSize: Int = 5, random: Random = Random ): WordleWord suspend fun isColorBlindEnabled(): Boolean suspend fun isLetterHintEnabled(): Boolean suspend fun isHardModeEnabled(): Boolean suspend fun getWordleMaxRows( defaultMaxRow: Int? = null ): Int fun validateWord(word: String, quizType: WordleQuizType): Result } ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/use_case/question/GetRandomMultiChoiceQuestionUseCase.kt ================================================ package com.infinitepower.newquiz.domain.use_case.question import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.CountryCapitalFlagsQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.FlagQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.GuessMathSolutionRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.LogoQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.MultiChoiceQuestionRepository import com.infinitepower.newquiz.domain.repository.numbers.NumberTriviaQuestionRepository import com.infinitepower.newquiz.model.FlowResource import com.infinitepower.newquiz.model.Resource import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion import kotlinx.coroutines.flow.flow import javax.inject.Inject class GetRandomMultiChoiceQuestionUseCase @Inject constructor( private val normalQuestionRepository: MultiChoiceQuestionRepository, private val flagQuizRepository: FlagQuizRepository, private val logoQuizRepository: LogoQuizRepository, private val guessMathSolutionRepository: GuessMathSolutionRepository, private val numberTriviaQuestionRepository: NumberTriviaQuestionRepository, private val countryCapitalFlagsQuizRepository: CountryCapitalFlagsQuizRepository ) { operator fun invoke( amount: Int = 5, category: MultiChoiceBaseCategory? = MultiChoiceBaseCategory.Normal(), difficulty: String? = null ): FlowResource> = flow { try { emit(Resource.Loading()) val questions = when (category) { is MultiChoiceBaseCategory.Normal -> normalQuestionRepository.getRandomQuestions( amount, category, difficulty ) is MultiChoiceBaseCategory.Flag -> flagQuizRepository.getRandomQuestions( amount, category, difficulty ) is MultiChoiceBaseCategory.Logo -> logoQuizRepository.getRandomQuestions( amount, category, difficulty ) is MultiChoiceBaseCategory.GuessMathSolution -> guessMathSolutionRepository.getRandomQuestions( amount, category, difficulty ) is MultiChoiceBaseCategory.CountryCapitalFlags -> countryCapitalFlagsQuizRepository.getRandomQuestions( amount, category, difficulty ) is MultiChoiceBaseCategory.NumberTrivia -> numberTriviaQuestionRepository.generateMultiChoiceQuestion( size = amount ) null -> throw IllegalArgumentException("Quiz type is null") } emit(Resource.Success(questions)) } catch (e: Exception) { e.printStackTrace() emit(Resource.Error(e.localizedMessage ?: "Error while loading questions")) } } } ================================================ FILE: domain/src/main/java/com/infinitepower/newquiz/domain/use_case/question/IsQuestionSavedUseCase.kt ================================================ package com.infinitepower.newquiz.domain.use_case.question import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.saved_questions.SavedMultiChoiceQuestionsRepository import com.infinitepower.newquiz.model.FlowResource import com.infinitepower.newquiz.model.Resource import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import javax.inject.Inject /** * Use case to check if a question is locally saved. */ class IsQuestionSavedUseCase @Inject constructor( private val savedQuestionRepository: SavedMultiChoiceQuestionsRepository ) { operator fun invoke(question: MultiChoiceQuestion): FlowResource = flow { try { emit(Resource.Loading()) // Get all the saved questions val savedQuestionsFlow = savedQuestionRepository.getFlowQuestions() savedQuestionsFlow.map { savedQuestions -> // Check if the question exists in the saved questions val questionExists = savedQuestions.any { savedQuestion -> savedQuestion == question } Resource.Success(questionExists) }.also { questionExistsFlow -> emitAll(questionExistsFlow) } } catch (e: Exception) { e.printStackTrace() emit(Resource.Error(e.localizedMessage ?: "An unknown error occurred...")) } } } ================================================ FILE: domain/src/test/kotlin/com/infinitepower/newquiz/domain/use_case/question/IsQuestionSavedUseCaseTest.kt ================================================ package com.infinitepower.newquiz.domain.use_case.question import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.saved_questions.SavedMultiChoiceQuestionsRepository import com.infinitepower.newquiz.model.Resource import com.infinitepower.newquiz.model.multi_choice_quiz.getBasicMultiChoiceQuestion import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest import kotlin.test.Test /** * Tests for [IsQuestionSavedUseCase]. */ internal class IsQuestionSavedUseCaseTest { private val savedQuestionRepository: SavedMultiChoiceQuestionsRepository = mockk() private lateinit var isQuestionSavedUseCase: IsQuestionSavedUseCase @BeforeTest fun setUp() { isQuestionSavedUseCase = IsQuestionSavedUseCase(savedQuestionRepository) } @Test fun `should return Resource#Success(true) when question is saved`() = runTest { val questions = List(5) { getBasicMultiChoiceQuestion(it) } every { savedQuestionRepository.getFlowQuestions() } returns flowOf(questions) isQuestionSavedUseCase(questions.first()).test { assertThat(awaitItem()).isEqualTo(Resource.Loading(null)) assertThat(awaitItem()).isEqualTo(Resource.Success(true)) awaitComplete() } } @Test fun `should return Resource#Success(false) when question is not saved`() = runTest { val questions = List(5) { getBasicMultiChoiceQuestion(it) } every { savedQuestionRepository.getFlowQuestions() } returns flowOf(questions) isQuestionSavedUseCase(getBasicMultiChoiceQuestion(-1)).test { assertThat(awaitItem()).isEqualTo(Resource.Loading(null)) assertThat(awaitItem()).isEqualTo(Resource.Success(false)) awaitComplete() } } @Test fun `should return Resource#Success(false) when questions saved are empty`() = runTest { every { savedQuestionRepository.getFlowQuestions() } returns flowOf(emptyList()) isQuestionSavedUseCase(getBasicMultiChoiceQuestion(-1)).test { assertThat(awaitItem()).isEqualTo(Resource.Loading(null)) assertThat(awaitItem()).isEqualTo(Resource.Success(false)) awaitComplete() } } @Test fun `should return Resource#Error when an exception occurs`() = runTest { val exception = Exception("An error occurred...") every { savedQuestionRepository.getFlowQuestions() } throws exception isQuestionSavedUseCase(getBasicMultiChoiceQuestion(-1)).test { assertThat(awaitItem()).isEqualTo(Resource.Loading(null)) assertThat(awaitItem()).isEqualTo(Resource.Error(exception.localizedMessage, null)) awaitComplete() } } } ================================================ FILE: feature/daily-challenge/.gitignore ================================================ /build ================================================ FILE: feature/daily-challenge/README.md ================================================ # :feature:daily-challenge module ================================================ FILE: feature/daily-challenge/build.gradle.kts ================================================ plugins { alias(libs.plugins.newquiz.android.feature) alias(libs.plugins.newquiz.android.compose.destinations) alias(libs.plugins.newquiz.detekt) } android { namespace = "com.infinitepower.newquiz.feature.daily_challenge" } dependencies { implementation(libs.kotlinx.datetime) implementation(projects.core.userServices) implementation(projects.domain) implementation(projects.data) } ================================================ FILE: feature/daily-challenge/src/main/AndroidManifest.xml ================================================ ================================================ FILE: feature/daily-challenge/src/main/kotlin/com/infinitepower/newquiz/feature/daily_challenge/DailyChallengeScreen.kt ================================================ package com.infinitepower.newquiz.feature.daily_challenge import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items 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.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState 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.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.components.icon.button.BackIconButton import com.infinitepower.newquiz.core.util.asString import com.infinitepower.newquiz.core.util.plus import com.infinitepower.newquiz.feature.daily_challenge.components.DailyChallengeCard import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.daily_challenge.DailyChallengeTask import com.infinitepower.newquiz.model.global_event.GameEvent import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.delay import kotlinx.datetime.Clock import kotlin.time.Duration.Companion.minutes import com.infinitepower.newquiz.core.R as CoreR @Composable @Destination @OptIn(ExperimentalMaterial3Api::class) fun DailyChallengeScreen( destinationsNavigator: DestinationsNavigator, dailyChallengeScreenNavigator: DailyChallengeScreenNavigator, viewModel: DailyChallengeScreenViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() DailyChallengeScreen( uiState = uiState, onBackClick = destinationsNavigator::popBackStack, onEvent = viewModel::onEvent, navigateToGame = dailyChallengeScreenNavigator::navigateWithGameEvent ) } @Composable @ExperimentalMaterial3Api private fun DailyChallengeScreen( uiState: DailyChallengeScreenUiState, onBackClick: () -> Unit = {}, onEvent: (event: DailyChallengeScreenUiEvent) -> Unit = {}, navigateToGame: ( event: GameEvent, comparisonQuizCategories: List ) -> Unit ) { val spaceMedium = MaterialTheme.spacing.medium val now by produceState(initialValue = Clock.System.now()) { while (true) { value = Clock.System.now() delay(timeMillis = 1000) } } val analyticsHelper = LocalAnalyticsHelper.current Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = CoreR.string.daily_challenge)) }, navigationIcon = { BackIconButton(onClick = onBackClick) } ) } ) { innerPadding -> LazyColumn( contentPadding = innerPadding + PaddingValues(spaceMedium), verticalArrangement = Arrangement.spacedBy(spaceMedium) ) { items( items = uiState.tasks, key = { task -> task.id } ) { task -> DailyChallengeCard( now = now, title = task.title.asString(), currentValue = task.currentValue, maxValue = task.maxValue, dateRange = task.dateRange, isCompleted = task.isCompleted(), isClaimed = task.isClaimed, modifier = Modifier.fillParentMaxWidth(), diamondsReward = task.diamondsReward, userCanClaim = uiState.userAvailable, onClaimClick = { analyticsHelper.logEvent( AnalyticsEvent.DailyChallengeItemClaim( event = task.event, steps = task.currentValue.toInt() ), AnalyticsEvent.EarnDiamonds(earned = task.diamondsReward.toInt()) ) onEvent(DailyChallengeScreenUiEvent.OnClaimTaskClick(task.event)) }, onCardClick = { analyticsHelper.logEvent( AnalyticsEvent.DailyChallengeItemClick(event = task.event) ) navigateToGame(task.event, uiState.comparisonQuizCategories) } ) } } } } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class) private fun DailyChallengeScreenPreview() { val now = Clock.System.now() NewQuizTheme { Surface { DailyChallengeScreen( uiState = DailyChallengeScreenUiState( tasks = List(10) { DailyChallengeTask( id = it, title = UiText.DynamicString("Task $it"), diamondsReward = 10u, experienceReward = 100u, isClaimed = false, dateRange = now.minus(1.minutes)..now.plus(1.minutes), currentValue = (0..10).random().toUInt(), maxValue = 10u, event = GameEvent.MultiChoice.EndQuiz ) } ), onBackClick = {}, onEvent = {}, navigateToGame = { _, _ -> } ) } } } ================================================ FILE: feature/daily-challenge/src/main/kotlin/com/infinitepower/newquiz/feature/daily_challenge/DailyChallengeScreenNavigator.kt ================================================ package com.infinitepower.newquiz.feature.daily_challenge import com.infinitepower.newquiz.model.comparison_quiz.ComparisonMode import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.global_event.GameEvent import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory import com.infinitepower.newquiz.model.wordle.WordleQuizType interface DailyChallengeScreenNavigator { fun navigateWithGameEvent( event: GameEvent, comparisonQuizCategories: List ) { when (event) { // Navigate to random multi choice quiz is GameEvent.MultiChoice.PlayRandomQuiz, is GameEvent.MultiChoice.EndQuiz, is GameEvent.MultiChoice.PlayQuestions, is GameEvent.MultiChoice.GetAnswersCorrect -> { navigateToMultiChoiceQuiz() } // Navigate to multi choice quiz with category is GameEvent.MultiChoice.PlayQuizWithCategory -> { val categoryKey = event.categoryId val category = MultiChoiceBaseCategory.fromId(categoryKey) navigateToMultiChoiceQuiz(category) } // Navigate to wordle quiz is GameEvent.Wordle.GetWordCorrect -> { navigateToWordleQuiz() } is GameEvent.Wordle.PlayWordWithCategory -> { navigateToWordleQuiz(event.wordleCategory) } // Navigate to comparison quiz is GameEvent.ComparisonQuiz.PlayAndGetScore -> { val randomCategory = comparisonQuizCategories.random() navigateToComparisonQuiz(randomCategory) } is GameEvent.ComparisonQuiz.PlayQuizWithCategory -> { val categoryId = event.categoryId val category = comparisonQuizCategories.find { it.id == categoryId } ?: return navigateToComparisonQuiz(category) } is GameEvent.ComparisonQuiz.PlayWithComparisonMode -> { val randomCategory = comparisonQuizCategories.random() navigateToComparisonQuiz(randomCategory, event.mode) } } } fun navigateToMultiChoiceQuiz( category: MultiChoiceBaseCategory = MultiChoiceBaseCategory.Random ) fun navigateToWordleQuiz( type: WordleQuizType = WordleQuizType.entries.random() ) fun navigateToComparisonQuiz( category: ComparisonQuizCategory, mode: ComparisonMode = ComparisonMode.entries.random() ) } ================================================ FILE: feature/daily-challenge/src/main/kotlin/com/infinitepower/newquiz/feature/daily_challenge/DailyChallengeScreenUiEvent.kt ================================================ package com.infinitepower.newquiz.feature.daily_challenge import androidx.annotation.Keep import com.infinitepower.newquiz.model.global_event.GameEvent interface DailyChallengeScreenUiEvent { @Keep data class OnClaimTaskClick( val taskType: GameEvent ) : DailyChallengeScreenUiEvent } ================================================ FILE: feature/daily-challenge/src/main/kotlin/com/infinitepower/newquiz/feature/daily_challenge/DailyChallengeScreenUiState.kt ================================================ package com.infinitepower.newquiz.feature.daily_challenge import androidx.annotation.Keep import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.daily_challenge.DailyChallengeTask @Keep data class DailyChallengeScreenUiState( val loading: Boolean = true, val tasks: List = emptyList(), val comparisonQuizCategories: List = emptyList(), val userAvailable: Boolean = false ) ================================================ FILE: feature/daily-challenge/src/main/kotlin/com/infinitepower/newquiz/feature/daily_challenge/DailyChallengeScreenViewModel.kt ================================================ package com.infinitepower.newquiz.feature.daily_challenge import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.infinitepower.newquiz.core.user_services.UserService import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.domain.repository.daily_challenge.DailyChallengeRepository 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.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class DailyChallengeScreenViewModel @Inject constructor( private val dailyChallengeRepository: DailyChallengeRepository, private val comparisonQuizRepository: ComparisonQuizRepository, private val userService: UserService ) : ViewModel() { private val _uiState = MutableStateFlow(DailyChallengeScreenUiState()) val uiState = combine( _uiState, dailyChallengeRepository.getAvailableTasksFlow() ) { uiState, tasks -> uiState.copy( // Sort tasks by first claimable and claimed at the end tasks = tasks .sortedByDescending { it.isClaimable() } .sortedBy { it.isClaimed } ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(), initialValue = DailyChallengeScreenUiState() ) init { viewModelScope.launch { _uiState.update { currentState -> currentState.copy( loading = false, comparisonQuizCategories = comparisonQuizRepository.getCategories(), userAvailable = userService.userAvailable() ) } } } fun onEvent(event: DailyChallengeScreenUiEvent) { when (event) { is DailyChallengeScreenUiEvent.OnClaimTaskClick -> claimTask(event.taskType) } } private fun claimTask(taskType: GameEvent) { viewModelScope.launch { dailyChallengeRepository.claimTask(taskType) } } } ================================================ FILE: feature/daily-challenge/src/main/kotlin/com/infinitepower/newquiz/feature/daily_challenge/components/DailyChallengeCard.kt ================================================ package com.infinitepower.newquiz.feature.daily_challenge.components import android.text.format.DateUtils 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.material3.Button import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.res.stringResource 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 kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlin.time.Duration.Companion.minutes import com.infinitepower.newquiz.core.R as CoreR @Composable internal fun DailyChallengeCard( modifier: Modifier = Modifier, onClaimClick: () -> Unit, onCardClick: () -> Unit, now: Instant, title: String, currentValue: UInt, maxValue: UInt, dateRange: ClosedRange, isCompleted: Boolean, isClaimed: Boolean, diamondsReward: UInt, userCanClaim: Boolean = true, ) { val isExpired = remember(dateRange, now) { now > dateRange.endInclusive } val remainingTime = remember(dateRange, now) { val remaining = dateRange.endInclusive - now if (remaining.isNegative()) { "0" } else { DateUtils.formatElapsedTime(remaining.inWholeSeconds) } } DailyChallengeCard( modifier = modifier, title = title, currentValue = currentValue, maxValue = maxValue, remainingTimeInSeconds = remainingTime, isCompleted = isCompleted, enabled = !isExpired && !isClaimed, userCanClaim = userCanClaim, diamondsReward = diamondsReward, onClaimClick = onClaimClick, onCardClick = onCardClick ) } @Composable internal fun DailyChallengeCard( modifier: Modifier = Modifier, onClaimClick: () -> Unit, onCardClick: () -> Unit, title: String, currentValue: UInt, maxValue: UInt, remainingTimeInSeconds: String, isCompleted: Boolean, diamondsReward: UInt, enabled: Boolean = true, userCanClaim: Boolean = true, ) { val spaceMedium = MaterialTheme.spacing.medium val progress = remember(currentValue, maxValue) { currentValue.toFloat() / maxValue.toFloat() } val progressColor = if (enabled) { ProgressIndicatorDefaults.linearColor } else { ProgressIndicatorDefaults.linearColor.copy(alpha = 0.3f) } val trackColor = if (enabled) { ProgressIndicatorDefaults.linearTrackColor } else { ProgressIndicatorDefaults.linearTrackColor.copy(alpha = 0.3f) } OutlinedCard( modifier = modifier, shape = MaterialTheme.shapes.medium, onClick = onCardClick, enabled = enabled ) { Column( modifier = Modifier.padding(spaceMedium) ) { Text( text = title, style = MaterialTheme.typography.titleMedium ) Spacer(modifier = Modifier.height(spaceMedium)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource( id = CoreR.string.remaining_time, remainingTimeInSeconds ), style = MaterialTheme.typography.bodyMedium ) Text( text = "$currentValue / $maxValue", style = MaterialTheme.typography.bodyMedium ) } Spacer(modifier = Modifier.height(spaceMedium)) LinearProgressIndicator( progress = { progress }, modifier = Modifier .height(8.dp) .fillMaxWidth(), color = progressColor, trackColor = trackColor, strokeCap = StrokeCap.Round, ) if (isCompleted && enabled && userCanClaim) { Spacer(modifier = Modifier.height(spaceMedium)) Button( onClick = onClaimClick, modifier = Modifier.align(Alignment.End) ) { Text( text = stringResource( id = CoreR.string.claim_n_diamonds, diamondsReward.toInt() ) ) } } } } } @Composable @PreviewLightDark private fun DailyChallengeCardPreview() { val now = Clock.System.now() NewQuizTheme { Surface { DailyChallengeCard( modifier = Modifier.padding(16.dp), now = now, title = "Daily Challenge", currentValue = 8u, maxValue = 10u, dateRange = now.minus(1.minutes)..now.plus(1.minutes), isCompleted = true, isClaimed = false, diamondsReward = 10u, onClaimClick = {}, onCardClick = {} ) } } } ================================================ FILE: feature/maze/.gitignore ================================================ /build ================================================ FILE: feature/maze/build.gradle.kts ================================================ plugins { alias(libs.plugins.newquiz.android.feature) alias(libs.plugins.newquiz.android.compose.destinations) alias(libs.plugins.newquiz.detekt) } android { namespace = "com.infinitepower.newquiz.feature.maze" } dependencies { implementation(libs.androidx.work.ktx) implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.compose.material.iconsExtended) implementation(libs.androidx.graphics.shapes) implementation(libs.lottie.compose) implementation(projects.core.datastore) implementation(projects.data) implementation(projects.domain) testImplementation(projects.core.testing) } ================================================ FILE: feature/maze/src/main/AndroidManifest.xml ================================================ ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreen.kt ================================================ package com.infinitepower.newquiz.feature.maze import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.RestartAlt import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.infinitepower.newquiz.core.navigation.MazeNavigator import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.SnackbarController import com.infinitepower.newquiz.core.ui.components.icon.button.BackIconButton import com.infinitepower.newquiz.feature.maze.components.CategoriesInfoBottomSheet import com.infinitepower.newquiz.feature.maze.components.InvalidCategoriesCard import com.infinitepower.newquiz.feature.maze.components.MazeCompletedCard import com.infinitepower.newquiz.feature.maze.components.MazePath import com.infinitepower.newquiz.feature.maze.generate.GenerateMazeScreen import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.maze.MazeQuiz import com.infinitepower.newquiz.model.maze.MazeQuiz.MazeItem import com.infinitepower.newquiz.model.question.QuestionDifficulty import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.model.wordle.WordleWord import com.ramcosta.composedestinations.annotation.DeepLink import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch import com.infinitepower.newquiz.core.R as CoreR @Composable @Destination( deepLinks = [ DeepLink(uriPattern = "newquiz://maze") ] ) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) fun MazeScreen( navigator: DestinationsNavigator, mazeNavigator: MazeNavigator, viewModel: MazeScreenViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() MazeScreenImpl( uiState = uiState, navigateBack = navigator::popBackStack, uiEvent = viewModel::onEvent, onItemClick = mazeNavigator::navigateToGame, ) } @Composable @ExperimentalMaterial3Api @ExperimentalFoundationApi private fun MazeScreenImpl( uiState: MazeScreenUiState, navigateBack: () -> Unit, uiEvent: (event: MazeScreenUiEvent) -> Unit, onItemClick: (item: MazeItem) -> Unit, ) { when { uiState.loading -> { Box { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } } !uiState.loading && uiState.isMazeEmpty -> GenerateMazeScreen(onBackClick = navigateBack) !uiState.loading && !uiState.isMazeEmpty -> { MazePathScreen( uiState = uiState, navigateBack = navigateBack, uiEvent = uiEvent, onItemClick = onItemClick ) } } } @Composable @ExperimentalMaterial3Api @ExperimentalFoundationApi private fun MazePathScreen( uiState: MazeScreenUiState, navigateBack: () -> Unit, uiEvent: (event: MazeScreenUiEvent) -> Unit, onItemClick: (item: MazeItem) -> Unit ) { val clipboardManager = LocalClipboardManager.current val spaceMedium = MaterialTheme.spacing.medium val scope = rememberCoroutineScope() var moreOptionsExpanded by remember { mutableStateOf(false) } var categoriesInfoBottomSheetVisible by remember { mutableStateOf(false) } var restartMazeDialogVisible by remember { mutableStateOf(false) } val showRestartMazeDialog = { restartMazeDialogVisible = true } val categoriesByGameMode = remember(uiState.maze) { uiState.getAvailableCategoriesByGameMode() } val invalidQuestions = remember(categoriesByGameMode) { uiState.getInvalidMazeItems(categoriesByGameMode) } Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = CoreR.string.maze)) }, navigationIcon = { BackIconButton(onClick = navigateBack) }, actions = { IconButton(onClick = { moreOptionsExpanded = true }) { Icon( imageVector = Icons.Rounded.MoreVert, contentDescription = stringResource(id = CoreR.string.more_options) ) } DropdownMenu( expanded = moreOptionsExpanded, onDismissRequest = { moreOptionsExpanded = false } ) { if (!uiState.isMazeEmpty) { DropdownMenuItem( text = { Text(text = stringResource(id = CoreR.string.category_information)) }, onClick = { moreOptionsExpanded = false categoriesInfoBottomSheetVisible = true }, leadingIcon = { Icon( imageVector = Icons.Rounded.Info, contentDescription = null ) } ) uiState.mazeSeed?.let { mazeSeed -> DropdownMenuItem( text = { Text(stringResource(id = CoreR.string.copy_maze_seed)) }, onClick = { clipboardManager.setText(AnnotatedString(mazeSeed.toString())) moreOptionsExpanded = false }, leadingIcon = { Icon( imageVector = Icons.Rounded.ContentCopy, contentDescription = stringResource(id = CoreR.string.copy_maze_seed) ) } ) } DropdownMenuItem( text = { Text(stringResource(id = CoreR.string.restart_maze)) }, onClick = { showRestartMazeDialog() moreOptionsExpanded = false }, leadingIcon = { Icon( imageVector = Icons.Rounded.RestartAlt, contentDescription = stringResource(id = CoreR.string.restart_maze) ) } ) } } }, ) } ) { innerPadding -> if (!uiState.isMazeEmpty) { Column( modifier = Modifier .padding(innerPadding) .fillMaxSize(), ) { MazePath( modifier = Modifier.weight(1f), items = uiState.maze.items, mazeSeed = uiState.mazeSeed ?: 0, onItemClick = { item -> if (item in invalidQuestions) { scope.launch { SnackbarController.sendShortMessage( UiText.StringResource(CoreR.string.invalid_questions) ) } } else { onItemClick(item) } }, startScrollToCurrentItem = uiState.autoScrollToCurrentItem ) if (invalidQuestions.isNotEmpty()) { InvalidCategoriesCard( invalidQuestionsCount = invalidQuestions.size, onRemoveClick = { uiEvent(MazeScreenUiEvent.RemoveInvalidCategories) }, onRestartClick = showRestartMazeDialog, modifier = Modifier .fillMaxWidth() .padding( start = spaceMedium, end = spaceMedium, bottom = spaceMedium ) ) } if (uiState.isMazeCompleted) { MazeCompletedCard( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.background) .padding( start = spaceMedium, end = spaceMedium, bottom = spaceMedium ), onRestartClick = showRestartMazeDialog ) } } } } if (categoriesInfoBottomSheetVisible) { CategoriesInfoBottomSheet( onDismissRequest = { categoriesInfoBottomSheetVisible = false }, categoriesByGameMode = categoriesByGameMode ) } if (restartMazeDialogVisible) { RestartMazeDialog( onDismissRequest = { restartMazeDialogVisible = false }, onConfirm = { uiEvent(MazeScreenUiEvent.RestartMaze) } ) } } @Composable private fun RestartMazeDialog( onDismissRequest: () -> Unit, onConfirm: () -> Unit ) { AlertDialog( title = { Text(text = stringResource(CoreR.string.restart_maze)) }, text = { Text(text = stringResource(CoreR.string.restart_maze_dialog_description)) }, onDismissRequest = onDismissRequest, confirmButton = { TextButton(onClick = onConfirm) { Text(text = stringResource(CoreR.string.restart)) } }, dismissButton = { TextButton(onClick = onDismissRequest) { Text(text = stringResource(CoreR.string.cancel)) } } ) } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) private fun MazeScreenPreview() { val completedItems = List(3) { MazeItem.Wordle( wordleWord = WordleWord("1+1=2"), difficulty = QuestionDifficulty.Easy, played = true, wordleQuizType = WordleQuizType.MATH_FORMULA, mazeSeed = 0 ) } val otherItems = List(8) { MazeItem.Wordle( wordleWord = WordleWord("1+1=2"), difficulty = QuestionDifficulty.Easy, wordleQuizType = WordleQuizType.MATH_FORMULA, mazeSeed = 0 ) } val mazeItems = (completedItems + otherItems).toPersistentList() NewQuizTheme { Surface { MazeScreenImpl( uiState = MazeScreenUiState( loading = false, maze = MazeQuiz(items = mazeItems) ), navigateBack = {}, uiEvent = {}, onItemClick = {}, ) } } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiEvent.kt ================================================ package com.infinitepower.newquiz.feature.maze sealed interface MazeScreenUiEvent { data object RestartMaze : MazeScreenUiEvent data object RemoveInvalidCategories : MazeScreenUiEvent } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiState.kt ================================================ package com.infinitepower.newquiz.feature.maze import androidx.annotation.Keep import com.infinitepower.newquiz.feature.maze.common.MazeCategories import com.infinitepower.newquiz.model.BaseCategory import com.infinitepower.newquiz.model.GameMode import com.infinitepower.newquiz.model.maze.MazeQuiz import com.infinitepower.newquiz.model.maze.emptyMaze typealias CategoriesByGameMode = Map> @Keep data class MazeScreenUiState( val loading: Boolean = true, val maze: MazeQuiz = emptyMaze(), val autoScrollToCurrentItem: Boolean = true, val comparisonQuizCategories: List = emptyList(), ) { val isMazeEmpty: Boolean get() = maze.items.isEmpty() val isMazeCompleted: Boolean get() = maze.items.all { it.played } val mazeSeed: Int? get() = maze.items.firstOrNull()?.mazeSeed fun getAvailableCategoriesByGameMode(): CategoriesByGameMode { return maze.items.groupBy { it.gameMode }.mapValues { (gameMode, categoryItems) -> if (categoryItems.isEmpty()) return@mapValues emptySet() val availableCategories = when (gameMode) { GameMode.MULTI_CHOICE -> MazeCategories.getMultiChoiceCategories() GameMode.WORDLE -> MazeCategories.availableWordleCategories GameMode.COMPARISON_QUIZ -> comparisonQuizCategories } categoryItems.mapNotNull { item -> availableCategories.find { it.id == item.categoryId } }.toSet() } } fun getInvalidMazeItems(availableCategories: CategoriesByGameMode): List { return maze.items.filter { item -> val categoriesForGameMode = availableCategories[item.gameMode] ?: emptyList() !categoriesForGameMode.any { it.id == item.categoryId } } } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenViewModel.kt ================================================ package com.infinitepower.newquiz.feature.maze import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.WorkManager import com.infinitepower.newquiz.core.analytics.AnalyticsEvent import com.infinitepower.newquiz.core.analytics.AnalyticsHelper import com.infinitepower.newquiz.core.datastore.common.SettingsCommon import com.infinitepower.newquiz.core.datastore.di.SettingsDataStoreManager import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import com.infinitepower.newquiz.data.worker.maze.CleanMazeQuizWorker import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository import com.infinitepower.newquiz.model.BaseCategory 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.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class MazeScreenViewModel @Inject constructor( private val mazeMathQuizRepository: MazeQuizRepository, private val workManager: WorkManager, private val analyticsHelper: AnalyticsHelper, @SettingsDataStoreManager private val settingsDataStoreManager: DataStoreManager, private val comparisonQuizRepository: ComparisonQuizRepository ) : ViewModel() { private val _uiState = MutableStateFlow(MazeScreenUiState()) val uiState = combine( _uiState, mazeMathQuizRepository.getSavedMazeQuizFlow() ) { uiState, savedMazeQuiz -> uiState.copy( maze = savedMazeQuiz, loading = false ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), initialValue = MazeScreenUiState() ) init { viewModelScope.launch { val autoScrollToCurrentItem = settingsDataStoreManager.getPreference( preferenceEntry = SettingsCommon.MazeAutoScrollToCurrentItem ) _uiState.update { currentState -> currentState.copy( autoScrollToCurrentItem = autoScrollToCurrentItem, comparisonQuizCategories = getComparisonQuizCategories() ) } } } fun onEvent(event: MazeScreenUiEvent) { when (event) { is MazeScreenUiEvent.RestartMaze -> cleanSavedMaze() is MazeScreenUiEvent.RemoveInvalidCategories -> viewModelScope.launch { removeInvalidQuestions() } } } private fun cleanSavedMaze() { analyticsHelper.logEvent(AnalyticsEvent.RestartMaze) CleanMazeQuizWorker.enqueue(workManager) } private suspend fun removeInvalidQuestions() { val state = uiState.first() val availableCategories = state.getAvailableCategoriesByGameMode() val invalidItems = state.getInvalidMazeItems(availableCategories) mazeMathQuizRepository.removeItems(invalidItems) } private fun getComparisonQuizCategories(): List { return comparisonQuizRepository.getCategories().filterNot { it.isMazeDisabled } } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/categories_info/MazeCategoriesInfoScreen.kt ================================================ package com.infinitepower.newquiz.feature.maze.categories_info import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.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.core.R import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.components.category.CategoryComponent import com.infinitepower.newquiz.core.ui.components.icon.button.BackIconButton import com.infinitepower.newquiz.core.util.asString import com.infinitepower.newquiz.data.local.multi_choice_quiz.category.multiChoiceQuestionCategories import com.infinitepower.newquiz.data.local.wordle.WordleCategories import com.infinitepower.newquiz.model.BaseCategory import com.infinitepower.newquiz.model.category.ShowCategoryConnectionInfo import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @Composable @Destination fun MazeCategoriesInfoScreen( navigator: DestinationsNavigator, viewModel: MazeCategoriesInfoViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() MazeCategoriesInfoScreenImpl( onBackClick = navigator::popBackStack, uiState = uiState ) } @Composable @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) private fun MazeCategoriesInfoScreenImpl( uiState: MazeCategoriesInfoUiState, onBackClick: () -> Unit = {}, ) { Scaffold( topBar = { TopAppBar( title = { Text(text = "Categories Info") }, navigationIcon = { BackIconButton(onClick = onBackClick) } ) } ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding), ) { if (uiState.loading) { CircularProgressIndicator() } else { CategoriesContent(uiState = uiState) } } } } @Composable @ExperimentalFoundationApi private fun CategoriesContent( uiState: MazeCategoriesInfoUiState, ) { val multiChoiceHeader = stringResource(id = R.string.multi_choice_quiz) val wordleHeader = stringResource(id = R.string.wordle) val comparisonQuizHeader = stringResource(id = R.string.comparison_quiz) LazyColumn( contentPadding = PaddingValues(bottom = MaterialTheme.spacing.medium), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium) ) { categoriesItemsWithHeader( title = multiChoiceHeader, categories = uiState.multiChoiceCategories ) categoriesItemsWithHeader( title = wordleHeader, categories = uiState.wordleCategories ) categoriesItemsWithHeader( title = comparisonQuizHeader, categories = uiState.comparisonQuizCategories ) } } @ExperimentalFoundationApi private fun LazyListScope.categoriesItemsWithHeader( title: String, categories: ImmutableList ) { if (categories.isNotEmpty()) { categoriesStickyHeader(title = title) categoriesItems(categories = categories, baseItemKey = title) } } @ExperimentalFoundationApi private fun LazyListScope.categoriesStickyHeader( title: String ) { stickyHeader { Text( text = title, style = MaterialTheme.typography.headlineMedium, modifier = Modifier .fillParentMaxWidth() .background(color = MaterialTheme.colorScheme.surface) .padding( horizontal = MaterialTheme.spacing.medium, vertical = MaterialTheme.spacing.small ) ) } } private fun LazyListScope.categoriesItems( categories: ImmutableList, baseItemKey: String = "", ) { items( items = categories, key = { category -> "$baseItemKey-${category.id}" } ) { category -> CategoryComponent( title = category.name.asString(), imageUrl = category.image, requireInternetConnection = category.requireInternetConnection, showConnectionInfo = ShowCategoryConnectionInfo.BOTH, modifier = Modifier .fillParentMaxWidth() .padding(horizontal = MaterialTheme.spacing.medium), clickEnabled = false ) } } @Composable @PreviewLightDark private fun MazeCategoriesInfoScreenPreview() { NewQuizTheme { MazeCategoriesInfoScreenImpl( uiState = MazeCategoriesInfoUiState( loading = false, multiChoiceCategories = multiChoiceQuestionCategories.take(2).toImmutableList(), wordleCategories = WordleCategories.allCategories.take(1).toImmutableList(), ) ) } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/categories_info/MazeCategoriesInfoUiState.kt ================================================ package com.infinitepower.newquiz.feature.maze.categories_info import com.infinitepower.newquiz.model.BaseCategory import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf data class MazeCategoriesInfoUiState( val loading: Boolean = true, val multiChoiceCategories: ImmutableList = persistentListOf(), val wordleCategories: ImmutableList = persistentListOf(), val comparisonQuizCategories: ImmutableList = persistentListOf(), ) ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/categories_info/MazeCategoriesInfoViewModel.kt ================================================ package com.infinitepower.newquiz.feature.maze.categories_info import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository import com.infinitepower.newquiz.feature.maze.common.MazeCategories import com.infinitepower.newquiz.model.BaseCategory import com.infinitepower.newquiz.model.GameMode import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class MazeCategoriesInfoViewModel @Inject constructor( private val mazeMathQuizRepository: MazeQuizRepository, private val comparisonQuizRepository: ComparisonQuizRepository ) : ViewModel() { private val categoriesByGameMode = mazeMathQuizRepository .getSavedMazeQuizFlow() .map { quiz -> quiz.items .groupBy { item -> item.gameMode } .mapValues { (gameMode, categoryItems) -> val categoriesIds = categoryItems.map { it.categoryId }.toSet() if (categoriesIds.isEmpty()) return@mapValues emptyList() val categories = when (gameMode) { GameMode.MULTI_CHOICE -> MazeCategories.getMultiChoiceCategories() GameMode.WORDLE -> MazeCategories.availableWordleCategories GameMode.COMPARISON_QUIZ -> getComparisonQuizCategories() } categories.filter { category -> categoriesIds.contains(category.id) } } } val uiState = categoriesByGameMode.map { MazeCategoriesInfoUiState( multiChoiceCategories = it[GameMode.MULTI_CHOICE].orEmpty().toImmutableList(), wordleCategories = it[GameMode.WORDLE].orEmpty().toImmutableList(), comparisonQuizCategories = it[GameMode.COMPARISON_QUIZ].orEmpty().toImmutableList(), loading = false ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), initialValue = MazeCategoriesInfoUiState() ) private fun getComparisonQuizCategories(): List { return comparisonQuizRepository.getCategories().filterNot { it.isMazeDisabled } } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/common/MazeCategories.kt ================================================ package com.infinitepower.newquiz.feature.maze.common import com.infinitepower.newquiz.data.local.multi_choice_quiz.category.multiChoiceQuestionCategories import com.infinitepower.newquiz.data.local.wordle.WordleCategories import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceCategory import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.core.R as CoreR object MazeCategories { private val availableMultiChoiceCategoriesIds = listOf( MultiChoiceBaseCategory.Logo.id, MultiChoiceBaseCategory.Flag.id, MultiChoiceBaseCategory.CountryCapitalFlags.id, MultiChoiceBaseCategory.GuessMathSolution.id, ) fun getMultiChoiceCategories(): List { val filteredCategories = multiChoiceQuestionCategories.filter { category -> availableMultiChoiceCategoriesIds.contains(category.id) } // Because with the implementation with OpenTriviaDB, we can't select // specific category, so we need to create a special category // for this case, that contains all the categories. val othersCategory = MultiChoiceCategory( id = "others", name = UiText.DynamicString("Others"), image = CoreR.drawable.general_knowledge, requireInternetConnection = true ) return filteredCategories + othersCategory } val availableWordleCategories = WordleCategories.allCategories.filter { category -> category.id != WordleQuizType.NUMBER_TRIVIA.name } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/CategoriesInfoBottomSheet.kt ================================================ package com.infinitepower.newquiz.feature.maze.components import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import com.infinitepower.newquiz.core.R import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.components.category.CategoryComponent import com.infinitepower.newquiz.core.util.asString import com.infinitepower.newquiz.data.local.multi_choice_quiz.category.multiChoiceQuestionCategories import com.infinitepower.newquiz.data.local.wordle.WordleCategories import com.infinitepower.newquiz.feature.maze.CategoriesByGameMode import com.infinitepower.newquiz.model.BaseCategory import com.infinitepower.newquiz.model.GameMode import com.infinitepower.newquiz.model.category.ShowCategoryConnectionInfo import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Composable @ExperimentalMaterial3Api @ExperimentalFoundationApi internal fun CategoriesInfoBottomSheet( onDismissRequest: () -> Unit, categoriesByGameMode: CategoriesByGameMode ) { ModalBottomSheet( onDismissRequest = onDismissRequest ) { CategoriesContent( multiChoiceCategories = categoriesByGameMode[GameMode.MULTI_CHOICE].orEmpty().toImmutableList(), wordleCategories = categoriesByGameMode[GameMode.WORDLE].orEmpty().toImmutableList(), comparisonQuizCategories = categoriesByGameMode[GameMode.COMPARISON_QUIZ].orEmpty().toImmutableList() ) } } @Composable @ExperimentalMaterial3Api @ExperimentalFoundationApi private fun CategoriesContent( modifier: Modifier = Modifier, multiChoiceCategories: ImmutableList, wordleCategories: ImmutableList, comparisonQuizCategories: ImmutableList, ) { val multiChoiceHeader = stringResource(id = R.string.multi_choice_quiz) val wordleHeader = stringResource(id = R.string.wordle) val comparisonQuizHeader = stringResource(id = R.string.comparison_quiz) LazyColumn( modifier = modifier, contentPadding = PaddingValues(bottom = MaterialTheme.spacing.medium), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium) ) { categoriesItemsWithHeader( title = multiChoiceHeader, categories = multiChoiceCategories ) categoriesItemsWithHeader( title = wordleHeader, categories = wordleCategories ) categoriesItemsWithHeader( title = comparisonQuizHeader, categories = comparisonQuizCategories ) } } @ExperimentalMaterial3Api @ExperimentalFoundationApi private fun LazyListScope.categoriesItemsWithHeader( title: String, categories: ImmutableList ) { if (categories.isNotEmpty()) { categoriesStickyHeader(title = title) categoriesItems(categories = categories) } } @ExperimentalMaterial3Api @ExperimentalFoundationApi private fun LazyListScope.categoriesStickyHeader( title: String, ) { stickyHeader { Text( text = title, style = MaterialTheme.typography.headlineMedium, modifier = Modifier .fillParentMaxWidth() .background(color = BottomSheetDefaults.ContainerColor) .padding( horizontal = MaterialTheme.spacing.medium, vertical = MaterialTheme.spacing.small ) ) } } private fun LazyListScope.categoriesItems( categories: ImmutableList, ) { items(items = categories) { category -> CategoryComponent( title = category.name.asString(), imageUrl = category.image, requireInternetConnection = category.requireInternetConnection, showConnectionInfo = ShowCategoryConnectionInfo.BOTH, modifier = Modifier .fillParentMaxWidth() .padding(horizontal = MaterialTheme.spacing.medium), clickEnabled = false ) } } @Composable @PreviewLightDark @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) private fun MazeCategoriesContentPreview() { NewQuizTheme { Surface { CategoriesContent( multiChoiceCategories = multiChoiceQuestionCategories.take(2).toImmutableList(), wordleCategories = WordleCategories.allCategories.take(1).toImmutableList(), comparisonQuizCategories = persistentListOf() ) } } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/InvalidCategoriesCard.kt ================================================ package com.infinitepower.newquiz.feature.maze.components 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.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.R as CoreR @Composable internal fun InvalidCategoriesCard( invalidQuestionsCount: Int, onRemoveClick: () -> Unit = {}, onRestartClick: () -> Unit = {}, modifier: Modifier = Modifier ) { Surface( modifier = modifier, color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium ) { Column( modifier = Modifier.padding(MaterialTheme.spacing.medium), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.extraSmall) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium) ) { Icon( imageVector = Icons.Rounded.Warning, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(48.dp) ) Column( verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small) ) { Text( text = stringResource(CoreR.string.invalid_questions), style = MaterialTheme.typography.titleMedium ) Text( text = stringResource(CoreR.string.invalid_questions, invalidQuestionsCount), style = MaterialTheme.typography.bodyMedium ) } } Spacer(modifier = Modifier.height(MaterialTheme.spacing.small)) Row( modifier = Modifier.align(Alignment.End), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small) ) { TextButton( onClick = onRestartClick, colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error ) ) { Text(text = stringResource(CoreR.string.restart_maze)) } Button( onClick = onRemoveClick, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError ), ) { Text(text = stringResource(CoreR.string.remove_them)) } } } } } @Composable @PreviewLightDark private fun InvalidCategoriesCardPreview() { NewQuizTheme { Surface { InvalidCategoriesCard( invalidQuestionsCount = 3, modifier = Modifier .padding(MaterialTheme.spacing.medium) .fillMaxWidth() ) } } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazeCompletedCard.kt ================================================ package com.infinitepower.newquiz.feature.maze.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.icons.Trophy import com.infinitepower.newquiz.core.R as CoreR @Composable internal fun MazeCompletedCard( onRestartClick: () -> Unit = {}, modifier: Modifier = Modifier ) { Surface( modifier = modifier, color = MaterialTheme.colorScheme.primaryContainer, shape = MaterialTheme.shapes.medium ) { Row( modifier = Modifier.padding(MaterialTheme.spacing.medium), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium) ) { Icon( imageVector = Trophy, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(48.dp) ) Column( verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.extraSmall) ) { Text( text = stringResource(id = CoreR.string.maze_completed), style = MaterialTheme.typography.titleMedium ) Button(onClick = onRestartClick) { Text(text = stringResource(id = CoreR.string.restart_maze)) } } } } } @Composable @PreviewLightDark private fun MazeCompletedCardPreview() { NewQuizTheme { Surface { MazeCompletedCard( modifier = Modifier .padding(MaterialTheme.spacing.medium) .fillMaxWidth() ) } } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazeItemButton.kt ================================================ package com.infinitepower.newquiz.feature.maze.components import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateIntAsState import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.theme.NewQuizTheme @Composable internal fun MazeItemButton( modifier: Modifier = Modifier, itemPlayed: Boolean, isPlayableItem: Boolean, colors: MazeColors = MazeDefaults.defaultColors(), ) { val canPress = isPlayableItem || itemPlayed val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val scale by animateFloatAsState( if (isPressed) CirclePressScale else 1f, label = "Scale" ) val circleRadius by animateIntAsState( if (isPressed) CirclePressedRadius else CircleUnPressedRadius, label = "Circle Radius" ) Box( modifier = modifier .size(CircleSize) .scale(scale) .background( color = colors.circleContainerColor( played = itemPlayed, isPlayItem = isPlayableItem ), shape = RoundedCornerShape(circleRadius) ) .then( if (isPlayableItem) { Modifier .padding(CurrentCircleInnerPadding) .background( color = colors.currentCircleInnerColor(), shape = CircleShape ) } else { Modifier } ) .clip(CircleShape) .clickable( interactionSource = interactionSource, indication = LocalIndication.current, onClick = {}, enabled = canPress, role = Role.Button ), contentAlignment = Alignment.Center ) { Icon( imageVector = when { !isPlayableItem && !itemPlayed -> Icons.Rounded.Lock !isPlayableItem && itemPlayed -> Icons.Rounded.Check else -> Icons.Rounded.PlayArrow }, contentDescription = null, tint = colors.circleContentColor( played = itemPlayed, isPlayItem = isPlayableItem ), modifier = Modifier.size(CircleSize / 2) ) } } private val CircleSize = 50.dp private val CurrentCircleInnerPadding = 6.dp private const val CirclePressScale = 1.1f private const val CirclePressedRadius = 20 private const val CircleUnPressedRadius = 50 @Composable @PreviewLightDark private fun MazeItemButtonPreview() { NewQuizTheme { Surface { Column( modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { MazeItemButton( itemPlayed = true, isPlayableItem = true, ) MazeItemButton( itemPlayed = false, isPlayableItem = true, ) MazeItemButton( itemPlayed = false, isPlayableItem = false, ) MazeItemButton( itemPlayed = true, isPlayableItem = false, ) } } } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/MazePath.kt ================================================ package com.infinitepower.newquiz.feature.maze.components import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.asComposePath import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.vector.VectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.graphics.shapes.CornerRounding import androidx.graphics.shapes.RoundedPolygon import androidx.graphics.shapes.toPath import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.util.collections.indexOfFirstOrNull import com.infinitepower.newquiz.model.maze.MazeQuiz import com.infinitepower.newquiz.model.maze.isItemPlayed import com.infinitepower.newquiz.model.maze.isPlayableItem import com.infinitepower.newquiz.model.question.QuestionDifficulty import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.model.wordle.WordleWord import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList import kotlin.math.PI import kotlin.math.pow import kotlin.math.sin import kotlin.random.Random @Composable fun MazePath( modifier: Modifier = Modifier, items: ImmutableList, startScrollToCurrentItem: Boolean = true, colors: MazeColors = MazeDefaults.defaultColors(), horizontalPadding: Dp = MazeDefaults.horizontalPadding, verticalPadding: Dp = MazeDefaults.verticalPadding, mazeSeed: Int = 0, onItemClick: (item: MazeQuiz.MazeItem) -> Unit = {}, ) { val random = remember(mazeSeed) { Random(mazeSeed) } val localDensity = LocalDensity.current val horizontalPaddingPx = with(localDensity) { horizontalPadding.toPx() } val verticalPaddingPx = with(localDensity) { verticalPadding.toPx() } val yPointsSize = items.size // Find the index of the current play item. val currentPlayItemIndex = remember(items) { items.indexOfFirstOrNull { !it.played } } BoxWithConstraints(modifier = modifier) { val screenHeight = constraints.maxHeight val screenWidth = constraints.maxWidth val points: List = remember(yPointsSize) { List(yPointsSize) { i -> // Get a random number between 2 and 5 to make the horizontal offset of the points more random val r = random.nextDouble( from = HorizontalOffsetRandomStart, until = HorizontalOffsetRandomEnd ).toFloat() val horizontalWidth = screenWidth - horizontalPaddingPx val x = sin((i.toFloat() / 2) * PI) * horizontalWidth / r + screenWidth / 2 val y = localDensity.getPointY( height = screenHeight.toFloat(), index = i, verticalPaddingPx = verticalPaddingPx, ) Offset(x.toFloat(), y) } } // Calculate the height of the graph based on the number of points. val graphHeight = remember(yPointsSize) { with(localDensity) { PointSpacing.toPx() * (yPointsSize - 1) } + 2 * verticalPaddingPx } var topScroll by remember { mutableFloatStateOf(0f) } val topScrollAnimated = animateFloatAsState( targetValue = topScroll, animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow ), label = "Top Scroll" ) val playPainter = rememberVectorPainter(image = Icons.Rounded.PlayArrow) val playedPainter = rememberVectorPainter(image = Icons.Rounded.Check) val lockPainter = rememberVectorPainter(image = Icons.Rounded.Lock) var pressedOffset by remember { mutableStateOf(Offset.Zero) } val scrollToCurrentItem: () -> Unit = { currentPlayItemIndex?.let { index -> // Get the current play item's y position. val currentPlayItemY = points[index].y // Get the height of the screen divided by 2. // This is used to center the current play item on the screen. val halfScreenHeight = screenHeight / 2 val newTopScroll = -currentPlayItemY + halfScreenHeight // If the top scroll is less than 0, it means that the current play item is // not above the screen. In this case, we don't need to scroll. if (newTopScroll >= 0) { val newGraphHeight = graphHeight - newTopScroll topScroll = if (newGraphHeight >= screenHeight) { newTopScroll } else { graphHeight - screenHeight } } else { topScroll = 0f } } } val visibleScreenHeightRange = remember(topScroll, graphHeight) { graphHeight - topScroll - screenHeight..graphHeight - topScroll } // The scroll button is visible if the current play item is not visible in the screen. val scrollButtonState = remember(currentPlayItemIndex, visibleScreenHeightRange) { if (currentPlayItemIndex == null) { ScrollButtonState.HIDDEN } else { val currentPlayItemY = points[currentPlayItemIndex].y + graphHeight - screenHeight if (currentPlayItemY !in visibleScreenHeightRange) { if (currentPlayItemY < visibleScreenHeightRange.start) { ScrollButtonState.SCROLL_TO_TOP } else { ScrollButtonState.SCROLL_TO_BOTTOM } } else { ScrollButtonState.HIDDEN } } } ScrollToCurrentQuestionButton( modifier = Modifier .zIndex(1f) .align(Alignment.BottomEnd) .padding(MaterialTheme.spacing.medium), state = scrollButtonState, onClick = scrollToCurrentItem ) // If the startScrollToCurrentItem is true, scroll to the current item. LaunchedEffect(key1 = Unit) { if (startScrollToCurrentItem) { scrollToCurrentItem() } } Canvas( modifier = Modifier .fillMaxWidth() .height(graphHeight.dp) .pointerInput(Unit) { detectVerticalDragGestures { _, dragAmount -> val newTopScroll = topScroll + dragAmount val newGraphHeight = graphHeight - newTopScroll if (newTopScroll >= 0 && newGraphHeight >= screenHeight) { topScroll += dragAmount } } } .pointerInput(currentPlayItemIndex) { detectTapGestures( onTap = { tapOffset -> // Find the index of the item that was tapped val tapIndex = points.indexOfFirstOrNull { point -> val realMazePoint = point.copy(y = point.y + topScroll) tapOffset.isInsideCircle(realMazePoint, CircleSize.toPx()) } if (tapIndex != null && tapIndex in items.indices) { if (items.isPlayableItem(tapIndex) || items.isItemPlayed(tapIndex)) { onItemClick(items[tapIndex]) } } }, onPress = { pressOffset -> pressedOffset = pressOffset.copy( y = pressOffset.y - topScroll ) awaitRelease() pressedOffset = Offset.Zero } ) } ) { val completedPath = Path() val remainingPath = Path() points.forEachIndexed { i, point -> val currentY = point.y val path = if (items.isItemPlayed(i) || items.isPlayableItem(i)) completedPath else remainingPath val isPlayItem = items.isPlayableItem(i - 1) val previousY = getPointY( height = size.height, index = i - 1, verticalPaddingPx = verticalPaddingPx, ) if (i == 0) { path.moveTo(size.width / 2, currentY) } else { if (isPlayItem) { path.moveTo( x = points[i - 1].x, y = previousY ) } val conY1 = PathSmoothness * previousY + (1 - PathSmoothness) * currentY val conY2 = PathSmoothness * currentY + (1 - PathSmoothness) * previousY val conX1 = points[i - 1].x val conX2 = points[i].x path.cubicTo( x1 = conX1, y1 = conY1, x2 = conX2, y2 = conY2, x3 = points[i].x, y3 = currentY ) } } translate(top = topScrollAnimated.value) { // Draw the path for the completed items drawMazePath( path = completedPath, color = colors.pathColor(played = true), ) // Draw the path for the remaining items drawMazePath( path = remainingPath, color = colors.pathColor(played = false), ) // Draw the points drawPoints( points = points, items = items, colors = colors, pressedOffset = pressedOffset, lockPainter = lockPainter, playPainter = playPainter, playedPainter = playedPainter, ) } } } } private fun DrawScope.drawMazePath( path: Path, color: Color, ) { drawPath( path = path, color = color, style = Stroke( width = LineSize.toPx(), cap = StrokeCap.Round, pathEffect = PathEffect.dashPathEffect(MazePathEffectInterval) ), ) } private fun DrawScope.drawPoints( points: List, items: ImmutableList, colors: MazeColors, pressedOffset: Offset, lockPainter: VectorPainter, playPainter: VectorPainter, playedPainter: VectorPainter, ) { points.forEachIndexed { itemIndex, pointOffset -> val itemPlayed = items.isItemPlayed(itemIndex) val isPlayableItem = items.isPlayableItem(itemIndex) val canPress = isPlayableItem || itemPlayed val isPressed = canPress && pressedOffset.isInsideCircle( pointOffset, CircleSize.toPx() ) scale( scale = if (isPressed) CirclePressScale else 1f, pivot = pointOffset ) { val currentItemRoundedPolygon = RoundedPolygon( numVertices = ITEM_POLYGON_NUM_VERTICES, radius = CircleSize.toPx(), centerX = pointOffset.x, centerY = pointOffset.y, rounding = CornerRounding( radius = size.minDimension / ITEM_POLYGON_RADIUS_PERCENTAGE, smoothing = 0.1f ) ) val roundedPolygonPath = currentItemRoundedPolygon.toPath().asComposePath() drawPath( path = roundedPolygonPath, color = colors.circleContainerColor( played = itemPlayed, isPlayItem = isPlayableItem ), ) if (isPlayableItem) { drawCircle( color = colors.currentCircleInnerColor(), radius = CircleInnerSize.toPx(), center = pointOffset ) } translate( left = pointOffset.x - IconSize.toPx() / 2, top = pointOffset.y - IconSize.toPx() / 2 ) { with( when { !isPlayableItem && !itemPlayed -> lockPainter // Locked !isPlayableItem && itemPlayed -> playedPainter // Played else -> playPainter } ) { draw( size = Size(IconSize.toPx(), IconSize.toPx()), colorFilter = androidx.compose.ui.graphics.ColorFilter.tint( colors.circleContentColor( played = itemPlayed, isPlayItem = isPlayableItem ) ) ) } } } } } /** * Returns true if the [Offset] is inside the circle with the given [center] and [radius]. */ private fun Offset.isInsideCircle( center: Offset, radius: Float ): Boolean { val dx = x - center.x val dy = y - center.y return dx.pow(2) + dy.pow(2) <= radius.pow(2) } private fun Density.getPointY( height: Float, index: Int, verticalPaddingPx: Float, ): Float { return height - index * PointSpacing.toPx() - verticalPaddingPx } object MazeDefaults { /** * The horizontal padding applied to the graph. */ val horizontalPadding: Dp @Composable get() = 120.dp /** * The vertical padding applied to the graph. */ val verticalPadding: Dp @Composable get() = MaterialTheme.spacing.extraLarge @Composable fun defaultColors( playedPathColor: Color = MaterialTheme.colorScheme.primary, playedCircleContainerColor: Color = MaterialTheme.colorScheme.primary, playedCircleContentColor: Color = MaterialTheme.colorScheme.onPrimary, lockedPathColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.75f), lockedCircleContainerColor: Color = MaterialTheme.colorScheme.surfaceVariant, lockedCircleContentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), currentCircleContainerColor: Color = playedCircleContainerColor, currentCircleInnerColor: Color = MaterialTheme.colorScheme.surface, currentCircleContentColor: Color = currentCircleContainerColor, ): MazeColors = MazeColors( playedPathColor = playedPathColor, playedCircleContainerColor = playedCircleContainerColor, playedCircleContentColor = playedCircleContentColor, lockedPathColor = lockedPathColor, lockedCircleContainerColor = lockedCircleContainerColor, lockedCircleContentColor = lockedCircleContentColor, currentCircleContainerColor = currentCircleContainerColor, currentCircleInnerColor = currentCircleInnerColor, currentCircleContentColor = currentCircleContentColor, ) } @Immutable class MazeColors internal constructor( private val playedPathColor: Color, private val playedCircleContainerColor: Color, private val playedCircleContentColor: Color, private val lockedPathColor: Color, private val lockedCircleContainerColor: Color, private val lockedCircleContentColor: Color, private val currentCircleInnerColor: Color, private val currentCircleContainerColor: Color, private val currentCircleContentColor: Color, ) { internal fun pathColor(played: Boolean): Color { return if (played) playedPathColor else lockedPathColor } internal fun circleContainerColor( played: Boolean, isPlayItem: Boolean, ): Color { return when { !isPlayItem && !played -> lockedCircleContainerColor !isPlayItem && played -> playedCircleContainerColor else -> currentCircleContainerColor } } internal fun circleContentColor( played: Boolean, isPlayItem: Boolean, ): Color = when { !isPlayItem && !played -> lockedCircleContentColor !isPlayItem && played -> playedCircleContentColor else -> currentCircleContentColor } internal fun currentCircleInnerColor(): Color { return currentCircleInnerColor } override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is MazeColors) return false if (playedPathColor != other.playedPathColor) return false if (lockedPathColor != other.lockedPathColor) return false if (playedCircleContainerColor != other.playedCircleContainerColor) return false if (lockedCircleContainerColor != other.lockedCircleContainerColor) return false if (playedCircleContentColor != other.playedCircleContentColor) return false if (lockedCircleContentColor != other.lockedCircleContentColor) return false return true } override fun hashCode(): Int { var result = playedPathColor.hashCode() result = 31 * result + lockedPathColor.hashCode() result = 31 * result + playedCircleContainerColor.hashCode() result = 31 * result + lockedCircleContainerColor.hashCode() result = 31 * result + playedCircleContentColor.hashCode() result = 31 * result + lockedCircleContentColor.hashCode() return result } } private val PointSpacing = 100.dp private val CircleSize = 35.dp private const val CIRCLE_INNER_SIZE_PERCENTAGE = 0.6f private val CircleInnerSize = CircleSize * CIRCLE_INNER_SIZE_PERCENTAGE private val IconSize = 30.dp private val LineSize = 12.dp /** * The smoothness of the path. */ private const val PathSmoothness = 0.25f private const val CirclePressScale = 1.1f private const val ITEM_POLYGON_NUM_VERTICES = 6 private const val ITEM_POLYGON_RADIUS_PERCENTAGE = 30f private const val MazePathEffectWidth = 50f private const val MazePathEffectSpacing = 50f private val MazePathEffectInterval = floatArrayOf(MazePathEffectWidth, MazePathEffectSpacing) /** * The range of the horizontal offset of the points. */ private const val HorizontalOffsetRandomStart = 2.0 private const val HorizontalOffsetRandomEnd = 5.0 @Composable @PreviewLightDark private fun MazeComponentPreview() { val completedItems = List(4) { MazeQuiz.MazeItem.Wordle( wordleWord = WordleWord("1+1=2"), difficulty = QuestionDifficulty.Easy, played = true, wordleQuizType = WordleQuizType.MATH_FORMULA, mazeSeed = 0 ) } val otherItems = List(8) { MazeQuiz.MazeItem.Wordle( wordleWord = WordleWord("1+1=2"), difficulty = QuestionDifficulty.Easy, wordleQuizType = WordleQuizType.MATH_FORMULA, mazeSeed = 0 ) } val items = (completedItems + otherItems).toPersistentList() NewQuizTheme { Surface { Box( modifier = Modifier .fillMaxWidth() ) { MazePath( items = items, startScrollToCurrentItem = false, ) } } } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/components/ScrollToCurrentQuestionButton.kt ================================================ package com.infinitepower.newquiz.feature.maze.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowDownward import androidx.compose.material.icons.rounded.ArrowUpward import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import com.infinitepower.newquiz.core.common.compose.preview.BooleanPreviewParameterProvider import com.infinitepower.newquiz.core.theme.NewQuizTheme @Composable internal fun ScrollToCurrentQuestionButton( modifier: Modifier = Modifier, state: ScrollButtonState = ScrollButtonState.SCROLL_TO_BOTTOM, onClick: () -> Unit = {} ) { if (state != ScrollButtonState.HIDDEN) { FloatingActionButton( modifier = modifier, onClick = onClick ) { val icon = if (state == ScrollButtonState.SCROLL_TO_TOP) { Icons.Rounded.ArrowUpward } else { Icons.Rounded.ArrowDownward } Icon( imageVector = icon, contentDescription = "Scroll to current question" ) } } } enum class ScrollButtonState { HIDDEN, SCROLL_TO_TOP, SCROLL_TO_BOTTOM } @Composable @PreviewLightDark private fun ScrollToCurrentQuestionButtonPreview( @PreviewParameter(BooleanPreviewParameterProvider::class) scrollToTop: Boolean ) { NewQuizTheme { ScrollToCurrentQuestionButton( state = if (scrollToTop) ScrollButtonState.SCROLL_TO_TOP else ScrollButtonState.SCROLL_TO_BOTTOM ) } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreen.kt ================================================ package com.infinitepower.newquiz.feature.maze.generate import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.SelectAll import androidx.compose.material.icons.rounded.SignalWifiConnectedNoInternet4 import androidx.compose.material3.AssistChip import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TriStateCheckbox import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.components.category.CategoryComponent import com.infinitepower.newquiz.core.ui.components.icon.button.BackIconButton import com.infinitepower.newquiz.core.util.asString import com.infinitepower.newquiz.data.local.multi_choice_quiz.category.multiChoiceQuestionCategories import com.infinitepower.newquiz.data.local.wordle.WordleCategories import com.infinitepower.newquiz.model.BaseCategory import com.infinitepower.newquiz.model.GameMode import com.infinitepower.newquiz.model.NumberFormatType import com.infinitepower.newquiz.model.category.ShowCategoryConnectionInfo import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizCategory import com.infinitepower.newquiz.model.toUiText import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import com.infinitepower.newquiz.core.R as CoreR @Composable @ExperimentalMaterial3Api internal fun GenerateMazeScreen( onBackClick: () -> Unit, viewModel: GenerateMazeScreenViewModel = hiltViewModel() ) { val uiState = viewModel.uiState.collectAsStateWithLifecycle() GenerateMazeScreenImpl( uiState = uiState.value, onEvent = viewModel::onEvent, onBackClick = onBackClick ) } @Composable @ExperimentalMaterial3Api internal fun GenerateMazeScreenImpl( uiState: GenerateMazeScreenUiState, onEvent: (event: GenerateMazeScreenUiEvent) -> Unit = {}, onBackClick: () -> Unit = {} ) { val showGenerateButton = remember( uiState.selectedMultiChoiceCategories.size, uiState.selectedWordleCategories.size, uiState.selectedComparisonQuizCategories.size, uiState.loading, uiState.generatingMaze ) { derivedStateOf { val anySelectCategory = uiState.selectedMultiChoiceCategories.isNotEmpty() || uiState.selectedWordleCategories.isNotEmpty() || uiState.selectedComparisonQuizCategories.isNotEmpty() !uiState.loading && !uiState.generatingMaze && anySelectCategory } } Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = CoreR.string.generate_maze)) }, navigationIcon = { BackIconButton(onClick = onBackClick) } ) }, floatingActionButton = { if (showGenerateButton.value) { ExtendedFloatingActionButton( onClick = { onEvent(GenerateMazeScreenUiEvent.GenerateMaze(seed = null)) }, ) { Text(text = stringResource(id = CoreR.string.generate)) } } } ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding), ) { if (uiState.loading || uiState.generatingMaze) { CircularProgressIndicator() } else { HelperChipsRow( onSelectAllClick = { onEvent(GenerateMazeScreenUiEvent.SelectAllCategories) }, onOnlyOfflineClick = { onEvent(GenerateMazeScreenUiEvent.SelectOnlyOfflineCategories) } ) CategoriesContent(onEvent = onEvent, uiState = uiState) } } } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun CategoriesContent( onEvent: (event: GenerateMazeScreenUiEvent) -> Unit, uiState: GenerateMazeScreenUiState, ) { val multiChoiceParentBoxState = rememberParentBoxState( selectedCategories = uiState.selectedMultiChoiceCategories, categories = uiState.multiChoiceCategories ) val wordleParentBoxState = rememberParentBoxState( selectedCategories = uiState.selectedWordleCategories, categories = uiState.wordleCategories ) val comparisonQuizParentBoxState = rememberParentBoxState( selectedCategories = uiState.selectedComparisonQuizCategories, categories = uiState.comparisonQuizCategories ) val multiChoiceHeader = stringResource(id = CoreR.string.multi_choice_quiz) val wordleHeader = stringResource(id = CoreR.string.wordle) val comparisonQuizHeader = stringResource(id = CoreR.string.comparison_quiz) LazyColumn( contentPadding = PaddingValues(vertical = MaterialTheme.spacing.medium), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium) ) { // Multi choice categoriesStickyHeader( title = multiChoiceHeader, parentBoxState = multiChoiceParentBoxState, onSelectAllClick = { selectAll -> onEvent( GenerateMazeScreenUiEvent.SelectCategories( gameMode = GameMode.MULTI_CHOICE, selectAll = selectAll ) ) } ) categoriesItems( categories = uiState.multiChoiceCategories, selectedCategories = uiState.selectedMultiChoiceCategories, baseItemKey = "multi-choice", onSelectClick = { category -> onEvent(GenerateMazeScreenUiEvent.SelectCategory(category = category)) } ) // Wordle categoriesStickyHeader( title = wordleHeader, parentBoxState = wordleParentBoxState, onSelectAllClick = { selectAll -> onEvent( GenerateMazeScreenUiEvent.SelectCategories( gameMode = GameMode.WORDLE, selectAll = selectAll ) ) } ) categoriesItems( categories = uiState.wordleCategories, selectedCategories = uiState.selectedWordleCategories, baseItemKey = "wordle", onSelectClick = { category -> onEvent(GenerateMazeScreenUiEvent.SelectCategory(category = category)) } ) // Comparison quiz categoriesStickyHeader( title = comparisonQuizHeader, parentBoxState = comparisonQuizParentBoxState, onSelectAllClick = { selectAll -> onEvent( GenerateMazeScreenUiEvent.SelectCategories( gameMode = GameMode.COMPARISON_QUIZ, selectAll = selectAll ) ) } ) categoriesItems( categories = uiState.comparisonQuizCategories, selectedCategories = uiState.selectedComparisonQuizCategories, baseItemKey = "comparison-quiz", onSelectClick = { category -> onEvent(GenerateMazeScreenUiEvent.SelectCategory(category = category)) } ) } } @Composable private fun rememberParentBoxState( selectedCategories: ImmutableList, categories: ImmutableList ): ToggleableState = remember( key1 = selectedCategories.size, key2 = categories.size ) { if (selectedCategories.isEmpty()) { ToggleableState.Off } else if (selectedCategories.size == categories.size) { ToggleableState.On } else { ToggleableState.Indeterminate } } /** * Creates a sticky header for the categories list with a parent checkbox. * * @param title the title of the sticky header * @param parentBoxState the state of the parent checkbox * @param onSelectAllClick called when the parent checkbox is clicked */ @ExperimentalFoundationApi private fun LazyListScope.categoriesStickyHeader( title: String, parentBoxState: ToggleableState, onSelectAllClick: (selectAll: Boolean) -> Unit ) { stickyHeader { Surface( modifier = Modifier.fillParentMaxWidth(), checked = parentBoxState != ToggleableState.Off, onCheckedChange = onSelectAllClick, ) { Row( modifier = Modifier .fillParentMaxWidth() .padding( horizontal = MaterialTheme.spacing.medium, vertical = MaterialTheme.spacing.small ) .background(color = MaterialTheme.colorScheme.surface), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = title, style = MaterialTheme.typography.headlineMedium ) TriStateCheckbox( state = parentBoxState, onClick = { val selectAll = parentBoxState == ToggleableState.Off onSelectAllClick(selectAll) }, ) } } } } /** * Creates a list of categories. * * @param categories the list of categories * @param selectedCategories the list of selected categories * @param baseItemKey the base key for the items, it is used to generate the key for each game mode. */ private fun LazyListScope.categoriesItems( categories: ImmutableList, selectedCategories: ImmutableList, baseItemKey: String = "", onSelectClick: (category: T) -> Unit, ) { items( items = categories, key = { category -> "$baseItemKey-${category.id}" } ) { category -> CategoryComponent( title = category.name.asString(), imageUrl = category.image, onClick = { onSelectClick(category) }, onCheckClick = { onSelectClick(category) }, requireInternetConnection = category.requireInternetConnection, showConnectionInfo = ShowCategoryConnectionInfo.BOTH, modifier = Modifier .fillParentMaxWidth() .padding(horizontal = MaterialTheme.spacing.medium), checked = category in selectedCategories, ) } } @Composable private fun HelperChipsRow( onSelectAllClick: () -> Unit, onOnlyOfflineClick: () -> Unit, modifier: Modifier = Modifier ) { LazyRow( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium), contentPadding = PaddingValues(horizontal = MaterialTheme.spacing.medium) ) { item { AssistChip( onClick = onSelectAllClick, label = { Text(text = stringResource(id = CoreR.string.select_all)) }, leadingIcon = { Icon( imageVector = Icons.Rounded.SelectAll, contentDescription = null ) }, ) } item { AssistChip( onClick = onOnlyOfflineClick, label = { Text(text = stringResource(id = CoreR.string.select_only_offline)) }, leadingIcon = { Icon( imageVector = Icons.Rounded.SignalWifiConnectedNoInternet4, contentDescription = null ) }, ) } } } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class) private fun MazeScreenPreview() { val multiChoiceQuestionCategories = multiChoiceQuestionCategories.take(8).toImmutableList() val selectedMultiChoiceCategories = multiChoiceQuestionCategories.take(2).toImmutableList() val wordleCategories = WordleCategories.allCategories.toImmutableList() val selectedWordleCategories = wordleCategories.take(2).toImmutableList() val comparisonQuizCategories = List(5) { ComparisonQuizCategory( id = it.toString(), name = "Category $it".toUiText(), image = "", description = "", questionDescription = ComparisonQuizCategory.QuestionDescription( greater = "greater", less = "less" ), formatType = NumberFormatType.DEFAULT, ) }.toImmutableList() NewQuizTheme { Surface { GenerateMazeScreenImpl( uiState = GenerateMazeScreenUiState( multiChoiceCategories = multiChoiceQuestionCategories, selectedMultiChoiceCategories = selectedMultiChoiceCategories, wordleCategories = wordleCategories, selectedWordleCategories = selectedWordleCategories, comparisonQuizCategories = comparisonQuizCategories, loading = false ) ) } } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenUiEvent.kt ================================================ package com.infinitepower.newquiz.feature.maze.generate import com.infinitepower.newquiz.model.BaseCategory import com.infinitepower.newquiz.model.GameMode sealed interface GenerateMazeScreenUiEvent { data class GenerateMaze( val seed: Int? ) : GenerateMazeScreenUiEvent data object SelectAllCategories : GenerateMazeScreenUiEvent data object SelectOnlyOfflineCategories : GenerateMazeScreenUiEvent data class SelectCategories( val gameMode: GameMode, val selectAll: Boolean, ) : GenerateMazeScreenUiEvent data class SelectCategory( val category: BaseCategory ) : GenerateMazeScreenUiEvent } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenUiState.kt ================================================ package com.infinitepower.newquiz.feature.maze.generate import androidx.annotation.Keep import com.infinitepower.newquiz.model.BaseCategory import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @Keep data class GenerateMazeScreenUiState( val loading: Boolean = true, val generatingMaze: Boolean = false, val multiChoiceCategories: ImmutableList = persistentListOf(), val selectedMultiChoiceCategories: ImmutableList = persistentListOf(), val wordleCategories: ImmutableList = persistentListOf(), val selectedWordleCategories: ImmutableList = persistentListOf(), val comparisonQuizCategories: ImmutableList = persistentListOf(), val selectedComparisonQuizCategories: ImmutableList = persistentListOf(), ) ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/generate/GenerateMazeScreenViewModel.kt ================================================ package com.infinitepower.newquiz.feature.maze.generate import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import androidx.work.WorkInfo import androidx.work.WorkManager import com.infinitepower.newquiz.core.ui.SnackbarController import com.infinitepower.newquiz.data.worker.maze.GenerateMazeQuizWorker import com.infinitepower.newquiz.domain.repository.comparison_quiz.ComparisonQuizRepository import com.infinitepower.newquiz.feature.maze.common.MazeCategories import com.infinitepower.newquiz.model.BaseCategory import com.infinitepower.newquiz.model.GameMode import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class GenerateMazeScreenViewModel @Inject constructor( private val workManager: WorkManager, private val comparisonQuizRepository: ComparisonQuizRepository ) : ViewModel() { private val _uiState = MutableStateFlow(GenerateMazeScreenUiState()) val uiState = _uiState.asStateFlow() init { viewModelScope.launch { _uiState.update { currentState -> currentState.copy( multiChoiceCategories = MazeCategories.getMultiChoiceCategories() .toImmutableList(), wordleCategories = MazeCategories.availableWordleCategories.toImmutableList(), comparisonQuizCategories = getComparisonQuizCategories().toImmutableList(), loading = false ) } } } fun onEvent(event: GenerateMazeScreenUiEvent) { when (event) { is GenerateMazeScreenUiEvent.SelectCategory -> selectCategory(event.category) is GenerateMazeScreenUiEvent.GenerateMaze -> generateMaze(seed = event.seed) is GenerateMazeScreenUiEvent.SelectAllCategories -> selectAllCategories() is GenerateMazeScreenUiEvent.SelectOnlyOfflineCategories -> selectOnlyOffline() is GenerateMazeScreenUiEvent.SelectCategories -> selectCategories( gameMode = event.gameMode, selectAll = event.selectAll ) } } /** * Select or deselect a category, depending on the current state. */ private fun selectCategory(category: BaseCategory) { _uiState.update { currentState -> // Get the list of selected categories, depending on the game mode. val selectedCategories = when (category.gameMode) { GameMode.MULTI_CHOICE -> currentState.selectedMultiChoiceCategories GameMode.WORDLE -> currentState.selectedWordleCategories GameMode.COMPARISON_QUIZ -> currentState.selectedComparisonQuizCategories }.toMutableList() // If the category is already selected, then deselect it. // If the category is not selected, then select it. if (selectedCategories.contains(category)) { selectedCategories.remove(category) } else { selectedCategories.add(category) } // Update the state, depending on the game mode. when (category.gameMode) { GameMode.MULTI_CHOICE -> currentState.copy( selectedMultiChoiceCategories = selectedCategories.toImmutableList() ) GameMode.WORDLE -> currentState.copy( selectedWordleCategories = selectedCategories.toImmutableList() ) GameMode.COMPARISON_QUIZ -> currentState.copy( selectedComparisonQuizCategories = selectedCategories.toImmutableList() ) } } } private fun selectAllCategories() { selectMultiChoiceCategories(selectAll = true) selectWordleCategories(selectAll = true) selectComparisonQuizCategories(selectAll = true) } private fun selectOnlyOffline() { _uiState.update { currentState -> val multiChoiceOnlyOffline = currentState.multiChoiceCategories.filter { category -> !category.requireInternetConnection } val wordleOnlyOffline = currentState.wordleCategories.filter { category -> !category.requireInternetConnection } val comparisonQuizOnlyOffline = currentState.comparisonQuizCategories.filter { category -> !category.requireInternetConnection } currentState.copy( selectedMultiChoiceCategories = multiChoiceOnlyOffline.toImmutableList(), selectedWordleCategories = wordleOnlyOffline.toImmutableList(), selectedComparisonQuizCategories = comparisonQuizOnlyOffline.toImmutableList() ) } } private fun selectCategories( gameMode: GameMode, selectAll: Boolean, ) { when (gameMode) { GameMode.MULTI_CHOICE -> selectMultiChoiceCategories(selectAll) GameMode.WORDLE -> selectWordleCategories(selectAll) GameMode.COMPARISON_QUIZ -> selectComparisonQuizCategories(selectAll) } } private fun selectMultiChoiceCategories(selectAll: Boolean) { _uiState.update { currentState -> if (selectAll) { currentState.copy(selectedMultiChoiceCategories = currentState.multiChoiceCategories) } else { currentState.copy(selectedMultiChoiceCategories = persistentListOf()) } } } private fun selectWordleCategories(selectAll: Boolean) { _uiState.update { currentState -> if (selectAll) { currentState.copy(selectedWordleCategories = currentState.wordleCategories) } else { currentState.copy(selectedWordleCategories = persistentListOf()) } } } private fun selectComparisonQuizCategories(selectAll: Boolean) { _uiState.update { currentState -> if (selectAll) { currentState.copy(selectedComparisonQuizCategories = currentState.comparisonQuizCategories) } else { currentState.copy(selectedComparisonQuizCategories = persistentListOf()) } } } private fun generateMaze(seed: Int?) { viewModelScope.launch { val currentState = uiState.first() val workId = GenerateMazeQuizWorker.enqueue( workManager = workManager, seed = seed, multiChoiceCategories = currentState.selectedMultiChoiceCategories, wordleCategories = currentState.selectedWordleCategories, comparisonQuizCategories = currentState.selectedComparisonQuizCategories ) workManager .getWorkInfoByIdLiveData(workId) .asFlow() .onEach { workInfo -> if (workInfo?.state == WorkInfo.State.FAILED) { SnackbarController.sendShortMessage("Failed to generate maze") } _uiState.update { currentState -> val isFinished = workInfo?.state?.isFinished ?: false currentState.copy(generatingMaze = !isFinished) } }.launchIn(viewModelScope) } } private fun getComparisonQuizCategories(): List { return comparisonQuizRepository.getCategories().filterNot { it.isMazeDisabled } } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/level_results/LevelResultsScreen.kt ================================================ package com.infinitepower.newquiz.feature.maze.level_results import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.infinitepower.newquiz.core.navigation.MazeNavigator import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.ui.SnackbarController import com.infinitepower.newquiz.feature.maze.level_results.components.LevelCompletedContent import com.infinitepower.newquiz.feature.maze.level_results.components.LevelFailedContent import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator @Composable @Destination(navArgsDelegate = LevelResultsScreenArgs::class) internal fun LevelResultsScreen( navigator: DestinationsNavigator, mazeNavigator: MazeNavigator, viewModel: LevelResultsScreenViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() LevelResultsScreenImpl( uiState = uiState, popBackStack = navigator::popBackStack, navigateToNextLevel = { uiState.nextAvailableItem?.let(mazeNavigator::navigateToGame) }, retryLevel = { uiState.currentItem?.let(mazeNavigator::navigateToGame) } ) } @Composable private fun LevelResultsScreenImpl( popBackStack: () -> Unit, navigateToNextLevel: () -> Unit, retryLevel: () -> Unit, uiState: LevelResultsScreenUiState ) { Scaffold( modifier = Modifier.fillMaxWidth(), ) { innerPadding -> when { uiState.loading -> CircularProgressIndicator() !uiState.loading && uiState.error != null -> { val currentPopBackStack by rememberUpdatedState(popBackStack) LaunchedEffect(Unit) { SnackbarController.sendShortMessage(uiState.error) currentPopBackStack() } } else -> { if (uiState.completed) { LevelCompletedContent( modifier = Modifier.padding(innerPadding), onNext = navigateToNextLevel, popBackStack = popBackStack, nextLevelExists = uiState.nextAvailableItem != null ) } else { LevelFailedContent( onRetry = retryLevel, popBackStack = popBackStack, modifier = Modifier.padding(innerPadding) ) } } } } } data class LevelResultsScreenArgs( val mazeItemId: Int ) @Composable @PreviewLightDark private fun LevelResultsScreenPreview() { NewQuizTheme { LevelResultsScreenImpl( uiState = LevelResultsScreenUiState(), popBackStack = {}, navigateToNextLevel = {}, retryLevel = {} ) } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/level_results/LevelResultsScreenUiState.kt ================================================ package com.infinitepower.newquiz.feature.maze.level_results import com.infinitepower.newquiz.model.maze.MazeQuiz data class LevelResultsScreenUiState( val loading: Boolean = true, val completed: Boolean = false, val currentItem: MazeQuiz.MazeItem? = null, val nextAvailableItem: MazeQuiz.MazeItem? = null, val error: String? = null ) ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/level_results/LevelResultsScreenViewModel.kt ================================================ package com.infinitepower.newquiz.feature.maze.level_results import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository import com.infinitepower.newquiz.feature.maze.destinations.LevelResultsScreenDestination import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class LevelResultsScreenViewModel @Inject constructor( private val mazeQuizRepository: MazeQuizRepository, savedStateHandle: SavedStateHandle ) : ViewModel() { private val navArgs = LevelResultsScreenDestination.argsFrom(savedStateHandle) private val _uiState = MutableStateFlow(LevelResultsScreenUiState()) val uiState = _uiState.asStateFlow() init { viewModelScope.launch { val item = mazeQuizRepository.getMazeItemById(navArgs.mazeItemId) if (item == null) { _uiState.update { currentState -> currentState.copy( loading = false, error = "Item not found" ) } return@launch } val nextAvailableLevel = mazeQuizRepository.getNextAvailableMazeItem() _uiState.update { currentState -> currentState.copy( loading = false, completed = item.played, currentItem = item, nextAvailableItem = nextAvailableLevel ) } } } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/level_results/components/LevelCompletedContent.kt ================================================ package com.infinitepower.newquiz.feature.maze.level_results.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowForward import androidx.compose.material.icons.rounded.Home import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.tooling.preview.PreviewLightDark 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.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.R as CoreR @Composable internal fun LevelCompletedContent( onNext: () -> Unit, popBackStack: () -> Unit, modifier: Modifier = Modifier, nextLevelExists: Boolean = true ) { Column( modifier = modifier .padding(MaterialTheme.spacing.large) .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = stringResource(id = CoreR.string.level_completed), style = MaterialTheme.typography.headlineLarge ) TrophyIcon(modifier = Modifier.padding(vertical = MaterialTheme.spacing.large)) if (nextLevelExists) { Button( onClick = onNext, modifier = Modifier.fillMaxWidth() ) { Text(text = stringResource(id = CoreR.string.next_level)) Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowForward, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize) ) } } TextButton( onClick = popBackStack, modifier = Modifier.fillMaxWidth(), ) { Icon( imageVector = Icons.Rounded.Home, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize) ) Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) Text(text = stringResource(id = CoreR.string.main_menu)) } } } @Composable private fun TrophyIcon(modifier: Modifier = Modifier) { val trophySpec = LottieCompositionSpec.RawRes(CoreR.raw.trophy2) val trophyLottieComposition by rememberLottieComposition(spec = trophySpec) val dynamicProperties = rememberLottieDynamicProperties( rememberLottieDynamicProperty( property = LottieProperty.COLOR_FILTER, value = SimpleColorFilter(MaterialTheme.colorScheme.primary.toArgb()), keyPath = arrayOf("**") ), ) LottieAnimation( composition = trophyLottieComposition, modifier = modifier.size(200.dp), dynamicProperties = dynamicProperties ) } @Composable @PreviewLightDark private fun LevelCompletedContentPreview() { NewQuizTheme { Surface { LevelCompletedContent( onNext = {}, popBackStack = {}, ) } } } ================================================ FILE: feature/maze/src/main/kotlin/com/infinitepower/newquiz/feature/maze/level_results/components/LevelFailedContent.kt ================================================ package com.infinitepower.newquiz.feature.maze.level_results.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.R as CoreR @Composable internal fun LevelFailedContent( onRetry: () -> Unit, popBackStack: () -> Unit, modifier: Modifier = Modifier, ) { Column( modifier = modifier .padding(MaterialTheme.spacing.large) .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = stringResource(id = CoreR.string.level_failed), style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.error ) Icon( imageVector = Icons.Rounded.Close, contentDescription = null, modifier = Modifier .size(200.dp) .padding(MaterialTheme.spacing.large) .background( color = MaterialTheme.colorScheme.errorContainer, shape = CircleShape ).padding(MaterialTheme.spacing.medium), tint = MaterialTheme.colorScheme.error ) Text( text = stringResource(id = CoreR.string.level_failed_description), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(bottom = MaterialTheme.spacing.large) ) Button( onClick = onRetry, modifier = Modifier.fillMaxWidth() ) { Icon( imageVector = Icons.Rounded.Refresh, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize) ) Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) Text(text = stringResource(id = CoreR.string.try_again)) } TextButton( onClick = popBackStack, modifier = Modifier.fillMaxWidth(), ) { Icon( imageVector = Icons.Rounded.Home, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize) ) Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) Text(text = stringResource(id = CoreR.string.main_menu)) } } } @Composable @PreviewLightDark private fun LevelFailedContentPreview() { NewQuizTheme { Surface { LevelFailedContent( onRetry = {}, popBackStack = {} ) } } } ================================================ FILE: feature/maze/src/test/kotlin/com/infinitepower/newquiz/feature/maze/MazeScreenUiStateTest.kt ================================================ package com.infinitepower.newquiz.feature.maze import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.core.testing.data.fake.FakeComparisonQuizData import com.infinitepower.newquiz.model.GameMode 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.multi_choice_quiz.MultiChoiceQuestionType import com.infinitepower.newquiz.model.multi_choice_quiz.QuestionLanguage import com.infinitepower.newquiz.model.question.QuestionDifficulty import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.model.wordle.WordleWord import kotlinx.collections.immutable.toImmutableList import kotlin.test.Test internal class MazeScreenUiStateTest { @Test fun `getAvailableCategoriesByGameMode returns empty when maze is empty`() { val maze = MazeQuiz(items = generateMaze().toImmutableList()) val uiState = MazeScreenUiState( loading = false, maze = maze ) val availableCategoriesByGameMode = uiState.getAvailableCategoriesByGameMode() assertThat(availableCategoriesByGameMode).isEmpty() } @Test fun `getAvailableCategoriesByGameMode returns all categories when all categories are available`() { val maze = MazeQuiz( items = generateMaze( multiChoiceQuestionCount = 1, wordleQuestionCount = 1, comparisonQuizQuestionCount = 1 ).toImmutableList(), ) val comparisonQuizCategory = FakeComparisonQuizData.generateCategory(1) val uiState = MazeScreenUiState( loading = false, maze = maze, comparisonQuizCategories = listOf(comparisonQuizCategory) ) val availableCategoriesByGameMode = uiState.getAvailableCategoriesByGameMode() availableCategoriesByGameMode.forEach { (gameMode, baseCategories) -> println("Game Mode: $gameMode") assertThat(baseCategories).hasSize(1) } } @Test fun `getAvailableCategoriesByGameMode returns only available categories`() { val maze = MazeQuiz( items = generateMaze( multiChoiceQuestionCount = 1, wordleQuestionCount = 1, comparisonQuizQuestionCount = 1 ).toImmutableList(), ) val uiState = MazeScreenUiState( loading = false, maze = maze, comparisonQuizCategories = emptyList() // No available comparison quiz categories ) val availableCategoriesByGameMode = uiState.getAvailableCategoriesByGameMode() availableCategoriesByGameMode.forEach { (gameMode, baseCategories) -> if (gameMode == GameMode.COMPARISON_QUIZ) { assertThat(baseCategories).hasSize(0) } else { assertThat(baseCategories).hasSize(1) } } } @Test fun `getInvalidMazeItems returns correct items`() { val maze = MazeQuiz( items = generateMaze( multiChoiceQuestionCount = 3, wordleQuestionCount = 5, comparisonQuizQuestionCount = 5 // 5 Invalid questions ).toImmutableList(), ) val uiState = MazeScreenUiState( loading = false, maze = maze, comparisonQuizCategories = emptyList() // No available comparison quiz categories ) val availableCategoriesByGameMode = uiState.getAvailableCategoriesByGameMode() val invalidItems = uiState.getInvalidMazeItems(availableCategoriesByGameMode) assertThat(invalidItems).hasSize(5) invalidItems.forEach { item -> assertThat(item.gameMode).isEqualTo(GameMode.COMPARISON_QUIZ) } } private fun generateMaze( seed: Int = 0, multiChoiceQuestionCount: Int = 0, wordleQuestionCount: Int = 0, comparisonQuizQuestionCount: Int = 0 ): List { val items = mutableListOf() List(multiChoiceQuestionCount) { MazeQuiz.MazeItem.MultiChoice( question = MultiChoiceQuestion( id = it, description = "description", answers = listOf("a", "b", "c"), lang = QuestionLanguage.EN, category = MultiChoiceBaseCategory.Flag, correctAns = 0, type = MultiChoiceQuestionType.MULTIPLE, difficulty = QuestionDifficulty.Easy ), mazeSeed = seed, ) }.also { items.addAll(it) } List(wordleQuestionCount) { MazeQuiz.MazeItem.Wordle( wordleWord = WordleWord(""), wordleQuizType = WordleQuizType.TEXT, mazeSeed = seed, ) }.also { items.addAll(it) } List(comparisonQuizQuestionCount) { MazeQuiz.MazeItem.ComparisonQuiz( question = FakeComparisonQuizData.generateQuestion(categoryId = "1"), mazeSeed = seed, ) }.also { items.addAll(it) } return items } } ================================================ FILE: feature/profile/.gitignore ================================================ /build ================================================ FILE: feature/profile/build.gradle.kts ================================================ plugins { alias(libs.plugins.newquiz.android.feature) alias(libs.plugins.newquiz.android.compose.destinations) alias(libs.plugins.newquiz.detekt) } android { namespace = "com.infinitepower.newquiz.feature.profile" } dependencies { implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.vico.compose) implementation(libs.vico.compose.m3) implementation(libs.kotlinx.datetime) implementation(libs.coil.kt.compose) implementation(projects.core) implementation(projects.core.userServices) } ================================================ FILE: feature/profile/src/main/AndroidManifest.xml ================================================ ================================================ FILE: feature/profile/src/main/kotlin/com/infinitepower/newquiz/feature/profile/ProfileScreen.kt ================================================ package com.infinitepower.newquiz.feature.profile import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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.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.user_services.DateTimeRangeFormatter import com.infinitepower.newquiz.core.user_services.model.User import com.infinitepower.newquiz.feature.profile.components.MainUserCard import com.infinitepower.newquiz.feature.profile.components.UserXpAndLevelCard import com.infinitepower.newquiz.feature.profile.components.XpEarnedByDayCard import com.infinitepower.newquiz.model.TimestampWithXP import com.infinitepower.newquiz.core.R as CoreR import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.collections.immutable.persistentListOf import kotlinx.datetime.Clock import kotlin.time.Duration.Companion.days @Composable @Destination @OptIn(ExperimentalMaterial3Api::class) fun ProfileScreen( navigator: DestinationsNavigator, viewModel: ProfileViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() ProfileScreen( uiState = uiState, onEvent = viewModel::onEvent, onBackClick = navigator::popBackStack ) } @Composable @ExperimentalMaterial3Api private fun ProfileScreen( uiState: ProfileScreenUiState, onEvent: (event: ProfileScreenUiEvent) -> Unit, onBackClick: () -> Unit ) { val options = mapOf( DateTimeRangeFormatter.Day to stringResource(id = CoreR.string.today), DateTimeRangeFormatter.Week to stringResource(id = CoreR.string.this_week), ) Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = CoreR.string.profile)) }, navigationIcon = { BackIconButton(onClick = onBackClick) } ) } ) { innerPadding -> LazyColumn( modifier = Modifier.padding(innerPadding), contentPadding = PaddingValues(MaterialTheme.spacing.medium), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.large) ) { if (uiState.loading) { item { CircularProgressIndicator() } } uiState.user?.let { user -> item { MainUserCard( modifier = Modifier.fillParentMaxWidth(), level = user.level, levelProgress = user.getLevelProgress(), userName = user.fullName, userPhoto = user.imageUrl ) } item { UserXpAndLevelCard( modifier = Modifier.fillParentMaxWidth(), totalXp = user.totalXp, level = user.level ) } item { SingleChoiceSegmentedButtonRow( modifier = Modifier.fillParentMaxWidth(), ) { options.onEachIndexed { index, (timeRange, label) -> SegmentedButton( shape = SegmentedButtonDefaults.itemShape( index = index, count = options.size ), onClick = { onEvent(ProfileScreenUiEvent.OnFilterByTimeRangeClick(timeRange)) }, selected = timeRange == uiState.selectedTimeRange ) { Text(text = label) } } } } item { OutlinedCard( modifier = Modifier.fillParentMaxWidth() ) { XpEarnedByDayCard( modifier = Modifier.padding(MaterialTheme.spacing.medium), formatter = uiState.selectedTimeRange, xpEarnedList = uiState.xpEarnedList ) } } } } } } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class) private fun ProfileScreenPreview() { val now = Clock.System.now() NewQuizTheme { ProfileScreen( uiState = ProfileScreenUiState( loading = false, user = User( uid = "uid", totalXp = 1235u ), selectedTimeRange = DateTimeRangeFormatter.Week, xpEarnedList = persistentListOf( TimestampWithXP((now - 4.days).toEpochMilliseconds(), 20), TimestampWithXP((now - 3.days).toEpochMilliseconds(), 10), TimestampWithXP((now - 1.days).toEpochMilliseconds(), 30), TimestampWithXP(now.toEpochMilliseconds(), 15) ) ), onEvent = {}, onBackClick = {} ) } } ================================================ FILE: feature/profile/src/main/kotlin/com/infinitepower/newquiz/feature/profile/ProfileScreenUiEvent.kt ================================================ package com.infinitepower.newquiz.feature.profile import com.infinitepower.newquiz.core.user_services.DateTimeRangeFormatter interface ProfileScreenUiEvent { data class OnFilterByTimeRangeClick(val timeRange: DateTimeRangeFormatter) : ProfileScreenUiEvent } ================================================ FILE: feature/profile/src/main/kotlin/com/infinitepower/newquiz/feature/profile/ProfileScreenUiState.kt ================================================ package com.infinitepower.newquiz.feature.profile import androidx.annotation.Keep import com.infinitepower.newquiz.core.user_services.DateTimeRangeFormatter import com.infinitepower.newquiz.core.user_services.model.User import com.infinitepower.newquiz.model.TimestampWithXP import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @Keep data class ProfileScreenUiState( val loading: Boolean = true, val user: User? = null, val selectedTimeRange: DateTimeRangeFormatter = DateTimeRangeFormatter.Day, val xpEarnedList: ImmutableList = persistentListOf(), ) ================================================ FILE: feature/profile/src/main/kotlin/com/infinitepower/newquiz/feature/profile/ProfileViewModel.kt ================================================ package com.infinitepower.newquiz.feature.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.infinitepower.newquiz.core.user_services.UserService import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class ProfileViewModel @Inject constructor( private val userService: UserService ) : ViewModel() { private val _uiState = MutableStateFlow(ProfileScreenUiState()) val uiState = _uiState.map { state -> val dateTimeRange = state.selectedTimeRange.getNowDateTimeRange() state.copy(xpEarnedList = userService.getXpEarnedBy(dateTimeRange).toPersistentList()) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(), initialValue = ProfileScreenUiState() ) init { viewModelScope.launch { val user = userService.getUser() _uiState.update { currentState -> currentState.copy( loading = false, user = user ) } } } fun onEvent(event: ProfileScreenUiEvent) { when (event) { is ProfileScreenUiEvent.OnFilterByTimeRangeClick -> { _uiState.update { currentState -> currentState.copy(selectedTimeRange = event.timeRange) } } } } } ================================================ FILE: feature/profile/src/main/kotlin/com/infinitepower/newquiz/feature/profile/components/GoodDayText.kt ================================================ package com.infinitepower.newquiz.feature.profile.components import androidx.annotation.StringRes 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.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle 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.R as CoreR import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime private sealed class DayState(@StringRes val text: Int) { data object Morning : DayState(text = CoreR.string.good_morning) data object Afternoon : DayState(text = CoreR.string.good_afternoon) data object Evening : DayState(text = CoreR.string.good_evening) data object Night : DayState(text = CoreR.string.good_night) companion object { @Suppress("MagicNumber") fun fromHour(hour: Int): DayState { return when (hour) { in 6..11 -> Morning in 12..17 -> Afternoon in 18..23 -> Evening else -> Night } } } } @Composable internal fun GoodDayText( modifier: Modifier = Modifier, name: String ) { val dayText = remember { val tz = TimeZone.currentSystemDefault() val now = Clock.System.now() val localTime = now.toLocalDateTime(tz) DayState.fromHour(localTime.hour) } GoodDayText( modifier = modifier, dayText = stringResource(id = dayText.text), name = name ) } @Composable private fun GoodDayText( modifier: Modifier = Modifier, dayText: String, name: String ) { Text( text = buildAnnotatedString { append(dayText) append(",\n") withStyle( style = SpanStyle(fontWeight = FontWeight.Bold) ) { append(name) } }, style = MaterialTheme.typography.headlineMedium, modifier = modifier ) } @Composable @PreviewLightDark private fun GoodDayTextPreview() { NewQuizTheme { Surface { GoodDayText( name = "NewQuiz User", modifier = Modifier.padding(16.dp) ) } } } ================================================ FILE: feature/profile/src/main/kotlin/com/infinitepower/newquiz/feature/profile/components/MainUserCard.kt ================================================ package com.infinitepower.newquiz.feature.profile.components import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row 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.Person import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import com.infinitepower.newquiz.core.common.DEFAULT_USER_PHOTO 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 MainUserCard( modifier: Modifier = Modifier, level: UInt, levelProgress: Float, userName: String, userPhoto: String, ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.large, Alignment.CenterHorizontally) ) { LevelCard( level = level, levelProgress = levelProgress, userPhoto = userPhoto, userName = userName ) GoodDayText(name = userName) } } private enum class AvatarState { LEVEL, PHOTO; fun toggle() = when (this) { LEVEL -> PHOTO PHOTO -> LEVEL } } @Composable private fun LevelCard( modifier: Modifier = Modifier, level: UInt, levelProgress: Float, userPhoto: String, userName: String, initialAvatarState: AvatarState = AvatarState.LEVEL ) { val levelProgressAnimated = animateFloatAsState( targetValue = levelProgress, label = "Level Progress", animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec ) var avatarState by remember { mutableStateOf(initialAvatarState) } val avatarLevelTransition = updateTransition( targetState = avatarState, label = "Avatar Level" ) Surface( modifier = modifier .size(75.dp), onClick = { avatarState = avatarState.toggle() }, shape = CircleShape ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() ) { CircularProgressIndicator( progress = { levelProgressAnimated.value }, modifier = Modifier.fillMaxSize(), trackColor = ProgressIndicatorDefaults.linearTrackColor, strokeCap = StrokeCap.Round, ) avatarLevelTransition.AnimatedContent { state -> when (state) { AvatarState.LEVEL -> { Box( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxSize() .padding(MaterialTheme.spacing.small) .background(MaterialTheme.colorScheme.primary, CircleShape) .clip(CircleShape) ) { Text( text = level.toString(), style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onPrimary ) } } AvatarState.PHOTO -> { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .crossfade(true) .data(userPhoto) .build(), contentDescription = stringResource(id = CoreR.string.photo_of_s, userName), placeholder = rememberVectorPainter(Icons.Rounded.Person), modifier = Modifier .fillMaxSize() .padding(MaterialTheme.spacing.small) .clip(CircleShape), contentScale = ContentScale.Crop ) } } } } } } @Composable @PreviewLightDark private fun MainUserCardPreview() { NewQuizTheme { Surface { MainUserCard( modifier = Modifier.padding(16.dp), level = 3u, levelProgress = 0.75f, userName = "NewQuiz User", userPhoto = DEFAULT_USER_PHOTO ) } } } ================================================ FILE: feature/profile/src/main/kotlin/com/infinitepower/newquiz/feature/profile/components/UserXpAndLevelCard.kt ================================================ package com.infinitepower.newquiz.feature.profile.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.height import androidx.compose.foundation.layout.padding 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.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.text.CompactDecimalText import com.infinitepower.newquiz.core.R as CoreR @Composable internal fun UserXpAndLevelCard( modifier: Modifier = Modifier, totalXp: ULong, level: UInt ) { Surface( modifier = modifier.height(90.dp), tonalElevation = 4.dp, shape = MaterialTheme.shapes.extraLarge ) { Row( modifier = Modifier.padding(MaterialTheme.spacing.medium) ) { CardItem( modifier = Modifier.weight(1f), title = stringResource(id = CoreR.string.level), value = level.toInt() ) VerticalDivider() CardItem( modifier = Modifier.weight(1f), title = stringResource(id = CoreR.string.total_xp), value = totalXp.toInt() ) } } } @Composable private fun CardItem( modifier: Modifier = Modifier, title: String, value: Int ) { Column( modifier = modifier.fillMaxHeight(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small, Alignment.CenterVertically) ) { Text( text = title, style = MaterialTheme.typography.bodyMedium ) CompactDecimalText( value = value, style = MaterialTheme.typography.titleMedium ) } } @Composable @PreviewLightDark private fun UserXpAndLevelCardPreview() { NewQuizTheme { Surface { UserXpAndLevelCard( modifier = Modifier.padding(16.dp), totalXp = 1234u, level = 1u ) } } } ================================================ FILE: feature/profile/src/main/kotlin/com/infinitepower/newquiz/feature/profile/components/XpEarnedByDayCard.kt ================================================ package com.infinitepower.newquiz.feature.profile.components import android.graphics.RectF import android.graphics.Typeface import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.user_services.DateTimeRangeFormatter import com.infinitepower.newquiz.feature.profile.components.chart.rememberMarker import com.infinitepower.newquiz.model.TimestampWithXP import com.patrykandpatryk.vico.compose.axis.axisGuidelineComponent import com.patrykandpatryk.vico.compose.axis.axisLabelComponent import com.infinitepower.newquiz.core.R as CoreR import com.patrykandpatryk.vico.compose.axis.horizontal.rememberBottomAxis import com.patrykandpatryk.vico.compose.axis.vertical.rememberStartAxis import com.patrykandpatryk.vico.compose.chart.Chart import com.patrykandpatryk.vico.compose.chart.column.columnChart import com.patrykandpatryk.vico.compose.chart.edges.rememberFadingEdges import com.patrykandpatryk.vico.compose.component.shapeComponent import com.patrykandpatryk.vico.compose.component.textComponent as composeTextComponent import com.patrykandpatryk.vico.compose.dimensions.dimensionsOf import com.patrykandpatryk.vico.compose.m3.style.m3ChartStyle import com.patrykandpatryk.vico.compose.style.ProvideChartStyle import com.patrykandpatryk.vico.core.axis.AxisItemPlacer import com.patrykandpatryk.vico.core.axis.AxisPosition import com.patrykandpatryk.vico.core.axis.formatter.AxisValueFormatter import com.patrykandpatryk.vico.core.axis.vertical.VerticalAxis import com.patrykandpatryk.vico.core.chart.decoration.Decoration import com.patrykandpatryk.vico.core.chart.draw.ChartDrawContext import com.patrykandpatryk.vico.core.component.shape.Shapes import com.patrykandpatryk.vico.core.component.text.TextComponent import com.patrykandpatryk.vico.core.component.text.textComponent import com.patrykandpatryk.vico.core.entry.entryModelOf import com.patrykandpatryk.vico.core.entry.entryOf import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.datetime.Clock import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours @Composable internal fun XpEarnedByDayCard( modifier: Modifier = Modifier, formatter: DateTimeRangeFormatter, xpEarnedList: ImmutableList ) { val resultsAggregated = remember(formatter, xpEarnedList) { formatter.aggregateResults(xpEarnedList) } val xValuesToDates = remember(resultsAggregated) { resultsAggregated.keys.associateBy { it.toFloat() } } val chartEntryModel = remember(xValuesToDates, resultsAggregated) { entryModelOf(xValuesToDates.keys.zip(resultsAggregated.values, ::entryOf)) } val horizontalAxisValueFormatter = remember(formatter, xValuesToDates) { AxisValueFormatter { value, _ -> val data = xValuesToDates[value] ?: value.toInt() formatter.formatValueToString(data) } } val noDataTextComponent = composeTextComponent( color = MaterialTheme.colorScheme.onSurface, textSize = NO_DATA_TEXT_SIZE ) val noDataText = stringResource(CoreR.string.no_data) val decorations = remember(xValuesToDates) { if (xValuesToDates.isEmpty()) { listOf( NoDataText( text = noDataText, textComponent = noDataTextComponent ), ) } else { emptyList() } } ProvideChartStyle( chartStyle = m3ChartStyle( axisGuidelineColor = MaterialTheme.colorScheme.outlineVariant, axisLineColor = MaterialTheme.colorScheme.outlineVariant, ) ) { Chart( modifier = modifier, chart = columnChart(decorations = decorations), model = chartEntryModel, startAxis = rememberStartAxis( label = if (resultsAggregated.isEmpty()) null else axisLabelComponent(), valueFormatter = { value, _ -> value.toInt().toString() }, itemPlacer = startAxisItemPlacer, horizontalLabelPosition = VerticalAxis.HorizontalLabelPosition.Outside, titleComponent = composeTextComponent( color = MaterialTheme.colorScheme.onPrimary, background = shapeComponent( shape = Shapes.pillShape, color = MaterialTheme.colorScheme.primary ), padding = axisTitlePadding, margins = startAxisTitleMargins, typeface = Typeface.MONOSPACE, ), title = stringResource(CoreR.string.xp), guideline = if (resultsAggregated.isEmpty()) null else axisGuidelineComponent() ), bottomAxis = rememberBottomAxis( label = if (resultsAggregated.isEmpty()) null else axisLabelComponent(), valueFormatter = horizontalAxisValueFormatter, guideline = null ), marker = rememberMarker(), fadingEdges = rememberFadingEdges(), ) } } private class NoDataText( val text: String, val textComponent: TextComponent = textComponent(), ) : Decoration { override fun onDrawAboveChart(context: ChartDrawContext, bounds: RectF) { textComponent.drawText( context = context, text = text, textX = bounds.centerX(), textY = bounds.centerY(), ) } } private const val MAX_START_AXIS_ITEM_COUNT = 6 private val startAxisItemPlacer = AxisItemPlacer.Vertical.default(MAX_START_AXIS_ITEM_COUNT) private val axisTitleHorizontalPaddingValue = 8.dp private val axisTitleVerticalPaddingValue = 2.dp private val axisTitlePadding = dimensionsOf( horizontal = axisTitleHorizontalPaddingValue, vertical = axisTitleVerticalPaddingValue ) private val axisTitleMarginValue = 4.dp private val startAxisTitleMargins = dimensionsOf(end = axisTitleMarginValue) private val NO_DATA_TEXT_SIZE = 18.sp @Composable @PreviewLightDark private fun EmptyXpCardPreview() { NewQuizTheme { Surface { XpEarnedByDayCard( modifier = Modifier.padding(16.dp), formatter = DateTimeRangeFormatter.Day, xpEarnedList = persistentListOf() ) } } } @Composable @PreviewLightDark private fun XpTodayCardPreview() { val now = Clock.System.now() NewQuizTheme { Surface { XpEarnedByDayCard( modifier = Modifier.padding(16.dp), formatter = DateTimeRangeFormatter.Day, xpEarnedList = persistentListOf( TimestampWithXP((now - 4.hours).toEpochMilliseconds(), 10), TimestampWithXP((now - 3.hours).toEpochMilliseconds(), 5), TimestampWithXP(now.toEpochMilliseconds(), 15) ) ) } } } @Composable @PreviewLightDark private fun XpThisWeekCardPreview() { val now = Clock.System.now() NewQuizTheme { Surface { XpEarnedByDayCard( modifier = Modifier.padding(16.dp), formatter = DateTimeRangeFormatter.Week, xpEarnedList = persistentListOf( TimestampWithXP((now - 4.days).toEpochMilliseconds(), 20), TimestampWithXP((now - 3.days).toEpochMilliseconds(), 10), TimestampWithXP((now - 1.days).toEpochMilliseconds(), 30), TimestampWithXP(now.toEpochMilliseconds(), 15) ) ) } } } ================================================ FILE: feature/profile/src/main/kotlin/com/infinitepower/newquiz/feature/profile/components/chart/Marker.kt ================================================ package com.infinitepower.newquiz.feature.profile.components.chart import android.graphics.Typeface import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp import com.patrykandpatryk.vico.compose.component.lineComponent import com.patrykandpatryk.vico.compose.component.overlayingComponent import com.patrykandpatryk.vico.compose.component.shapeComponent import com.patrykandpatryk.vico.compose.component.textComponent import com.patrykandpatryk.vico.compose.dimensions.dimensionsOf import com.patrykandpatryk.vico.core.chart.dimensions.HorizontalDimensions import com.patrykandpatryk.vico.core.chart.insets.Insets import com.patrykandpatryk.vico.core.component.marker.MarkerComponent import com.patrykandpatryk.vico.core.component.shape.DashedShape import com.patrykandpatryk.vico.core.component.shape.ShapeComponent import com.patrykandpatryk.vico.core.component.shape.Shapes import com.patrykandpatryk.vico.core.component.shape.cornered.Corner import com.patrykandpatryk.vico.core.component.shape.cornered.MarkerCorneredShape import com.patrykandpatryk.vico.core.context.MeasureContext import com.patrykandpatryk.vico.core.extension.copyColor import com.patrykandpatryk.vico.core.marker.Marker @Composable internal fun rememberMarker(): Marker { val labelBackgroundColor = MaterialTheme.colorScheme.surface val labelBackground = remember(labelBackgroundColor) { ShapeComponent(labelBackgroundShape, labelBackgroundColor.toArgb()).setShadow( radius = LABEL_BACKGROUND_SHADOW_RADIUS, dy = LABEL_BACKGROUND_SHADOW_DY, applyElevationOverlay = true, ) } val label = textComponent( background = labelBackground, lineCount = LABEL_LINE_COUNT, padding = labelPadding, typeface = Typeface.MONOSPACE, ) val indicatorInnerComponent = shapeComponent(Shapes.pillShape, MaterialTheme.colorScheme.surface) val indicatorCenterComponent = shapeComponent(Shapes.pillShape, Color.White) val indicatorOuterComponent = shapeComponent(Shapes.pillShape, Color.White) val indicator = overlayingComponent( outer = indicatorOuterComponent, inner = overlayingComponent( outer = indicatorCenterComponent, inner = indicatorInnerComponent, innerPaddingAll = indicatorInnerAndCenterComponentPaddingValue, ), innerPaddingAll = indicatorCenterAndOuterComponentPaddingValue, ) val guideline = lineComponent( MaterialTheme.colorScheme.onSurface.copy(GUIDELINE_ALPHA), guidelineThickness, guidelineShape, ) return remember(label, indicator, guideline) { object : MarkerComponent(label, indicator, guideline) { init { indicatorSizeDp = INDICATOR_SIZE_DP onApplyEntryColor = { entryColor -> indicatorOuterComponent.color = entryColor.copyColor( INDICATOR_OUTER_COMPONENT_ALPHA ) with(indicatorCenterComponent) { color = entryColor setShadow(radius = INDICATOR_CENTER_COMPONENT_SHADOW_RADIUS, color = entryColor) } } } override fun getInsets( context: MeasureContext, outInsets: Insets, horizontalDimensions: HorizontalDimensions, ) = with(context) { outInsets.top = label.getHeight(context) + labelBackgroundShape.tickSizeDp.pixels + LABEL_BACKGROUND_SHADOW_RADIUS.pixels * SHADOW_RADIUS_MULTIPLIER - LABEL_BACKGROUND_SHADOW_DY.pixels } } } } private const val LABEL_BACKGROUND_SHADOW_RADIUS = 4f private const val LABEL_BACKGROUND_SHADOW_DY = 2f private const val LABEL_LINE_COUNT = 1 private const val GUIDELINE_ALPHA = .2f private const val INDICATOR_SIZE_DP = 36f private const val INDICATOR_OUTER_COMPONENT_ALPHA = 32 private const val INDICATOR_CENTER_COMPONENT_SHADOW_RADIUS = 12f private const val GUIDELINE_DASH_LENGTH_DP = 8f private const val GUIDELINE_GAP_LENGTH_DP = 4f private const val SHADOW_RADIUS_MULTIPLIER = 1.3f private val labelBackgroundShape = MarkerCorneredShape(Corner.FullyRounded) private val labelHorizontalPaddingValue = 8.dp private val labelVerticalPaddingValue = 4.dp private val labelPadding = dimensionsOf(labelHorizontalPaddingValue, labelVerticalPaddingValue) private val indicatorInnerAndCenterComponentPaddingValue = 5.dp private val indicatorCenterAndOuterComponentPaddingValue = 10.dp private val guidelineThickness = 2.dp private val guidelineShape = DashedShape(Shapes.pillShape, GUIDELINE_DASH_LENGTH_DP, GUIDELINE_GAP_LENGTH_DP) ================================================ FILE: feature/settings/.gitignore ================================================ /build ================================================ FILE: feature/settings/README.md ================================================ # :feature:settings module ================================================ FILE: feature/settings/build.gradle.kts ================================================ plugins { alias(libs.plugins.newquiz.android.feature) alias(libs.plugins.newquiz.android.compose.destinations) alias(libs.plugins.newquiz.detekt) } android { namespace = "com.infinitepower.newquiz.feature.settings" } dependencies { implementation(libs.androidx.compose.material3.windowSizeClass) implementation(libs.androidx.compose.material.iconsExtended) implementation(libs.google.oss.licenses) { exclude(group = "androidx.appcompat") } implementation(projects.core.datastore) implementation(projects.domain) implementation(projects.data) normalImplementation(projects.core.translation) } ================================================ FILE: feature/settings/src/foss/kotlin/com/infinitepower/newquiz/feature/settings/common/BuildVariant.kt ================================================ package com.infinitepower.newquiz.feature.settings.common object BuildVariant { internal const val DISTRIBUTION_FLAVOR = "foss" } ================================================ FILE: feature/settings/src/foss/kotlin/com/infinitepower/newquiz/feature/settings/screens/PreferencesScreen.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.infinitepower.newquiz.feature.settings.model.ScreenKey import com.infinitepower.newquiz.feature.settings.screens.about_and_help.AboutAndHelpScreen import com.infinitepower.newquiz.feature.settings.screens.animations.AnimationsScreen import com.infinitepower.newquiz.feature.settings.screens.general.GeneralScreen import com.infinitepower.newquiz.feature.settings.screens.multi_choice_quiz.MultiChoiceQuizScreen import com.infinitepower.newquiz.feature.settings.screens.wordle.WordleScreen @Composable @ExperimentalMaterial3Api internal fun PreferencesScreen( modifier: Modifier = Modifier, currentScreen: ScreenKey?, isScreenExpanded: Boolean, onBackClick: () -> Unit, navigateToScreen: (key: ScreenKey) -> Unit, ) { when (currentScreen) { null -> {} ScreenKey.GENERAL -> GeneralScreen( modifier = modifier, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick, navigateToScreen = navigateToScreen ) ScreenKey.MULTI_CHOICE_QUIZ -> MultiChoiceQuizScreen( modifier = modifier, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick, ) ScreenKey.WORDLE -> WordleScreen( modifier = modifier, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick, ) ScreenKey.ABOUT_AND_HELP -> AboutAndHelpScreen( modifier = modifier, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick, ) ScreenKey.ANIMATIONS -> AnimationsScreen( modifier = modifier, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick, ) // Don't exist in the foss version ScreenKey.ANALYTICS -> {} ScreenKey.TRANSLATION -> {} } } ================================================ FILE: feature/settings/src/main/AndroidManifest.xml ================================================ ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/SettingsScreen.kt ================================================ package com.infinitepower.newquiz.feature.settings import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding 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.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.feature.settings.destinations.SettingsScreenDestination import com.infinitepower.newquiz.feature.settings.model.ScreenKey import com.infinitepower.newquiz.feature.settings.screens.PreferencesScreen import com.infinitepower.newquiz.feature.settings.screens.main.MainScreen import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator @Composable @Destination @OptIn(ExperimentalMaterial3Api::class) fun SettingsScreen( screenKey: ScreenKey? = null, windowSizeClass: WindowSizeClass, navigator: DestinationsNavigator, ) { val isScreenExpanded = remember(windowSizeClass.widthSizeClass) { windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded } SettingsContainer( currentScreen = screenKey, windowWidthSizeClass = windowSizeClass.widthSizeClass, mainContent = { MainScreen( currentScreen = screenKey, onScreenSelect = { key -> navigator.navigate(SettingsScreenDestination(key)) }, onBackClick = navigator::popBackStack, ) }, preferencesContent = { PreferencesScreen( currentScreen = screenKey, isScreenExpanded = isScreenExpanded, onBackClick = navigator::popBackStack, navigateToScreen = { key -> navigator.navigate(SettingsScreenDestination(key)) } ) } ) } @Composable private fun SettingsContainer( modifier: Modifier = Modifier, currentScreen: ScreenKey?, windowWidthSizeClass: WindowWidthSizeClass, mainContent: @Composable BoxScope.() -> Unit, preferencesContent: @Composable BoxScope.() -> Unit ) { val isInMainScreen = remember(currentScreen) { currentScreen == null } val isExpanded = remember(windowWidthSizeClass) { windowWidthSizeClass == WindowWidthSizeClass.Expanded } // If the screen is not in the main screen and the window is expanded, we want to show the // main content in the left side of the screen and the preferences content in the right side. // Otherwise, we only show the current screen content. val inPreferencesAndExpanded = remember(isInMainScreen, isExpanded) { !isInMainScreen && isExpanded } val leftContent = if (isInMainScreen || isExpanded) mainContent else preferencesContent Row(modifier = modifier.fillMaxSize()) { Surface( modifier = Modifier .weight(weight = 1f) .padding(if (inPreferencesAndExpanded) MaterialTheme.spacing.medium else 0.dp), tonalElevation = if (inPreferencesAndExpanded) 4.dp else 0.dp, shape = if (inPreferencesAndExpanded) MaterialTheme.shapes.extraLarge else RectangleShape, ) { Box( modifier = Modifier.padding( if (inPreferencesAndExpanded) MaterialTheme.spacing.medium else 0.dp ), content = leftContent ) } if (inPreferencesAndExpanded) { Box( modifier = Modifier.weight(2f), content = preferencesContent ) } } } @Composable @PreviewScreenSizes @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) private fun SettingsScreenPreview() { BoxWithConstraints { val windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)) NewQuizTheme { Surface { SettingsScreen( screenKey = ScreenKey.WORDLE, windowSizeClass = windowSizeClass, navigator = EmptyDestinationsNavigator ) } } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/components/AboutAndHelpButtons.kt ================================================ package com.infinitepower.newquiz.feature.settings.components import android.content.Intent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ContactSupport import androidx.compose.material.icons.rounded.Balance import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.Update import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.R as CoreR private const val NEWQUIZ_REPOSITORY_LINK = "https://github.com/joaomanaia/newquiz" @Composable internal fun AboutAndHelpButtons( modifier: Modifier = Modifier, iconSize: Dp = 50.dp ) { val uriHandler = LocalUriHandler.current val context = LocalContext.current Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.medium) ) { FilledIconButton( imageVector = ImageVector.vectorResource(id = CoreR.drawable.github_logo), contentDescription = "Github repository", onClick = { uriHandler.openUri(NEWQUIZ_REPOSITORY_LINK) }, modifier = Modifier.size(iconSize) ) FilledIconButton( imageVector = Icons.AutoMirrored.Rounded.ContactSupport, contentDescription = "Contact support", onClick = { uriHandler.openUri("$NEWQUIZ_REPOSITORY_LINK/issues") }, modifier = Modifier.size(iconSize) ) FilledIconButton( imageVector = Icons.Rounded.Update, contentDescription = "Update", onClick = { uriHandler.openUri("$NEWQUIZ_REPOSITORY_LINK/releases") }, modifier = Modifier.size(iconSize) ) FilledIconButton( imageVector = Icons.Rounded.Balance, contentDescription = "Open source licences", onClick = { context.startActivity( Intent(context, OssLicensesMenuActivity::class.java).apply { action = Intent.ACTION_VIEW } ) }, modifier = Modifier.size(iconSize) ) } } @Composable internal fun FilledIconButton( modifier: Modifier = Modifier, onClick: () -> Unit, imageVector: ImageVector, contentDescription: String?, containerColor: Color = MaterialTheme.colorScheme.primary ) { FilledIconButton( modifier = modifier, onClick = onClick, colors = IconButtonDefaults.filledIconButtonColors(containerColor = containerColor) ) { Icon( imageVector = imageVector, contentDescription = contentDescription ) } } @Composable @PreviewLightDark private fun AboutAndHelpButtonsPreview() { NewQuizTheme { Surface { AboutAndHelpButtons( modifier = Modifier.padding(16.dp) ) } } } @Composable @PreviewLightDark private fun PrimaryButtonPreview() { NewQuizTheme { Surface { FilledIconButton( modifier = Modifier.padding(16.dp), imageVector = Icons.Rounded.Favorite, contentDescription = "Favorite", onClick = {} ) } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/components/preferences/PreferenceGroupHeader.kt ================================================ package com.infinitepower.newquiz.feature.settings.components.preferences import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewLightDark import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing @Composable internal fun LazyItemScope.PreferenceGroupHeader(title: String) { val spaceSmall = MaterialTheme.spacing.small val spaceMedium = MaterialTheme.spacing.medium Box( contentAlignment = Alignment.CenterStart, modifier = Modifier .fillParentMaxWidth() .padding(bottom = spaceSmall, top = spaceMedium) ) { Text( text = title, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(start = spaceMedium), color = MaterialTheme.colorScheme.primary ) } } @Composable @PreviewLightDark private fun PreferenceGroupHeaderPreview() { NewQuizTheme { Surface { LazyColumn { item { PreferenceGroupHeader("NewSocial") } } } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/components/preferences/PreferenceItem.kt ================================================ package com.infinitepower.newquiz.feature.settings.components.preferences import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.datastore.preferences.core.Preferences import com.infinitepower.newquiz.core.compose.preferences.LocalPreferenceEnabledStatus import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import com.infinitepower.newquiz.feature.settings.components.preferences.widgets.DropDownPreferenceWidget import com.infinitepower.newquiz.feature.settings.components.preferences.widgets.ListPreferenceWidget import com.infinitepower.newquiz.feature.settings.components.preferences.widgets.MultiSelectListPreferenceWidget import com.infinitepower.newquiz.feature.settings.components.preferences.widgets.NavigationButtonWidget import com.infinitepower.newquiz.feature.settings.components.preferences.widgets.SeekBarPreferenceWidget import com.infinitepower.newquiz.feature.settings.components.preferences.widgets.SwitchPreferenceWidget import com.infinitepower.newquiz.feature.settings.components.preferences.widgets.TextPreferenceWidget import com.infinitepower.newquiz.feature.settings.model.Preference import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable internal fun PreferenceItem( item: Preference.PreferenceItem<*>, prefs: Preferences?, dataStoreManager: DataStoreManager ) { val scope = rememberCoroutineScope() when (item) { is Preference.PreferenceItem.NavigationButton -> { NavigationButtonWidget(preference = item) } is Preference.PreferenceItem.SwitchPreference -> { val enabled = LocalPreferenceEnabledStatus.current && item.enabled SwitchPreferenceWidget( preference = item, checked = prefs?.get(item.request.key) ?: item.request.defaultValue, onCheckChange = { newValue -> scope.launch(Dispatchers.IO) { item.onCheckChange(newValue && enabled) dataStoreManager.editPreference(item.request.key, newValue) } } ) } is Preference.PreferenceItem.ListPreference -> { ListPreferenceWidget( preference = item, value = prefs?.get(item.request.key) ?: item.request.defaultValue, onValueChange = { newValue -> scope.launch(Dispatchers.IO) { dataStoreManager.editPreference( item.request.key, newValue ) } } ) } is Preference.PreferenceItem.SeekBarPreference -> { SeekBarPreferenceWidget( preference = item, value = prefs?.get(item.request.key) ?: item.request.defaultValue, onValueChange = { newValue -> scope.launch(Dispatchers.IO) { dataStoreManager.editPreference( item.request.key, newValue ) } } ) } is Preference.PreferenceItem.DropDownMenuPreference -> { DropDownPreferenceWidget( preference = item, value = prefs?.get(item.request.key) ?: item.request.defaultValue, onValueChange = { newValue -> scope.launch(Dispatchers.IO) { dataStoreManager.editPreference( item.request.key, newValue ) } } ) } is Preference.PreferenceItem.TextPreference -> { TextPreferenceWidget( preference = item, onClick = item.onClick ) } is Preference.PreferenceItem.MultiSelectListPreference -> { val values = remember { (prefs?.get(item.request.key) ?: item.request.defaultValue).toImmutableSet() } MultiSelectListPreferenceWidget( preference = item, values = values, onValuesChange = { newValues -> scope.launch(Dispatchers.IO) { dataStoreManager.editPreference( item.request.key, newValues ) } } ) } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/components/preferences/widgets/CustomPreferenceWidget.kt ================================================ package com.infinitepower.newquiz.feature.settings.components.preferences.widgets import androidx.compose.foundation.layout.Box import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewLightDark import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.feature.settings.model.Preference @Composable internal fun CustomPreferenceWidget( preference: Preference.CustomPreference ) { Box( modifier = Modifier.semantics { contentDescription = preference.title }, content = preference.content ) } @Composable @PreviewLightDark private fun CustomPreferenceWidgetPreview() { NewQuizTheme { Surface { CustomPreferenceWidget( preference = Preference.CustomPreference( title = "Custom Preference", content = { Text(text = "Custom Preference") } ) ) } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/components/preferences/widgets/DropDownPreferenceWidget.kt ================================================ package com.infinitepower.newquiz.feature.settings.components.preferences.widgets import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.feature.settings.model.Preference @Composable internal fun DropDownPreferenceWidget( preference: Preference.PreferenceItem.DropDownMenuPreference, value: String, onValueChange: (String) -> Unit ) { val (isExpanded, expand) = remember { mutableStateOf(false) } TextPreferenceWidgetRes( preference = preference, summary = preference.entries[value], onClick = { expand(!isExpanded) } ) Box( modifier = Modifier.padding(start = MaterialTheme.spacing.extraLarge) ) { DropdownMenu( expanded = isExpanded, onDismissRequest = { expand(!isExpanded) } ) { preference.entries.forEach { item -> DropdownMenuItem( onClick = { onValueChange(item.key) expand(!isExpanded) }, text = { Text( text = item.value, style = MaterialTheme.typography.bodySmall.merge() ) } ) } } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/components/preferences/widgets/ListPreferenceWidget.kt ================================================ package com.infinitepower.newquiz.feature.settings.components.preferences.widgets import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material3.AlertDialog import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.PreviewLightDark import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.feature.settings.model.Preference import com.infinitepower.newquiz.core.R as CoreR @Composable internal fun ListPreferenceWidget( preference: Preference.PreferenceItem.ListPreference, value: String, onValueChange: (String) -> Unit ) { val (isDialogShown, showDialog) = remember { mutableStateOf(false) } val dismissDialog = { showDialog(false) } val (newValue, setNewValue) = remember(value) { mutableStateOf(value) } TextPreferenceWidget( preference = preference, summary = preference.entries[value], onClick = { showDialog(!isDialogShown) }, ) if (isDialogShown) { AlertDialog( onDismissRequest = dismissDialog, title = { Text(text = preference.title) }, text = { LazyColumn(modifier = Modifier.selectableGroup()) { items(preference.entries.keys.toList()) { key -> val isSelected = newValue == key val onSelected = { setNewValue(key) } SelectableListItem( text = preference.entries[key].orEmpty(), isSelected = isSelected, onClick = onSelected ) } } }, confirmButton = { TextButton( onClick = { dismissDialog() preference.onItemClick(newValue) onValueChange(newValue) } ) { Text(text = stringResource(id = CoreR.string.confirm)) } }, dismissButton = { TextButton(onClick = dismissDialog) { Text(text = stringResource(id = CoreR.string.dismiss)) } } ) } } @Composable private fun SelectableListItem( modifier: Modifier = Modifier, onClick: () -> Unit, text: String, isSelected: Boolean ) { ListItem( headlineContent = { Text(text = text) }, leadingContent = { RadioButton( selected = isSelected, onClick = onClick, ) }, modifier = modifier .clip(MaterialTheme.shapes.medium) .selectable( selected = isSelected, onClick = onClick, role = Role.RadioButton ), colors = ListItemDefaults.colors( containerColor = Color.Transparent ) ) } @Composable @PreviewLightDark private fun SelectableListItemPreview() { NewQuizTheme { Surface { SelectableListItem( text = "NewQuiz", isSelected = true, onClick = {} ) } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/components/preferences/widgets/MultiSelectListPreferenceWidget.kt ================================================ package com.infinitepower.newquiz.feature.settings.components.preferences.widgets import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf 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.unit.dp import com.infinitepower.newquiz.feature.settings.model.Preference import kotlinx.collections.immutable.ImmutableSet @Composable internal fun MultiSelectListPreferenceWidget( preference: Preference.PreferenceItem.MultiSelectListPreference, values: ImmutableSet, onValuesChange: (Set) -> Unit ) { val (isDialogShown, showDialog) = remember { mutableStateOf(false) } val description = remember(preference.entries, values) { preference .entries .filter { values.contains(it.key) } .map { it.value } .joinToString(separator = ", ", limit = 3) } TextPreferenceWidget( preference = preference, summary = description.ifBlank { null }, onClick = { showDialog(!isDialogShown) } ) if (isDialogShown) { AlertDialog( onDismissRequest = { showDialog(false) }, title = { Text(text = preference.title) }, text = { Column( modifier = Modifier .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 16.dp) .verticalScroll(state = rememberScrollState()) ) { preference.entries.forEach { current -> val isSelected = values.contains(current.key) val onSelectionChanged = { val result = when (!isSelected) { true -> values + current.key false -> values - current.key } onValuesChange(result) } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .selectable( selected = isSelected, onClick = { onSelectionChanged() } ) .padding(4.dp) ) { Checkbox( checked = isSelected, onCheckedChange = { onSelectionChanged() } ) Text( text = current.value, style = MaterialTheme.typography.bodyLarge.merge(), modifier = Modifier.padding(start = 16.dp) ) } } } }, confirmButton = { TextButton( onClick = { showDialog(false) }, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.secondary), ) { Text(text = stringResource(android.R.string.ok)) } } ) } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/components/preferences/widgets/NavigationButtonWidget.kt ================================================ package com.infinitepower.newquiz.feature.settings.components.preferences.widgets import androidx.compose.animation.animateColor import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Build import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults 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.text.style.TextOverflow 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.common.compose.preview.BooleanPreviewParameterProvider import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.feature.settings.model.Preference @Composable internal fun NavigationButtonWidget( modifier: Modifier = Modifier, preference: Preference.PreferenceItem.NavigationButton ) { NavigationButton( modifier = modifier, title = preference.title, icon = preference.icon, description = preference.summary, isSelected = preference.itemSelected, enabled = preference.enabled, singleLineTitle = preference.singleLineTitle, singleLineSummary = preference.singleLineSummary, onClick = preference.onClick, ) } @Composable internal fun NavigationButton( modifier: Modifier = Modifier, onClick: () -> Unit, title: String, icon: @Composable (() -> Unit)?, description: String? = null, isSelected: Boolean = false, enabled: Boolean = true, singleLineTitle: Boolean = true, singleLineSummary: Boolean = true, ) { val transition = updateTransition( targetState = isSelected, label = "Navigation Button" ) val containerColor = transition.animateColor( label = "Container Color" ) { selected -> if (selected) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surface } } Surface( modifier = modifier, selected = isSelected, onClick = onClick, shape = MaterialTheme.shapes.extraLarge, color = containerColor.value, enabled = enabled, ) { ListItem( headlineContent = { Text( text = title, maxLines = if (singleLineTitle) 1 else Int.MAX_VALUE ) }, supportingContent = description?.let { { Text( text = it, maxLines = if (singleLineSummary) 1 else Int.MAX_VALUE, overflow = TextOverflow.Ellipsis ) } }, leadingContent = icon, colors = ListItemDefaults.colors( containerColor = containerColor.value ), modifier = Modifier.padding(MaterialTheme.spacing.small), ) } } @Composable @PreviewLightDark private fun NavigationButtonPreview( @PreviewParameter(BooleanPreviewParameterProvider::class) isSelected: Boolean, ) { NewQuizTheme { Surface { NavigationButton( title = "Settings", icon = { Icon( imageVector = Icons.Rounded.Build, contentDescription = null ) }, description = "Settings description", isSelected = isSelected, onClick = {}, modifier = Modifier.padding(16.dp) ) } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/components/preferences/widgets/SeekBarPreferenceWidget.kt ================================================ package com.infinitepower.newquiz.feature.settings.components.preferences.widgets import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.infinitepower.newquiz.core.compose.preferences.LocalPreferenceEnabledStatus import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.feature.settings.model.Preference import kotlin.math.roundToInt @Composable internal fun SeekBarPreferenceWidget( preference: Preference.PreferenceItem.SeekBarPreference, value: Int, onValueChange: (Int) -> Unit, ) { var currentValue by remember(value) { mutableIntStateOf(value) } TextPreferenceWidget( preference = preference, summary = { PreferenceSummary( preference = preference, sliderValue = currentValue, onValueChange = { currentValue = it }, onValueChangeEnd = { onValueChange(currentValue) } ) } ) } @Composable private fun PreferenceSummary( preference: Preference.PreferenceItem.SeekBarPreference, sliderValue: Int, onValueChange: (Int) -> Unit, onValueChangeEnd: () -> Unit, ) { val isEnabled = LocalPreferenceEnabledStatus.current && preference.enabled Column { preference.summary?.let { Text(text = it) } Row(verticalAlignment = Alignment.CenterVertically) { Text(text = preference.valueRepresentation(sliderValue)) Spacer(modifier = Modifier.width(MaterialTheme.spacing.medium)) Slider( value = sliderValue.toFloat(), onValueChange = { if (preference.enabled) onValueChange(it.roundToInt()) }, valueRange = preference.valueRange.toClosedFloatingPointRange(), steps = preference.steps, onValueChangeFinished = onValueChangeEnd, enabled = isEnabled ) } } } private fun ClosedRange.toClosedFloatingPointRange(): ClosedFloatingPointRange { return start.toFloat()..endInclusive.toFloat() } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/components/preferences/widgets/SwitchPreferenceWidget.kt ================================================ package com.infinitepower.newquiz.feature.settings.components.preferences.widgets import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.datastore.preferences.core.booleanPreferencesKey import com.infinitepower.newquiz.core.common.compose.preview.BooleanPreviewParameterProvider import com.infinitepower.newquiz.core.compose.preferences.LocalPreferenceEnabledStatus import com.infinitepower.newquiz.core.datastore.PreferenceRequest import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.feature.settings.model.Preference @Composable internal fun SwitchPreferenceWidget( preference: Preference.PreferenceItem.SwitchPreference, checked: Boolean, onCheckChange: (Boolean) -> Unit ) { val isEnabled = LocalPreferenceEnabledStatus.current && preference.enabled SwitchPreferenceContainer( checked = checked, isEnabled = isEnabled, isPrimary = preference.primarySwitch ) { TextPreferenceWidget( preference = preference, onClick = { onCheckChange(!checked) } ) { Switch( checked = checked, onCheckedChange = onCheckChange, enabled = isEnabled ) } } } @Composable private fun SwitchPreferenceContainer( checked: Boolean, isEnabled: Boolean, isPrimary: Boolean, content: @Composable () -> Unit ) { if (isPrimary) { val containerColor = if (checked) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surfaceVariant } val tonalElevation = if (checked && isEnabled) 8.dp else 0.dp Surface( color = containerColor, tonalElevation = tonalElevation, shape = MaterialTheme.shapes.extraLarge, modifier = Modifier.padding(MaterialTheme.spacing.medium), content = content ) } else { content() } } @Composable @PreviewLightDark private fun SwitchPreferencePreview( @PreviewParameter(BooleanPreviewParameterProvider::class) checked: Boolean ) { NewQuizTheme { Surface { SwitchPreferenceWidget( preference = Preference.PreferenceItem.SwitchPreference( request = PreferenceRequest( key = booleanPreferencesKey("switch_preference"), defaultValue = false ), title = "Switch Preference", enabled = true, primarySwitch = true ), checked = checked, onCheckChange = {} ) } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/components/preferences/widgets/TextPreferenceWidget.kt ================================================ package com.infinitepower.newquiz.feature.settings.components.preferences.widgets import androidx.compose.foundation.clickable import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.infinitepower.newquiz.core.compose.preferences.LocalPreferenceEnabledStatus import com.infinitepower.newquiz.core.ui.DisabledContentEmphasis import com.infinitepower.newquiz.feature.settings.model.Preference @Composable internal fun TextPreferenceWidget( preference: Preference.PreferenceItem<*>, summary: String? = null, onClick: () -> Unit = {}, trailing: @Composable (() -> Unit)? = null ) { val isEnabled = LocalPreferenceEnabledStatus.current && preference.enabled val summaryText = summary ?: preference.summary DisabledContentEmphasis(enabled = isEnabled) { if (summaryText == null) { ListItem( headlineContent = { Text( text = preference.title, maxLines = if (preference.singleLineTitle) 1 else Int.MAX_VALUE ) }, leadingContent = preference.icon, modifier = Modifier.clickable( enabled = isEnabled, onClick = onClick ), trailingContent = trailing, colors = listItemColors() ) } else { ListItem( headlineContent = { Text( text = preference.title, maxLines = if (preference.singleLineTitle) 1 else Int.MAX_VALUE ) }, supportingContent = { Text( text = summaryText, maxLines = if (preference.singleLineSummary) 1 else Int.MAX_VALUE ) }, leadingContent = preference.icon, modifier = Modifier.clickable( enabled = isEnabled, onClick = onClick ), trailingContent = trailing, colors = listItemColors() ) } } } @Composable internal fun TextPreferenceWidgetRes( preference: Preference.PreferenceItem<*>, summary: String? = null, onClick: () -> Unit = { }, trailing: @Composable (() -> Unit)? = null ) { val isEnabled = LocalPreferenceEnabledStatus.current && preference.enabled DisabledContentEmphasis(enabled = isEnabled) { ListItem( headlineContent = { Text( text = preference.title, maxLines = if (preference.singleLineTitle) 1 else Int.MAX_VALUE ) }, supportingContent = { val text = summary ?: preference.summary if (text != null) { Text( text = text, maxLines = if (preference.singleLineSummary) 1 else Int.MAX_VALUE ) } }, leadingContent = preference.icon, modifier = Modifier.clickable( enabled = isEnabled, onClick = onClick ), trailingContent = trailing, colors = listItemColors() ) } } @Composable internal fun TextPreferenceWidget( preference: Preference.PreferenceItem<*>, summary: @Composable () -> Unit, trailing: @Composable (() -> Unit)? = null, onClick: () -> Unit = { } ) { val isEnabled = LocalPreferenceEnabledStatus.current && preference.enabled DisabledContentEmphasis(enabled = isEnabled) { ListItem( headlineContent = { Text( text = preference.title, maxLines = if (preference.singleLineTitle) 1 else Int.MAX_VALUE ) }, supportingContent = summary, leadingContent = preference.icon, modifier = Modifier.clickable( enabled = isEnabled, onClick = onClick ), trailingContent = trailing, colors = listItemColors() ) } } @Composable private fun listItemColors() = ListItemDefaults.colors( containerColor = Color.Transparent ) ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/model/Preference.kt ================================================ package com.infinitepower.newquiz.feature.settings.model import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable import com.infinitepower.newquiz.core.datastore.PreferenceRequest import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf /** * The basic building block that represents an individual setting displayed to a user in the preference hierarchy. */ sealed class Preference { abstract val title: String abstract val enabled: Boolean abstract val visible: Boolean /** * A single [Preference] item */ sealed class PreferenceItem : Preference() { abstract val summary: String? abstract val singleLineTitle: Boolean abstract val singleLineSummary: Boolean /** * Represents the keys of a SwitchPreference that controls the state of this Preference. * When the corresponding switch is turned off, this Preference is disabled and is unable to be modified. */ abstract val dependency: ImmutableList> abstract val icon: @Composable (() -> Unit)? data class NavigationButton( override val title: String, override val summary: String? = null, override val singleLineTitle: Boolean = true, override val singleLineSummary: Boolean = false, override val dependency: ImmutableList> = persistentListOf(), override val icon: @Composable (() -> Unit)? = null, override val enabled: Boolean = true, override val visible: Boolean = true, val screenKey: ScreenKey, val itemSelected: Boolean, val onClick: () -> Unit ) : PreferenceItem() /** * A basic [PreferenceItem] that only displays text. */ data class TextPreference( override val title: String, override val summary: String? = null, override val singleLineTitle: Boolean = true, override val singleLineSummary: Boolean = false, override val dependency: ImmutableList> = persistentListOf(), override val icon: @Composable (() -> Unit)? = null, override val enabled: Boolean = true, override val visible: Boolean = true, val onClick: () -> Unit = {} ) : PreferenceItem() /** * A [PreferenceItem] that provides a two-state toggleable option. * * @param onCheckChange Called when the switch is toggled, the value is the new state of the switch * or the value of the dependency if the switch is disabled * @param primarySwitch If true, the switch will be displayed with the primary color in the container * and the container will have a tonal elevation and padding. */ data class SwitchPreference( val request: PreferenceRequest, override val title: String, override val summary: String? = null, override val singleLineTitle: Boolean = true, override val singleLineSummary: Boolean = false, override val dependency: ImmutableList> = persistentListOf(), override val icon: @Composable (() -> Unit)? = null, override val enabled: Boolean = true, override val visible: Boolean = true, val onCheckChange: (newValue: Boolean) -> Unit = {}, val primarySwitch: Boolean = false ) : PreferenceItem() /** * A [PreferenceItem] that displays a list of entries as a dialog. * Only one entry can be selected at any given time. */ data class ListPreference( val request: PreferenceRequest, override val title: String, override val summary: String? = null, override val singleLineTitle: Boolean = true, override val singleLineSummary: Boolean = false, override val dependency: ImmutableList> = persistentListOf(), override val icon: @Composable (() -> Unit)? = null, override val enabled: Boolean = true, override val visible: Boolean = true, val entries: Map, val onItemClick: (value: String) -> Unit = {} ) : PreferenceItem() /** * A [PreferenceItem] that displays a list of entries as a dialog. * Multiple entries can be selected at the same time. */ data class MultiSelectListPreference( val request: PreferenceRequest>, override val title: String, override val summary: String? = null, override val singleLineTitle: Boolean = true, override val singleLineSummary: Boolean = false, override val dependency: ImmutableList> = persistentListOf(), override val icon: @Composable (() -> Unit)? = null, override val enabled: Boolean = true, override val visible: Boolean = true, val entries: Map, ) : PreferenceItem>() /** * A [PreferenceItem] that displays a seekBar and the currently selected value. */ data class SeekBarPreference( val request: PreferenceRequest, override val title: String, override val summary: String? = null, override val singleLineTitle: Boolean = true, override val singleLineSummary: Boolean = false, override val dependency: ImmutableList> = persistentListOf(), override val icon: @Composable (() -> Unit)? = null, override val enabled: Boolean = true, override val visible: Boolean = true, val valueRange: ClosedRange, val steps: Int = 0, val valueRepresentation: (Int) -> String = { it.toString() } ) : PreferenceItem() /** * A [PreferenceItem] that displays a list of entries as a DropDownMenu. * Only one entry can be selected at any given time. */ data class DropDownMenuPreference( val request: PreferenceRequest, override val title: String, override val summary: String? = null, override val singleLineTitle: Boolean = true, override val singleLineSummary: Boolean = false, override val dependency: ImmutableList> = persistentListOf(), override val icon: @Composable (() -> Unit)? = null, override val enabled: Boolean = true, override val visible: Boolean = true, val entries: Map, ) : PreferenceItem() } /** * A container for multiple [PreferenceItem]s */ data class PreferenceGroup( override val title: String, override val enabled: Boolean = true, override val visible: Boolean = true, val preferenceItems: List> ) : Preference() /** * @param title in custom preference title will be the semantic description */ data class CustomPreference( override val title: String, override val enabled: Boolean = true, override val visible: Boolean = true, val content: @Composable BoxScope.() -> Unit ) : Preference() } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/model/ScreenKey.kt ================================================ package com.infinitepower.newquiz.feature.settings.model enum class ScreenKey { GENERAL, MULTI_CHOICE_QUIZ, WORDLE, TRANSLATION, ABOUT_AND_HELP, ANALYTICS, ANIMATIONS, } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/screens/PreferenceScreen.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens import androidx.compose.foundation.layout.PaddingValues 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.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.infinitepower.newquiz.core.compose.preferences.LocalPreferenceEnabledStatus import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import com.infinitepower.newquiz.core.ui.components.icon.button.BackIconButton import com.infinitepower.newquiz.feature.settings.components.preferences.PreferenceGroupHeader import com.infinitepower.newquiz.feature.settings.components.preferences.PreferenceItem import com.infinitepower.newquiz.feature.settings.components.preferences.widgets.CustomPreferenceWidget import com.infinitepower.newquiz.feature.settings.model.Preference import kotlinx.collections.immutable.ImmutableList @Composable @ExperimentalMaterial3Api fun PreferenceScreen( modifier: Modifier = Modifier, onBackClick: () -> Unit, title: String, items: ImmutableList, dataStoreManager: DataStoreManager, isScreenExpanded: Boolean ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() Scaffold( topBar = { LargeTopAppBar( title = { Text(text = title) }, scrollBehavior = scrollBehavior, navigationIcon = { if (!isScreenExpanded) { BackIconButton(onClick = onBackClick) } } ) }, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) ) { innerPadding -> PreferenceScreen( modifier = Modifier.padding(innerPadding), items = items, dataStoreManager = dataStoreManager ) } } /** * Preference Screen composable which contains a list of [Preference] items * @param items [Preference] items which should be displayed on the preference screen. An item can be a single [PreferenceItem] or a group ([Preference.PreferenceGroup]) * @param dataStoreManager a [DataStoreManager] responsible for the dataStore backing the preference screen * @param modifier [Modifier] to be applied to the preferenceScreen layout */ @Composable internal fun PreferenceScreen( modifier: Modifier = Modifier, items: ImmutableList, dataStoreManager: DataStoreManager, contentPadding: PaddingValues = PaddingValues(0.dp) ) { val prefs by dataStoreManager.preferenceFlow.collectAsStateWithLifecycle(initialValue = null) LazyColumn( modifier = modifier, contentPadding = contentPadding ) { items.forEach { preference -> if (preference.visible) { when (preference) { // Create Preference Group is Preference.PreferenceGroup -> { item { PreferenceGroupHeader(title = preference.title) } items(preference.preferenceItems) { item -> val enabled = preference.enabled && item.dependency.all { dependency -> prefs?.get(dependency.key) ?: dependency.defaultValue } CompositionLocalProvider(LocalPreferenceEnabledStatus provides enabled) { PreferenceItem( item = item, prefs = prefs, dataStoreManager = dataStoreManager ) } } item { Spacer(modifier = Modifier.height(16.dp)) } } // Create Preference Item is Preference.PreferenceItem<*> -> item { val enabled = preference.enabled && preference.dependency.all { dependency -> prefs?.get(dependency.key) ?: dependency.defaultValue } CompositionLocalProvider(LocalPreferenceEnabledStatus provides enabled) { PreferenceItem( item = preference, prefs = prefs, dataStoreManager = dataStoreManager ) } } is Preference.CustomPreference -> { item { CustomPreferenceWidget(preference = preference) } } } } } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/screens/about_and_help/AboutAndHelpScreen.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens.about_and_help import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable 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.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.R import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.components.AppNameWithLogo import com.infinitepower.newquiz.feature.settings.components.AboutAndHelpButtons import com.infinitepower.newquiz.feature.settings.model.Preference import com.infinitepower.newquiz.feature.settings.screens.PreferenceScreen import com.infinitepower.newquiz.feature.settings.util.datastore.rememberSettingsDataStoreManager import kotlinx.collections.immutable.persistentListOf @Composable @ExperimentalMaterial3Api internal fun AboutAndHelpScreen( modifier: Modifier = Modifier, onBackClick: () -> Unit, isScreenExpanded: Boolean, ) { val dataStoreManager = rememberSettingsDataStoreManager() val items = remember { persistentListOf( Preference.CustomPreference( title = "NewQuiz Logo", content = { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(MaterialTheme.spacing.medium) ) { AppNameWithLogo() Spacer(modifier = Modifier.height(MaterialTheme.spacing.extraLarge)) AboutAndHelpButtons() } } ) ) } PreferenceScreen( modifier = modifier, title = stringResource(id = R.string.about_and_help), items = items, dataStoreManager = dataStoreManager, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick ) } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class) private fun AboutAndHelpScreenPreview() { NewQuizTheme { Surface { AboutAndHelpScreen( modifier = Modifier.padding(16.dp), isScreenExpanded = true, onBackClick = {} ) } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/screens/animations/AnimationsScreen.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens.animations import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.datastore.common.SettingsCommon import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.feature.settings.model.Preference import com.infinitepower.newquiz.feature.settings.screens.PreferenceScreen import com.infinitepower.newquiz.feature.settings.util.datastore.rememberSettingsDataStoreManager import kotlinx.collections.immutable.persistentListOf import com.infinitepower.newquiz.core.R as CoreR @Composable @ExperimentalMaterial3Api internal fun AnimationsScreen( modifier: Modifier = Modifier, onBackClick: () -> Unit, isScreenExpanded: Boolean, ) { val dataStoreManager = rememberSettingsDataStoreManager() val items = persistentListOf( Preference.PreferenceItem.SwitchPreference( request = SettingsCommon.GlobalAnimationsEnabled, title = stringResource(id = CoreR.string.animations_enabled), primarySwitch = true ), Preference.PreferenceItem.SwitchPreference( request = SettingsCommon.WordleAnimationsEnabled, title = stringResource(id = CoreR.string.wordle_animations_enabled), summary = stringResource(id = CoreR.string.wordle_animations_enabled_description), dependency = persistentListOf(SettingsCommon.GlobalAnimationsEnabled) ), Preference.PreferenceItem.SwitchPreference( request = SettingsCommon.MultiChoiceAnimationsEnabled, title = stringResource(id = CoreR.string.multi_choice_animations_enabled), summary = stringResource(id = CoreR.string.multi_choice_animations_enabled_description), dependency = persistentListOf(SettingsCommon.GlobalAnimationsEnabled) ) ) PreferenceScreen( modifier = modifier, title = stringResource(id = CoreR.string.animations), items = items, dataStoreManager = dataStoreManager, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick ) } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class) private fun AnimationsScreenPreview() { NewQuizTheme { Surface { AnimationsScreen( modifier = Modifier.padding(16.dp), isScreenExpanded = true, onBackClick = {} ) } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/screens/general/GeneralScreen.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens.general import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Analytics import androidx.compose.material.icons.rounded.Animation import androidx.compose.material.icons.rounded.ClearAll import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Place import androidx.compose.material.icons.rounded.Thermostat import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope 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.core.R import com.infinitepower.newquiz.core.datastore.common.SettingsCommon import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.feature.settings.common.BuildVariant import com.infinitepower.newquiz.feature.settings.model.Preference import com.infinitepower.newquiz.feature.settings.model.ScreenKey import com.infinitepower.newquiz.feature.settings.screens.PreferenceScreen import com.infinitepower.newquiz.feature.settings.util.datastore.rememberSettingsDataStoreManager import com.infinitepower.newquiz.feature.settings.util.getShowCategoryConnectionInfoEntryMap import com.infinitepower.newquiz.model.regional_preferences.DistanceUnitType import com.infinitepower.newquiz.model.regional_preferences.TemperatureUnit import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable @ExperimentalMaterial3Api internal fun GeneralScreen( modifier: Modifier = Modifier, isScreenExpanded: Boolean, onBackClick: () -> Unit, navigateToScreen: (key: ScreenKey) -> Unit, viewModel: GeneralScreenViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() GeneralScreen( modifier = modifier, uiState = uiState, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick, navigateToScreen = navigateToScreen, onEvent = viewModel::onEvent ) } @Composable @ExperimentalMaterial3Api internal fun GeneralScreen( modifier: Modifier = Modifier, uiState: GeneralScreenUiState = GeneralScreenUiState(), onEvent: (GeneralScreenUiEvent) -> Unit, isScreenExpanded: Boolean, onBackClick: () -> Unit, navigateToScreen: (key: ScreenKey) -> Unit ) { val dataStoreManager = rememberSettingsDataStoreManager() val scope = rememberCoroutineScope() val items = persistentListOf( Preference.PreferenceItem.TextPreference( title = stringResource(id = R.string.clear_settings), summary = stringResource(id = R.string.remove_all_saved_settings), icon = { Icon( imageVector = Icons.Rounded.Delete, contentDescription = stringResource(id = R.string.clear_settings) ) }, enabled = true, onClick = { scope.launch(Dispatchers.IO) { dataStoreManager.clearPreferences() } } ), Preference.PreferenceGroup( title = stringResource(id = R.string.categories), preferenceItems = listOf( Preference.PreferenceItem.ListPreference( title = stringResource(id = R.string.show_category_connection_info), request = SettingsCommon.CategoryConnectionInfoBadge( default = uiState.defaultShowCategoryConnectionInfo ), entries = getShowCategoryConnectionInfoEntryMap(), ), Preference.PreferenceItem.SwitchPreference( title = stringResource(id = R.string.hide_online_categories), summary = stringResource(id = R.string.hide_online_categories_description), request = SettingsCommon.HideOnlineCategories, icon = { Icon( imageVector = Icons.Rounded.Visibility, contentDescription = stringResource(id = R.string.hide_online_categories) ) } ), Preference.PreferenceItem.TextPreference( title = stringResource(id = R.string.clear_recent_categories), summary = stringResource(id = R.string.clear_recent_categories_description), onClick = { onEvent(GeneralScreenUiEvent.ClearHomeRecentCategories) }, icon = { Icon( imageVector = Icons.Rounded.ClearAll, contentDescription = stringResource(id = R.string.clear_recent_categories), ) } ) ) ), Preference.PreferenceGroup( title = stringResource(id = R.string.regional_preferences), preferenceItems = listOf( Preference.PreferenceItem.ListPreference( title = stringResource(id = R.string.temperature_unit), request = SettingsCommon.TemperatureUnit, entries = mapOf( "" to stringResource(id = R.string.system_default), TemperatureUnit.CELSIUS.name to stringResource(id = R.string.celsius), TemperatureUnit.FAHRENHEIT.name to stringResource(id = R.string.fahrenheit), ), icon = { Icon( imageVector = Icons.Rounded.Thermostat, contentDescription = stringResource(id = R.string.temperature_unit) ) } ), Preference.PreferenceItem.ListPreference( title = stringResource(id = R.string.distance_unit), request = SettingsCommon.DistanceUnitType, entries = mapOf( "" to stringResource(id = R.string.system_default), DistanceUnitType.METRIC.name to stringResource(id = R.string.metric), DistanceUnitType.IMPERIAL.name to stringResource(id = R.string.imperial), ), icon = { Icon( imageVector = Icons.Rounded.Place, contentDescription = stringResource(id = R.string.distance_unit) ) } ), ) ), Preference.PreferenceGroup( title = stringResource(id = R.string.maze), preferenceItems = listOf( Preference.PreferenceItem.SwitchPreference( title = stringResource(id = R.string.maze_settings_auto_scroll_title), summary = stringResource(id = R.string.maze_settings_auto_scroll_summary), request = SettingsCommon.MazeAutoScrollToCurrentItem, ) ) ), Preference.PreferenceItem.TextPreference( title = stringResource(id = R.string.animations), icon = { Icon( imageVector = Icons.Rounded.Animation, contentDescription = stringResource(id = R.string.animations) ) }, onClick = { navigateToScreen(ScreenKey.ANIMATIONS) } ), Preference.PreferenceItem.TextPreference( title = stringResource(id = R.string.analytics), icon = { Icon( imageVector = Icons.Rounded.Analytics, contentDescription = stringResource(id = R.string.analytics) ) }, onClick = { navigateToScreen(ScreenKey.ANALYTICS) }, visible = BuildVariant.DISTRIBUTION_FLAVOR == "normal" ) ) PreferenceScreen( modifier = modifier, title = "General", items = items, dataStoreManager = dataStoreManager, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick ) } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class) private fun GeneralScreenPreview() { NewQuizTheme { Surface { GeneralScreen( uiState = GeneralScreenUiState(), isScreenExpanded = false, onBackClick = {}, navigateToScreen = {}, onEvent = {} ) } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/screens/general/GeneralScreenUiEvent.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens.general interface GeneralScreenUiEvent { data object ClearHomeRecentCategories : GeneralScreenUiEvent } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/screens/general/GeneralScreenUiState.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens.general import androidx.annotation.Keep import com.infinitepower.newquiz.model.category.ShowCategoryConnectionInfo @Keep data class GeneralScreenUiState( val defaultShowCategoryConnectionInfo: ShowCategoryConnectionInfo = ShowCategoryConnectionInfo.NONE ) ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/screens/general/GeneralScreenViewModel.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens.general import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.infinitepower.newquiz.domain.repository.home.RecentCategoriesRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class GeneralScreenViewModel @Inject constructor( private val recentCategoriesRepository: RecentCategoriesRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(GeneralScreenUiState()) val uiState = _uiState.asStateFlow() init { _uiState.update { currentState -> val defaultShowCategoryConnectionInfo = recentCategoriesRepository.getDefaultShowCategoryConnectionInfo() currentState.copy( defaultShowCategoryConnectionInfo = defaultShowCategoryConnectionInfo, ) } } fun onEvent(event: GeneralScreenUiEvent){ when(event) { is GeneralScreenUiEvent.ClearHomeRecentCategories -> { viewModelScope.launch { recentCategoriesRepository.cleanAllSavedCategories() } } } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/screens/main/MainScreen.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens.main import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Help import androidx.compose.material.icons.automirrored.rounded.ListAlt import androidx.compose.material.icons.rounded.Password import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Translate import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable 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.compose.ui.unit.dp import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.ui.components.icon.button.BackIconButton import com.infinitepower.newquiz.feature.settings.common.BuildVariant import com.infinitepower.newquiz.feature.settings.model.Preference import com.infinitepower.newquiz.feature.settings.model.ScreenKey import com.infinitepower.newquiz.feature.settings.screens.PreferenceScreen import com.infinitepower.newquiz.feature.settings.util.datastore.rememberSettingsDataStoreManager import kotlinx.collections.immutable.persistentListOf import com.infinitepower.newquiz.core.R as CoreR @Composable @ExperimentalMaterial3Api internal fun MainScreen( modifier: Modifier = Modifier, onBackClick: () -> Unit, onScreenSelect: (key: ScreenKey) -> Unit, currentScreen: ScreenKey? ) { val dataStoreManager = rememberSettingsDataStoreManager() val items = persistentListOf( // General Preference.PreferenceItem.NavigationButton( title = stringResource(id = CoreR.string.general), summary = stringResource(id = CoreR.string.general_settings_summary), singleLineSummary = true, icon = { Icon( imageVector = Icons.Rounded.Settings, contentDescription = null ) }, screenKey = ScreenKey.GENERAL, itemSelected = currentScreen == ScreenKey.GENERAL, onClick = { onScreenSelect(ScreenKey.GENERAL) }, ), // Multi Choice Quiz Preference.PreferenceItem.NavigationButton( title = stringResource(id = CoreR.string.multi_choice_quiz), summary = stringResource(id = CoreR.string.multi_choice_settings_summary), singleLineSummary = true, icon = { Icon( imageVector = Icons.AutoMirrored.Rounded.ListAlt, contentDescription = null ) }, screenKey = ScreenKey.MULTI_CHOICE_QUIZ, itemSelected = currentScreen == ScreenKey.MULTI_CHOICE_QUIZ, onClick = { onScreenSelect(ScreenKey.MULTI_CHOICE_QUIZ) }, ), // Wordle Preference.PreferenceItem.NavigationButton( title = stringResource(id = CoreR.string.wordle), summary = stringResource(id = CoreR.string.wordle_settings_summary), singleLineSummary = true, icon = { Icon( imageVector = Icons.Rounded.Password, contentDescription = null ) }, screenKey = ScreenKey.WORDLE, itemSelected = currentScreen == ScreenKey.WORDLE, onClick = { onScreenSelect(ScreenKey.WORDLE) }, ), // Translation Preference.PreferenceItem.NavigationButton( title = stringResource(id = CoreR.string.translation), summary = stringResource(id = CoreR.string.translation_settings_summary), singleLineSummary = true, icon = { Icon( imageVector = Icons.Rounded.Translate, contentDescription = null ) }, screenKey = ScreenKey.TRANSLATION, itemSelected = currentScreen == ScreenKey.TRANSLATION, onClick = { onScreenSelect(ScreenKey.TRANSLATION) }, visible = BuildVariant.DISTRIBUTION_FLAVOR == "normal" ), // About & Help Preference.PreferenceItem.NavigationButton( title = stringResource(id = CoreR.string.about_and_help), summary = stringResource(id = CoreR.string.about_and_help_settings_summary), singleLineSummary = true, icon = { Icon( imageVector = Icons.AutoMirrored.Rounded.Help, contentDescription = null ) }, screenKey = ScreenKey.ABOUT_AND_HELP, itemSelected = currentScreen == ScreenKey.ABOUT_AND_HELP, onClick = { onScreenSelect(ScreenKey.ABOUT_AND_HELP) }, ), ) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() Scaffold( topBar = { LargeTopAppBar( title = { Text(text = stringResource(id = CoreR.string.settings)) }, scrollBehavior = scrollBehavior, navigationIcon = { BackIconButton(onClick = onBackClick) } ) }, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) ) { innerPadding -> PreferenceScreen( modifier = Modifier.padding(innerPadding), items = items, dataStoreManager = dataStoreManager ) } } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class) private fun MainScreenPreview() { NewQuizTheme { Surface { MainScreen( modifier = Modifier.padding(16.dp), currentScreen = ScreenKey.GENERAL, onScreenSelect = {}, onBackClick = {} ) } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/screens/multi_choice_quiz/MultiChoiceQuizScreen.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens.multi_choice_quiz import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.infinitepower.newquiz.core.datastore.common.SettingsCommon import com.infinitepower.newquiz.feature.settings.model.Preference import com.infinitepower.newquiz.feature.settings.screens.PreferenceScreen import com.infinitepower.newquiz.feature.settings.util.datastore.rememberSettingsDataStoreManager import kotlinx.collections.immutable.persistentListOf import com.infinitepower.newquiz.core.R as CoreR @Composable @ExperimentalMaterial3Api internal fun MultiChoiceQuizScreen( modifier: Modifier = Modifier, onBackClick: () -> Unit, isScreenExpanded: Boolean, ) { val dataStoreManager = rememberSettingsDataStoreManager() val items = persistentListOf( Preference.PreferenceItem.SeekBarPreference( request = SettingsCommon.MultiChoiceQuizQuestionsSize, title = stringResource(id = CoreR.string.quiz_question_size), singleLineTitle = true, valueRange = (5..20) ) ) PreferenceScreen( modifier = modifier, title = stringResource(id = CoreR.string.multi_choice_quiz), items = items, dataStoreManager = dataStoreManager, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick ) } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/screens/wordle/WordleScreen.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens.wordle import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Language import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.analytics.LocalAnalyticsHelper import com.infinitepower.newquiz.core.analytics.UserProperty import com.infinitepower.newquiz.core.datastore.common.SettingsCommon import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.feature.settings.model.Preference import com.infinitepower.newquiz.feature.settings.screens.PreferenceScreen import com.infinitepower.newquiz.feature.settings.util.datastore.rememberSettingsDataStoreManager import kotlinx.collections.immutable.persistentListOf import com.infinitepower.newquiz.core.R as CoreR @Composable @ExperimentalMaterial3Api internal fun WordleScreen( modifier: Modifier = Modifier, onBackClick: () -> Unit, isScreenExpanded: Boolean, ) { val dataStoreManager = rememberSettingsDataStoreManager() val analyticsHelper = LocalAnalyticsHelper.current val items = persistentListOf( Preference.PreferenceItem.SwitchPreference( request = SettingsCommon.WordleHardMode, title = stringResource(id = CoreR.string.hard_mode), summary = stringResource(id = CoreR.string.any_revealed_hints_must_be_used_in_subsequest_guesses) ), Preference.PreferenceItem.SwitchPreference( request = SettingsCommon.WordleColorBlindMode, title = stringResource(id = CoreR.string.color_blind_mode), summary = stringResource(id = CoreR.string.high_contrast_colors) ), Preference.PreferenceItem.SwitchPreference( request = SettingsCommon.WordleLetterHints, title = stringResource(id = CoreR.string.letter_hints), summary = stringResource( id = CoreR.string.hint_above_the_letter_that_it_appears_twice_or_more_in_the_hidden_word ) ), Preference.PreferenceGroup( title = stringResource(id = CoreR.string.wordle_infinite), preferenceItems = listOf( Preference.PreferenceItem.ListPreference( request = SettingsCommon.InfiniteWordleQuizLanguage, title = stringResource(id = CoreR.string.quiz_language), icon = { Icon( imageVector = Icons.Rounded.Language, contentDescription = stringResource(id = CoreR.string.quiz_language), ) }, entries = mapOf( "en" to stringResource(id = CoreR.string.english), "pt" to stringResource(id = CoreR.string.portuguese), "es" to stringResource(id = CoreR.string.spanish), "fr" to stringResource(id = CoreR.string.french) ), onItemClick = { lang -> analyticsHelper.setUserProperty(UserProperty.WordleLanguage(lang)) } ), Preference.PreferenceItem.SwitchPreference( request = SettingsCommon.WordleInfiniteRowsLimited, title = stringResource(id = CoreR.string.rows_limited), summary = stringResource(id = CoreR.string.wordle_infinite_row_limited) ), Preference.PreferenceItem.SeekBarPreference( request = SettingsCommon.WordleInfiniteRowsLimit, title = stringResource(id = CoreR.string.rows_limited), summary = stringResource(id = CoreR.string.wordle_infinite_row_limit_value), valueRange = 2..30, dependency = persistentListOf(SettingsCommon.WordleInfiniteRowsLimited) ) ) ) ) PreferenceScreen( modifier = modifier, title = stringResource(id = CoreR.string.wordle), items = items, dataStoreManager = dataStoreManager, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick ) } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class) private fun WordleScreenPreview() { NewQuizTheme { Surface { WordleScreen( modifier = Modifier.padding(16.dp), isScreenExpanded = true, onBackClick = {} ) } } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/util/ShowCategoryConnectionInfoUtils.kt ================================================ package com.infinitepower.newquiz.feature.settings.util import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import com.infinitepower.newquiz.core.util.asString import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.category.ShowCategoryConnectionInfo import com.infinitepower.newquiz.core.R as CoreR @Composable @ReadOnlyComposable internal fun ShowCategoryConnectionInfo.getTitle(): UiText { return when (this) { ShowCategoryConnectionInfo.NONE -> UiText.StringResource(CoreR.string.none) ShowCategoryConnectionInfo.BOTH -> UiText.StringResource(CoreR.string.both) ShowCategoryConnectionInfo.REQUIRE_CONNECTION -> UiText.StringResource(CoreR.string.require_internet_connection) ShowCategoryConnectionInfo.DONT_REQUIRE_CONNECTION -> { UiText.StringResource(CoreR.string.dont_require_internet_connection) } } } @Composable @ReadOnlyComposable internal fun getShowCategoryConnectionInfoEntryMap(): Map { return ShowCategoryConnectionInfo.entries.associate { info -> info.name to info.getTitle().asString() } } ================================================ FILE: feature/settings/src/main/kotlin/com/infinitepower/newquiz/feature/settings/util/datastore/DatastoreUtils.kt ================================================ package com.infinitepower.newquiz.feature.settings.util.datastore import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import com.infinitepower.newquiz.core.datastore.common.settingsDataStore import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import com.infinitepower.newquiz.core.datastore.manager.PreferencesDatastoreManager @Composable fun rememberSettingsDataStoreManager(): DataStoreManager { val context = LocalContext.current val dataStore = context.settingsDataStore return remember { PreferencesDatastoreManager(dataStore) } } ================================================ FILE: feature/settings/src/normal/kotlin/com/infinitepower/newquiz/feature/settings/common/BuildVariant.kt ================================================ package com.infinitepower.newquiz.feature.settings.common object BuildVariant { internal const val DISTRIBUTION_FLAVOR = "normal" } ================================================ FILE: feature/settings/src/normal/kotlin/com/infinitepower/newquiz/feature/settings/screens/PreferencesScreen.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.infinitepower.newquiz.feature.settings.model.ScreenKey import com.infinitepower.newquiz.feature.settings.screens.about_and_help.AboutAndHelpScreen import com.infinitepower.newquiz.feature.settings.screens.analytics.AnalyticsScreen import com.infinitepower.newquiz.feature.settings.screens.animations.AnimationsScreen import com.infinitepower.newquiz.feature.settings.screens.general.GeneralScreen import com.infinitepower.newquiz.feature.settings.screens.multi_choice_quiz.MultiChoiceQuizScreen import com.infinitepower.newquiz.feature.settings.screens.translation.TranslationScreen import com.infinitepower.newquiz.feature.settings.screens.wordle.WordleScreen @Composable @ExperimentalMaterial3Api internal fun PreferencesScreen( modifier: Modifier = Modifier, currentScreen: ScreenKey?, isScreenExpanded: Boolean, onBackClick: () -> Unit, navigateToScreen: (key: ScreenKey) -> Unit, ) { when (currentScreen) { null -> {} ScreenKey.GENERAL -> GeneralScreen( modifier = modifier, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick, navigateToScreen = navigateToScreen ) ScreenKey.MULTI_CHOICE_QUIZ -> MultiChoiceQuizScreen( modifier = modifier, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick, ) ScreenKey.WORDLE -> WordleScreen( modifier = modifier, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick, ) ScreenKey.ABOUT_AND_HELP -> AboutAndHelpScreen( modifier = modifier, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick, ) ScreenKey.ANIMATIONS -> AnimationsScreen( modifier = modifier, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick, ) ScreenKey.ANALYTICS -> AnalyticsScreen( modifier = modifier, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick, ) ScreenKey.TRANSLATION -> TranslationScreen( modifier = modifier, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick, ) } } ================================================ FILE: feature/settings/src/normal/kotlin/com/infinitepower/newquiz/feature/settings/screens/analytics/AnimationsScreen.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens.analytics import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Analytics import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.MonitorHeart import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.analytics.AnalyticsHelper import com.infinitepower.newquiz.core.analytics.LocalAnalyticsHelper import com.infinitepower.newquiz.core.datastore.common.DataAnalyticsCommon 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 com.infinitepower.newquiz.core.R as CoreR import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.feature.settings.model.Preference import com.infinitepower.newquiz.feature.settings.screens.PreferenceScreen import com.infinitepower.newquiz.model.DataAnalyticsConsentState import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch @Composable fun rememberDataAnalyticsDataStoreManager(): DataStoreManager { val context = LocalContext.current val dataStore = context.dataAnalyticsDataStore return remember { PreferencesDatastoreManager(dataStore) } } @Composable @ExperimentalMaterial3Api internal fun AnalyticsScreen( modifier: Modifier = Modifier, isScreenExpanded: Boolean, onBackClick: () -> Unit, ) { val dataStoreManager = rememberDataAnalyticsDataStoreManager() val scope = rememberCoroutineScope() val analyticsHelper = LocalAnalyticsHelper.current val items = persistentListOf( // Global analytics dependency Preference.PreferenceItem.SwitchPreference( request = DataAnalyticsCommon.GloballyAnalyticsCollectionEnabled, title = stringResource(id = CoreR.string.analytics_collection_enabled), onCheckChange = { enabled -> scope.launch { enableLoggingAnalytics( enabled = enabled, dataAnalyticsDataStoreManager = dataStoreManager, analyticsHelper = analyticsHelper ) } }, primarySwitch = true ), Preference.PreferenceItem.SwitchPreference( request = DataAnalyticsCommon.GeneralAnalyticsEnabled, title = stringResource(id = CoreR.string.general_analytics_enabled), summary = stringResource(id = CoreR.string.general_analytics_description_enabled), dependency = persistentListOf(DataAnalyticsCommon.GloballyAnalyticsCollectionEnabled), icon = { Icon( imageVector = Icons.Rounded.Analytics, contentDescription = stringResource(id = CoreR.string.general_analytics_enabled) ) }, onCheckChange = analyticsHelper::setGeneralAnalyticsEnabled ), Preference.PreferenceItem.SwitchPreference( request = DataAnalyticsCommon.CrashlyticsEnabled, title = stringResource(id = CoreR.string.crash_analytics_enabled), summary = stringResource(id = CoreR.string.crash_analytics_description_enabled), dependency = persistentListOf(DataAnalyticsCommon.GloballyAnalyticsCollectionEnabled), icon = { Icon( imageVector = Icons.Rounded.BugReport, contentDescription = stringResource(id = CoreR.string.crash_analytics_enabled) ) }, onCheckChange = analyticsHelper::setCrashlyticsEnabled ), Preference.PreferenceItem.SwitchPreference( request = DataAnalyticsCommon.PerformanceMonitoringEnabled, title = stringResource(id = CoreR.string.performance_monitoring_enabled), summary = stringResource(id = CoreR.string.performance_monitoring_description_enabled), dependency = persistentListOf(DataAnalyticsCommon.GloballyAnalyticsCollectionEnabled), icon = { Icon( imageVector = Icons.Rounded.MonitorHeart, contentDescription = stringResource(id = CoreR.string.performance_monitoring_enabled) ) }, onCheckChange = analyticsHelper::setPerformanceEnabled ) ) PreferenceScreen( modifier = modifier, title = stringResource(id = CoreR.string.analytics), items = items, dataStoreManager = dataStoreManager, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick ) } private suspend fun enableLoggingAnalytics( enabled: Boolean, dataAnalyticsDataStoreManager: DataStoreManager, analyticsHelper: AnalyticsHelper ) { val consentState = if (enabled) { DataAnalyticsConsentState.AGREED } else { DataAnalyticsConsentState.DISAGREED } dataAnalyticsDataStoreManager.editPreference( key = DataAnalyticsCommon.DataAnalyticsConsent.key, newValue = consentState.name ) // Enable general analytics val generalEnabled = dataAnalyticsDataStoreManager.getPreference(DataAnalyticsCommon.GeneralAnalyticsEnabled) analyticsHelper.setGeneralAnalyticsEnabled(generalEnabled && enabled) // Enable crashlytics val crashlyticsEnabled = dataAnalyticsDataStoreManager.getPreference(DataAnalyticsCommon.CrashlyticsEnabled) analyticsHelper.setCrashlyticsEnabled(crashlyticsEnabled && enabled) // Enable performance monitoring val performanceMonitoringEnabled = dataAnalyticsDataStoreManager.getPreference( DataAnalyticsCommon.PerformanceMonitoringEnabled ) analyticsHelper.setPerformanceEnabled(performanceMonitoringEnabled && enabled) } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class) private fun AnalyticsScreenPreview() { NewQuizTheme { Surface { AnalyticsScreen( modifier = Modifier.padding(16.dp), isScreenExpanded = true, onBackClick = {} ) } } } ================================================ FILE: feature/settings/src/normal/kotlin/com/infinitepower/newquiz/feature/settings/screens/translation/TranslationScreen.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens.translation import android.os.Build import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.infinitepower.newquiz.core.datastore.common.TranslationCommon import com.infinitepower.newquiz.core.R as CoreR import com.infinitepower.newquiz.core.translation.TranslatorModelState import com.infinitepower.newquiz.feature.settings.model.Preference import com.infinitepower.newquiz.feature.settings.screens.PreferenceScreen import com.infinitepower.newquiz.feature.settings.util.datastore.rememberSettingsDataStoreManager import kotlinx.collections.immutable.persistentListOf @Composable @ExperimentalMaterial3Api internal fun TranslationScreen( modifier: Modifier = Modifier, isScreenExpanded: Boolean, onBackClick: () -> Unit, viewModel: TranslationScreenViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() TranslationScreen( modifier = modifier, uiState = uiState, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick, onEvent = viewModel::onEvent ) } @Composable @ExperimentalMaterial3Api internal fun TranslationScreen( modifier: Modifier = Modifier, uiState: TranslationScreenUiState, isScreenExpanded: Boolean, onBackClick: () -> Unit, onEvent: (event: TranslationScreenUiEvent) -> Unit ) { val dataStoreManager = rememberSettingsDataStoreManager() val items = persistentListOf( Preference.PreferenceItem.SwitchPreference( request = TranslationCommon.Enabled, title = stringResource(id = CoreR.string.translation_enabled), primarySwitch = true ), Preference.PreferenceItem.ListPreference( request = TranslationCommon.TargetLanguage, title = stringResource(id = CoreR.string.target_language), summary = stringResource(id = CoreR.string.target_language_description), entries = uiState.translatorTargetLanguages, enabled = uiState.translationModelState == TranslatorModelState.None, dependency = persistentListOf(TranslationCommon.Enabled) ), Preference.PreferenceItem.TextPreference( title = stringResource(id = CoreR.string.download_translation_model), summary = stringResource(id = CoreR.string.download_translation_model_description), dependency = persistentListOf(TranslationCommon.Enabled), visible = uiState.translationModelState == TranslatorModelState.None, enabled = uiState.translatorTargetLanguage.isNotBlank(), onClick = { onEvent(TranslationScreenUiEvent.DownloadTranslationModel) } ), Preference.PreferenceItem.TextPreference( title = stringResource(id = CoreR.string.delete_translation_model), dependency = persistentListOf(TranslationCommon.Enabled), visible = uiState.translationModelState == TranslatorModelState.Downloaded, onClick = { onEvent(TranslationScreenUiEvent.DeleteTranslationModel) } ), Preference.PreferenceGroup( title = stringResource(id = CoreR.string.download_settings), preferenceItems = listOf( Preference.PreferenceItem.SwitchPreference( request = TranslationCommon.RequireWifi, title = stringResource(id = CoreR.string.require_wifi), summary = stringResource(id = CoreR.string.translation_require_wifi_description), dependency = persistentListOf(TranslationCommon.Enabled), ), Preference.PreferenceItem.SwitchPreference( request = TranslationCommon.RequireCharging, title = stringResource(id = CoreR.string.require_charging), summary = stringResource(id = CoreR.string.translation_require_charging_description), dependency = persistentListOf(TranslationCommon.Enabled), visible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N ), ) ) ) PreferenceScreen( modifier = modifier, title = stringResource(id = CoreR.string.translation), items = items, dataStoreManager = dataStoreManager, isScreenExpanded = isScreenExpanded, onBackClick = onBackClick ) } ================================================ FILE: feature/settings/src/normal/kotlin/com/infinitepower/newquiz/feature/settings/screens/translation/TranslationScreenUiEvent.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens.translation interface TranslationScreenUiEvent { data object DownloadTranslationModel : TranslationScreenUiEvent data object DeleteTranslationModel : TranslationScreenUiEvent } ================================================ FILE: feature/settings/src/normal/kotlin/com/infinitepower/newquiz/feature/settings/screens/translation/TranslationScreenUiState.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens.translation import androidx.annotation.Keep import com.infinitepower.newquiz.core.translation.TranslatorModelState import com.infinitepower.newquiz.core.translation.TranslatorTargetLanguages @Keep data class TranslationScreenUiState( val translationModelState: TranslatorModelState = TranslatorModelState.None, val translatorTargetLanguages: TranslatorTargetLanguages = emptyMap(), val translatorTargetLanguage: String = "", ) ================================================ FILE: feature/settings/src/normal/kotlin/com/infinitepower/newquiz/feature/settings/screens/translation/TranslationScreenViewModel.kt ================================================ package com.infinitepower.newquiz.feature.settings.screens.translation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.infinitepower.newquiz.core.datastore.common.TranslationCommon import com.infinitepower.newquiz.core.datastore.di.SettingsDataStoreManager import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import com.infinitepower.newquiz.core.translation.TranslatorModelState import com.infinitepower.newquiz.core.translation.TranslatorUtil import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class TranslationScreenViewModel @Inject constructor( private val translatorUtil: TranslatorUtil, @SettingsDataStoreManager private val settingsDataStoreManager: DataStoreManager, ) : ViewModel() { private val _uiState = MutableStateFlow(TranslationScreenUiState()) val uiState = combine( _uiState, settingsDataStoreManager.getPreferenceFlow(TranslationCommon.TargetLanguage) ) { uiState, targetLanguage -> uiState.copy( translatorTargetLanguage = targetLanguage ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(), initialValue = TranslationScreenUiState() ) init { viewModelScope.launch { _uiState.update { currentState -> val translationModelState = if (translatorUtil.isModelDownloaded()) { TranslatorModelState.Downloaded } else { TranslatorModelState.None } currentState.copy( translationModelState = translationModelState, translatorTargetLanguages = translatorUtil.availableTargetLanguages, ) } } } fun onEvent(event: TranslationScreenUiEvent) { when (event) { is TranslationScreenUiEvent.DownloadTranslationModel -> downloadTranslationModel() is TranslationScreenUiEvent.DeleteTranslationModel -> { viewModelScope.launch { translatorUtil.deleteModel() } } } } private fun downloadTranslationModel() = viewModelScope.launch { val targetLanguage = settingsDataStoreManager.getPreference(TranslationCommon.TargetLanguage) // Check if the target language is picked by the user if (targetLanguage.isEmpty()) { return@launch } val requireWifi = settingsDataStoreManager.getPreference(TranslationCommon.RequireWifi) val requireCharging = settingsDataStoreManager.getPreference(TranslationCommon.RequireCharging) translatorUtil.downloadModel( targetLanguage = targetLanguage, requireWifi = requireWifi, requireCharging = requireCharging ).onEach { downloadState -> _uiState.update { currentState -> currentState.copy( translationModelState = downloadState ) } }.catch { exception -> exception.printStackTrace() _uiState.update { currentState -> currentState.copy( translationModelState = TranslatorModelState.None ) } }.launchIn(viewModelScope) } } ================================================ FILE: gradle/libs.versions.toml ================================================ [versions] androidDesugarJdkLibs = "2.1.3" androidGradlePlugin = "8.7.3" androidxAnnotation = "1.9.1" androidxActivity = "1.9.3" androidxAppCompat = "1.7.0" androidxCoreKtx = "1.15.0" androidxCoreSplashscreen = "1.0.1" androidxComposeCompiler = "1.5.14" androidxComposeBom = "2024.11.00" androidxDataStore = "1.1.1" androidxStartup = "1.2.0" androidxLifecycle = "2.8.0" androidxWork = "2.10.0" androidxTestRules = "1.6.1" androidxTestRunner = "1.6.1" androidxTracing = "1.2.0" coil = "2.7.0" composeDestinations = "1.11.7" constraintlayoutCompose = "1.1.0" firebaseCrashlyticsPlugin = "3.0.2" firebasePerfPlugin = "1.4.2" gmsPlugin = "4.4.2" googleMaterial = "1.12.0" googleTruth = "1.4.4" googleOss = "17.1.0" googleOssPlugin = "0.10.6" graphicsShapes = "1.0.1" hilt = "2.52" hiltExt = "1.2.0" kotlin = "2.0.21" kotlinxCollectionsImmutable = "0.3.8" kotlinxCoroutines = "1.9.0" kotlinxDatetime = "0.6.1" kotlinxSerializationJson = "1.7.3" ksp = "2.0.21-1.0.28" ktor = "2.3.12" lottie = "6.6.0" firebaseBom = "33.6.0" slf4j = "2.0.16" junitJupiter = "5.11.3" mockk = "1.13.13" room = "2.6.1" javaxInject = "1" firebaseUi = "8.0.2" vico = "1.15.0" mlKitTranslate = "17.0.3" turbine = "1.2.0" detekt = "1.23.7" detektCompose = "0.4.19" junit = "4.13.2" junitVersion = "1.1.5" espressoCore = "3.5.1" [libraries] android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "androidxAnnotation" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" } androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayoutCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" } androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-ui-testManifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCoreKtx" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidxCoreSplashscreen" } androidx-dataStore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDataStore" } androidx-graphics-shapes = { module = "androidx.graphics:graphics-shapes", version.ref = "graphicsShapes" } androidx-startup-runtime = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidxStartup" } androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx" } androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose" } androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose" } androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" } androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "androidxWork" } androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } androidx-tracing-ktx = { group = "androidx.tracing", name = "tracing-ktx", version.ref = "androidxTracing" } coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } compose-destinations-animations-core = { group = "io.github.raamcosta.compose-destinations", name = "animations-core", version.ref = "composeDestinations" } compose-destinations-core = { group = "io.github.raamcosta.compose-destinations", name = "core", version.ref = "composeDestinations" } compose-destinations-ksp = { group = "io.github.raamcosta.compose-destinations", name = "ksp", version.ref = "composeDestinations" } google-material = { group = "com.google.android.material", name = "material", version.ref = "googleMaterial" } google-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "googleOss" } google-oss-licenses-plugin = { group = "com.google.android.gms", name = "oss-licenses-plugin", version.ref = "googleOssPlugin" } google-truth = { group = "com.google.truth", name = "truth", version.ref = "googleTruth" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltExt" } hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" } hilt-navigationCompose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltExt" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-playServices = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } ktor-client-serialization = { group = "io.ktor", name = "ktor-client-serialization", version.ref = "ktor" } ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } firebase-remoteConfig = { group = "com.google.firebase", name = "firebase-config" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } firebase-perf = { group = "com.google.firebase", name = "firebase-perf" } firebase-auth = { group = "com.google.firebase", name = "firebase-auth" } firebase-firestore = { group = "com.google.firebase", name = "firebase-firestore" } slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junitJupiter" } junit-jupiter-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junitJupiter" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } javax-inject = { group = "javax.inject", name = "javax.inject", version.ref = "javaxInject" } firebaseUi-auth = { group = "com.firebaseui", name = "firebase-ui-auth", version.ref = "firebaseUi" } vico-compose = { group = "com.patrykandpatryk.vico", name = "compose", version.ref = "vico" } vico-compose-m3 = { group = "com.patrykandpatryk.vico", name = "compose-m3", version.ref = "vico" } google-mlKit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "mlKitTranslate" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } detekt-compose = { group = "io.nlopez.compose.rules", name = "detekt", version.ref = "detektCompose" } # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } firebase-crashlytics-gradlePlugin = { group = "com.google.firebase", name = "firebase-crashlytics-gradle", version.ref = "firebaseCrashlyticsPlugin" } firebase-performance-gradlePlugin = { group = "com.google.firebase", name = "perf-plugin", version.ref = "firebasePerfPlugin" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } detekt-gradlePlugin = { group = "io.gitlab.arturbosch.detekt", name = "detekt-gradle-plugin", version.ref = "detekt" } compose-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" } firebase-perf = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerfPlugin" } gms = { id = "com.google.gms.google-services", version.ref = "gmsPlugin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } newquiz-android-application = { id = "newquiz.android.application" } newquiz-android-application-compose = { id = "newquiz.android.application.compose" } newquiz-android-feature = { id = "newquiz.android.feature" } newquiz-android-hilt = { id = "newquiz.android.hilt" } newquiz-android-compose-destinations = { id = "newquiz.android.compose.destinations" } newquiz-android-library = { id = "newquiz.android.library" } newquiz-android-library-compose = { id = "newquiz.android.library.compose" } newquiz-android-room = { id = "newquiz.android.room" } newquiz-kotlin-serialization = { id = "newquiz.kotlin.serialization" } newquiz-jvm-library = { id = "newquiz.jvm.library" } newquiz-detekt = { id = "newquiz.detekt" } #jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android" } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ #Mon Jun 03 19:47:53 WEST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xms1024m -Xmx4096m -Dfile.encoding=UTF-8 -XX:+UseParallelGC # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=false # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official # Enable Gradle Daemon org.gradle.daemon=true # Enable R8 full mode android.enableR8.fullMode = true # Turn on parallel compilation, caching and on-demand configuration org.gradle.configureondemand=true org.gradle.caching=true org.gradle.parallel=true kotlin.incremental=true org.gradle.unsafe.configuration-cache=true android.nonTransitiveRClass=true android.nonFinalResIds=false ================================================ FILE: gradlew ================================================ #!/usr/bin/env sh # # Copyright 2015 the original author or authors. # # 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 # # https://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. # ############################################################################## ## ## Gradle start up script for UN*X ## ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" # Need this for relative symlinks. while [ -h "$PRG" ] ; do ls=`ls -ld "$PRG"` link=`expr "$ls" : '.*-> \(.*\)$'` if expr "$link" : '/.*' > /dev/null; then PRG="$link" else PRG=`dirname "$PRG"`"/$link" fi done SAVED="`pwd`" cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" warn () { echo "$*" } die () { echo echo "$*" echo exit 1 } # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "`uname`" in CYGWIN* ) cygwin=true ;; Darwin* ) darwin=true ;; MINGW* ) msys=true ;; NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD="java" which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD="$MAX_FD_LIMIT" fi ulimit -n $MAX_FD if [ $? -ne 0 ] ; then warn "Could not set maximum file descriptor limit: $MAX_FD" fi else warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" fi fi # For Darwin, add options to specify how the application appears in the dock if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi # For Cygwin or MSYS, switch paths to Windows format before running java if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" for dir in $ROOTDIRSRAW ; do ROOTDIRS="$ROOTDIRS$SEP$dir" SEP="|" done OURCYGPATTERN="(^($ROOTDIRS))" # Add a user-defined pattern to the cygpath arguments if [ "$GRADLE_CYGPATTERN" != "" ] ; then OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" fi # Now convert the arguments - kludge to limit ourselves to /bin/sh i=0 for arg in "$@" ; do CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` else eval `echo args$i`="\"$arg\"" fi i=`expr $i + 1` done case $i in 0) set -- ;; 1) set -- "$args0" ;; 2) set -- "$args0" "$args1" ;; 3) set -- "$args0" "$args1" "$args2" ;; 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } APP_ARGS=`save "$@"` # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: lint.xml ================================================ ================================================ FILE: model/.gitignore ================================================ /build ================================================ FILE: model/build.gradle.kts ================================================ plugins { alias(libs.plugins.newquiz.jvm.library) alias(libs.plugins.newquiz.kotlin.serialization) alias(libs.plugins.newquiz.detekt) } dependencies { testImplementation(libs.google.truth) testImplementation(libs.junit.jupiter) testImplementation(libs.mockk) implementation(libs.androidx.annotation) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) api(libs.kotlinx.collections.immutable) } tasks.withType { useJUnitPlatform() } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/BaseCategory.kt ================================================ package com.infinitepower.newquiz.model interface BaseCategory : GameModeCategory { val id: String val name: UiText val image: Any val requireInternetConnection: Boolean } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/DataAnalyticsConsentState.kt ================================================ package com.infinitepower.newquiz.model enum class DataAnalyticsConsentState { NONE, AGREED, DISAGREED } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/GameMode.kt ================================================ package com.infinitepower.newquiz.model /** * This enum class is used to determine which game mode the user is playing. */ enum class GameMode { /** * Multiple choice game mode. */ MULTI_CHOICE, /** * Wordle game mode. */ WORDLE, /** * Comparison game mode. */ COMPARISON_QUIZ } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/GameModeCategory.kt ================================================ package com.infinitepower.newquiz.model interface GameModeCategory { val gameMode: GameMode } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/Language.kt ================================================ package com.infinitepower.newquiz.model @JvmInline value class Language(val value: String) { companion object { val ENGLISH = Language("en") } init { require(value.isNotEmpty()) { "Language value must not be empty" } } } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/NumberFormatType.kt ================================================ package com.infinitepower.newquiz.model /** * This enum class is used to define the number format type. */ enum class NumberFormatType { /** * Default number format type, no conversion is done. */ DEFAULT, /** * Converts the number in milliseconds to a date. */ DATE, /** * Converts the number in milliseconds to a time. */ TIME, /** * Converts the number in milliseconds to a date and time. */ DATETIME, /** * Converts the number to a percentage. */ PERCENTAGE, /** * Converts the number to a temperature. * The number is converted to a temperature unit based on the locale * or the settings configuration. */ TEMPERATURE, /** * Converts the number to a distance. * The number is converted to a distance unit based on the locale * or the settings configuration. */ DISTANCE, } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/RemainingTime.kt ================================================ package com.infinitepower.newquiz.model import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @JvmInline value class RemainingTime constructor(val value: Duration) { companion object { val ZERO = RemainingTime(Duration.ZERO) /** * Creates a new instance of [RemainingTime] from the given milliseconds value. */ fun fromMilliseconds(millis: Long): RemainingTime { require(millis >= 0L) { "RemainingTime value must be greater than or equal to 0" } return RemainingTime(millis.milliseconds) } private const val SECONDS_IN_MINUTE = 60 } init { require(value >= Duration.ZERO) { "RemainingTime value must be greater than 0" } } fun isZero(): Boolean = value == Duration.ZERO /** * @return the remaining percentage of the time. * @param maxTime in milliseconds */ fun getRemainingPercent(maxTime: Duration): Double = value / maxTime /** * @return the remaining time in minute:second format. */ fun toMinuteSecondFormatted(): String { val minutes = value.inWholeMinutes val seconds = value.inWholeSeconds % SECONDS_IN_MINUTE return if (minutes == 0L) seconds.toString() else "$minutes:$seconds" } /** * Returns the elapsed seconds from the max time. * @param maxTime in milliseconds */ fun getElapsedSeconds(maxTime: Duration): Long = maxTime.inWholeSeconds - value.inWholeSeconds } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/Resource.kt ================================================ package com.infinitepower.newquiz.model import kotlinx.coroutines.flow.Flow sealed class Resource( val data: T? = null, val message: String? = null ) { class Success(data: T) : Resource(data) class Error(message: String, data: T? = null) : Resource(data, message) class Loading(data: T? = null) : Resource(data) override fun toString(): String { return when (this) { is Success -> "Success with data: $data" is Error -> "Error: $message, with data: $data" is Loading -> "Loading with data $data" } } fun isSuccess(): Boolean = this is Success fun isLoading(): Boolean = this is Loading fun isError(): Boolean = this is Error override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Resource<*>) return false return this.data == other.data && this.message == other.message } override fun hashCode(): Int { var result = data?.hashCode() ?: 0 result = 31 * result + (message?.hashCode() ?: 0) return result } } typealias FlowResource = Flow> ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/TimestampWithValue.kt ================================================ package com.infinitepower.newquiz.model data class TimestampWithValue ( val timestamp: Long, val value: T ) ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/UiText.kt ================================================ package com.infinitepower.newquiz.model import androidx.annotation.PluralsRes import androidx.annotation.StringRes sealed interface UiText { val args: Array override fun toString(): String class DynamicString( val value: String, override vararg val args: Any ) : UiText { override fun toString(): String = value.format(args.toList().toTypedArray()) } class StringResource( @StringRes val resId: Int, override vararg val args: Any ) : UiText { override fun toString(): String { return "String resource: $resId with args: ${args.joinToString()}" } } class PluralStringResource( @PluralsRes val resId: Int, val quantity: Int, override vararg val args: Any ) : UiText { override fun toString(): String { return "Plural string resource: $resId with quantity: $quantity and args: ${args.joinToString()}" } } } fun String.toUiText( vararg args: Any ): UiText = UiText.DynamicString(value = this, args = args) ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/XP.kt ================================================ package com.infinitepower.newquiz.model typealias XP = Int typealias TimestampWithXP = TimestampWithValue ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/category/ShowCategoryConnectionInfo.kt ================================================ package com.infinitepower.newquiz.model.category import androidx.annotation.Keep /** * Enum class that represents the possible values for the category connection info badge. * The badge is shown on the category card in the home screen. * * This is used in an entry of the settings screen. */ @Keep enum class ShowCategoryConnectionInfo { /** * Don't show the badge. */ NONE, /** * Show the badge if the category requires internet connection or not. */ BOTH, /** * Show the badge only if the category requires internet connection. */ REQUIRE_CONNECTION, /** * Show the badge only if the category doesn't require internet connection. */ DONT_REQUIRE_CONNECTION; /** * Returns true if the badge should be shown for the category. * * @param requireInternetConnection Whether the category requires internet connection. * @return True if the badge should be shown for the category. */ fun shouldShowBadge( requireInternetConnection: Boolean, ): Boolean { return when (this) { NONE -> false BOTH -> true REQUIRE_CONNECTION -> requireInternetConnection DONT_REQUIRE_CONNECTION -> !requireInternetConnection } } } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/comparison_quiz/ComparisonMode.kt ================================================ package com.infinitepower.newquiz.model.comparison_quiz /** * Represents the comparison mode for a quiz question. */ enum class ComparisonMode { GREATER, LESSER } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/comparison_quiz/ComparisonQuizCategory.kt ================================================ package com.infinitepower.newquiz.model.comparison_quiz import androidx.annotation.Keep import com.infinitepower.newquiz.model.BaseCategory import com.infinitepower.newquiz.model.GameMode import com.infinitepower.newquiz.model.NumberFormatType import com.infinitepower.newquiz.model.UiText import kotlinx.serialization.Serializable /** * A category of comparison quizzes. * @param id The id of the category. * @param name The title of the category. * @param image The url of the image of the category. * @param isMazeDisabled Whether the category is not available for the maze. * @param questionDescription The description of the question. * @param helperValueSuffix The suffix of the question value. * @param generateQuestionsLocally Whether the questions should be generated locally. */ @Keep @Serializable data class ComparisonQuizCategory( override val id: String, override val name: UiText, override val image: String, override val requireInternetConnection: Boolean = true, val generateQuestionsLocally: Boolean = false, val isMazeDisabled: Boolean = false, val description: String, val questionDescription: QuestionDescription, val formatType: NumberFormatType, val helperValueSuffix: String? = null, val dataSourceAttribution: DataSourceAttribution? = null ) : BaseCategory, java.io.Serializable, Comparable { override val gameMode: GameMode = GameMode.COMPARISON_QUIZ fun getQuestionDescription( comparisonMode: ComparisonMode ): String = when (comparisonMode) { ComparisonMode.GREATER -> questionDescription.greater ComparisonMode.LESSER -> questionDescription.less } override fun compareTo(other: ComparisonQuizCategory): Int { return id.compareTo(other.id) } override fun equals(other: Any?): Boolean { // The categories are equal if they have the same id. return other is ComparisonQuizCategory && id == other.id } override fun hashCode(): Int { return id.hashCode() } @Keep @Serializable data class QuestionDescription( val greater: String, val less: String ) : java.io.Serializable /** * The data source attribution of the category. */ @Keep @Serializable data class DataSourceAttribution( val text: String, val logo: String? = null ) : java.io.Serializable fun toEntity(): ComparisonQuizCategoryEntity = ComparisonQuizCategoryEntity( id = id, name = name.toString(), image = image, requireInternetConnection = requireInternetConnection, generateQuestionsLocally = generateQuestionsLocally, description = description, questionDescription = questionDescription, formatType = formatType.name.lowercase(), helperValueSuffix = helperValueSuffix, dataSourceAttribution = dataSourceAttribution ) } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/comparison_quiz/ComparisonQuizCategoryEntity.kt ================================================ package com.infinitepower.newquiz.model.comparison_quiz import androidx.annotation.Keep import com.infinitepower.newquiz.model.NumberFormatType import com.infinitepower.newquiz.model.toUiText import kotlinx.serialization.Serializable @Keep @Serializable data class ComparisonQuizCategoryEntity( val id: String, val name: String, val image: String, val requireInternetConnection: Boolean = true, val generateQuestionsLocally: Boolean = false, val description: String, val questionDescription: ComparisonQuizCategory.QuestionDescription, val formatType: String, val helperValueSuffix: String? = null, val dataSourceAttribution: ComparisonQuizCategory.DataSourceAttribution? = null ) : java.io.Serializable { fun toModel(): ComparisonQuizCategory = ComparisonQuizCategory( id = id, name = name.toUiText(), image = image, requireInternetConnection = requireInternetConnection, generateQuestionsLocally = generateQuestionsLocally, description = description, questionDescription = questionDescription, formatType = NumberFormatType.valueOf(formatType.uppercase()), helperValueSuffix = helperValueSuffix, dataSourceAttribution = dataSourceAttribution ) } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/comparison_quiz/ComparisonQuizHelperValueState.kt ================================================ package com.infinitepower.newquiz.model.comparison_quiz /** * Represents the state of the helper value for the comparison quiz item. * @see ComparisonQuizItem */ enum class ComparisonQuizHelperValueState { /** * The helper value is hidden. */ HIDDEN, /** * The helper value is visible and has a normal state. */ NORMAL } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/comparison_quiz/ComparisonQuizItem.kt ================================================ package com.infinitepower.newquiz.model.comparison_quiz import androidx.annotation.Keep import java.net.URI @Keep data class ComparisonQuizItem( val title: String, val value: Double, val imgUri: URI ) ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/comparison_quiz/ComparisonQuizItemEntity.kt ================================================ package com.infinitepower.newquiz.model.comparison_quiz import androidx.annotation.Keep import kotlinx.serialization.Serializable @Keep @Serializable data class ComparisonQuizItemEntity( val title: String, val value: Double = 0.0, val imgUrl: String ) : java.io.Serializable ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/comparison_quiz/ComparisonQuizQuestion.kt ================================================ package com.infinitepower.newquiz.model.comparison_quiz typealias QuestionPair = Pair /** * Represents a question in the comparison quiz. * @param questions Pair of questions to compare. * @see ComparisonQuizItem */ data class ComparisonQuizQuestion( val questions: QuestionPair, val categoryId: String, // val category: ComparisonQuizCategory, val comparisonMode: ComparisonMode ) { /** * Returns true if the [answer] is the correct answer for the [comparisonMode]. * If the values are equal, the answer is considered correct. * * @param answer The user selected answer to check. * @return True if the [answer] is the correct answer for the [comparisonMode]. * @see ComparisonMode * @see ComparisonQuizItem */ fun isCorrectAnswer(answer: ComparisonQuizItem): Boolean { val correctValue = when (comparisonMode) { ComparisonMode.GREATER -> maxOf(questions.first.value, questions.second.value) ComparisonMode.LESSER -> minOf(questions.first.value, questions.second.value) } return answer.value == correctValue } /** * Returns a new [ComparisonQuizQuestion] with the second question of the [questions] as the first one and the [newQuestion] as the second one. * @param newQuestion New second question. * @return New [ComparisonQuizQuestion] with the second question of the [questions] as the first one. * @see ComparisonQuizItem */ fun nextQuestion(newQuestion: ComparisonQuizItem): ComparisonQuizQuestion { return copy(questions = questions.second to newQuestion) } } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/country/Continent.kt ================================================ package com.infinitepower.newquiz.model.country @JvmInline value class Continent private constructor( val name: String ) { companion object { private val Africa = Continent("Africa") private val Asia = Continent("Asia") private val Europe = Continent("Europe") private val NorthAmerica = Continent("North America") private val SouthAmerica = Continent("South America") private val Oceania = Continent("Oceania") private val allContinents = listOf( Africa, Asia, Europe, NorthAmerica, SouthAmerica, Oceania ) fun from(name: String): Continent = allContinents .firstOrNull { it.name == name } ?: throw IllegalArgumentException("Unknown continent with name: $name") } } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/country/Country.kt ================================================ package com.infinitepower.newquiz.model.country import androidx.annotation.Keep import com.infinitepower.newquiz.model.question.QuestionDifficulty import java.net.URI /** * Data class representing a country. * * @property countryCode The alpha-2 country code. * @property countryName The country name. * @property capital The capital of the country. * @property population The population of the country. * @property area The area of the country, in square kilometers. * @property continent The continent the country is in. * @property difficulty The difficulty of the question. * @property flagImage The flag image of the country. */ @Keep data class Country( val countryCode: String, val countryName: String, val capital: String, val population: Long, val area: Double, val continent: Continent, val difficulty: QuestionDifficulty, val flagImage: URI ) ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/daily_challenge/DailyChallengeTask.kt ================================================ package com.infinitepower.newquiz.model.daily_challenge import androidx.annotation.Keep import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.global_event.GameEvent import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import kotlin.time.Duration.Companion.days /** * A challenge task. * * @property id The task's id. * @property diamondsReward The number of diamonds rewarded for completing the task. * @property experienceReward The number of experience points rewarded for completing the task. * @property isClaimed Whether the task has been claimed. * @property dateRange The date range in which the task is valid. * @property currentValue The current value of the task. * @property maxValue The maximum value of the task. * */ @Keep data class DailyChallengeTask( val id: Int, val title: UiText, val diamondsReward: UInt, val experienceReward: UInt, val isClaimed: Boolean, val dateRange: ClosedRange, val currentValue: UInt, val maxValue: UInt, val event: GameEvent ) { init { require(dateRange.start <= dateRange.endInclusive) { val tz = TimeZone.currentSystemDefault() val start = dateRange.start.toLocalDateTime(tz).date val end = dateRange.endInclusive.toLocalDateTime(tz).date "The start date ($start) must be less than or equal to the end date ($end)." } } /** * A task is daily if the [dateRange] is one day long. */ fun isDaily(): Boolean { val start = dateRange.start val end = dateRange.endInclusive return start == end - 1.days } /** * A task is weekly if the [dateRange] is seven days long. */ fun isWeekly(): Boolean { val start = dateRange.start val end = dateRange.endInclusive return start == end - 7.days } /** * A task is expired if the current time is not in the task's date range. * The function compares the current time to the task's date range. * * @return true if the task is expired, false otherwise. */ fun isExpired(): Boolean { val now = Clock.System.now() return now !in dateRange } /** * A task is completed if the current [currentValue] is equal to the [maxValue]. * * @return true if the task is completed, false otherwise. */ fun isCompleted(): Boolean = currentValue >= maxValue /** * A task is claimable if it is completed and not claimed. * * @return true if the task is claimable, false otherwise. */ fun isClaimable(): Boolean = !isExpired() && isCompleted() && !isClaimed /** * A task is active if it is not expired and not claimed. * * @return true if the task is active, false otherwise. */ fun isActive(): Boolean = !isExpired() && !isClaimed override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is DailyChallengeTask) return false val dateRangeEquals = dateRange.start.toEpochMilliseconds() == other.dateRange.start.toEpochMilliseconds() && dateRange.endInclusive.toEpochMilliseconds() == other.dateRange.endInclusive.toEpochMilliseconds() return id == other.id && diamondsReward == other.diamondsReward && experienceReward == other.experienceReward && isClaimed == other.isClaimed && dateRangeEquals && currentValue == other.currentValue && maxValue == other.maxValue && event == other.event } override fun hashCode(): Int { var result = id result = 31 * result + diamondsReward.hashCode() result = 31 * result + experienceReward.hashCode() result = 31 * result + isClaimed.hashCode() result = 31 * result + dateRange.hashCode() result = 31 * result + currentValue.hashCode() result = 31 * result + maxValue.hashCode() result = 31 * result + event.hashCode() return result } } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/global_event/GameEvent.kt ================================================ package com.infinitepower.newquiz.model.global_event 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.multi_choice_quiz.MultiChoiceCategory import com.infinitepower.newquiz.model.wordle.WordleQuizType import kotlin.random.Random sealed class GameEvent( val key: String, val valueRange: UIntProgression ) { override fun toString(): String = key companion object { private fun eventsWithoutArgs(): List = listOf( // Multi choice MultiChoice.PlayRandomQuiz, MultiChoice.EndQuiz, MultiChoice.PlayQuestions, MultiChoice.GetAnswersCorrect, // Wordle Wordle.GetWordCorrect ) fun fromKey(key: String): GameEvent { val eventsWithoutArgs = eventsWithoutArgs() return when { // Multi choice play quiz with category key.startsWith(MultiChoice.PlayQuizWithCategory.KEY_PREFIX) -> { val categoryKey = key.substringAfter(MultiChoice.PlayQuizWithCategory.KEY_PREFIX) MultiChoice.PlayQuizWithCategory(categoryKey) } // Wordle play word with category key.startsWith(Wordle.PlayWordWithCategory.KEY_PREFIX) -> { val categoryKey = key.substringAfter(Wordle.PlayWordWithCategory.KEY_PREFIX) Wordle.PlayWordWithCategory(WordleQuizType.valueOf(categoryKey)) } // Comparison quiz play quiz with category key.startsWith(ComparisonQuiz.PlayQuizWithCategory.KEY_PREFIX) -> { val categoryId = key.substringAfter(ComparisonQuiz.PlayQuizWithCategory.KEY_PREFIX) ComparisonQuiz.PlayQuizWithCategory(categoryId) } // Comparison quiz play quiz with mode key.startsWith(ComparisonQuiz.PlayWithComparisonMode.KEY_PREFIX) -> { val modeId = key.substringAfter(ComparisonQuiz.PlayWithComparisonMode.KEY_PREFIX) ComparisonQuiz.PlayWithComparisonMode(ComparisonMode.valueOf(modeId)) } // Comparison quiz play and get score key.startsWith(ComparisonQuiz.PlayAndGetScore.KEY_PREFIX) -> { val score = key.substringAfter(ComparisonQuiz.PlayAndGetScore.KEY_PREFIX).toInt() ComparisonQuiz.PlayAndGetScore(score) } // Other events without args eventsWithoutArgs.any { it.key == key } -> eventsWithoutArgs.first { it.key == key } else -> throw IllegalArgumentException("Unknown key: $key") } } fun getRandomEvents( count: Int, multiChoiceCategories: List, comparisonQuizCategories: List, random: Random = Random ): List { val multiChoicePlayQuizWithCategory = MultiChoice.PlayQuizWithCategory( categoryId = multiChoiceCategories.random(random).id ) val wordleWithType = Wordle.PlayWordWithCategory( wordleCategory = WordleQuizType.entries.random(random) ) val comparisonPlayWithCategory = ComparisonQuiz.PlayQuizWithCategory( categoryId = comparisonQuizCategories.random(random).id ) val comparisonPlayWithMode = ComparisonQuiz.PlayWithComparisonMode( mode = ComparisonMode.entries.random(random) ) val comparisonPlayAndGetScore = ComparisonQuiz.PlayAndGetScore( score = random.nextInt(from = 5, until = 20) ) val allTypes = eventsWithoutArgs() + listOf( multiChoicePlayQuizWithCategory, wordleWithType, comparisonPlayWithCategory, comparisonPlayWithMode, comparisonPlayAndGetScore ) return allTypes.shuffled(random).take(count) } } sealed class MultiChoice( key: String, taskValueRange: UIntProgression ) : GameEvent(key, taskValueRange) { object PlayRandomQuiz : MultiChoice( key = "multi_choice_play_random_quiz", taskValueRange = 1u..5u ) object EndQuiz : MultiChoice( key = "multi_choice_end_quiz", taskValueRange = 1u..5u ) @Keep data class PlayQuizWithCategory( val categoryId: String ) : MultiChoice( key = "$KEY_PREFIX$categoryId", taskValueRange = 1u..5u ) { companion object { internal const val KEY_PREFIX = "multi_choice_play_quiz_category:" } } object PlayQuestions : MultiChoice( key = "multi_choice_play_questions", taskValueRange = 10u..50u step 10 ) object GetAnswersCorrect : MultiChoice( key = "multi_choice_get_questions_correct", taskValueRange = 5u..20u step 5 ) } sealed class Wordle( key: String, taskValueRange: UIntProgression ) : GameEvent(key, taskValueRange) { object GetWordCorrect : Wordle( key = "wordle_get_word_correct", taskValueRange = 1u..5u ) @Keep data class PlayWordWithCategory( val wordleCategory: WordleQuizType ) : Wordle( key = "$KEY_PREFIX${wordleCategory.name}", taskValueRange = 1u..5u ) { companion object { internal const val KEY_PREFIX = "wordle_word_play_with_category:" } } } sealed class ComparisonQuiz( key: String, taskValueRange: UIntProgression ) : GameEvent(key, taskValueRange) { @Keep data class PlayQuizWithCategory( val categoryId: String ) : ComparisonQuiz( key = "$KEY_PREFIX$categoryId", taskValueRange = 1u..5u ) { companion object { internal const val KEY_PREFIX = "comparison_play_with_category:" } } @Keep data class PlayAndGetScore( val score: Int ) : ComparisonQuiz( key = "$KEY_PREFIX$score", taskValueRange = 1u..5u ) { companion object { internal const val KEY_PREFIX = "comparison_play_and_get_score:" } } @Keep data class PlayWithComparisonMode( val mode: ComparisonMode ) : ComparisonQuiz( key = "$KEY_PREFIX${mode.name}", taskValueRange = 1u..5u ) { companion object { internal const val KEY_PREFIX = "comparison_play_with_comparison_mode:" } } } override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is GameEvent) return false return key == other.key } override fun hashCode(): Int { return key.hashCode() } } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/math_quiz/MathFormula.kt ================================================ package com.infinitepower.newquiz.model.math_quiz import androidx.annotation.Keep @Keep data class MathFormula( val leftFormula: String, val solution: Int ) { val fullFormula: String get() = "$leftFormula=$solution" override fun toString(): String = fullFormula companion object { val mathFormulaRegex = "^-*(\\d+[-+*/])+\\d+\\=-*\\d+\$".toRegex() fun fromStringFullFormula(value: String): MathFormula = MathFormula( leftFormula = value.takeWhile { it != '=' }, solution = value .takeLastWhile { it != '=' } .toIntOrNull() ?: error("Invalid MathFormula: $value") ) } } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/maze/MazePoint.kt ================================================ package com.infinitepower.newquiz.model.maze import androidx.annotation.Keep import kotlin.math.pow @Keep data class MazePoint( val x: Float, val y: Float ) { override fun toString(): String = "x: $x, y: $y" } /** * Checks if this point is inside the circle defined by [center] and [radius], using the * Pythagorean theorem. * * @param center the center point of the circle * @param radius the radius of the circle * @return true if this point is inside the circle defined by [center] and [radius] */ fun MazePoint.isInsideCircle( center: MazePoint, radius: Float ): Boolean { val dx = x - center.x val dy = y - center.y return dx.pow(2) + dy.pow(2) <= radius.pow(2) } /** * Creates a maze like this: * * () * | * ()---| * | * |---()---() * | * ()---()---| * | * ()---()---() */ fun generateMazePointsTopToBottom( startPoint: MazePoint, increment: MazePoint ): Sequence = sequence { yield(MazePoint(x = startPoint.x, y = startPoint.y)) var yValue = startPoint.y + increment.y while (true) { yield(MazePoint(x = startPoint.x, y = yValue)) yield(MazePoint(x = startPoint.x + increment.x, y = yValue)) yValue += increment.y yield(MazePoint(x = startPoint.x + increment.x, y = yValue)) yield(MazePoint(x = startPoint.x, y = yValue)) yield(MazePoint(x = startPoint.x - increment.x, y = yValue)) yValue += increment.y yield(MazePoint(x = startPoint.x - increment.x, y = yValue)) } } /** * Creates a maze like this: * * ()---()---() * | * ()---()---| * | * |---()---() * | * ()---| * | * () */ fun generateMazePointsBottomToTop( startPoint: MazePoint, increment: MazePoint ): Sequence = sequence { yield(MazePoint(x = startPoint.x, y = startPoint.y)) var yValue = startPoint.y - increment.y while (true) { yield(MazePoint(x = startPoint.x, y = yValue)) yield(MazePoint(x = startPoint.x + increment.x, y = yValue)) yValue -= increment.y yield(MazePoint(x = startPoint.x + increment.x, y = yValue)) yield(MazePoint(x = startPoint.x, y = yValue)) yield(MazePoint(x = startPoint.x - increment.x, y = yValue)) yValue -= increment.y yield(MazePoint(x = startPoint.x - increment.x, y = yValue)) } } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/maze/MazeQuiz.kt ================================================ package com.infinitepower.newquiz.model.maze import androidx.annotation.Keep import com.infinitepower.newquiz.model.GameMode import com.infinitepower.newquiz.model.comparison_quiz.ComparisonQuizQuestion import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion import com.infinitepower.newquiz.model.question.QuestionDifficulty import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.model.wordle.WordleWord import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @Keep data class MazeQuiz( val items: ImmutableList ) { sealed interface MazeItem { val id: Int val mazeSeed: Int val difficulty: QuestionDifficulty val played: Boolean val gameMode: GameMode val categoryId: String @Keep data class Wordle( val wordleWord: WordleWord, val wordleQuizType: WordleQuizType, override val id: Int = 0, override val mazeSeed: Int, override val difficulty: QuestionDifficulty = QuestionDifficulty.Easy, override val played: Boolean = false, ) : MazeItem { override val gameMode: GameMode = GameMode.WORDLE override val categoryId: String = wordleQuizType.name } @Keep data class MultiChoice( val question: MultiChoiceQuestion, override val id: Int = 0, override val mazeSeed: Int, override val difficulty: QuestionDifficulty = QuestionDifficulty.Easy, override val played: Boolean = false ) : MazeItem { override val gameMode: GameMode = GameMode.MULTI_CHOICE override val categoryId: String = question.category.id } @Keep data class ComparisonQuiz( val question: ComparisonQuizQuestion, override val id: Int = 0, override val mazeSeed: Int, override val difficulty: QuestionDifficulty = QuestionDifficulty.Easy, override val played: Boolean = false ) : MazeItem { override val gameMode: GameMode = GameMode.COMPARISON_QUIZ override val categoryId: String = question.categoryId } } } fun emptyMaze(): MazeQuiz = MazeQuiz(items = persistentListOf()) /** * Check if an item at a given index in a list of MazeItem objects is playable. * An item is considered playable if it has not been played and the previous item has been played. * * @param index The index of the item to check. * @return true if the current item has not been played and either the previous item does not exist or has been played */ fun List.isPlayableItem(index: Int): Boolean { // Check if the current item has been played. If it has, return false. if (isItemPlayed(index)) return false // If the current item is the first item in the list, return true. if (index == 0) return true // If the current item is not the first item in the list, check if the previous item has been played. // If it has, return true. Otherwise, return false. return isItemPlayed(index - 1) } /** * Returns true if the item at the given index has been played, false otherwise. * * @param index The index of the item to check. * @return True if the item has been played, false otherwise. */ infix fun List.isItemPlayed( index: Int ): Boolean = getOrNull(index)?.played == true ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/multi_choice_quiz/MultiChoiceBaseCategory.kt ================================================ package com.infinitepower.newquiz.model.multi_choice_quiz import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder @Serializable(with = MultiChoiceBaseCategorySerializer::class) sealed class MultiChoiceBaseCategory( val id: String, ) : java.io.Serializable { companion object { fun fromId(id: String) = when (id) { Logo.id -> Logo Flag.id -> Flag GuessMathSolution.id -> GuessMathSolution NumberTrivia.id -> NumberTrivia CountryCapitalFlags.id -> CountryCapitalFlags else -> Normal(id) } } override fun toString(): String = id val hasCategory: Boolean get() = id.isNotBlank() && id != "random" /** * Random multi choice category using [Normal] class */ object Random : Normal() /** * Normal multi choice type with category * @param categoryId category to the quiz */ open class Normal( val categoryId: String ) : MultiChoiceBaseCategory(id = categoryId) { /** Sets multi choice type as no category */ constructor() : this("random") override fun toString(): String = categoryId override fun equals(other: Any?): Boolean { if (other !is Normal) return false return this.categoryId == other.categoryId } override fun hashCode(): Int = categoryId.hashCode() } /** Logo multi choice quiz category */ object Logo : MultiChoiceBaseCategory(id = "logo") /** Flag multi choice quiz category */ object Flag : MultiChoiceBaseCategory(id = "flag") /** Number trivia multi choice quiz category */ object CountryCapitalFlags : MultiChoiceBaseCategory(id = "country_capital_flags") /** Guess math solution multi choice quiz category */ object GuessMathSolution : MultiChoiceBaseCategory(id = "guess_math_solution") /** Number trivia multi choice quiz category */ object NumberTrivia : MultiChoiceBaseCategory(id = "number_trivia") } object MultiChoiceBaseCategorySerializer : KSerializer { override fun serialize(encoder: Encoder, value: MultiChoiceBaseCategory) { encoder.encodeString(value.toString()) } override fun deserialize(decoder: Decoder): MultiChoiceBaseCategory { return MultiChoiceBaseCategory.fromId(decoder.decodeString()) } override val descriptor: SerialDescriptor get() = PrimitiveSerialDescriptor("Category", PrimitiveKind.STRING) } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/multi_choice_quiz/MultiChoiceCategory.kt ================================================ package com.infinitepower.newquiz.model.multi_choice_quiz import androidx.annotation.Keep import com.infinitepower.newquiz.model.BaseCategory import com.infinitepower.newquiz.model.GameMode import com.infinitepower.newquiz.model.UiText @Keep data class MultiChoiceCategory( override val id: String, override val name: UiText, override val image: Any, override val requireInternetConnection: Boolean = true ) : BaseCategory { override val gameMode: GameMode = GameMode.MULTI_CHOICE } fun MultiChoiceCategory.toBaseCategory() = MultiChoiceBaseCategory.fromId(id) ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/multi_choice_quiz/MultiChoiceQuestion.kt ================================================ package com.infinitepower.newquiz.model.multi_choice_quiz import androidx.annotation.Keep import com.infinitepower.newquiz.model.question.QuestionDifficulty import com.infinitepower.newquiz.model.util.serializers.URISerializer import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.net.URI import kotlin.random.Random @Keep @Serializable data class MultiChoiceQuestion( val id: Int, val description: String, @Serializable(with = URISerializer::class) val image: URI? = null, val answers: List, val lang: QuestionLanguage, val category: MultiChoiceBaseCategory, val correctAns: Int, val type: MultiChoiceQuestionType, val difficulty: QuestionDifficulty ) : java.io.Serializable { constructor( description: String, image: URI? = null, answers: List, lang: QuestionLanguage, category: MultiChoiceBaseCategory, correctAns: Int, type: MultiChoiceQuestionType, difficulty: QuestionDifficulty ) : this( // Generate an id based in all the fields of the question. // Answers are sorted to avoid different ids for the same question with different order of answers. // Correct answer is not included because it depends on the order of the answers. id = (description + image?.toString() + answers.sorted() + lang + category + type + difficulty).hashCode(), description = description, image = image, answers = answers, lang = lang, category = category, correctAns = correctAns, type = type, difficulty = difficulty ) fun toQuestionStep() = MultiChoiceQuestionStep.NotCurrent(this) override fun toString(): String = Json.encodeToString(this) override fun hashCode(): Int = id override fun equals(other: Any?): Boolean { if (other !is MultiChoiceQuestion) return false return id == other.id } } /** * Generates a random [MultiChoiceQuestion]. * * @param id The id of the question. * @param correctAns The index of the correct answer. */ fun getBasicMultiChoiceQuestion( id: Int = Random.nextInt(), answers: List = listOf( "Answer 1", "Answer 2", "Answer 3", "Answer 4" ), correctAns: Int = (0..answers.lastIndex).random(), difficulty: QuestionDifficulty = QuestionDifficulty.random() ): MultiChoiceQuestion { return MultiChoiceQuestion( id = id, description = "Question description", image = null, answers = answers, lang = QuestionLanguage.EN, category = MultiChoiceBaseCategory.Random, correctAns = correctAns, type = MultiChoiceQuestionType.MULTIPLE, difficulty = difficulty ) } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/multi_choice_quiz/MultiChoiceQuestionStep.kt ================================================ package com.infinitepower.newquiz.model.multi_choice_quiz import androidx.annotation.Keep import kotlinx.serialization.Serializable @Keep @Serializable sealed class MultiChoiceQuestionStep : java.io.Serializable { abstract val question: MultiChoiceQuestion @Keep @Serializable data class NotCurrent( override val question: MultiChoiceQuestion ) : MultiChoiceQuestionStep(), java.io.Serializable { fun changeToCurrent() = Current(question) } @Keep @Serializable data class Current( override val question: MultiChoiceQuestion ) : MultiChoiceQuestionStep(), java.io.Serializable { fun changeToCompleted( correct: Boolean, selectedAnswer: SelectedAnswer, questionTime: Long, skipped: Boolean ) = Completed(question, correct, selectedAnswer, questionTime, skipped) } @Keep @Serializable data class Completed( override val question: MultiChoiceQuestion, val correct: Boolean, val selectedAnswer: SelectedAnswer = SelectedAnswer.NONE, val questionTime: Long = 0, val skipped: Boolean = false ) : MultiChoiceQuestionStep(), java.io.Serializable fun asCurrent() = Current(question) } fun List.isAllCompleted(): Boolean = all { it is MultiChoiceQuestionStep.Completed } fun List.isAllCorrect(): Boolean = all { it.correct } fun List.countCorrectQuestions(): Int = count { it.correct } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/multi_choice_quiz/MultiChoiceQuestionType.kt ================================================ package com.infinitepower.newquiz.model.multi_choice_quiz enum class MultiChoiceQuestionType { MULTIPLE, BOOLEAN } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/multi_choice_quiz/QuestionLanguage.kt ================================================ package com.infinitepower.newquiz.model.multi_choice_quiz enum class QuestionLanguage { EN } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/multi_choice_quiz/SelectedAnswer.kt ================================================ package com.infinitepower.newquiz.model.multi_choice_quiz import kotlinx.serialization.Serializable @JvmInline @Serializable value class SelectedAnswer private constructor(val index: Int) : java.io.Serializable { companion object { val NONE = SelectedAnswer(-1) fun fromIndex(index: Int): SelectedAnswer = SelectedAnswer(index) } private val isNone: Boolean get() = index == -1 val isSelected: Boolean get() = !isNone infix fun isCorrect(question: MultiChoiceQuestion): Boolean = !isNone && question.correctAns == index init { require(index >= -1) { "SelectedAnswer index must be greater than -1" } } } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/multi_choice_quiz/logo_quiz/LogoQuizBaseItem.kt ================================================ package com.infinitepower.newquiz.model.multi_choice_quiz.logo_quiz import androidx.annotation.Keep import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Keep @Serializable data class LogoQuizBaseItem( val description: String, val name: String, @SerialName("img_url") val imgUrl: String, @SerialName("incorrect_answers") val incorrectAnswers: List, val difficulty: String ) ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/multi_choice_quiz/saved/SortSavedQuestionsBy.kt ================================================ package com.infinitepower.newquiz.model.multi_choice_quiz.saved enum class SortSavedQuestionsBy { BY_DEFAULT, BY_DESCRIPTION, BY_CATEGORY } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/number/NumberTriviaQuestion.kt ================================================ package com.infinitepower.newquiz.model.number import androidx.annotation.Keep import kotlinx.serialization.Serializable @Keep @Serializable data class NumberTriviaQuestion( val number: Int, val question: String ) : java.io.Serializable ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/number/NumberTriviaQuestionsEntity.kt ================================================ package com.infinitepower.newquiz.model.number import androidx.annotation.Keep import kotlinx.serialization.Serializable /** * Entity for NumberTriviaQuestions * * @property message error message */ @Keep @Serializable data class NumberTriviaQuestionsEntity( val questions: List, val message: String? = null ) : java.io.Serializable { fun toNumberTriviaQuestions(): List { if (message != null) error(message) return questions.map { entity -> NumberTriviaQuestion( number = entity.number, question = entity.question ) } } } @Keep @Serializable data class NumberTriviaQuestionEntity( val number: Int, val question: String ) : java.io.Serializable ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/question/QuestionDifficulty.kt ================================================ package com.infinitepower.newquiz.model.question import kotlinx.serialization.Serializable import kotlin.random.Random @Serializable sealed class QuestionDifficulty( val id: String ) : java.io.Serializable { companion object { fun items() = listOf(Easy, Medium, Hard) fun random( random: Random = Random ) = items().random(random) fun from(id: String): QuestionDifficulty = when (id) { Easy.id -> Easy Medium.id -> Medium Hard.id -> Hard else -> throw IllegalArgumentException("Question difficulty not found") } } override fun toString(): String = id @Serializable object Easy : QuestionDifficulty(id = "easy") @Serializable object Medium : QuestionDifficulty(id = "medium") @Serializable object Hard : QuestionDifficulty(id = "hard") } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/regional_preferences/DistanceUnitType.kt ================================================ package com.infinitepower.newquiz.model.regional_preferences enum class DistanceUnitType { METRIC, IMPERIAL, } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/regional_preferences/RegionalPreferences.kt ================================================ package com.infinitepower.newquiz.model.regional_preferences import java.util.Locale /** * * @param locale The locale of the user. */ data class RegionalPreferences( val locale: Locale = Locale.getDefault(), val temperatureUnit: TemperatureUnit? = null, val distanceUnitType: DistanceUnitType? = null, ) ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/regional_preferences/TemperatureUnit.kt ================================================ package com.infinitepower.newquiz.model.regional_preferences enum class TemperatureUnit( val key: String, val value: String, ) { CELSIUS(key = "celsius", value = "°C"), FAHRENHEIT(key = "fahrenhe", value = "°F"), KELVIN(key = "kelvin", value = "K"); companion object { fun fromKey(key: String): TemperatureUnit = entries .firstOrNull { it.key == key } ?: throw IllegalArgumentException("Unknown temperature unit: $key") } @Suppress("MagicNumber") fun convert( to: TemperatureUnit, value: Double ): Double { if (this == to) return value return when (this) { CELSIUS -> when (to) { FAHRENHEIT -> (value * 9 / 5) + 32 KELVIN -> value + 273.15 else -> value } FAHRENHEIT -> when (to) { CELSIUS -> (value - 32) * 5 / 9 KELVIN -> (value + 459.67) * 5 / 9 else -> value } KELVIN -> when (to) { CELSIUS -> value - 273.15 FAHRENHEIT -> (value * 9 / 5) - 459.67 else -> value } } } override fun toString(): String = key } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/util/base64/Base64.kt ================================================ package com.infinitepower.newquiz.model.util.base64 /** * Encode a [String] to Base64 standard encoded [String]. */ val String.base64Encoded: String get() = encodeInternal(Base64Encoding.Standard) /** * Encode a [ByteArray] to Base64 standard encoded [String]. */ val ByteArray.base64Encoded: String get() = asCharArray().concatToString().base64Encoded /** * Decode a Base64 standard encoded [String] to [String]. */ val String.base64Decoded: String get() = decodeInternal(Base64Encoding.Standard) .map { it.toChar() } .joinToString("") .dropLast(count { it == '=' }) /** * Decode a Base64 standard encoded [String] to [ByteArray]. */ val String.base64DecodedBytes: ByteArray get() = decodeInternal(Base64Encoding.Standard) .map { it.toByte() } .toList() .dropLast(count { it == '=' }) .toByteArray() /** * Decode a Base64 standard encoded [ByteArray] to [String]. */ val ByteArray.base64Decoded: String get() = asCharArray().concatToString().base64Decoded ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/util/base64/Base64Encoding.kt ================================================ @file:Suppress("MagicNumber") package com.infinitepower.newquiz.model.util.base64 internal sealed interface Base64Encoding { val alphabet: String val requiresPadding: Boolean object Standard : Base64Encoding { override val alphabet: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" override val requiresPadding: Boolean = true } object UrlSafe : Base64Encoding { override val alphabet: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" override val requiresPadding: Boolean = false // Padding is optional } } internal fun String.encodeInternal(encoding: Base64Encoding): String { val padLength = when (length % 3) { 1 -> 2 2 -> 1 else -> 0 } val raw = this + 0.toChar().toString().repeat(maxOf(0, padLength)) val encoded = raw.chunkedSequence(3) { Triple(it[0].code, it[1].code, it[2].code) }.map { (first, second, third) -> (0xFF.and(first) shl 16) + (0xFF.and(second) shl 8) + 0xFF.and(third) }.map { n -> sequenceOf((n shr 18) and 0x3F, (n shr 12) and 0x3F, (n shr 6) and 0x3F, n and 0x3F) }.flatten() .map { encoding.alphabet[it] } .joinToString("") .dropLast(padLength) return when (encoding.requiresPadding) { true -> encoded.padEnd(encoded.length + padLength, '=') else -> encoded } } internal fun String.decodeInternal(encoding: Base64Encoding): Sequence { val padLength = when (length % 4) { 1 -> 3 2 -> 2 3 -> 1 else -> 0 } return padEnd(length + padLength, '=') .replace("=", "A") .chunkedSequence(4) { (encoding.alphabet.indexOf(it[0]) shl 18) + (encoding.alphabet.indexOf(it[1]) shl 12) + (encoding.alphabet.indexOf(it[2]) shl 6) + encoding.alphabet.indexOf(it[3]) } .map { sequenceOf(0xFF.and(it shr 16), 0xFF.and(it shr 8), 0xFF.and(it)) } .flatten() } internal fun ByteArray.asCharArray(): CharArray { val chars = CharArray(size) for (i in chars.indices) { chars[i] = get(i).toInt().toChar() } return chars } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/util/base64/Base64Url.kt ================================================ package com.infinitepower.newquiz.model.util.base64 /** * Encode a [String] to Base64 URL-safe encoded [String]. */ val String.base64UrlEncoded: String get() = encodeInternal(Base64Encoding.UrlSafe) /** * Encode a [ByteArray] to Base64 URL-safe encoded [String]. */ val ByteArray.base64UrlEncoded: String get() = asCharArray().concatToString().base64UrlEncoded /** * Decode a Base64 URL-safe encoded [String] to [String]. */ val String.base64UrlDecoded: String get() { val ret = decodeInternal(Base64Encoding.UrlSafe).map { it.toChar() } val foo = ret.joinToString("") val bar = foo.dropLast(count { it == '=' }) return bar.filterNot { it.code == 0 } } /** * Decode a Base64 URL-safe encoded [String] to [ByteArray]. */ val String.base64UrlDecodedBytes: ByteArray get() = decodeInternal(Base64Encoding.UrlSafe) .map { it.toByte() } .toList() .dropLast(count { it == '=' }) .toByteArray() /** * Decode a Base64 URL-safe encoded [ByteArray] to [String]. */ val ByteArray.base64UrlDecoded: String get() = asCharArray().concatToString().base64UrlDecoded ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/util/serializers/URISerializer.kt ================================================ package com.infinitepower.newquiz.model.util.serializers import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.net.URI object URISerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( serialName = "URI", kind = PrimitiveKind.STRING ) override fun serialize(encoder: Encoder, value: URI) { encoder.encodeString(value.toASCIIString()) } override fun deserialize(decoder: Decoder): URI { return URI.create(decoder.decodeString()) } } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/wordle/WordleCategory.kt ================================================ package com.infinitepower.newquiz.model.wordle import androidx.annotation.Keep import com.infinitepower.newquiz.model.BaseCategory import com.infinitepower.newquiz.model.GameMode import com.infinitepower.newquiz.model.UiText @Keep data class WordleCategory( val wordleQuizType: WordleQuizType, override val id: String = wordleQuizType.name, override val name: UiText, override val image: Any, override val requireInternetConnection: Boolean = false ) : BaseCategory { override val gameMode: GameMode = GameMode.WORDLE } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/wordle/WordleItem.kt ================================================ package com.infinitepower.newquiz.model.wordle @JvmInline value class WordleChar(val value: Char) { companion object { val Empty = WordleChar(' ') } override fun toString(): String = value.toString() fun isEmpty(): Boolean = value == ' ' } sealed class WordleItem { abstract val char: WordleChar object Empty : WordleItem() { override val char: WordleChar get() = WordleChar.Empty } data class None( override val char: WordleChar, val verified: Boolean = false ) : WordleItem() data class Present( override val char: WordleChar ) : WordleItem() data class Correct( override val char: WordleChar ) : WordleItem() val isCompleted: Boolean get() = this !is Empty val isVerified: Boolean get() = when (this) { is Empty -> false is None -> this.verified else -> true } companion object { /** Creates [None] wordle item from char with false verified */ fun fromChar(char: Char) = None(WordleChar(char), false) } } inline fun List.countByItem(): Int = filterIsInstance().count() fun List.itemsToString(): String = joinToString("") { item -> item.char.toString() } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/wordle/WordleQuizType.kt ================================================ package com.infinitepower.newquiz.model.wordle enum class WordleQuizType { TEXT, NUMBER, MATH_FORMULA, NUMBER_TRIVIA } ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/wordle/WordleRowItem.kt ================================================ package com.infinitepower.newquiz.model.wordle @JvmInline value class WordleRowItem( val items: List ) { val isRowCorrect: Boolean get() = items.all { item -> item is WordleItem.Correct } val isRowCompleted: Boolean get() = items.all { item -> item.isCompleted } val isRowVerified: Boolean get() = items.all { item -> item.isVerified } } fun emptyRowItem(size: Int = 6): WordleRowItem = WordleRowItem( items = List(size) { WordleItem.Empty } ) ================================================ FILE: model/src/main/java/com/infinitepower/newquiz/model/wordle/WordleWord.kt ================================================ package com.infinitepower.newquiz.model.wordle import androidx.annotation.Keep @Keep data class WordleWord( val word: String, val textHelper: String? = null ) ================================================ FILE: model/src/test/java/com/infinitepower/newquiz/model/RemainingTimeTest.kt ================================================ package com.infinitepower.newquiz.model import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds internal class RemainingTimeTest { @Test fun `creates a new instance of RemainingTime from zero time`() { val remainingTime = RemainingTime.ZERO assertThat(remainingTime.value).isEqualTo(Duration.ZERO) } @Test fun `creates a new instance of RemainingTime from the given duration value`() { val duration = 5.milliseconds val remainingTime = RemainingTime(duration) assertThat(remainingTime.value).isEqualTo(duration) val duration2 = 12.seconds val remainingTime2 = RemainingTime(duration2) assertThat(remainingTime2.value).isEqualTo(duration2) } @Test fun `fromMilliseconds creates a new instance of RemainingTime from the given milliseconds value`() { val millis = 300000L val remainingTime = RemainingTime.fromMilliseconds(millis) assertThat(remainingTime.value).isEqualTo(millis.milliseconds) } @Test fun `fromMilliseconds throws an exception when the value is lower than 0`() { val negativeMillis = -1000L val exception = assertThrows { RemainingTime.fromMilliseconds(negativeMillis) } assertThat(exception) .hasMessageThat() .isEqualTo("RemainingTime value must be greater than or equal to 0") } @Test fun `getRemainingPercent returns the remaining percentage of the time`() { val duration = 10.seconds val maxTime = 50.seconds val remainingTime = RemainingTime(duration) assertThat(remainingTime.getRemainingPercent(maxTime)).isEqualTo(0.2) } @Test fun `minuteSecondFormatted returns the remaining time in minute second format`() { val duration = 5.minutes + 10.seconds val remainingTime = RemainingTime(duration) assertThat(remainingTime.toMinuteSecondFormatted()).isEqualTo("5:10") } @Test fun `getElapsedSeconds returns the elapsed seconds from the max time`() { val duration = 10.seconds val maxTime = 60.seconds val remainingTime = RemainingTime(duration) val result = remainingTime.getElapsedSeconds(maxTime) assertThat(result).isEqualTo(50) } } ================================================ FILE: model/src/test/java/com/infinitepower/newquiz/model/category/ShowCategoryConnectionInfoTest.kt ================================================ package com.infinitepower.newquiz.model.category import com.google.common.truth.Truth.assertThat import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource /** * Tests for [ShowCategoryConnectionInfo] */ internal class ShowCategoryConnectionInfoTest { @ParameterizedTest @CsvSource( "NONE, false, false", "NONE, true, false", "BOTH, false, true", "BOTH, true, true", "REQUIRE_CONNECTION, false, false", "REQUIRE_CONNECTION, true, true", "DONT_REQUIRE_CONNECTION, false, true", "DONT_REQUIRE_CONNECTION, true, false", ) fun test_shouldShowBadge( showCategoryConnectionInfo: ShowCategoryConnectionInfo, requireInternetConnection: Boolean, expected: Boolean, ) { assertThat(showCategoryConnectionInfo.shouldShowBadge(requireInternetConnection)).isEqualTo(expected) } } ================================================ FILE: model/src/test/java/com/infinitepower/newquiz/model/comparison_quiz/ComparisonQuizCategoryTest.kt ================================================ package com.infinitepower.newquiz.model.comparison_quiz import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.model.NumberFormatType import com.infinitepower.newquiz.model.toUiText import org.junit.jupiter.api.Test internal class ComparisonQuizCategoryTest { @Test fun `getQuestionDescription returns correct string for GREATER comparison mode`() { val questionDescription = ComparisonQuizCategory.QuestionDescription( greater = "Which is greater?", less = "Which is less?" ) val category = ComparisonQuizCategory( id = "1", name = "Category Title".toUiText(), description = "Category Description", image = "https://example.com/image.png", questionDescription = questionDescription, formatType = NumberFormatType.DEFAULT, helperValueSuffix = null, dataSourceAttribution = null ) val description = category.getQuestionDescription(ComparisonMode.GREATER) assertThat(description).isEqualTo("Which is greater?") } @Test fun `getQuestionDescription returns correct string for LESSER comparison mode`() { val questionDescription = ComparisonQuizCategory.QuestionDescription( greater = "Which is greater?", less = "Which is less?" ) val category = ComparisonQuizCategory( id = "1", name = "Category Title".toUiText(), description = "Category Description", image = "https://example.com/image.png", questionDescription = questionDescription, formatType = NumberFormatType.DEFAULT, helperValueSuffix = null, dataSourceAttribution = null ) val description = category.getQuestionDescription(ComparisonMode.LESSER) assertThat(description).isEqualTo("Which is less?") } } ================================================ FILE: model/src/test/java/com/infinitepower/newquiz/model/comparison_quiz/ComparisonQuizQuestionTest.kt ================================================ package com.infinitepower.newquiz.model.comparison_quiz import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test import java.net.URI internal class ComparisonQuizQuestionTest { private val emptyUri = URI("") @Test fun `nextQuestion returns a new ComparisonQuizCurrentQuestion with the second question replaced`() { val quizItem1 = ComparisonQuizItem( title = "A", imgUri = emptyUri, value = 5.0 ) val quizItem2 = ComparisonQuizItem( title = "B", imgUri = emptyUri, value = 10.0 ) val question = ComparisonQuizQuestion( questions = quizItem1 to quizItem2, categoryId = "", comparisonMode = ComparisonMode.GREATER ) val newQuestion = ComparisonQuizItem( title = "C", imgUri = emptyUri, value = 7.0 ) assertThat(question.questions.first).isEqualTo(quizItem1) assertThat(question.questions.second).isEqualTo(quizItem2) val updatedQuestion = question.nextQuestion(newQuestion) assertThat(updatedQuestion.questions.first).isEqualTo(quizItem2) assertThat(updatedQuestion.questions.second).isEqualTo(newQuestion) } @Test fun `test isCorrectAnswer when correct answer is first and the user answer is first`() { val quizItem1 = ComparisonQuizItem( title = "A", imgUri = emptyUri, value = 2.0 ) val quizItem2 = ComparisonQuizItem( title = "B", imgUri = emptyUri, value = 1.0 ) val question = ComparisonQuizQuestion( questions = quizItem1 to quizItem2, categoryId = "", comparisonMode = ComparisonMode.GREATER ) val isCorrectGreater = question.isCorrectAnswer(quizItem1) assertThat(isCorrectGreater).isTrue() } @Test fun `test isCorrectAnswer when correct answer is first and the user answer is second`() { val quizItem1 = ComparisonQuizItem( title = "A", imgUri = emptyUri, value = 2.0 ) val quizItem2 = ComparisonQuizItem( title = "B", imgUri = emptyUri, value = 1.0 ) val question = ComparisonQuizQuestion( questions = quizItem1 to quizItem2, categoryId = "", comparisonMode = ComparisonMode.GREATER ) val isCorrectGreater = question.isCorrectAnswer(quizItem2) assertThat(isCorrectGreater).isFalse() } @Test fun `test isCorrectAnswer when correct answer is second and the user answer is first`() { val quizItem1 = ComparisonQuizItem( title = "A", imgUri = emptyUri, value = 1.0 ) val quizItem2 = ComparisonQuizItem( title = "B", imgUri = emptyUri, value = 2.0 ) val question = ComparisonQuizQuestion( questions = quizItem1 to quizItem2, categoryId = "", comparisonMode = ComparisonMode.GREATER ) val isCorrectGreater = question.isCorrectAnswer(quizItem1) assertThat(isCorrectGreater).isFalse() } @Test fun `test isCorrectAnswer when correct answer is second and the user answer is second`() { val quizItem1 = ComparisonQuizItem( title = "A", imgUri = emptyUri, value = 1.0 ) val quizItem2 = ComparisonQuizItem( title = "B", imgUri = emptyUri, value = 2.0 ) val question = ComparisonQuizQuestion( questions = quizItem1 to quizItem2, categoryId = "", comparisonMode = ComparisonMode.GREATER ) val isCorrectGreater = question.isCorrectAnswer(quizItem2) assertThat(isCorrectGreater).isTrue() } @Test fun `test isCorrectAnswer when values are the same`() { val quizItem1 = ComparisonQuizItem( title = "A", imgUri = emptyUri, value = 1.0 ) val quizItem2 = ComparisonQuizItem( title = "B", imgUri = emptyUri, value = 1.0 ) val question = ComparisonQuizQuestion( questions = quizItem1 to quizItem2, categoryId = "", comparisonMode = ComparisonMode.GREATER ) val isCorrectGreater = question.isCorrectAnswer(quizItem1) assertThat(isCorrectGreater).isTrue() } } ================================================ FILE: model/src/test/java/com/infinitepower/newquiz/model/daily_challenge/DailyChallengeTaskTest.kt ================================================ package com.infinitepower.newquiz.model.daily_challenge import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.model.global_event.GameEvent import com.infinitepower.newquiz.model.toUiText import kotlinx.datetime.Clock import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import kotlin.time.Duration.Companion.days /** * Tests for [DailyChallengeTask]. */ internal class DailyChallengeTaskTest { @Test fun `test daily challenge task isDaily, when date range is one day long, returns true`() { val now = Clock.System.now() val task = DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = false, dateRange = now.rangeTo(now + 1.days), // Not expired, one day long currentValue = 0u, // Not completed maxValue = 1u, event = GameEvent.MultiChoice.PlayQuestions, title = "Test".toUiText() ) assertThat(task.isDaily()).isTrue() } @Test fun `test daily challenge task isDaily, when date range is not one day long, returns false`() { val now = Clock.System.now() val task = DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = false, dateRange = now.rangeTo(now + 5.days), // Not expired, one day long currentValue = 0u, // Not completed maxValue = 1u, event = GameEvent.MultiChoice.PlayQuestions, title = "Test".toUiText() ) assertThat(task.isDaily()).isFalse() } @Test fun `test when the current time is not in the task's date range`() { val now = Clock.System.now() val task = DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = false, dateRange = (now - 2.days).rangeTo(now - 1.days), // Expired currentValue = 0u, // Not completed maxValue = 1u, event = GameEvent.MultiChoice.PlayQuestions, title = "Test".toUiText() ) assertThat(task.isExpired()).isTrue() assertThat(task.isCompleted()).isFalse() // The task is expired and not completed, so it is not active. assertThat(task.isActive()).isFalse() // The task is not completed, so it is not claimable. assertThat(task.isClaimable()).isFalse() } @Test fun `test when the current time is in the task's date range`() { val now = Clock.System.now() val task = DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = false, dateRange = (now - 1.days).rangeTo(now + 1.days), // Not expired currentValue = 0u, // Not completed maxValue = 1u, event = GameEvent.MultiChoice.PlayQuestions, title = "Test".toUiText() ) assertThat(task.isExpired()).isFalse() assertThat(task.isCompleted()).isFalse() // The task is not expired and not completed, so it is active. assertThat(task.isActive()).isTrue() // The task is not completed, so it is not claimable. assertThat(task.isClaimable()).isFalse() } @Test fun `test when the current value is equal to the maximum value`() { val now = Clock.System.now() val task = DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = false, dateRange = (now - 1.days).rangeTo(now + 1.days), // Not expired currentValue = 1u, // Completed maxValue = 1u, event = GameEvent.MultiChoice.PlayQuestions, title = "Test".toUiText() ) assertThat(task.isExpired()).isFalse() assertThat(task.isCompleted()).isTrue() // The task is not expired and not claimed, so it is active. assertThat(task.isActive()).isTrue() // The task is completed, so it is claimable. assertThat(task.isClaimable()).isTrue() } @Test fun `test when the task is already claimed`() { val now = Clock.System.now() val task = DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = true, dateRange = (now - 1.days).rangeTo(now + 1.days), // Not expired currentValue = 1u, // Completed maxValue = 1u, event = GameEvent.MultiChoice.PlayQuestions, title = "Test".toUiText() ) assertThat(task.isExpired()).isFalse() assertThat(task.isCompleted()).isTrue() assertThat(task.isActive()).isFalse() assertThat(task.isClaimable()).isFalse() } @Test fun `test when the task is expired`() { val now = Clock.System.now() val task = DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = false, dateRange = (now - 2.days).rangeTo(now - 1.days), // Expired currentValue = 1u, // Completed maxValue = 1u, event = GameEvent.MultiChoice.PlayQuestions, title = "Test".toUiText() ) assertThat(task.isExpired()).isTrue() assertThat(task.isCompleted()).isTrue() // The task is expired and completed, so it is not active. assertThat(task.isActive()).isFalse() // The task is completed, so it is claimable. assertThat(task.isClaimable()).isFalse() } @Test fun `test when the task is expired and already claimed`() { val now = Clock.System.now() val task = DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = true, dateRange = (now - 2.days).rangeTo(now - 1.days), // Expired currentValue = 1u, // Completed maxValue = 1u, event = GameEvent.MultiChoice.PlayQuestions, title = "Test".toUiText() ) assertThat(task.isExpired()).isTrue() assertThat(task.isCompleted()).isTrue() // The task is expired and completed, so it is not active. assertThat(task.isActive()).isFalse() // The task is completed, so it is claimable. assertThat(task.isClaimable()).isFalse() } @Test fun `test when the task is expired and not completed and already claimed`() { val now = Clock.System.now() val task = DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = true, dateRange = (now - 2.days).rangeTo(now - 1.days), // Expired currentValue = 0u, // Not completed maxValue = 1u, event = GameEvent.MultiChoice.PlayQuestions, title = "Test".toUiText() ) assertThat(task.isExpired()).isTrue() assertThat(task.isCompleted()).isFalse() // The task is expired and not completed, so it is not active. assertThat(task.isActive()).isFalse() // The task is not completed, so it is not claimable. assertThat(task.isClaimable()).isFalse() } // Test value out of range and date range out of range @Test fun `when current value is greater than the maximum value, should be completed`() { val now = Clock.System.now() val task = DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = false, dateRange = (now - 1.days).rangeTo(now + 1.days), // Not expired currentValue = 2u, // Completed maxValue = 1u, event = GameEvent.MultiChoice.PlayQuestions, title = "Test".toUiText() ) assertThat(task.isExpired()).isFalse() assertThat(task.isCompleted()).isTrue() } @Test fun `when the date range is invalid, should throw IllegalArgumentException`() { val now = Clock.System.now() val dateRange = (now + 1.days).rangeTo(now - 1.days) val tz = TimeZone.currentSystemDefault() val start = dateRange.start.toLocalDateTime(tz).date val end = dateRange.endInclusive.toLocalDateTime(tz).date val e = assertThrows { DailyChallengeTask( id = 1, diamondsReward = 1u, experienceReward = 1u, isClaimed = false, dateRange = dateRange, // Not expired currentValue = 1u, // Completed maxValue = 1u, event = GameEvent.MultiChoice.PlayQuestions, title = "Test".toUiText() ) } assertThat(e) .hasMessageThat() .isEqualTo("The start date ($start) must be less than or equal to the end date ($end).") } } ================================================ FILE: model/src/test/java/com/infinitepower/newquiz/model/daily_challenge/DailyChallengeTaskTypeTest.kt ================================================ package com.infinitepower.newquiz.model.daily_challenge import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.model.global_event.GameEvent import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows /** * Tests for [GameEvent]. */ internal class DailyChallengeTaskTypeTest { @Test fun `test fromKey, when key is not valid, throws IllegalArgumentException`() { assertThrows { GameEvent.fromKey("invalid") } } @Test fun `test fromKey, when key is valid, returns the correct type`() { val type = GameEvent.fromKey("multi_choice_play_questions") assertThat(type).isEqualTo(GameEvent.MultiChoice.PlayQuestions) } @Test fun `test fromKey, when key is PlayQuizWithCategory, returns the correct type`() { val keyPrefix = GameEvent.MultiChoice.PlayQuizWithCategory.KEY_PREFIX val categoryKey = "category_key" val type = GameEvent.fromKey("$keyPrefix$categoryKey") val expectedType = GameEvent.MultiChoice.PlayQuizWithCategory(categoryKey) assertThat(type).isEqualTo(expectedType) } } ================================================ FILE: model/src/test/java/com/infinitepower/newquiz/model/math_quiz/maze/MazePointTest.kt ================================================ package com.infinitepower.newquiz.model.math_quiz.maze import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.model.maze.MazePoint import com.infinitepower.newquiz.model.maze.generateMazePointsBottomToTop import com.infinitepower.newquiz.model.maze.generateMazePointsTopToBottom import com.infinitepower.newquiz.model.maze.isInsideCircle import org.junit.jupiter.api.Test internal class MazePointTest { @Test fun `generate maze points, top to bottom test`() { val points = generateMazePointsTopToBottom( startPoint = MazePoint(50f, 0f), increment = MazePoint(50f, 50f) ).take(8).toList() println(points) val expectedPoints = listOf( MazePoint(x = 50f, 0f), MazePoint(x = 50f, 50f), MazePoint(x = 100f, 50f), MazePoint(x = 100f, 100f), MazePoint(x = 50f, 100f), MazePoint(x = 0f, 100f), MazePoint(x = 0f, 150f), MazePoint(x = 50f, 150f) ) assertThat(points).isEqualTo(expectedPoints) } @Test fun `generate maze points, bottom to top test`() { val points = generateMazePointsBottomToTop( startPoint = MazePoint(50f, 1000f), increment = MazePoint(50f, 50f) ).take(9).toList() println(points) val expectedPoints = listOf( MazePoint(x = 50f, 1000f), MazePoint(x = 50f, 950f), MazePoint(x = 100f, 950f), MazePoint(x = 100f, 900f), MazePoint(x = 50f, 900f), MazePoint(x = 0f, 900f), MazePoint(x = 0f, 850f), MazePoint(x = 50f, 850f), MazePoint(x = 100f, 850f) ) assertThat(points).isEqualTo(expectedPoints) } @Test fun `is inside circle test`() { val circlePoint = MazePoint(0f, 0f) val testPointInside = MazePoint(1f, 1f) val testPointOutside = MazePoint(2f, 2f) // Set the radius of the circle val radius = 2f assertThat(testPointInside.isInsideCircle(circlePoint, radius)).isTrue() assertThat(testPointOutside.isInsideCircle(circlePoint, radius)).isFalse() } } ================================================ FILE: model/src/test/java/com/infinitepower/newquiz/model/math_quiz/maze/MazeQuizTest.kt ================================================ package com.infinitepower.newquiz.model.math_quiz.maze internal class MazeQuizTest { /* @Test fun testIsPlayableItem_noPlayedItems() { // Create a list of MazeItem objects val items = listOf( MazeQuiz.MazeItem(formula = MathFormula.fromStringFullFormula("1+1=2")), // not played MazeQuiz.MazeItem(formula = MathFormula.fromStringFullFormula("1+1=2")), // not played MazeQuiz.MazeItem(formula = MathFormula.fromStringFullFormula("1+1=2")), // not played ) // Check if the first item is playable assertThat(items.isPlayableItem(0)).isTrue() // Check if the second item is playable assertThat(items.isPlayableItem(1)).isFalse() // Check if the third item is playable assertThat(items.isPlayableItem(2)).isFalse() } @Test fun testIsPlayableItem_firstPlayed() { // Create a list of MazeItem objects val items = listOf( MazeQuiz.MazeItem( formula = MathFormula.fromStringFullFormula("1+1=2"), played = true ), // played MazeQuiz.MazeItem(formula = MathFormula.fromStringFullFormula("1+1=2")), // not played MazeQuiz.MazeItem(formula = MathFormula.fromStringFullFormula("1+1=2")), // not played ) // Check if the first item is playable assertThat(items.isPlayableItem(0)).isFalse() // Check if the second item is playable assertThat(items.isPlayableItem(1)).isTrue() // Check if the third item is playable assertThat(items.isPlayableItem(2)).isFalse() } @Test fun testIsItemPlayed_playedItem() { val mazeItems = listOf( MazeQuiz.MazeItem( formula = MathFormula.fromStringFullFormula("1+1=2"), played = true ), MazeQuiz.MazeItem( formula = MathFormula.fromStringFullFormula("1+1=2"), played = true ), ) assertThat(mazeItems.isItemPlayed(0)).isTrue() } @Test fun testIsItemPlayed_unPlayedItem() { val mazeItems = listOf( MazeQuiz.MazeItem( formula = MathFormula.fromStringFullFormula("1+1=2"), played = true ), MazeQuiz.MazeItem( formula = MathFormula.fromStringFullFormula("1+1=2"), played = false ), ) assertThat(mazeItems.isItemPlayed(1)).isFalse() } @Test fun testIsItemPlayed_outOfBounds() { val mazeItems = listOf( MazeQuiz.MazeItem( formula = MathFormula.fromStringFullFormula("1+1=2"), played = true ), MazeQuiz.MazeItem( formula = MathFormula.fromStringFullFormula("1+1=2"), played = false ), ) assertThat(mazeItems.isItemPlayed(2)).isFalse() } */ } ================================================ FILE: model/src/test/java/com/infinitepower/newquiz/model/multi_choice_quiz/MultiChoiceQuestionTest.kt ================================================ package com.infinitepower.newquiz.model.multi_choice_quiz import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.model.question.QuestionDifficulty import kotlin.test.Test /** * Tests for [MultiChoiceQuestion]. */ internal class MultiChoiceQuestionTest { @Test fun `test question generated id is based in the fields`() { val question = MultiChoiceQuestion( description = "description", answers = listOf("a", "b", "c"), lang = QuestionLanguage.EN, category = MultiChoiceBaseCategory.Random, correctAns = 0, type = MultiChoiceQuestionType.MULTIPLE, difficulty = QuestionDifficulty.Easy ) assertThat(question.id).isEqualTo(376535089) val questionWithShuffledAnswers = MultiChoiceQuestion( description = "description", answers = listOf("c", "b", "a"), lang = QuestionLanguage.EN, category = MultiChoiceBaseCategory.Random, correctAns = 0, type = MultiChoiceQuestionType.MULTIPLE, difficulty = QuestionDifficulty.Easy ) assertThat(questionWithShuffledAnswers.id).isEqualTo(376535089) } } ================================================ FILE: model/src/test/java/com/infinitepower/newquiz/model/util/base64/Base64Test.kt ================================================ package com.infinitepower.newquiz.model.util.base64 import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Test internal class Base64Test { @Test fun byteArray_base64Decoded() { assertEquals("Hello, world!", "SGVsbG8sIHdvcmxkIQ==".encodeToByteArray().base64Decoded) assertArrayEquals( byteArrayOf( -94, 124, -26, -112, -72, -84, 16, 11, 67, -45, 107, 38, -99, 79, 62, -49, 83, 26, -85, -70, -122, 53, 67, 42, -94, -87, 61, -74, 66, 0, 80, -125, -17, -11, -125, 63, 109, -15, 56, -95, -33, 18, 110, 47, 47, -20, -72, -34, 53, -69, 49, -45, 54, 53, -21, 43, 9, -84, -125, 72, -61, 76, 31, -46 ), "onzmkLisEAtD02smnU8+z1Maq7qGNUMqoqk9tkIAUIPv9YM/bfE4od8Sbi8v7LjeNbsx0zY16ysJrINIw0wf0g==".base64DecodedBytes ) } @Test fun byteArray_base64Encoded() { assertEquals( "xvrp9DBWlei2mG0ov9MN+A==", // value1 byteArrayOf(-58, -6, -23, -12, 48, 86, -107, -24, -74, -104, 109, 40, -65, -45, 13, -8).base64Encoded ) assertEquals( "IkYJxF8nIQD9RY7Yk6r26A==", // value222 byteArrayOf(34, 70, 9, -60, 95, 39, 33, 0, -3, 69, -114, -40, -109, -86, -10, -24).base64Encoded ) assertEquals( "U0GeVBi2dNcdL2IO0nJo5Q==", // value555 byteArrayOf(83, 65, -98, 84, 24, -74, 116, -41, 29, 47, 98, 14, -46, 114, 104, -27).base64Encoded ) } @Test fun string_base64Decoded() { assertEquals("word", "d29yZA==".base64Decoded) assertEquals("Word", "V29yZA==".base64Decoded) assertEquals("Hello", "SGVsbG8=".base64Decoded) assertEquals("World!", "V29ybGQh".base64Decoded) assertEquals("Hello, world!", "SGVsbG8sIHdvcmxkIQ==".base64Decoded) assertEquals( Base64Encoding.Standard.alphabet, "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLw==".base64Decoded ) assertEquals("abcd", "YWJjZA==".base64Decoded) assertEquals( "1234567890-=!@#\$%^&*()_+qwertyuiop[];'\\,./?><|\":}{P`~", "MTIzNDU2Nzg5MC09IUAjJCVeJiooKV8rcXdlcnR5dWlvcFtdOydcLC4vPz48fCI6fXtQYH4=".base64Decoded ) assertEquals("saschpe", "c2FzY2hwZQ==".base64Decoded) } @Test fun string_base64Encoded() { assertEquals("d29yZA==", "word".base64Encoded) assertEquals("V29yZA==", "Word".base64Encoded) assertEquals("SGVsbG8=", "Hello".base64Encoded) assertEquals("V29ybGQh", "World!".base64Encoded) assertEquals("SGVsbG8sIHdvcmxkIQ==", "Hello, world!".base64Encoded) assertEquals("SGVsbG8sIHdvcmxkIQ==", "Hello, world!".encodeToByteArray().base64Encoded) assertEquals( "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLw==", Base64Encoding.Standard.alphabet.base64Encoded ) assertEquals("YWJjZA==", "abcd".base64Encoded) assertEquals( "MTIzNDU2Nzg5MC09IUAjJCVeJiooKV8rcXdlcnR5dWlvcFtdOydcLC4vPz48fCI6fXtQYH4=", "1234567890-=!@#\$%^&*()_+qwertyuiop[];'\\,./?><|\":}{P`~".base64Encoded ) assertEquals("c2FzY2hwZQ==", "saschpe".base64Encoded) } @Test fun string_roundTrip() { assertEquals( Base64Encoding.Standard.alphabet, Base64Encoding.Standard.alphabet.base64Encoded.base64Decoded ) } } ================================================ FILE: model/src/test/java/com/infinitepower/newquiz/model/util/base64/Base64UrlTest.kt ================================================ package com.infinitepower.newquiz.model.util.base64 import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Test internal class Base64UrlTest { @Test fun byteArray_base64UrlDecoded() { assertEquals("Hello, world!", "SGVsbG8sIHdvcmxkIQ==".encodeToByteArray().base64UrlDecoded) assertArrayEquals( byteArrayOf( -94, 124, -26, -112, -72, -84, 16, 11, 67, -45, 107, 38, -99, 79, 62, -49, 83, 26, -85, -70, -122, 53, 67, 42, -94, -87, 61, -74, 66, 0, 80, -125, -17, -11, -125, 63, 109, -15, 56, -95, -33, 18, 110, 47, 47, -20, -72, -34, 53, -69, 49, -45, 54, 53, -21, 43, 9, -84, -125, 72, -61, 76, 31, -46 ), "onzmkLisEAtD02smnU8-z1Maq7qGNUMqoqk9tkIAUIPv9YM_bfE4od8Sbi8v7LjeNbsx0zY16ysJrINIw0wf0g==".base64UrlDecodedBytes ) } @Test fun byteArray_base64UrlEncoded() { assertEquals( "xvrp9DBWlei2mG0ov9MN-A", // value1 byteArrayOf(-58, -6, -23, -12, 48, 86, -107, -24, -74, -104, 109, 40, -65, -45, 13, -8).base64UrlEncoded ) assertEquals( "IkYJxF8nIQD9RY7Yk6r26A", // value222 byteArrayOf(34, 70, 9, -60, 95, 39, 33, 0, -3, 69, -114, -40, -109, -86, -10, -24).base64UrlEncoded ) assertEquals( "U0GeVBi2dNcdL2IO0nJo5Q", // value555 byteArrayOf(83, 65, -98, 84, 24, -74, 116, -41, 29, 47, 98, 14, -46, 114, 104, -27).base64UrlEncoded ) } @Test fun string_base64UrlDecoded() { assertEquals("word", "d29yZA==".base64UrlDecoded) assertEquals("Word", "V29yZA==".base64UrlDecoded) assertEquals("Hello", "SGVsbG8=".base64UrlDecoded) assertEquals("World!", "V29ybGQh".base64UrlDecoded) assertEquals("Hello, world!", "SGVsbG8sIHdvcmxkIQ==".base64UrlDecoded) assertEquals( Base64Encoding.Standard.alphabet, "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLw==".base64UrlDecoded ) assertEquals("Salt", "U2FsdA==".base64UrlDecoded) assertEquals("Pepper", "UGVwcGVy".base64UrlDecoded) assertEquals("abcd", "YWJjZA".base64UrlDecoded) assertEquals( "1234567890-=!@#\$%^&*()_+qwertyuiop[];'\\,./?><|\":}{P`~", "MTIzNDU2Nzg5MC09IUAjJCVeJiooKV8rcXdlcnR5dWlvcFtdOydcLC4vPz48fCI6fXtQYH4".base64UrlDecoded ) assertEquals("saschpe", "c2FzY2hwZQ".base64UrlDecoded) } @Test fun string_base64UrlEncoded() { assertEquals("d29yZA", "word".base64UrlEncoded) assertEquals("V29yZA", "Word".base64UrlEncoded) assertEquals("SGVsbG8", "Hello".base64UrlEncoded) assertEquals("V29ybGQh", "World!".base64UrlEncoded) assertEquals("SGVsbG8sIHdvcmxkIQ", "Hello, world!".base64UrlEncoded) assertEquals("SGVsbG8sIHdvcmxkIQ", "Hello, world!".encodeToByteArray().base64UrlEncoded) assertEquals( "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODkrLw", Base64Encoding.Standard.alphabet.base64UrlEncoded ) assertEquals("U2FsdA", "Salt".base64UrlEncoded) assertEquals("UGVwcGVy", "Pepper".base64UrlEncoded) assertEquals("YWJjZA", "abcd".base64UrlEncoded) assertEquals( "MTIzNDU2Nzg5MC09IUAjJCVeJiooKV8rcXdlcnR5dWlvcFtdOydcLC4vPz48fCI6fXtQYH4", "1234567890-=!@#\$%^&*()_+qwertyuiop[];'\\,./?><|\":}{P`~".base64UrlEncoded ) assertEquals("c2FzY2hwZQ", "saschpe".base64UrlEncoded) } @Test fun string_roundTrip() { assertEquals( Base64Encoding.UrlSafe.alphabet, Base64Encoding.UrlSafe.alphabet.base64UrlEncoded.base64UrlDecoded ) } } ================================================ FILE: model/src/test/java/com/infinitepower/newquiz/model/wordle/WordleItemTest.kt ================================================ package com.infinitepower.newquiz.model.wordle import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test internal class WordleItemTest { @Test fun isCompleted() { val empty = WordleItem.Empty assertThat(empty.isCompleted).isFalse() val none = WordleItem.None(WordleChar('A')) assertThat(none.isCompleted).isTrue() val present = WordleItem.Present(WordleChar('A')) assertThat(present.isCompleted).isTrue() val correct = WordleItem.Correct(WordleChar('A')) assertThat(correct.isCompleted).isTrue() } @Test fun isVerified() { val empty = WordleItem.Empty assertThat(empty.isVerified).isFalse() val none = WordleItem.None(WordleChar('A')) assertThat(none.isVerified).isFalse() val none2 = WordleItem.None(WordleChar('A'), true) assertThat(none2.isVerified).isTrue() val present = WordleItem.Present(WordleChar('A')) assertThat(present.isVerified).isTrue() val correct = WordleItem.Correct(WordleChar('A')) assertThat(correct.isVerified).isTrue() } @Test fun `list wordle item count by type`() { val items = listOf( WordleItem.Empty, WordleItem.None(WordleChar('A')), WordleItem.None(WordleChar('A')), WordleItem.Present(WordleChar('A')), WordleItem.Present(WordleChar('A')), WordleItem.Present(WordleChar('A')), WordleItem.Correct(WordleChar('A')), WordleItem.Correct(WordleChar('A')), WordleItem.Correct(WordleChar('A')), WordleItem.Correct(WordleChar('A')) ) assertThat(items.countByItem()).isEqualTo(1) assertThat(items.countByItem()).isEqualTo(2) assertThat(items.countByItem()).isEqualTo(3) assertThat(items.countByItem()).isEqualTo(4) } } ================================================ FILE: model/src/test/java/com/infinitepower/newquiz/model/wordle/WordleRowItemTest.kt ================================================ package com.infinitepower.newquiz.model.wordle import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test internal class WordleRowItemTest { @Test fun isRowCorrect() { val rowItem1 = WordleRowItem(items = listOf(WordleItem.Empty)) assertThat(rowItem1.isRowCorrect).isFalse() val rowItem2 = WordleRowItem(items = listOf(WordleItem.fromChar('A'))) assertThat(rowItem2.isRowCorrect).isFalse() val rowItem3 = WordleRowItem( items = listOf( WordleItem.fromChar('A'), WordleItem.Present(WordleChar('B')), WordleItem.Correct(WordleChar('C')) ) ) assertThat(rowItem3.isRowCorrect).isFalse() val rowItem4 = WordleRowItem( items = listOf( WordleItem.fromChar('A'), WordleItem.Empty, WordleItem.Present(WordleChar('B')), WordleItem.Correct(WordleChar('C')) ) ) assertThat(rowItem4.isRowCorrect).isFalse() val rowItem5 = WordleRowItem(items = listOf(WordleItem.Correct(WordleChar('C')))) assertThat(rowItem5.isRowCorrect).isTrue() } @Test fun isRowCompleted() { val rowItem1 = WordleRowItem(items = listOf(WordleItem.Empty)) assertThat(rowItem1.isRowCompleted).isFalse() val rowItem2 = WordleRowItem(items = listOf(WordleItem.fromChar('A'))) assertThat(rowItem2.isRowCompleted).isTrue() val rowItem3 = WordleRowItem( items = listOf( WordleItem.fromChar('A'), WordleItem.Present(WordleChar('B')), WordleItem.Correct(WordleChar('C')) ) ) assertThat(rowItem3.isRowCompleted).isTrue() val rowItem4 = WordleRowItem( items = listOf( WordleItem.fromChar('A'), WordleItem.Empty, WordleItem.Present(WordleChar('B')), WordleItem.Correct(WordleChar('C')) ) ) assertThat(rowItem4.isRowCompleted).isFalse() } @Test fun isRowVerified() { val rowItem1 = WordleRowItem(items = listOf(WordleItem.Empty)) assertThat(rowItem1.isRowVerified).isFalse() val rowItem2 = WordleRowItem(items = listOf(WordleItem.fromChar('A'))) assertThat(rowItem2.isRowVerified).isFalse() val rowItem3 = WordleRowItem( items = listOf( WordleItem.fromChar('A'), WordleItem.Present(WordleChar('B')), WordleItem.Correct(WordleChar('C')) ) ) assertThat(rowItem3.isRowVerified).isFalse() val rowItem4 = WordleRowItem( items = listOf( WordleItem.fromChar('A'), WordleItem.Empty, WordleItem.Present(WordleChar('B')), WordleItem.Correct(WordleChar('C')) ) ) assertThat(rowItem4.isRowVerified).isFalse() val rowItem5 = WordleRowItem( items = listOf( WordleItem.None(WordleChar('A'), true), WordleItem.Present(WordleChar('B')), WordleItem.Correct(WordleChar('C')) ) ) assertThat(rowItem5.isRowVerified).isTrue() val rowItem6 = WordleRowItem(items = listOf(WordleItem.Present(WordleChar('A')))) assertThat(rowItem6.isRowVerified).isTrue() val rowItem7 = WordleRowItem(items = listOf(WordleItem.Correct(WordleChar('A')))) assertThat(rowItem7.isRowVerified).isTrue() } } ================================================ FILE: multi-choice-quiz/.gitignore ================================================ /build ================================================ FILE: multi-choice-quiz/README.md ================================================ # Multi choice quiz This is the code for the multi choice quiz game mode. ![NewQuiz purple light](../pictures/multichoicequiz.jpg) ================================================ FILE: multi-choice-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) } android { namespace = "com.infinitepower.newquiz.multi_choice_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) androidTestImplementation(libs.androidx.work.testing) implementation(libs.hilt.navigationCompose) implementation(libs.room.runtime) ksp(libs.room.compiler) implementation(libs.room.ktx) implementation(libs.room.testing) implementation(libs.lottie.compose) implementation(libs.coil.kt.compose) implementation(libs.coil.kt.svg) implementation(libs.androidx.work.ktx) implementation(projects.core) implementation(projects.core.analytics) implementation(projects.core.datastore) implementation(projects.core.translation) implementation(projects.core.userServices) implementation(projects.core.remoteConfig) implementation(projects.model) implementation(projects.domain) implementation(projects.data) androidTestImplementation(projects.core.testing) } ================================================ FILE: multi-choice-quiz/consumer-rules.pro ================================================ ================================================ FILE: multi-choice-quiz/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.** { *; } -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.** { *; } ================================================ FILE: multi-choice-quiz/src/androidTest/java/com/infinitepower/newquiz/multi_choice_quiz/MultiChoiceQuizScreenTest.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz 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.assertAll import androidx.compose.ui.test.assertContentDescriptionEquals import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.isNotEnabled import androidx.compose.ui.test.isNotSelected import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription 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.work.WorkManager import androidx.work.testing.WorkManagerTestInitHelper import com.infinitepower.newquiz.core.analytics.LocalDebugAnalyticsHelper import com.infinitepower.newquiz.core.datastore.common.SettingsCommon import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import com.infinitepower.newquiz.core.remote_config.RemoteConfig import com.infinitepower.newquiz.core.testing.utils.setTestContent import com.infinitepower.newquiz.core.translation.TranslatorUtil import com.infinitepower.newquiz.core.user_services.UserService import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.MultiChoiceQuestionRepository import com.infinitepower.newquiz.domain.use_case.question.GetRandomMultiChoiceQuestionUseCase import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory import com.infinitepower.newquiz.model.multi_choice_quiz.getBasicMultiChoiceQuestion import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.mockk import org.junit.Rule import org.junit.runner.RunWith import javax.inject.Inject import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test /** * Tests for [MultiChoiceQuizScreen]. */ @HiltAndroidTest @RunWith(AndroidJUnit4::class) @OptIn( ExperimentalTestApi::class, ExperimentalMaterial3WindowSizeClassApi::class ) internal class MultiChoiceQuizScreenTest { @get:Rule val hiltRule = HiltAndroidRule(this) @get:Rule val composeTestRule = createAndroidComposeRule() private lateinit var workManager: WorkManager @Inject lateinit var getRandomQuestionUseCase: GetRandomMultiChoiceQuestionUseCase @Inject lateinit var userService: UserService @Inject lateinit var remoteConfig: RemoteConfig private val settingsDataStoreManager = mockk() private val translationUtil = mockk() @BeforeTest fun setUp() { hiltRule.inject() remoteConfig.initialize() WorkManagerTestInitHelper.initializeTestWorkManager(composeTestRule.activity) workManager = WorkManager.getInstance(composeTestRule.activity) val questionsRepository = mockk() coEvery { questionsRepository.getRandomQuestions(any(), any(), any()) } returns List(5) { getBasicMultiChoiceQuestion( correctAns = 0 ) } } @AfterTest fun tearDown() { clearAllMocks() } @Test fun testMultiChoiceQuizScreen() { coEvery { settingsDataStoreManager.getPreference(SettingsCommon.MultiChoiceQuizQuestionsSize) } returns 5 coEvery { translationUtil.isReadyToTranslate() } returns false composeTestRule.setTestContent { val windowSizeClass = calculateWindowSizeClass(composeTestRule.activity) MultiChoiceQuizScreen( navigator = EmptyDestinationsNavigator, windowSizeClass = windowSizeClass, viewModel = QuizScreenViewModel( getRandomQuestionUseCase = getRandomQuestionUseCase, settingsDataStoreManager = settingsDataStoreManager, savedQuestionsRepository = mockk(), recentCategoriesRepository = mockk(), savedStateHandle = SavedStateHandle( mapOf( "category" to MultiChoiceBaseCategory.Random ) ), translationUtil = translationUtil, workManager = workManager, isQuestionSavedUseCase = mockk(relaxed = true), analyticsHelper = LocalDebugAnalyticsHelper(), userService = userService, remoteConfig = remoteConfig ) ) } composeTestRule.waitUntilDoesNotExist(hasText("Loading...")) // Test quiz step row composeTestRule .onNodeWithContentDescription("Quiz steps container") .assertIsDisplayed() .onChildren() .assertCountEquals(5) .assertAll(isNotEnabled()) .onFirst() .assertTextEquals("1") for (questionNumber in 1..5) { composeTestRule .onNodeWithText("Question $questionNumber/5") .assertIsDisplayed() // Test question description composeTestRule.onNodeWithText("Question ${questionNumber - 1}").assertIsDisplayed() // Test question answers composeTestRule .onNodeWithContentDescription("Answers container") .assertIsDisplayed() .onChildren() .assertCountEquals(4) .assertAll(isEnabled() and isNotSelected()) // Test verify button composeTestRule.onNodeWithText("Verify").assertDoesNotExist() val randomAnswer = (0..3).random() val randomAnswerText = "Answer $randomAnswer" // Click on random answer composeTestRule .onNodeWithText(randomAnswerText) .assertIsDisplayed() .assertIsEnabled() .performClick() .assertIsEnabled() .assertIsSelected() // Check if verify button is displayed after selecting an answer composeTestRule .onNode(hasText("Verify") or hasContentDescription("Verify")) .assertIsDisplayed() .assertIsEnabled() .performClick() // Click on verify button .assertDoesNotExist() // Check if verify button is not displayed after clicking on it // Check if correct answer is displayed in the steps composeTestRule .onNodeWithContentDescription("Quiz steps container") .assertIsDisplayed() .onChildren() .assertAll(isNotEnabled()) } } } ================================================ FILE: multi-choice-quiz/src/androidTest/java/com/infinitepower/newquiz/multi_choice_quiz/components/CardQuestionOptionTest.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.components 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.* import androidx.compose.ui.test.junit4.createComposeRule 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.multi_choice_quiz.SelectedAnswer import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @SmallTest @RunWith(AndroidJUnit4::class) class CardQuestionOptionTest { @get:Rule val composeTestRule = createComposeRule() @Test fun testButtonClick_whenNotSelected() { var clicked by mutableStateOf(false) composeTestRule.setTestContent { CardQuestionAnswer( answer = "Test", selected = clicked, onClick = { clicked = true } ) } composeTestRule .onNodeWithText("Test") .assertHasClickAction() .assertIsNotSelected() .performClick() .assertIsSelected() assertThat(clicked).isTrue() } @Test fun testButtonClick_whenSelected() { var clicked by mutableStateOf(true) composeTestRule.setTestContent { CardQuestionAnswer( answer = "Test", selected = clicked, onClick = { clicked = true } ) } composeTestRule .onNodeWithText("Test") .assertHasClickAction() .assertIsSelected() .performClick() .assertIsSelected() assertThat(clicked).isTrue() } @Test fun testButtonsClick() { val answers = listOf("A", "B", "C", "D") var selectedAnswer: SelectedAnswer by mutableStateOf(SelectedAnswer.NONE) composeTestRule.setTestContent { CardQuestionAnswers( modifier = Modifier.testTag("Answers"), answers = answers, selectedAnswer = selectedAnswer, onOptionClick = { selectedAnswer = it } ) } composeTestRule .onNodeWithTag("Answers") .assertContentDescriptionEquals("Answers container") .onChildren() .assertCountEquals(answers.size) .assertAll(isNotSelected() and hasClickAction()) .onFirst() .performClick() .assertIsSelected() composeTestRule .onNodeWithTag("Answers") .onChildren() .filterToOne(isSelected()) } } ================================================ FILE: multi-choice-quiz/src/androidTest/java/com/infinitepower/newquiz/multi_choice_quiz/components/QuizStepViewRowTest.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.components import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertContentDescriptionEquals import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.filter import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.isNotEnabled 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.onNodeWithTag 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.multi_choice_quiz.MultiChoiceQuestionStep import com.infinitepower.newquiz.model.multi_choice_quiz.getBasicMultiChoiceQuestion import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith /** * Tests for [QuizStepViewRow] */ @SmallTest @RunWith(AndroidJUnit4::class) class QuizStepViewRowTest { @get:Rule val composeTestRule = createComposeRule() @Test fun quizStepViewRow_WithQuestionSteps_whenGameScreen() { var clicked = false val questionSteps = listOf( MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true ), MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = false ), MultiChoiceQuestionStep.Current(question = getBasicMultiChoiceQuestion()), MultiChoiceQuestionStep.NotCurrent(question = getBasicMultiChoiceQuestion()) ) composeTestRule.setTestContent { QuizStepViewRow( questionSteps = questionSteps, isResultsScreen = false, // Game screen onClick = { _, _ -> clicked = true }, modifier = Modifier.testTag("QuizStepViewRow") ) } composeTestRule .onNodeWithTag("QuizStepViewRow") .onChildren() .filter(isNotEnabled()) .assertCountEquals(4) .apply { onFirst().assertContentDescriptionEquals("Question 1 - Correct") this[1].assertContentDescriptionEquals("Question 2 - Incorrect") this[2].assertTextEquals("3") onLast() .assertTextEquals("4") .performClick() } assertThat(clicked).isFalse() } @Test fun quizStepViewRow_WithQuestionSteps_whenResultsScreen() { var clickedIndex = -1 var clickedQuestionStep: MultiChoiceQuestionStep? = null val questionSteps = listOf( MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true ), MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = false ), MultiChoiceQuestionStep.Current(question = getBasicMultiChoiceQuestion()), MultiChoiceQuestionStep.NotCurrent(question = getBasicMultiChoiceQuestion()) ) composeTestRule.setTestContent { QuizStepViewRow( questionSteps = questionSteps, isResultsScreen = true, // Results screen onClick = { index, questionStep -> clickedIndex = index clickedQuestionStep = questionStep }, modifier = Modifier.testTag("QuizStepViewRow") ) } composeTestRule .onNodeWithTag("QuizStepViewRow") .onChildren() .filter(isEnabled() and hasClickAction()) .assertCountEquals(4) .apply { onFirst().assertContentDescriptionEquals("Question 1 - Correct") this[1].assertContentDescriptionEquals("Question 2 - Incorrect") this[2].assertTextEquals("3") onLast() .assertTextEquals("4") .performClick() } assertThat(clickedIndex).isEqualTo(3) assertThat(clickedQuestionStep).isEqualTo(questionSteps[3]) } } ================================================ FILE: multi-choice-quiz/src/androidTest/java/com/infinitepower/newquiz/multi_choice_quiz/components/QuizStepViewTest.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.components import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertContentDescriptionEquals import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.assertWidthIsEqualTo 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.compose.ui.unit.dp 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.multi_choice_quiz.MultiChoiceQuestionStep import com.infinitepower.newquiz.model.multi_choice_quiz.getBasicMultiChoiceQuestion import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith /** * Tests for [QuizStepView] */ @SmallTest @RunWith(AndroidJUnit4::class) internal class QuizStepViewTest { @get:Rule val composeTestRule = createComposeRule() @Test fun quizStepView_whenCompletedCorrectAnswer_showsCheckIcon() { var clicked = false composeTestRule.setTestContent { QuizStepView( questionStep = MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true ), position = 1, enabled = false, onClick = { clicked = true }, modifier = Modifier.testTag("QuizStepView") ) } composeTestRule.onNodeWithText("1").assertDoesNotExist() composeTestRule .onNodeWithTag("QuizStepView") .assertIsNotEnabled() .assertWidthIsEqualTo(35.dp) .assertHeightIsEqualTo(35.dp) .assertContentDescriptionEquals("Question 1 - Correct") assertThat(clicked).isFalse() } @Test fun quizStepView_whenCompletedIncorrectAnswer_showsCloseIcon() { composeTestRule.setTestContent { QuizStepView( questionStep = MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = false ), position = 1, enabled = false, onClick = {}, modifier = Modifier.testTag("QuizStepView") ) } composeTestRule.onNodeWithText("1").assertDoesNotExist() composeTestRule .onNodeWithTag("QuizStepView") .assertIsNotEnabled() .assertWidthIsEqualTo(35.dp) .assertHeightIsEqualTo(35.dp) .assertContentDescriptionEquals("Question 1 - Incorrect") } @Test fun quizStepView_whenCurrentStep_showsPosition() { composeTestRule.setTestContent { QuizStepView( questionStep = MultiChoiceQuestionStep.Current( question = getBasicMultiChoiceQuestion() ), position = 1, enabled = false, onClick = {}, modifier = Modifier.testTag("QuizStepView") ) } composeTestRule .onNodeWithTag("QuizStepView") .assertTextEquals("1") .assertIsNotEnabled() .assertWidthIsEqualTo(35.dp) .assertHeightIsEqualTo(35.dp) } @Test fun quizStepView_whenNotCurrentStep_showsPosition() { composeTestRule.setTestContent { QuizStepView( questionStep = MultiChoiceQuestionStep.NotCurrent( question = getBasicMultiChoiceQuestion() ), position = 1, enabled = false, onClick = {}, modifier = Modifier.testTag("QuizStepView") ) } composeTestRule .onNodeWithTag("QuizStepView") .assertTextEquals("1") .assertIsNotEnabled() .assertWidthIsEqualTo(35.dp) .assertHeightIsEqualTo(35.dp) } @Test fun quizStepView_whenEnabled_shouldClick() { var clicked = false composeTestRule.setTestContent { QuizStepView( questionStep = MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true ), position = 1, enabled = true, onClick = { clicked = true }, modifier = Modifier.testTag("QuizStepView") ) } composeTestRule .onNodeWithTag("QuizStepView") .assertIsEnabled() .assertHasClickAction() .assertWidthIsEqualTo(35.dp) .assertHeightIsEqualTo(35.dp) .assertContentDescriptionEquals("Question 1 - Correct") .performClick() assertThat(clicked).isTrue() } } ================================================ FILE: multi-choice-quiz/src/main/AndroidManifest.xml ================================================ ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/MultiChoiceQuizScreen.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz import androidx.annotation.Keep import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi 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.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext 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.ImageLoader import coil.compose.AsyncImage import coil.decode.SvgDecoder import com.infinitepower.newquiz.core.navigation.MazeNavigator import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.ui.components.skip_question.SkipQuestionDialog import com.infinitepower.newquiz.core.util.toAndroidUri import com.infinitepower.newquiz.model.RemainingTime 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.MultiChoiceQuestionStep import com.infinitepower.newquiz.model.multi_choice_quiz.SelectedAnswer import com.infinitepower.newquiz.model.multi_choice_quiz.getBasicMultiChoiceQuestion import com.infinitepower.newquiz.multi_choice_quiz.components.CardQuestionAnswers import com.infinitepower.newquiz.multi_choice_quiz.components.MultiChoiceQuizContainer import com.infinitepower.newquiz.multi_choice_quiz.components.QuizStepViewRow import com.infinitepower.newquiz.multi_choice_quiz.components.QuizTopBar import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizResultsScreenDestination import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizScreenDestination import com.ramcosta.composedestinations.annotation.DeepLink import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.time.Duration.Companion.seconds internal val MULTI_CHOICE_QUIZ_COUNTDOWN_TIME = 30.seconds @Keep data class MultiChoiceQuizScreenNavArg( val initialQuestions: ArrayList = arrayListOf(), val category: MultiChoiceBaseCategory = MultiChoiceBaseCategory.Normal(), val difficulty: String? = null, val mazeItemId: String? = null ) @Composable @Destination( navArgsDelegate = MultiChoiceQuizScreenNavArg::class, deepLinks = [ DeepLink(uriPattern = "newquiz://quickquiz") ] ) @OptIn(ExperimentalMaterial3Api::class) fun MultiChoiceQuizScreen( navigator: DestinationsNavigator, mazeNavigator: MazeNavigator, navController: NavController, windowSizeClass: WindowSizeClass, viewModel: QuizScreenViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(uiState.isGameEnded) { if (uiState.isGameEnded) { val backStackEntry = navController.getBackStackEntry(MultiChoiceQuizScreenDestination.route) val args = MultiChoiceQuizScreenDestination.argsFrom(backStackEntry) navigateToResultsScreen( navigator = navigator, mazeNavigator = mazeNavigator, args = args, questionSteps = uiState.questionSteps.filterIsInstance() ) } } MultiChoiceQuizScreenImpl( onBackClick = navigator::popBackStack, windowSizeClass = windowSizeClass, uiState = uiState, onEvent = viewModel::onEvent ) SkipQuestionDialog( userDiamonds = uiState.userDiamonds, skipCost = uiState.skipCost, loading = uiState.userDiamondsLoading, onSkipClick = { viewModel.onEvent(MultiChoiceQuizScreenUiEvent.SkipQuestion) }, onDismissClick = { viewModel.onEvent(MultiChoiceQuizScreenUiEvent.CleanUserSkipQuestionDiamonds) } ) } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MultiChoiceQuizScreenImpl( uiState: MultiChoiceQuizScreenUiState, windowSizeClass: WindowSizeClass, onBackClick: () -> Unit, onEvent: (event: MultiChoiceQuizScreenUiEvent) -> Unit ) { val context = LocalContext.current val imageLoader = ImageLoader .Builder(context) .components { add(SvgDecoder.Factory()) }.build() val widthCompact = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact val currentQuestion = uiState.currentQuestionStep?.question MultiChoiceQuizContainer( modifier = Modifier.fillMaxSize(), loading = uiState.loading, windowSizeClass = windowSizeClass, answerSelected = uiState.selectedAnswer.isSelected, onVerifyQuestionClick = { onEvent(MultiChoiceQuizScreenUiEvent.VerifyAnswer) }, topBarContent = { QuizTopBar( remainingTime = uiState.remainingTime, windowHeightSizeClass = windowSizeClass.heightSizeClass, skipsAvailable = uiState.skipsAvailable, onBackClick = onBackClick, onSkipClick = { onEvent(MultiChoiceQuizScreenUiEvent.GetUserSkipQuestionDiamonds) }, onSaveClick = { onEvent(MultiChoiceQuizScreenUiEvent.SaveQuestion) }, modifier = Modifier .windowInsetsPadding(TopAppBarDefaults.windowInsets) .fillMaxWidth(), currentQuestionNull = uiState.currentQuestionStep == null, questionSaved = uiState.questionSaved ) }, stepsContent = { QuizStepViewRow( questionSteps = uiState.questionSteps, isResultsScreen = false ) }, questionPositionContent = { Text( text = uiState.getQuestionPositionFormatted(), style = MaterialTheme.typography.headlineSmall ) }, questionDescriptionContent = { if (currentQuestion != null) { Text( text = currentQuestion.description, style = MaterialTheme.typography.bodyLarge ) } }, questionImageContent = { currentQuestion?.image?.let { image -> val imageScale = if (currentQuestion.category == MultiChoiceBaseCategory.Logo) { ContentScale.FillHeight } else ContentScale.Crop val maxWidth = if (widthCompact) 1f else 0.4f Box( modifier = Modifier.fillMaxWidth(maxWidth) ) { AsyncImage( model = image.toAndroidUri(), contentDescription = "Image", modifier = Modifier .aspectRatio(16 / 9f) .clip(MaterialTheme.shapes.medium), contentScale = imageScale, imageLoader = imageLoader ) } } }, answersContent = { CardQuestionAnswers( answers = currentQuestion?.answers.orEmpty(), selectedAnswer = uiState.selectedAnswer, onOptionClick = { answer -> onEvent(MultiChoiceQuizScreenUiEvent.SelectAnswer(answer)) } ) } ) } private fun navigateToResultsScreen( navigator: DestinationsNavigator, mazeNavigator: MazeNavigator, args: MultiChoiceQuizScreenNavArg, questionSteps: List ) { val questionFromMaze = args.mazeItemId != null if (questionFromMaze) { mazeNavigator.navigateToMazeResults(args.mazeItemId?.toIntOrNull() ?: return) } else { val questionStepsStr = Json.encodeToString(questionSteps) navigator.navigate( MultiChoiceQuizResultsScreenDestination( questionStepsStr = questionStepsStr, byInitialQuestions = args.initialQuestions.isNotEmpty(), category = args.category, difficulty = args.difficulty ) ) { launchSingleTop = true popUpTo(MultiChoiceQuizScreenDestination) { inclusive = true } } } } @Composable @PreviewScreenSizes @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) private fun QuizScreenPreview() { val questionSteps = listOf( MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true ), MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = false ), MultiChoiceQuestionStep.Current(question = getBasicMultiChoiceQuestion()), MultiChoiceQuestionStep.NotCurrent(question = getBasicMultiChoiceQuestion()), ) val configuration = LocalConfiguration.current val screenHeight = configuration.screenHeightDp.dp val screenWidth = configuration.screenWidthDp.dp val windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(screenWidth, screenHeight)) NewQuizTheme { MultiChoiceQuizScreenImpl( uiState = MultiChoiceQuizScreenUiState( questionSteps = questionSteps, selectedAnswer = SelectedAnswer.fromIndex((0..3).random()), currentQuestionIndex = 2, loading = false, remainingTime = RemainingTime(20.seconds) ), windowSizeClass = windowSizeClass, onBackClick = {}, onEvent = {}, ) } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/MultiChoiceQuizScreenUiEvent.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz import com.infinitepower.newquiz.model.multi_choice_quiz.SelectedAnswer sealed class MultiChoiceQuizScreenUiEvent { data class SelectAnswer(val answer: SelectedAnswer) : MultiChoiceQuizScreenUiEvent() object VerifyAnswer : MultiChoiceQuizScreenUiEvent() object SaveQuestion : MultiChoiceQuizScreenUiEvent() object GetUserSkipQuestionDiamonds : MultiChoiceQuizScreenUiEvent() object CleanUserSkipQuestionDiamonds : MultiChoiceQuizScreenUiEvent() object SkipQuestion : MultiChoiceQuizScreenUiEvent() } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/MultiChoiceQuizScreenUiState.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz import androidx.annotation.Keep import com.infinitepower.newquiz.model.RemainingTime import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestionStep import com.infinitepower.newquiz.model.multi_choice_quiz.SelectedAnswer import com.infinitepower.newquiz.model.multi_choice_quiz.isAllCompleted @Keep data class MultiChoiceQuizScreenUiState( val loading: Boolean = true, val questionSteps: List = emptyList(), val currentQuestionIndex: Int = -1, val selectedAnswer: SelectedAnswer = SelectedAnswer.NONE, val questionSaved: Boolean = false, val remainingTime: RemainingTime = RemainingTime.ZERO, val userDiamonds: Int = -1, val userDiamondsLoading: Boolean = false, val skipsAvailable: Boolean = false, val skipCost: Int = 1, ) { val currentQuestionStep: MultiChoiceQuestionStep.Current? = questionSteps.getOrNull(currentQuestionIndex)?.asCurrent() fun getQuestionPositionFormatted(): String = "Question ${currentQuestionIndex + 1}/${questionSteps.size}" /** * Gets new question index. * If question is the last question returns -1. * @return new question index */ fun getNextIndex(): Int = if (currentQuestionIndex == questionSteps.lastIndex) -1 else currentQuestionIndex + 1 val isGameEnded: Boolean get() = !loading && questionSteps.isAllCompleted() } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/MultiChoiceQuizScreenViewModel.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz import android.os.CountDownTimer import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.workDataOf import com.infinitepower.newquiz.core.analytics.AnalyticsEvent import com.infinitepower.newquiz.core.analytics.AnalyticsHelper import com.infinitepower.newquiz.core.datastore.common.SettingsCommon import com.infinitepower.newquiz.core.datastore.di.SettingsDataStoreManager import com.infinitepower.newquiz.core.datastore.manager.DataStoreManager import com.infinitepower.newquiz.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.translation.TranslatorUtil import com.infinitepower.newquiz.core.user_services.UserService import com.infinitepower.newquiz.core.user_services.workers.MultiChoiceQuizEndGameWorker import com.infinitepower.newquiz.data.worker.UpdateGlobalEventDataWorker import com.infinitepower.newquiz.domain.repository.home.RecentCategoriesRepository import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.saved_questions.SavedMultiChoiceQuestionsRepository import com.infinitepower.newquiz.domain.use_case.question.GetRandomMultiChoiceQuestionUseCase import com.infinitepower.newquiz.domain.use_case.question.IsQuestionSavedUseCase import com.infinitepower.newquiz.model.RemainingTime import com.infinitepower.newquiz.model.Resource import com.infinitepower.newquiz.model.global_event.GameEvent 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.MultiChoiceQuestionStep import com.infinitepower.newquiz.model.multi_choice_quiz.SelectedAnswer import com.infinitepower.newquiz.model.multi_choice_quiz.isAllCorrect import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import javax.inject.Inject const val QUIZ_COUNTDOWN_IN_MILLIS = 30000L @HiltViewModel @OptIn(ExperimentalCoroutinesApi::class) class QuizScreenViewModel @Inject constructor( private val getRandomQuestionUseCase: GetRandomMultiChoiceQuestionUseCase, @SettingsDataStoreManager private val settingsDataStoreManager: DataStoreManager, private val savedQuestionsRepository: SavedMultiChoiceQuestionsRepository, private val recentCategoriesRepository: RecentCategoriesRepository, savedStateHandle: SavedStateHandle, private val translationUtil: TranslatorUtil, private val workManager: WorkManager, private val isQuestionSavedUseCase: IsQuestionSavedUseCase, private val analyticsHelper: AnalyticsHelper, private val userService: UserService, private val remoteConfig: RemoteConfig, private val mazeQuizRepository: MazeQuizRepository ) : ViewModel() { private val navArgs: MultiChoiceQuizScreenNavArg = savedStateHandle.navArgs() private val _uiState = MutableStateFlow(MultiChoiceQuizScreenUiState()) val uiState = _uiState.asStateFlow() private val timer = object : CountDownTimer(QUIZ_COUNTDOWN_IN_MILLIS, 250) { override fun onTick(millisUntilFinished: Long) { _uiState.update { currentState -> currentState.copy(remainingTime = RemainingTime.fromMilliseconds(millisUntilFinished)) } } override fun onFinish() { verifyQuestion() } } override fun onCleared() { timer.cancel() super.onCleared() } fun onEvent(event: MultiChoiceQuizScreenUiEvent) { when (event) { is MultiChoiceQuizScreenUiEvent.SelectAnswer -> selectAnswer(event.answer) is MultiChoiceQuizScreenUiEvent.VerifyAnswer -> verifyQuestion() is MultiChoiceQuizScreenUiEvent.SaveQuestion -> saveQuestion() is MultiChoiceQuizScreenUiEvent.GetUserSkipQuestionDiamonds -> getUserDiamonds() is MultiChoiceQuizScreenUiEvent.CleanUserSkipQuestionDiamonds -> { _uiState.update { currentState -> currentState.copy( userDiamonds = -1, userDiamondsLoading = false ) } } is MultiChoiceQuizScreenUiEvent.SkipQuestion -> skipQuestion() } } init { viewModelScope.launch { val initialQuestions = navArgs.initialQuestions if (initialQuestions.isEmpty()) { loadByCloudQuestions() } else { createQuestionSteps(initialQuestions, MultiChoiceBaseCategory.Normal()) } } viewModelScope.launch { _uiState.update { currentState -> currentState.copy( skipsAvailable = userService.userAvailable(), skipCost = remoteConfig.get(RemoteConfigValue.MULTICHOICE_SKIP_COST) ) } } uiState .distinctUntilChangedBy { it.currentQuestionStep?.question } .filter { it.currentQuestionStep?.question != null } .flatMapLatest { state -> val question = state.currentQuestionStep?.question ?: return@flatMapLatest emptyFlow() isQuestionSavedUseCase(question) }.onEach { res -> if (res.isSuccess()) { val questionSaved = res.data == true _uiState.update { currentState -> currentState.copy(questionSaved = questionSaved) } } }.launchIn(viewModelScope) } private fun skipQuestion() = viewModelScope.launch { _uiState.update { currentState -> if (currentState.userDiamonds < 1) return@launch val currentQuestionStep = currentState.currentQuestionStep val correctAnswer = currentQuestionStep?.question?.correctAns ?: return@launch selectAnswer(SelectedAnswer.fromIndex(correctAnswer)) verifyQuestion(skipped = true) userService.addRemoveDiamonds(-currentState.skipCost) analyticsHelper.logEvent( AnalyticsEvent.SpendDiamonds( currentState.skipCost, "skip_multichoicequestion" ) ) currentState.copy(userDiamonds = -1) } } private suspend fun loadByCloudQuestions() { val questionSize = settingsDataStoreManager.getPreference(SettingsCommon.MultiChoiceQuizQuestionsSize) val category = navArgs.category val difficulty = navArgs.difficulty if (category.hasCategory) { recentCategoriesRepository.addMultiChoiceCategory(category.id) } getRandomQuestionUseCase(questionSize, category, difficulty).collect { res -> if (res is Resource.Success) { createQuestionSteps(res.data.orEmpty(), category, difficulty) } } } private suspend fun List.getOrTranslateQuestions(): List { // If is not ready to translate or translation not enabled, return the same list if (!translationUtil.isReadyToTranslate()) return this return map { question -> question translateQuestionWith translationUtil } } private suspend infix fun MultiChoiceQuestion.translateQuestionWith(translationUtil: TranslatorUtil): MultiChoiceQuestion { return copy( description = translationUtil.translate(description), answers = translationUtil.translate(answers) ) } private suspend fun createQuestionSteps( questions: List, category: MultiChoiceBaseCategory, difficulty: String? = null ) { val questionSteps = questions .getOrTranslateQuestions() .map { question -> question.toQuestionStep() } _uiState.update { currentState -> currentState.copy( questionSteps = questionSteps, loading = false ) } viewModelScope.launch(Dispatchers.IO) { // Update global event data, for daily challenge and achievements val event = if (category.hasCategory) { GameEvent.MultiChoice.PlayQuizWithCategory(category.id) } else { GameEvent.MultiChoice.PlayRandomQuiz } UpdateGlobalEventDataWorker.enqueueWork(workManager = workManager, event) // Log game start analyticsHelper.logEvent( AnalyticsEvent.MultiChoiceGameStart( questionsSize = questionSteps.size, category = category.toString(), difficulty = difficulty ) ) } nextQuestion() } private fun nextQuestion() { _uiState.update { currentState -> val nextIndex = currentState.getNextIndex() when { currentState.isGameEnded -> { endGame(currentState.questionSteps.filterIsInstance()) currentState.copy(currentQuestionIndex = -1) } nextIndex == -1 -> currentState.copy(currentQuestionIndex = nextIndex) else -> { timer.start() val newSteps = currentState .questionSteps .toMutableList() .apply { val step = currentState.questionSteps[nextIndex].asCurrent() set(nextIndex, step) } currentState.copy( questionSteps = newSteps, currentQuestionIndex = nextIndex ) } } } } private fun selectAnswer(answer: SelectedAnswer) { _uiState.update { currentState -> currentState.copy(selectedAnswer = answer) } } private fun verifyQuestion(skipped: Boolean = false) { timer.cancel() _uiState.update { currentState -> val steps = currentState .questionSteps .toMutableList() .apply { val currentQuestionIndex = currentState.currentQuestionIndex val currentQuestionStep = currentState.currentQuestionStep val questionTime = currentState.remainingTime.getElapsedSeconds( MULTI_CHOICE_QUIZ_COUNTDOWN_TIME ) if (currentQuestionStep != null) { val questionCorrect = currentState.selectedAnswer isCorrect currentQuestionStep.question // Update play question and get answers correct for global event data viewModelScope.launch(Dispatchers.IO) { val events = if (questionCorrect) { arrayOf( GameEvent.MultiChoice.PlayQuestions, GameEvent.MultiChoice.GetAnswersCorrect ) } else { arrayOf(GameEvent.MultiChoice.PlayQuestions) } UpdateGlobalEventDataWorker.enqueueWork( workManager = workManager, event = events ) } val completedQuestionStep = currentQuestionStep.changeToCompleted( correct = questionCorrect, selectedAnswer = currentState.selectedAnswer, questionTime = questionTime, skipped = skipped ) set(currentQuestionIndex, completedQuestionStep) } } currentState.copy( questionSteps = steps, selectedAnswer = SelectedAnswer.NONE ) } nextQuestion() } private fun saveQuestion() = viewModelScope.launch(Dispatchers.IO) { val currentQuestionStep = uiState.first().currentQuestionStep ?: return@launch val currentQuestion = currentQuestionStep.question savedQuestionsRepository.insertQuestions(currentQuestion) analyticsHelper.logEvent(AnalyticsEvent.MultiChoiceSaveQuestion) } private fun endGame(questionSteps: List) { viewModelScope.launch(Dispatchers.IO) { val questionStepsStr = Json.encodeToString(questionSteps) val endGameWorkRequest = OneTimeWorkRequestBuilder() .setInputData( workDataOf( MultiChoiceQuizEndGameWorker.INPUT_QUESTION_STEPS to questionStepsStr, // Only generate XP if is not initial questions (saved questions) MultiChoiceQuizEndGameWorker.INPUT_GENERATE_XP to navArgs.initialQuestions.isEmpty(), ) ).build() val allCorrect = questionSteps.isAllCorrect() val mazeItemId = navArgs.mazeItemId?.toIntOrNull() if (mazeItemId != null) { analyticsHelper.logEvent(AnalyticsEvent.MazeItemPlayed(allCorrect)) if (allCorrect) { mazeQuizRepository.completeMazeItem(mazeItemId) } } workManager.enqueue(endGameWorkRequest) UpdateGlobalEventDataWorker.enqueueWork( workManager = workManager, GameEvent.MultiChoice.EndQuiz ) } } private fun getUserDiamonds() = viewModelScope.launch(Dispatchers.IO) { _uiState.update { currentState -> currentState.copy(userDiamondsLoading = true) } val userDiamonds = userService.getUserDiamonds() _uiState.update { currentState -> currentState.copy( userDiamonds = userDiamonds.toInt(), userDiamondsLoading = false ) } } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/components/CardQuestionAnswer.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.components import androidx.annotation.VisibleForTesting import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape 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.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.common.compose.preview.BooleanPreviewParameterProvider import com.infinitepower.newquiz.core.theme.CustomColor import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.extendedColors import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.model.multi_choice_quiz.SelectedAnswer @Composable internal fun CardQuestionAnswers( modifier: Modifier = Modifier, answers: List, selectedAnswer: SelectedAnswer, isResultsScreen: Boolean = false, correctAnswer: SelectedAnswer = SelectedAnswer.NONE, onOptionClick: (selectedAnswer: SelectedAnswer) -> Unit = {} ) { val spaceSmall = MaterialTheme.spacing.small Column( modifier = modifier.semantics { contentDescription = "Answers container" }, verticalArrangement = Arrangement.spacedBy(spaceSmall) ) { answers.forEachIndexed { index, answer -> CardQuestionAnswer( modifier = Modifier.fillMaxWidth(), answer = answer, selected = selectedAnswer.index == index, isResults = isResultsScreen, resultAnswerCorrect = correctAnswer.index == index, answerCorrect = selectedAnswer == correctAnswer, onClick = { onOptionClick(SelectedAnswer.fromIndex(index)) } ) } } } @Composable @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal fun CardQuestionAnswer( modifier: Modifier = Modifier, answer: String, selected: Boolean, isResults: Boolean = false, resultAnswerCorrect: Boolean = false, answerCorrect: Boolean = false, colors: CardQuestionAnswerColors = CardQuestionAnswerDefaults.cardColors(), tonalElevation: Dp = CardQuestionAnswerDefaults.cardTonalElevation, textPadding: Dp = CardQuestionAnswerDefaults.textPadding, textStyle: TextStyle = CardQuestionAnswerDefaults.textStyle, cardShape: Shape = CardQuestionAnswerDefaults.cardShape, onClick: () -> Unit ) { val containerColor by colors.containerColor( isResults = isResults, selected = selected, answerCorrect = answerCorrect, resultAnswerCorrect = resultAnswerCorrect ) val containerColorAnimated by animateColorAsState( targetValue = containerColor, label = "container color animation" ) val contentColor by colors.contentColor( isResults = isResults, selected = selected, answerCorrect = answerCorrect, resultAnswerCorrect = resultAnswerCorrect ) val contentColorAnimated by animateColorAsState( targetValue = contentColor, label = "content color animation" ) Surface( modifier = modifier.fillMaxWidth(), shape = cardShape, tonalElevation = tonalElevation, color = containerColorAnimated, onClick = onClick, selected = selected, enabled = !isResults ) { Text( text = answer, modifier = Modifier.padding(textPadding), style = textStyle, color = contentColorAnimated ) } } object CardQuestionAnswerDefaults { val cardTonalElevation = 8.dp val textPadding: Dp @Composable get() = MaterialTheme.spacing.medium val textStyle: TextStyle @Composable get() = MaterialTheme.typography.bodyLarge val cardShape = CircleShape @Composable fun cardColors( normalContainerColor: Color = MaterialTheme.colorScheme.surface, normalContentColor: Color = MaterialTheme.colorScheme.onSurface, selectedContainerColor: Color = MaterialTheme.colorScheme.primary, selectedContentColor: Color = MaterialTheme.colorScheme.onPrimary, correctContainerColor: Color = MaterialTheme.extendedColors.getColorByKey(key = CustomColor.Key.Green), correctContentColor: Color = MaterialTheme.extendedColors.getOnColorByKey(key = CustomColor.Key.Green), incorrectContainerColor: Color = MaterialTheme.extendedColors.getColorByKey(key = CustomColor.Key.Red), incorrectContentColor: Color = MaterialTheme.extendedColors.getOnColorByKey(key = CustomColor.Key.Red) ): CardQuestionAnswerColors = CardQuestionAnswerColors( normalContainerColor = normalContainerColor, normalContentColor = normalContentColor, selectedContainerColor = selectedContainerColor, selectedContentColor = selectedContentColor, correctContainerColor = correctContainerColor, correctContentColor = correctContentColor, incorrectContainerColor = incorrectContainerColor, incorrectContentColor = incorrectContentColor ) } @Immutable class CardQuestionAnswerColors internal constructor( private val normalContainerColor: Color, private val normalContentColor: Color, private val selectedContainerColor: Color, private val selectedContentColor: Color, private val correctContainerColor: Color, private val correctContentColor: Color, private val incorrectContainerColor: Color, private val incorrectContentColor: Color, ) { @Composable internal fun containerColor( isResults: Boolean, selected: Boolean, answerCorrect: Boolean, resultAnswerCorrect: Boolean ): State { return rememberUpdatedState( newValue = when { isResults && selected && !answerCorrect -> incorrectContainerColor isResults && resultAnswerCorrect -> correctContainerColor selected -> selectedContainerColor else -> normalContainerColor } ) } @Composable internal fun contentColor( isResults: Boolean, selected: Boolean, answerCorrect: Boolean, resultAnswerCorrect: Boolean ): State { return rememberUpdatedState( newValue = when { isResults && selected && !answerCorrect -> incorrectContentColor isResults && resultAnswerCorrect -> correctContentColor selected -> selectedContentColor else -> normalContentColor } ) } override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || other !is CardQuestionAnswerColors) return false if (normalContainerColor != other.normalContainerColor) return false if (normalContentColor != other.normalContentColor) return false if (incorrectContainerColor != other.incorrectContainerColor) return false if (incorrectContentColor != other.incorrectContentColor) return false if (correctContainerColor != other.correctContainerColor) return false if (correctContentColor != other.correctContentColor) return false if (selectedContainerColor != other.selectedContainerColor) return false if (selectedContentColor != other.selectedContentColor) return false return true } override fun hashCode(): Int { var result = normalContainerColor.hashCode() result = 31 * result + normalContentColor.hashCode() result = 31 * result + incorrectContainerColor.hashCode() result = 31 * result + incorrectContentColor.hashCode() result = 31 * result + correctContainerColor.hashCode() result = 31 * result + correctContentColor.hashCode() result = 31 * result + selectedContainerColor.hashCode() result = 31 * result + selectedContentColor.hashCode() return result } } @Composable @PreviewLightDark private fun CardQuestionsPreview() { NewQuizTheme { Surface { CardQuestionAnswers( answers = listOf("A", "B", "C", "D"), selectedAnswer = SelectedAnswer.fromIndex(1), modifier = Modifier.padding(16.dp) ) } } } @Composable @PreviewLightDark private fun CardQuestionOptionPreview( @PreviewParameter(BooleanPreviewParameterProvider::class) selected: Boolean ) { NewQuizTheme { Surface { CardQuestionAnswer( answer = "Answer A", selected = selected, onClick = {}, modifier = Modifier.padding(16.dp) ) } } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/components/MultiChoiceQuizContainer.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.components 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.Spacer import androidx.compose.foundation.layout.consumeWindowInsets 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.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text 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.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension import com.infinitepower.newquiz.core.R import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.util.plus @Composable internal fun MultiChoiceQuizContainer( modifier: Modifier = Modifier, loading: Boolean = false, windowSizeClass: WindowSizeClass, answerSelected: Boolean, topBarContent: @Composable () -> Unit, stepsContent: @Composable BoxScope.() -> Unit, questionPositionContent: @Composable () -> Unit, questionDescriptionContent: @Composable () -> Unit, answersContent: @Composable BoxScope.() -> Unit, questionImageContent: (@Composable () -> Unit)? = null, onVerifyQuestionClick: () -> Unit ) { val verticalContent = windowSizeClass.heightSizeClass > WindowHeightSizeClass.Compact val horizontalFractionSize = remember { if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded) 0.5f else 1f } Scaffold( modifier = modifier, topBar = topBarContent, floatingActionButton = { if (answerSelected) { ExtendedFloatingActionButton( onClick = onVerifyQuestionClick, text = { Text(text = stringResource(id = R.string.verify)) }, icon = { Icon( imageVector = Icons.Rounded.Check, contentDescription = stringResource(id = R.string.verify) ) } ) } } ) { innerPadding -> if (loading) { Column( modifier = Modifier .fillMaxSize() .padding(innerPadding), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { CircularProgressIndicator() Spacer(modifier = Modifier.height(MaterialTheme.spacing.medium)) Text(text = stringResource(id = R.string.loading_questions)) } } else { if (verticalContent) { VerticalContent( innerPadding = innerPadding, stepsContent = stepsContent, questionPositionContent = questionPositionContent, questionDescriptionContent = questionDescriptionContent, answersContent = answersContent, questionImageContent = questionImageContent, horizontalFractionSize = horizontalFractionSize ) } else { HorizontalContent( innerPadding = innerPadding, stepsContent = stepsContent, questionPositionContent = questionPositionContent, questionDescriptionContent = questionDescriptionContent, answersContent = answersContent, questionImageContent = questionImageContent, windowSizeClass = windowSizeClass ) } } } } @Composable private fun VerticalContent( modifier: Modifier = Modifier, innerPadding: PaddingValues = PaddingValues(), horizontalFractionSize: Float = 1f, stepsContent: @Composable BoxScope.() -> Unit, questionPositionContent: @Composable () -> Unit, questionDescriptionContent: @Composable () -> Unit, answersContent: @Composable BoxScope.() -> Unit, questionImageContent: (@Composable () -> Unit)? = null, ) { val spaceMedium = MaterialTheme.spacing.medium LazyColumn( contentPadding = innerPadding + PaddingValues( start = spaceMedium, end = spaceMedium, top = spaceMedium, bottom = MaterialTheme.spacing.extraLarge ), verticalArrangement = Arrangement.spacedBy(spaceMedium), modifier = modifier .consumeWindowInsets(innerPadding) .fillMaxWidth(horizontalFractionSize) ) { item(key = "steps") { Box( content = stepsContent, modifier = Modifier .fillParentMaxWidth() .padding(vertical = spaceMedium) ) } item { questionPositionContent() } item { questionDescriptionContent() } // Question image, if exists if (questionImageContent != null) { item { questionImageContent() } } item { Box( content = answersContent, modifier = Modifier.fillParentMaxWidth() ) } } } @Composable private fun HorizontalContent( modifier: Modifier = Modifier, innerPadding: PaddingValues = PaddingValues(), windowSizeClass: WindowSizeClass, stepsContent: @Composable BoxScope.() -> Unit, questionPositionContent: @Composable () -> Unit, questionDescriptionContent: @Composable () -> Unit, answersContent: @Composable BoxScope.() -> Unit, questionImageContent: (@Composable () -> Unit)? = null, ) { val spaceMedium = MaterialTheme.spacing.medium val spaceLarge = MaterialTheme.spacing.large val heightCompact = windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact ConstraintLayout( modifier = modifier .padding(innerPadding + PaddingValues(spaceMedium)) .consumeWindowInsets(innerPadding) .fillMaxSize() ) { val (stepsRef, descriptionRef, answersRef) = createRefs() // Steps content Box( modifier = Modifier.constrainAs(stepsRef) { width = Dimension.wrapContent if (heightCompact) { top.linkTo(parent.top) start.linkTo(descriptionRef.start) end.linkTo(descriptionRef.end) } else { top.linkTo(parent.top, spaceLarge) start.linkTo(parent.start) end.linkTo(parent.end) } }, content = stepsContent ) // Position, description and image content Column( modifier = Modifier.constrainAs(descriptionRef) { if (heightCompact) { top.linkTo(stepsRef.bottom, spaceLarge) start.linkTo(parent.start) end.linkTo(answersRef.start) } else { top.linkTo(stepsRef.bottom, spaceLarge) start.linkTo(parent.start) end.linkTo(parent.end) } width = Dimension.fillToConstraints } ) { questionPositionContent() Spacer(modifier = Modifier.height(spaceMedium)) questionDescriptionContent() // Question image, if exists if (questionImageContent != null) { Spacer(modifier = Modifier.height(spaceMedium)) questionImageContent() } } // Answers content Box( modifier = Modifier.constrainAs(answersRef) { if (heightCompact) { top.linkTo(parent.top) start.linkTo(descriptionRef.end) end.linkTo(parent.end) } else { top.linkTo(descriptionRef.bottom, spaceMedium) start.linkTo(parent.start) end.linkTo(parent.end) } width = Dimension.fillToConstraints }, content = answersContent ) } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/components/QuizStepView.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Close 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.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.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign 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.multi_choice_quiz.MultiChoiceQuestionStep import com.infinitepower.newquiz.model.multi_choice_quiz.getBasicMultiChoiceQuestion import com.infinitepower.newquiz.core.R as CoreR @Composable internal fun QuizStepViewRow( modifier: Modifier = Modifier, questionSteps: List, isResultsScreen: Boolean = false, onClick: (index: Int, questionStep: MultiChoiceQuestionStep) -> Unit = { _, _ -> } ) { val rowDescription = stringResource(id = CoreR.string.quiz_steps_row_container) LazyRow( modifier = modifier .fillMaxWidth() .semantics { contentDescription = rowDescription }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), contentPadding = PaddingValues(horizontal = MaterialTheme.spacing.medium) ) { itemsIndexed( items = questionSteps, key = { _, step -> step.question.id } ) { index, step -> val position = index + 1 QuizStepView( questionStep = step, position = position, enabled = isResultsScreen, onClick = { onClick(index, step) } ) } } } @Composable internal fun QuizStepView( modifier: Modifier = Modifier, questionStep: MultiChoiceQuestionStep, position: Int, enabled: Boolean = true, onClick: () -> Unit = {} ) { val stepBackgroundColor by animateColorAsState( targetValue = if (questionStep is MultiChoiceQuestionStep.Current) { MaterialTheme.colorScheme.tertiary } else { MaterialTheme.colorScheme.surface }, label = "Step background color" ) val stepTextColor by animateColorAsState( targetValue = if (questionStep is MultiChoiceQuestionStep.Current) { MaterialTheme.colorScheme.onTertiary } else { MaterialTheme.colorScheme.onSurface }, label = "Step text color" ) QuizStepView( modifier = modifier, questionStep = questionStep, position = position, stepBackgroundColor = stepBackgroundColor, stepTextColor = stepTextColor, enabled = enabled, onClick = onClick ) } @Composable internal fun QuizStepView( modifier: Modifier = Modifier, questionStep: MultiChoiceQuestionStep, position: Int, stepBackgroundColor: Color, stepTextColor: Color, enabled: Boolean, onClick: () -> Unit ) { Surface( shape = CircleShape, tonalElevation = 8.dp, modifier = modifier.size(35.dp), color = stepBackgroundColor, onClick = onClick, enabled = enabled ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() ) { AnimatedVisibility( visible = questionStep is MultiChoiceQuestionStep.Completed, enter = scaleIn(), exit = scaleOut() ) { val correct = questionStep is MultiChoiceQuestionStep.Completed && questionStep.correct Icon( imageVector = if (correct) Icons.Rounded.Check else Icons.Rounded.Close, contentDescription = if (correct) { stringResource(id = CoreR.string.question_n_correct, position) } else { stringResource(id = CoreR.string.question_n_incorrect, position) }, modifier = Modifier.size(25.dp), tint = stepTextColor ) } val textStyle = if (position >= 100) { MaterialTheme.typography.bodyMedium } else MaterialTheme.typography.titleLarge AnimatedVisibility( visible = questionStep !is MultiChoiceQuestionStep.Completed, enter = scaleIn(), exit = scaleOut() ) { Text( text = position.toString(), color = stepTextColor, style = textStyle, textAlign = TextAlign.Center ) } } } } @Composable @PreviewLightDark private fun AllStepsPreview() { val items = listOf( MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true ), MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = false ), MultiChoiceQuestionStep.Current(question = getBasicMultiChoiceQuestion()), MultiChoiceQuestionStep.NotCurrent(question = getBasicMultiChoiceQuestion()), ) NewQuizTheme(dynamicColor = false) { Surface { LazyRow( horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(16.dp) ) { itemsIndexed( items = items, key = { index, _ -> index } ) { _, step -> QuizStepView( questionStep = step, position = (1..9).random(), onClick = {} ) } } } } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/components/QuizTopBar.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Save import androidx.compose.material.icons.rounded.SkipNext import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.animationsEnabled import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.components.RemainingTimeComponent import com.infinitepower.newquiz.core.ui.components.icon.button.BackIconButton import com.infinitepower.newquiz.model.RemainingTime import com.infinitepower.newquiz.multi_choice_quiz.MULTI_CHOICE_QUIZ_COUNTDOWN_TIME import kotlin.time.Duration.Companion.seconds import com.infinitepower.newquiz.core.R as CoreR @Composable internal fun QuizTopBar( modifier: Modifier = Modifier, windowHeightSizeClass: WindowHeightSizeClass, remainingTime: RemainingTime, skipsAvailable: Boolean, questionSaved: Boolean, currentQuestionNull: Boolean = true, onBackClick: () -> Unit, onSkipClick: () -> Unit, onSaveClick: () -> Unit ) { val progressIndicatorVisible = windowHeightSizeClass > WindowHeightSizeClass.Compact QuizTopBarContainer( modifier = modifier, backButtonContent = { BackIconButton(onClick = onBackClick) }, remainingTimerContent = { if (!remainingTime.isZero()) { RemainingTimeComponent( remainingTime = remainingTime, maxTime = MULTI_CHOICE_QUIZ_COUNTDOWN_TIME, showProgressIndicator = progressIndicatorVisible, animationsEnabled = MaterialTheme.animationsEnabled.multiChoice ) } }, onSkipClick = onSkipClick, onSaveClick = onSaveClick, skipsAvailable = skipsAvailable, questionSaved = questionSaved, currentQuestionNull = currentQuestionNull ) } @Composable private fun QuizTopBarContainer( modifier: Modifier = Modifier, skipsAvailable: Boolean, questionSaved: Boolean, currentQuestionNull: Boolean = true, backButtonContent: @Composable BoxScope.() -> Unit, remainingTimerContent: @Composable BoxScope.() -> Unit, onSkipClick: () -> Unit, onSaveClick: () -> Unit ) { val spaceMedium = MaterialTheme.spacing.medium var moreOptionsPopupExpanded by remember { mutableStateOf(false) } ConstraintLayout(modifier = modifier) { val (btnBackRef, progressRef, btnMoreOptions) = createRefs() Box( modifier = Modifier.constrainAs(btnBackRef) { top.linkTo(progressRef.top) bottom.linkTo(progressRef.bottom) start.linkTo(parent.start, spaceMedium) }, content = backButtonContent, contentAlignment = Alignment.Center ) Box( modifier = Modifier.constrainAs(progressRef) { top.linkTo(parent.top, spaceMedium) start.linkTo(parent.start) end.linkTo(parent.end) }, content = remainingTimerContent, contentAlignment = Alignment.Center ) if (!currentQuestionNull && (skipsAvailable || !questionSaved)) { Box( modifier = Modifier.constrainAs(btnMoreOptions) { top.linkTo(progressRef.top) bottom.linkTo(progressRef.bottom) end.linkTo(parent.end, spaceMedium) }, contentAlignment = Alignment.Center ) { IconButton(onClick = { moreOptionsPopupExpanded = true }) { Icon( imageVector = Icons.Rounded.MoreVert, contentDescription = stringResource(CoreR.string.more_options) ) } DropdownMenu( expanded = moreOptionsPopupExpanded, onDismissRequest = { moreOptionsPopupExpanded = false } ) { if (skipsAvailable) { DropdownMenuItem( text = { Text(text = stringResource(CoreR.string.skip)) }, leadingIcon = { Icon( imageVector = Icons.Rounded.SkipNext, contentDescription = stringResource(CoreR.string.skip) ) }, onClick = onSkipClick ) } if (!questionSaved) { DropdownMenuItem( text = { Text(text = stringResource(CoreR.string.save)) }, leadingIcon = { Icon( imageVector = Icons.Rounded.Save, contentDescription = stringResource(CoreR.string.save) ) }, onClick = onSaveClick ) } } } } } } @Composable @PreviewLightDark private fun QuizTopBarPreview() { NewQuizTheme { Surface { QuizTopBar( windowHeightSizeClass = WindowHeightSizeClass.Medium, remainingTime = RemainingTime(20.seconds), skipsAvailable = true, onBackClick = {}, onSkipClick = {}, onSaveClick = {}, modifier = Modifier .fillMaxWidth() .padding(16.dp), currentQuestionNull = false, questionSaved = false ) } } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/components/difficulty/BaseCardDifficultyContent.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.components.difficulty import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.SignalCellularAlt import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.theme.spacing @Composable internal fun BaseCardDifficultyContent( modifier: Modifier = Modifier, text: String ) { val spaceMedium = MaterialTheme.spacing.medium Column(modifier = modifier.padding(spaceMedium)) { Text( text = text, style = MaterialTheme.typography.titleMedium ) Spacer(modifier = Modifier.height(spaceMedium)) Box(modifier = Modifier.padding(start = MaterialTheme.spacing.extraLarge)) { Icon( imageVector = Icons.Rounded.SignalCellularAlt, contentDescription = text, modifier = Modifier.size(35.dp) ) } } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/components/difficulty/FilledCardDifficulty.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.components.difficulty import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.theme.CustomColor import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.extendedColors import com.infinitepower.newquiz.core.util.asString import com.infinitepower.newquiz.core.util.model.getText import com.infinitepower.newquiz.model.question.QuestionDifficulty @Composable internal fun FilledCardDifficulty( modifier: Modifier = Modifier, multiChoiceQuizDifficulty: QuestionDifficulty, enabled: Boolean = true, onClick: () -> Unit ) { val colorRoles = MaterialTheme.extendedColors.getColorsByKey( key = when (multiChoiceQuizDifficulty) { is QuestionDifficulty.Easy -> CustomColor.Key.Green is QuestionDifficulty.Medium -> CustomColor.Key.Yellow is QuestionDifficulty.Hard -> CustomColor.Key.Red } ) FilledCardDifficulty( modifier = modifier, text = multiChoiceQuizDifficulty.getText().asString(), containerColor = colorRoles.color, contentColor = colorRoles.onColor, onClick = onClick, enabled = enabled ) } @Composable internal fun FilledCardDifficulty( modifier: Modifier = Modifier, text: String, containerColor: Color, contentColor: Color, enabled: Boolean = true, onClick: () -> Unit ) { FilledCardDifficultyContainer( modifier = modifier, containerColor = containerColor, contentColor = contentColor, onClick = onClick, enabled = enabled ) { BaseCardDifficultyContent(text = text) } } @Composable private fun FilledCardDifficultyContainer( modifier: Modifier = Modifier, containerColor: Color, contentColor: Color, enabled: Boolean = true, onClick: () -> Unit, content: @Composable () -> Unit ) { Card( modifier = modifier, colors = CardDefaults.cardColors( containerColor = containerColor, contentColor = contentColor ), enabled = enabled, onClick = onClick, shape = MaterialTheme.shapes.large ) { content() } } @Composable @PreviewLightDark private fun CardDifficultyPreview() { NewQuizTheme { Surface { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { FilledCardDifficulty( multiChoiceQuizDifficulty = QuestionDifficulty.Easy, onClick = {} ) FilledCardDifficulty( multiChoiceQuizDifficulty = QuestionDifficulty.Medium, onClick = {} ) FilledCardDifficulty( multiChoiceQuizDifficulty = QuestionDifficulty.Hard, onClick = {} ) } } } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/components/difficulty/OutlinedCardDifficulty.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.components.difficulty import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.theme.CustomColor import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.extendedColors import com.infinitepower.newquiz.core.util.asString import com.infinitepower.newquiz.core.util.model.getText import com.infinitepower.newquiz.model.question.QuestionDifficulty @Composable internal fun OutlinedCardDifficulty( modifier: Modifier = Modifier, multiChoiceQuizDifficulty: QuestionDifficulty, onClick: () -> Unit ) { val contentColor = MaterialTheme.extendedColors.getColorByKey( key = when (multiChoiceQuizDifficulty) { is QuestionDifficulty.Easy -> CustomColor.Key.Green is QuestionDifficulty.Medium -> CustomColor.Key.Yellow is QuestionDifficulty.Hard -> CustomColor.Key.Red } ) OutlinedCardDifficulty( modifier = modifier, text = multiChoiceQuizDifficulty.getText().asString(), color = contentColor, onClick = onClick ) } @Composable internal fun OutlinedCardDifficulty( modifier: Modifier = Modifier, text: String, color: Color, onClick: () -> Unit ) { OutlinedCardDifficultyContainer( modifier = modifier, color = color, onClick = onClick, ) { BaseCardDifficultyContent(text = text) } } @Composable private fun OutlinedCardDifficultyContainer( modifier: Modifier = Modifier, color: Color, onClick: () -> Unit, content: @Composable () -> Unit ) { OutlinedCard( modifier = modifier, colors = CardDefaults.outlinedCardColors( contentColor = color ), onClick = onClick, border = BorderStroke(1.dp, color), shape = MaterialTheme.shapes.large ) { content() } } @Composable @PreviewLightDark private fun OutlinedCardDifficultyPreview() { NewQuizTheme { Surface { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { OutlinedCardDifficulty( multiChoiceQuizDifficulty = QuestionDifficulty.Easy, onClick = {} ) OutlinedCardDifficulty( multiChoiceQuizDifficulty = QuestionDifficulty.Medium, onClick = {} ) OutlinedCardDifficulty( multiChoiceQuizDifficulty = QuestionDifficulty.Hard, onClick = {} ) } } } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/components/difficulty/SelectableDifficultyRow.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.components.difficulty import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf 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.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.question.QuestionDifficulty import com.infinitepower.newquiz.core.R as CoreR @Composable internal fun SelectableDifficultyRow( modifier: Modifier = Modifier, selectedDifficulty: QuestionDifficulty?, setSelectedDifficulty: (QuestionDifficulty?) -> Unit ) { val spaceMedium = MaterialTheme.spacing.medium val items = remember { QuestionDifficulty.items() } LazyRow( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(spaceMedium), verticalAlignment = Alignment.CenterVertically, contentPadding = PaddingValues(horizontal = spaceMedium) ) { item { if (selectedDifficulty == null) { FilledCardDifficulty( text = stringResource(id = CoreR.string.random), containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, onClick = {} ) } else { OutlinedCardDifficulty( text = stringResource(id = CoreR.string.random), color = MaterialTheme.colorScheme.primary, onClick = { setSelectedDifficulty(null) } ) } } items( items = items, key = { it.id } ) { item -> if (item == selectedDifficulty) { FilledCardDifficulty( multiChoiceQuizDifficulty = item, onClick = {} ) } else { OutlinedCardDifficulty( multiChoiceQuizDifficulty = item, onClick = { setSelectedDifficulty(item) } ) } } } } @Composable @PreviewLightDark private fun CardDifficultyPreview() { val (selectedItem, setSelectedItem) = remember { // When null, difficulty will be random mutableStateOf(QuestionDifficulty.Easy) } NewQuizTheme { Surface { SelectableDifficultyRow( modifier = Modifier .padding(16.dp) .fillMaxWidth(), selectedDifficulty = selectedItem, setSelectedDifficulty = setSelectedItem ) } } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/list/MultiChoiceQuizListScreen.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.list import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Save 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.pluralStringResource 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.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.core.ui.home_card.components.HomeGroupTitle import com.infinitepower.newquiz.core.ui.home_card.components.HomeMediumCard import com.infinitepower.newquiz.core.ui.home_card.components.PlayRandomQuizCard import com.infinitepower.newquiz.core.ui.home_card.model.CardIcon import com.infinitepower.newquiz.core.ui.home_card.model.HomeCardItem import com.infinitepower.newquiz.model.multi_choice_quiz.toBaseCategory import com.infinitepower.newquiz.model.question.QuestionDifficulty import com.infinitepower.newquiz.multi_choice_quiz.components.difficulty.SelectableDifficultyRow import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizScreenDestination import com.infinitepower.newquiz.multi_choice_quiz.destinations.SavedMultiChoiceQuestionsScreenDestination import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import com.infinitepower.newquiz.core.R as CoreR @Composable @Destination fun MultiChoiceQuizListScreen( navigator: DestinationsNavigator, viewModel: MultiChoiceQuizListScreenViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() MultiChoiceQuizListScreenImpl( uiState = uiState, navigator = navigator ) } @Composable private fun MultiChoiceQuizListScreenImpl( uiState: MultiChoiceQuizListScreenUiState, navigator: DestinationsNavigator ) { val analyticsHelper = LocalAnalyticsHelper.current val spaceMedium = MaterialTheme.spacing.medium val questionsAvailableText = pluralStringResource( id = CoreR.plurals.n_questions_available, count = uiState.savedQuestionsSize, formatArgs = arrayOf(uiState.savedQuestionsSize) ) val (selectedDifficulty, setSelectedDifficulty) = remember { // When null, difficulty will be random mutableStateOf(null) } var seeAllCategories by remember { mutableStateOf(false) } HomeLazyColumn( contentPadding = PaddingValues( top = MaterialTheme.spacing.medium, bottom = MaterialTheme.spacing.large ) ) { item { PlayRandomQuizCard( modifier = Modifier .fillParentMaxWidth() .padding(horizontal = spaceMedium), title = stringResource(id = CoreR.string.quiz_with_random_categories), buttonTitle = stringResource(id = CoreR.string.random_quiz), containerMainColor = MaterialTheme.colorScheme.primary, onClick = { navigator.navigate(MultiChoiceQuizScreenDestination(difficulty = selectedDifficulty?.id)) }, enabled = uiState.internetConnectionAvailable, ) } item { Text( text = stringResource(id = CoreR.string.difficulty), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = spaceMedium) ) } item { SelectableDifficultyRow( selectedDifficulty = selectedDifficulty, setSelectedDifficulty = setSelectedDifficulty ) } item { Text( text = stringResource(id = CoreR.string.categories), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = spaceMedium) ) } homeCategoriesItems( contentPadding = PaddingValues( horizontal = spaceMedium ), seeAllCategories = seeAllCategories, recentCategories = uiState.homeCategories.recentCategories, otherCategories = uiState.homeCategories.otherCategories, isInternetAvailable = uiState.internetConnectionAvailable, showConnectionInfo = uiState.showCategoryConnectionInfo, onCategoryClick = { category -> analyticsHelper.logEvent( AnalyticsEvent.CategoryClicked( game = AnalyticsEvent.Game.MULTI_CHOICE_QUIZ, categoryId = category.id, otherData = mapOf( "difficulty" to selectedDifficulty?.id ) ) ) navigator.navigate( MultiChoiceQuizScreenDestination( category = category.toBaseCategory(), difficulty = selectedDifficulty?.id ) ) }, onSeeAllCategoriesClick = { seeAllCategories = !seeAllCategories } ) item { HomeGroupTitle( title = stringResource(id = CoreR.string.saved_questions), modifier = Modifier.padding(horizontal = spaceMedium) ) } item { HomeMediumCard( modifier = Modifier .fillParentMaxWidth() .padding(horizontal = spaceMedium), data = HomeCardItem.MediumCard( title = CoreR.string.saved_questions, icon = CardIcon.Icon(Icons.Rounded.Save), onClick = { navigator.navigate(SavedMultiChoiceQuestionsScreenDestination) }, description = questionsAvailableText ) ) } } } @Composable @PreviewLightDark private fun MultiChoiceCategoriesPreview() { NewQuizTheme { Surface { MultiChoiceQuizListScreenImpl( uiState = MultiChoiceQuizListScreenUiState(), navigator = EmptyDestinationsNavigator ) } } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/list/MultiChoiceQuizListScreenUiState.kt ================================================ package com.infinitepower.newquiz.multi_choice_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.multi_choice_quiz.MultiChoiceCategory @Keep data class MultiChoiceQuizListScreenUiState( val savedQuestionsSize: Int = 0, val homeCategories: HomeCategories = emptyHomeCategories(), val internetConnectionAvailable: Boolean = true, val showCategoryConnectionInfo: ShowCategoryConnectionInfo = ShowCategoryConnectionInfo.NONE ) ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/list/MultiChoiceQuizListScreenViewModel.kt ================================================ package com.infinitepower.newquiz.multi_choice_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 com.infinitepower.newquiz.domain.repository.multi_choice_quiz.saved_questions.SavedMultiChoiceQuestionsRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class MultiChoiceQuizListScreenViewModel @Inject constructor( savedQuestionsRepository: SavedMultiChoiceQuestionsRepository, networkStatusTracker: NetworkStatusTracker, recentCategoriesRepository: RecentCategoriesRepository ) : ViewModel() { // Searching if this is the problem with the TooManyRequestsException /* val uiState: StateFlow = combine( savedQuestionsRepository.getFlowQuestions(), networkStatusTracker.isOnline ) { savedQuestions, isOnline -> MultiChoiceQuizListScreenUiState( savedQuestionsSize = savedQuestions.size, internetConnectionAvailable = isOnline ) }.catch {e -> // Try to fix ConnectivityManager$TooManyRequestsException e.printStackTrace() emit(MultiChoiceQuizListScreenUiState()) }.flatMapLatest { state -> // Get the recent categories recentCategoriesRepository .getMultiChoiceCategories(isInternetAvailable = state.internetConnectionAvailable) .map { homeCategories -> state.copy(homeCategories = homeCategories) } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = MultiChoiceQuizListScreenUiState() ) */ val uiState: StateFlow = combine( savedQuestionsRepository.getCount(), recentCategoriesRepository.getMultiChoiceCategories( isInternetAvailable = networkStatusTracker.isCurrentlyConnected() ), recentCategoriesRepository.getShowCategoryConnectionInfoFlow() ) { savedQuestionsCount, recentCategories, showCategoryConnectionInfo -> MultiChoiceQuizListScreenUiState( savedQuestionsSize = savedQuestionsCount, homeCategories = recentCategories, internetConnectionAvailable = networkStatusTracker.isCurrentlyConnected(), showCategoryConnectionInfo = showCategoryConnectionInfo ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = MultiChoiceQuizListScreenUiState() ) } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/results/MultiChoiceQuizResultsScreen.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.results 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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio 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.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.rememberLottieComposition import com.infinitepower.newquiz.core.R import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceBaseCategory import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestionStep import com.infinitepower.newquiz.model.multi_choice_quiz.SelectedAnswer import com.infinitepower.newquiz.model.multi_choice_quiz.countCorrectQuestions import com.infinitepower.newquiz.model.multi_choice_quiz.getBasicMultiChoiceQuestion import com.infinitepower.newquiz.multi_choice_quiz.components.CardQuestionAnswers import com.infinitepower.newquiz.multi_choice_quiz.components.QuizStepViewRow import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizResultsScreenDestination import com.infinitepower.newquiz.multi_choice_quiz.destinations.MultiChoiceQuizScreenDestination import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.serialization.json.Json import com.infinitepower.newquiz.core.R as CoreR @Composable @Destination fun MultiChoiceQuizResultsScreen( questionStepsStr: String, category: MultiChoiceBaseCategory, byInitialQuestions: Boolean = false, difficulty: String? = null, navigator: DestinationsNavigator, windowSizeClass: WindowSizeClass ) { val questionSteps: List = remember { Json.decodeFromString(questionStepsStr) } val initialQuestions = remember(questionSteps) { ArrayList(questionSteps.map(MultiChoiceQuestionStep::question)) } MultiChoiceQuizResultsScreenImpl( questionSteps = questionSteps, onBackClick = navigator::popBackStack, onPlayAgainClick = { navigator.navigate( MultiChoiceQuizScreenDestination( initialQuestions = if (byInitialQuestions) initialQuestions else ArrayList(), category = category, difficulty = difficulty ) ) { popUpTo(MultiChoiceQuizResultsScreenDestination) { inclusive = true } launchSingleTop = true } }, windowHeightSizeClass = windowSizeClass.heightSizeClass ) } @Composable @OptIn(ExperimentalMaterial3Api::class) private fun MultiChoiceQuizResultsScreenImpl( questionSteps: List, windowHeightSizeClass: WindowHeightSizeClass, onBackClick: () -> Unit, onPlayAgainClick: () -> Unit ) { val winnerSpec = LottieCompositionSpec.RawRes(R.raw.trophy_winner) val winnerLottieComposition by rememberLottieComposition(spec = winnerSpec) val spaceMedium = MaterialTheme.spacing.medium val spaceLarge = MaterialTheme.spacing.large val (questionDialog, setQuestionDialog) = remember { mutableStateOf(null) } Scaffold( topBar = { TopAppBar( title = { Text(text = stringResource(id = CoreR.string.results_screen)) } ) } ) { innerPadding -> ResultsScreenContainer( modifier = Modifier .padding(innerPadding) .padding(spaceMedium) .fillMaxSize(), windowHeightSizeClass = windowHeightSizeClass, animationContent = { Surface( modifier = Modifier.size(300.dp), tonalElevation = 8.dp, shape = CircleShape ) { LottieAnimation( composition = winnerLottieComposition, modifier = Modifier .fillMaxSize() .padding(spaceMedium), iterations = LottieConstants.IterateForever, ) } }, resultScoreTextContent = { Text( text = stringResource( id = CoreR.string.n_correct_questions, "${questionSteps.countCorrectQuestions()}/${questionSteps.size}" ), style = MaterialTheme.typography.headlineMedium ) }, stepRowContent = { QuizStepViewRow( modifier = Modifier.fillMaxWidth(), questionSteps = questionSteps, isResultsScreen = true, onClick = { index, _ -> setQuestionDialog(index) } ) }, actionButtonsContent = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spaceMedium), modifier = Modifier.padding(bottom = spaceLarge) ) { OutlinedButton( onClick = onPlayAgainClick, modifier = Modifier.weight(1f) ) { Text(text = stringResource(id = CoreR.string.play_again)) } Button( onClick = onBackClick, modifier = Modifier.weight(1f) ) { Text(text = stringResource(id = CoreR.string.back)) } } } ) } if (questionDialog != null) { val questionStep = questionSteps[questionDialog] val question = questionStep.question AlertDialog( onDismissRequest = { setQuestionDialog(null) }, title = { Text(text = question.description) }, text = { LazyColumn { // Question image, if exists question.image?.let { imageUrl -> item { Spacer(modifier = Modifier.height(spaceMedium)) AsyncImage( model = imageUrl, contentDescription = "Flag Image", modifier = Modifier .aspectRatio(16 / 9f) .clip(MaterialTheme.shapes.medium), contentScale = ContentScale.Crop ) Spacer(modifier = Modifier.height(spaceMedium)) } } item { CardQuestionAnswers( answers = question.answers, selectedAnswer = questionStep.selectedAnswer, correctAnswer = SelectedAnswer.fromIndex(question.correctAns), isResultsScreen = true ) } } }, confirmButton = { TextButton(onClick = { setQuestionDialog(null) }) { Text(text = stringResource(id = CoreR.string.close)) } } ) } } @Composable private fun ResultsScreenContainer( modifier: Modifier = Modifier, windowHeightSizeClass: WindowHeightSizeClass, animationContent: @Composable BoxScope.() -> Unit, resultScoreTextContent: @Composable () -> Unit, stepRowContent: @Composable () -> Unit, actionButtonsContent: @Composable () -> Unit ) { val spaceLarge = MaterialTheme.spacing.large if (windowHeightSizeClass == WindowHeightSizeClass.Compact) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically ) { Box( content = animationContent, modifier = Modifier.weight(1f) ) Column( modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally ) { resultScoreTextContent() Spacer(modifier = Modifier.height(spaceLarge)) stepRowContent() Spacer(modifier = Modifier.weight(1f)) actionButtonsContent() } } } else { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally ) { Box(content = animationContent) Spacer(modifier = Modifier.height(spaceLarge)) resultScoreTextContent() Spacer(modifier = Modifier.height(spaceLarge)) stepRowContent() Spacer(modifier = Modifier.weight(1f)) actionButtonsContent() } } } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) private fun MultiChoiceQuizResultsScreenPreview() { val questionSteps = listOf( MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true ), MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = false ), MultiChoiceQuestionStep.Completed( question = getBasicMultiChoiceQuestion(), correct = true ), ) val configuration = LocalConfiguration.current val screenHeight = configuration.screenHeightDp.dp val screenWidth = configuration.screenWidthDp.dp val windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(screenWidth, screenHeight)) NewQuizTheme { Surface { MultiChoiceQuizResultsScreenImpl( questionSteps = questionSteps, onBackClick = {}, onPlayAgainClick = {}, windowHeightSizeClass = windowSizeClass.heightSizeClass ) } } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/saved_questions/SavedMultiChoiceQuestionsScreen.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.saved_questions import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Sort import androidx.compose.material.icons.rounded.Category import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Description import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Reorder import androidx.compose.material.icons.rounded.SaveAlt import androidx.compose.material.icons.rounded.SelectAll import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBarDefaults import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator 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.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.components.RoundedPolygonShape import com.infinitepower.newquiz.core.ui.components.icon.button.BackIconButton import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion import com.infinitepower.newquiz.model.multi_choice_quiz.getBasicMultiChoiceQuestion import com.infinitepower.newquiz.model.multi_choice_quiz.saved.SortSavedQuestionsBy import com.infinitepower.newquiz.multi_choice_quiz.saved_questions.components.SavedQuestionItem import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.infinitepower.newquiz.core.R as CoreR @Composable @Destination @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) fun SavedMultiChoiceQuestionsScreen( navigator: DestinationsNavigator, savedQuestionsScreenNavigator: SavedQuestionsScreenNavigator, viewModel: SavedMultiChoiceQuestionsViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val analyticsHelper = LocalAnalyticsHelper.current SavedMultiChoiceQuestionsScreenImpl( uiState = uiState, onBackClick = navigator::popBackStack, onEvent = viewModel::onEvent, playWithQuestions = { questions -> analyticsHelper.logEvent(AnalyticsEvent.MultiChoicePlaySavedQuestions(questions.size)) savedQuestionsScreenNavigator.navigateToMultiChoiceQuiz(ArrayList(questions)) } ) } @Composable @ExperimentalMaterial3Api @ExperimentalFoundationApi private fun SavedMultiChoiceQuestionsScreenImpl( uiState: SavedMultiChoiceQuestionsUiState, onBackClick: () -> Unit, playWithQuestions: (questions: List) -> Unit, onEvent: (event: SavedMultiChoiceQuestionsUiEvent) -> Unit ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val (moreVertPopupExpanded, setMoreVertPopupExpanded) = remember { mutableStateOf(false) } val (sortPopupExpanded, setSortPopupExpanded) = remember { mutableStateOf(false) } Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { SavedQuestionsTopAppBar( scrollBehavior = scrollBehavior, selectedQuestionsSize = uiState.selectedQuestions.size, onBackClick = onBackClick, onCleanSelectedClick = { onEvent(SavedMultiChoiceQuestionsUiEvent.SelectNone) } ) }, bottomBar = { if (uiState.questions.isNotEmpty()) { BottomAppBar( actions = { TopBarActionButton( imageVector = Icons.Rounded.MoreVert, contentDescription = stringResource(id = CoreR.string.more_options), onClick = { setMoreVertPopupExpanded(true) } ) MoreVertPopup( expanded = moreVertPopupExpanded, isQuestionsNotEmpty = uiState.questions.isNotEmpty(), questionsSelected = uiState.selectedQuestions.isNotEmpty(), onDismiss = { setMoreVertPopupExpanded(false) }, onSelectAllClick = { onEvent(SavedMultiChoiceQuestionsUiEvent.SelectAll) setMoreVertPopupExpanded(false) }, onDeleteAllSelectedClick = { onEvent(SavedMultiChoiceQuestionsUiEvent.DeleteAllSelected) setMoreVertPopupExpanded(false) }, onDownloadQuestionsClick = { onEvent(SavedMultiChoiceQuestionsUiEvent.DownloadQuestions) setMoreVertPopupExpanded(false) } ) TopBarActionButton( imageVector = Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(id = CoreR.string.sort_questions), onClick = { setSortPopupExpanded(true) } ) SortPopup( expanded = sortPopupExpanded, onDismiss = { setSortPopupExpanded(false) }, onSortClick = { sortBy -> setSortPopupExpanded(false) onEvent(SavedMultiChoiceQuestionsUiEvent.SortQuestions(sortBy)) } ) TopBarActionButton( imageVector = Icons.Rounded.Shuffle, contentDescription = stringResource(id = CoreR.string.play_with_random_saved_questions), onClick = { playWithQuestions(uiState.randomQuestions()) } ) }, floatingActionButton = { if (uiState.selectedQuestions.isNotEmpty()) { ExtendedFloatingActionButton( text = { Text(text = stringResource(id = CoreR.string.play)) }, icon = { Icon( imageVector = Icons.Rounded.PlayArrow, contentDescription = stringResource(id = CoreR.string.play_with_selected_questions) ) }, onClick = { // Ensure question size is below 50 val playQuestions = uiState.selectedQuestions.take(50) playWithQuestions(playQuestions) }, containerColor = BottomAppBarDefaults.bottomAppBarFabColor, elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation() ) } } ) } } ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding), horizontalAlignment = Alignment.CenterHorizontally ) { if (uiState.loading || uiState.downloadingQuestions) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } if (!uiState.loading) { if (uiState.questions.isEmpty()) { EmptyQuestions( modifier = Modifier.padding(innerPadding), onDownloadQuestionsClick = { onEvent(SavedMultiChoiceQuestionsUiEvent.DownloadQuestions) }, downloadButtonEnabled = !uiState.downloadingQuestions ) } else { LazyColumn { items( items = uiState.questions, key = { it.id } ) { question -> val selected = question in uiState.selectedQuestions SavedQuestionItem( modifier = Modifier .fillMaxWidth() .animateItem(), question = question, selected = selected, onClick = { onEvent(SavedMultiChoiceQuestionsUiEvent.SelectQuestion(question)) } ) } } } } } } } @Composable @ExperimentalMaterial3Api private fun SavedQuestionsTopAppBar( modifier: Modifier = Modifier, scrollBehavior: TopAppBarScrollBehavior, selectedQuestionsSize: Int, onBackClick: () -> Unit, onCleanSelectedClick: () -> Unit ) { val title = if (selectedQuestionsSize == 0) { stringResource(id = CoreR.string.saved_questions) } else { selectedQuestionsSize.toString() } val barColors = if (selectedQuestionsSize == 0) { TopAppBarDefaults.topAppBarColors() } else { TopAppBarDefaults.topAppBarColors( // containerColor = MaterialTheme.colorScheme.primary, // titleContentColor = MaterialTheme.colorScheme.onPrimary, // navigationIconContentColor = MaterialTheme.colorScheme.onPrimary ) } TopAppBar( modifier = modifier, title = { Text(text = title) }, scrollBehavior = scrollBehavior, navigationIcon = { if (selectedQuestionsSize == 0) { BackIconButton(onClick = onBackClick) } else { IconButton(onClick = onCleanSelectedClick) { Icon( imageVector = Icons.Rounded.Close, contentDescription = null ) } } }, colors = barColors ) } @Composable private fun EmptyQuestions( modifier: Modifier = Modifier, onDownloadQuestionsClick: () -> Unit, downloadButtonEnabled: Boolean = true ) { Column( modifier = modifier .fillMaxSize() .padding(MaterialTheme.spacing.large), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy( space = MaterialTheme.spacing.medium, alignment = Alignment.CenterVertically ) ) { Surface( tonalElevation = 4.dp, shape = RoundedPolygonShape(sides = 8) ) { Icon( modifier = Modifier .size(170.dp) .padding(48.dp), imageVector = Icons.Rounded.SaveAlt, contentDescription = null, ) } Text( text = stringResource(id = CoreR.string.no_questions_saved_yet), style = MaterialTheme.typography.titleLarge ) Text( text = stringResource(id = CoreR.string.no_questions_saved_description), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), textAlign = TextAlign.Center ) Button( onClick = onDownloadQuestionsClick, enabled = downloadButtonEnabled ) { Text(text = stringResource(id = CoreR.string.download_questions)) } } } @Composable private fun TopBarActionButton( imageVector: ImageVector, contentDescription: String, onClick: () -> Unit ) { IconButton(onClick = onClick) { Icon( imageVector = imageVector, contentDescription = contentDescription ) } } @Composable private fun MoreVertPopup( isQuestionsNotEmpty: Boolean, questionsSelected: Boolean, expanded: Boolean, onDismiss: () -> Unit, onSelectAllClick: () -> Unit, onDeleteAllSelectedClick: () -> Unit, onDownloadQuestionsClick: () -> Unit ) { DropdownMenu( expanded = expanded, onDismissRequest = onDismiss ) { if (isQuestionsNotEmpty) { if (questionsSelected) { DropdownMenuItem( text = { Text(text = stringResource(id = CoreR.string.unselect_all)) }, leadingIcon = { Icon( imageVector = Icons.Rounded.SelectAll, contentDescription = stringResource(id = CoreR.string.unselect_all) ) }, onClick = onSelectAllClick ) DropdownMenuItem( text = { Text(text = stringResource(id = CoreR.string.delete_selected)) }, leadingIcon = { Icon( imageVector = Icons.Rounded.Delete, contentDescription = stringResource(id = CoreR.string.delete_selected) ) }, onClick = onDeleteAllSelectedClick ) } else { DropdownMenuItem( text = { Text(text = stringResource(id = CoreR.string.select_all)) }, leadingIcon = { Icon( imageVector = Icons.Rounded.SelectAll, contentDescription = stringResource(id = CoreR.string.select_all) ) }, onClick = onSelectAllClick ) } } DropdownMenuItem( text = { Text(text = stringResource(id = CoreR.string.download_questions)) }, leadingIcon = { Icon( imageVector = Icons.Rounded.Download, contentDescription = stringResource(id = CoreR.string.download_questions) ) }, onClick = onDownloadQuestionsClick ) } } @Composable private fun SortPopup( expanded: Boolean, onDismiss: () -> Unit, onSortClick: (sortBy: SortSavedQuestionsBy) -> Unit, ) { DropdownMenu( expanded = expanded, onDismissRequest = onDismiss ) { DropdownMenuItem( text = { Text(text = stringResource(id = CoreR.string.sort_by_default)) }, leadingIcon = { Icon( imageVector = Icons.Rounded.Reorder, contentDescription = stringResource(id = CoreR.string.sort_by_default) ) }, onClick = { onSortClick(SortSavedQuestionsBy.BY_DEFAULT) } ) DropdownMenuItem( text = { Text(text = stringResource(id = CoreR.string.sort_by_description)) }, leadingIcon = { Icon( imageVector = Icons.Rounded.Description, contentDescription = stringResource(id = CoreR.string.sort_by_description) ) }, onClick = { onSortClick(SortSavedQuestionsBy.BY_DESCRIPTION) } ) DropdownMenuItem( text = { Text(text = stringResource(id = CoreR.string.sort_by_category)) }, leadingIcon = { Icon( imageVector = Icons.Rounded.Category, contentDescription = stringResource(id = CoreR.string.sort_by_category) ) }, onClick = { onSortClick(SortSavedQuestionsBy.BY_CATEGORY) } ) } } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) private fun SavedMultiChoiceQuestionsScreenPreview() { val questions = List(10) { getBasicMultiChoiceQuestion() } NewQuizTheme { SavedMultiChoiceQuestionsScreenImpl( uiState = SavedMultiChoiceQuestionsUiState( questions = questions, selectedQuestions = questions.take(3), loading = false ), onBackClick = {}, onEvent = {}, playWithQuestions = {} ) } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/saved_questions/SavedMultiChoiceQuestionsScreenNavigator.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.saved_questions import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion interface SavedQuestionsScreenNavigator { fun navigateToMultiChoiceQuiz(initialQuestions: ArrayList) } internal object SavedQuestionsScreenNavigatorPreview : SavedQuestionsScreenNavigator { override fun navigateToMultiChoiceQuiz(initialQuestions: ArrayList) { println("Navigating to quick quiz") } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/saved_questions/SavedMultiChoiceQuestionsUiEvent.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.saved_questions import androidx.annotation.Keep import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion import com.infinitepower.newquiz.model.multi_choice_quiz.saved.SortSavedQuestionsBy sealed interface SavedMultiChoiceQuestionsUiEvent { data class SelectQuestion(val question: MultiChoiceQuestion) : SavedMultiChoiceQuestionsUiEvent data object SelectAll : SavedMultiChoiceQuestionsUiEvent data object SelectNone : SavedMultiChoiceQuestionsUiEvent data object DeleteAllSelected : SavedMultiChoiceQuestionsUiEvent data object DownloadQuestions : SavedMultiChoiceQuestionsUiEvent @Keep data class SortQuestions( val sortBy: SortSavedQuestionsBy ) : SavedMultiChoiceQuestionsUiEvent } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/saved_questions/SavedMultiChoiceQuestionsUiState.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.saved_questions import androidx.annotation.Keep import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion @Keep data class SavedMultiChoiceQuestionsUiState( val questions: List = emptyList(), val selectedQuestions: List = emptyList(), val loading: Boolean = true, val downloadingQuestions: Boolean = false ) { fun randomQuestions(limit: Int = 5): List { return questions.shuffled().take(limit) } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/saved_questions/SavedMultiChoiceQuestionsViewModel.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.saved_questions import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.Constraints import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager import com.infinitepower.newquiz.core.analytics.AnalyticsEvent import com.infinitepower.newquiz.core.analytics.AnalyticsHelper import com.infinitepower.newquiz.core.ui.SnackbarController import com.infinitepower.newquiz.data.worker.multichoicequiz.DownloadMultiChoiceQuestionsWorker import com.infinitepower.newquiz.domain.repository.multi_choice_quiz.saved_questions.SavedMultiChoiceQuestionsRepository import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion import com.infinitepower.newquiz.model.multi_choice_quiz.saved.SortSavedQuestionsBy import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @OptIn(ExperimentalCoroutinesApi::class) class SavedMultiChoiceQuestionsViewModel @Inject constructor( private val savedQuestionsRepository: SavedMultiChoiceQuestionsRepository, private val workManager: WorkManager, private val analyticsHelper: AnalyticsHelper ) : ViewModel() { private val _uiState = MutableStateFlow(SavedMultiChoiceQuestionsUiState()) val uiState = _uiState.asStateFlow() private val sortQuestionsBy = MutableStateFlow(SortSavedQuestionsBy.BY_DEFAULT) init { sortQuestionsBy .flatMapLatest { sortBy -> savedQuestionsRepository.getFlowQuestions(sortBy) }.onEach { questions -> _uiState.update { currentState -> currentState.copy( questions = questions, loading = false ) } }.launchIn(viewModelScope) } fun onEvent(event: SavedMultiChoiceQuestionsUiEvent) { when (event) { is SavedMultiChoiceQuestionsUiEvent.SelectQuestion -> selectQuestion(event.question) is SavedMultiChoiceQuestionsUiEvent.SelectAll -> selectAllQuestions() is SavedMultiChoiceQuestionsUiEvent.SelectNone -> _uiState.update { it.copy(selectedQuestions = emptyList()) } is SavedMultiChoiceQuestionsUiEvent.DeleteAllSelected -> deleteAllSelected() is SavedMultiChoiceQuestionsUiEvent.DownloadQuestions -> downloadQuestions() is SavedMultiChoiceQuestionsUiEvent.SortQuestions -> { viewModelScope.launch(Dispatchers.IO) { sortQuestionsBy.emit(event.sortBy) } } } } private fun selectQuestion(question: MultiChoiceQuestion) { _uiState.update { currentState -> val selectedQuestions = if (question in currentState.selectedQuestions) { currentState.selectedQuestions - question } else { currentState.selectedQuestions + question } currentState.copy(selectedQuestions = selectedQuestions) } } private fun selectAllQuestions() { _uiState.update { currentState -> val selectedQuestions = if (currentState.selectedQuestions.isEmpty()) { currentState.questions } else emptyList() currentState.copy(selectedQuestions = selectedQuestions) } } private fun downloadQuestions() { analyticsHelper.logEvent(AnalyticsEvent.MultiChoiceDownloadQuestions) val downloadQuestionsRequest = OneTimeWorkRequestBuilder() .setConstraints( Constraints( requiredNetworkType = NetworkType.CONNECTED ) ).build() workManager.enqueue(downloadQuestionsRequest) workManager .getWorkInfoByIdFlow(downloadQuestionsRequest.id) .onEach { info -> if (info?.state == WorkInfo.State.SUCCEEDED) { SnackbarController.sendShortMessage("Downloaded successfully") } else if (info?.state == WorkInfo.State.FAILED) { SnackbarController.sendShortMessage("Failed to download the questions") } _uiState.update { currentState -> val isFinished = info?.state?.isFinished ?: false currentState.copy(downloadingQuestions = !isFinished) } }.launchIn(viewModelScope) } private fun deleteAllSelected() = viewModelScope.launch(Dispatchers.IO) { val allSelectedQuestions = uiState.first().selectedQuestions savedQuestionsRepository.deleteAllSelected(allSelectedQuestions) // Clear the selected questions after deleting them _uiState.update { it.copy(selectedQuestions = emptyList()) } } } ================================================ FILE: multi-choice-quiz/src/main/java/com/infinitepower/newquiz/multi_choice_quiz/saved_questions/components/SavedQuestionItem.kt ================================================ package com.infinitepower.newquiz.multi_choice_quiz.saved_questions.components import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Android import androidx.compose.material.icons.rounded.Category import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.FormatListNumbered import androidx.compose.material.icons.rounded.Numbers import androidx.compose.material3.ExperimentalMaterial3Api 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.ReadOnlyComposable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 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.multi_choice_quiz.MultiChoiceBaseCategory import com.infinitepower.newquiz.model.multi_choice_quiz.MultiChoiceQuestion import com.infinitepower.newquiz.model.multi_choice_quiz.getBasicMultiChoiceQuestion @Composable @ExperimentalMaterial3Api internal fun SavedQuestionItem( modifier: Modifier = Modifier, question: MultiChoiceQuestion, selected: Boolean, onClick: () -> Unit, ) { Surface( onClick = onClick, selected = selected, tonalElevation = if (selected) 8.dp else 0.dp, modifier = modifier ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(MaterialTheme.spacing.medium) ) { Icon( imageVector = getIconByCategory(multiChoiceQuestionCategory = question.category), contentDescription = null ) Spacer(modifier = Modifier.width(MaterialTheme.spacing.medium)) Text( text = question.description, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } @Composable @ReadOnlyComposable private fun getIconByCategory( multiChoiceQuestionCategory: MultiChoiceBaseCategory ) = when (multiChoiceQuestionCategory) { is MultiChoiceBaseCategory.Logo -> Icons.Rounded.Android is MultiChoiceBaseCategory.Flag, MultiChoiceBaseCategory.CountryCapitalFlags -> Icons.Rounded.Flag is MultiChoiceBaseCategory.GuessMathSolution -> Icons.Rounded.FormatListNumbered is MultiChoiceBaseCategory.NumberTrivia -> Icons.Rounded.Numbers else -> Icons.Rounded.Category } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class) private fun SavedMultiChoiceQuestionsScreenPreview( @PreviewParameter(BooleanPreviewParameterProvider::class) selected: Boolean ) { NewQuizTheme { Surface { SavedQuestionItem( question = getBasicMultiChoiceQuestion(), onClick = {}, selected = selected, modifier = Modifier.padding(16.dp) ) } } } ================================================ FILE: multi-choice-quiz/src/main/res/values/strings.xml ================================================ ================================================ FILE: settings.gradle.kts ================================================ pluginManagement { includeBuild("build-logic") repositories { gradlePluginPortal() google() mavenCentral() maven { url = uri("https://jitpack.io") } } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() maven { url = uri("https://jitpack.io") } } } rootProject.name = "NewQuiz" enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":app") include(":core") include(":core:analytics") include(":core:database") include(":core:datastore") include(":core:remote-config") include(":core:testing") include(":core:translation") include(":core:user-services") include(":feature:daily-challenge") include(":feature:maze") include(":feature:settings") include(":feature:profile") include(":model") include(":domain") include(":data") include(":multi-choice-quiz") include(":wordle") include(":comparison-quiz") ================================================ FILE: wordle/.gitignore ================================================ /build ================================================ FILE: wordle/README.md ================================================ # Wordle This is the code for the wordle game mode. ![NewQuiz purple light](../pictures/wordle.jpg) ================================================ FILE: wordle/build.gradle.kts ================================================ plugins { alias(libs.plugins.newquiz.android.library.compose) alias(libs.plugins.newquiz.android.hilt) alias(libs.plugins.newquiz.android.compose.destinations) } android { namespace = "com.infinitepower.newquiz.wordle" } 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) // Hilt navigation compose utils implementation(libs.hilt.navigationCompose) // Hilt work manager implementation(libs.hilt.ext.work) ksp(libs.hilt.ext.compiler) implementation(libs.kotlinx.datetime) implementation(libs.androidx.work.ktx) androidTestImplementation(libs.androidx.work.testing) implementation(projects.core) implementation(projects.core.analytics) implementation(projects.core.userServices) implementation(projects.model) implementation(projects.domain) implementation(projects.data) androidTestImplementation(projects.core.testing) } ================================================ FILE: wordle/consumer-rules.pro ================================================ ================================================ FILE: wordle/proguard-rules.pro ================================================ -dontwarn java.lang.invoke.StringConcatFactory ================================================ FILE: wordle/src/androidTest/java/com/infinitepower/newquiz/wordle/WordleScreenTest.kt ================================================ package com.infinitepower.newquiz.wordle import android.util.Log import androidx.activity.ComponentActivity import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.hilt.work.HiltWorkerFactory import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.work.WorkManager import androidx.work.testing.SynchronousExecutor import androidx.work.testing.WorkManagerTestInitHelper import com.infinitepower.newquiz.core.analytics.LocalDebugAnalyticsHelper import com.infinitepower.newquiz.core.testing.utils.setTestContent import com.infinitepower.newquiz.domain.repository.wordle.WordleRepository import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Rule import org.junit.runner.RunWith import java.util.* import javax.inject.Inject import kotlin.test.BeforeTest import kotlin.test.Test @HiltAndroidTest @RunWith(AndroidJUnit4::class) @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) class WordleScreenTest { @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeRule = createAndroidComposeRule() private lateinit var savedStateHandle: SavedStateHandle private lateinit var viewModel: WordleScreenViewModel @Inject lateinit var wordleRepository: WordleRepository @Inject lateinit var workerFactory: HiltWorkerFactory private lateinit var workManager: WorkManager @BeforeTest fun setup() { hiltRule.inject() val context = InstrumentationRegistry.getInstrumentation().context val workConfig = androidx.work.Configuration.Builder() .setWorkerFactory(workerFactory) .setMinimumLoggingLevel(Log.DEBUG) .setExecutor(SynchronousExecutor()) .build() WorkManagerTestInitHelper.initializeTestWorkManager(context, workConfig) workManager = WorkManager.getInstance(context) composeRule.setTestContent { savedStateHandle = SavedStateHandle( mapOf( WordleScreenNavArgs::rowLimit.name to 3, WordleScreenNavArgs::word.name to "TEST" ) ) viewModel = WordleScreenViewModel( wordleRepository = wordleRepository, savedStateHandle = savedStateHandle, workManager = workManager, analyticsHelper = LocalDebugAnalyticsHelper() ) val windowSizeClass = calculateWindowSizeClass(activity = composeRule.activity) CompositionLocalProvider(LocalInspectionMode provides true) { WordleScreen( wordleScreenViewModel = viewModel, navigator = EmptyDestinationsNavigator, windowSizeClass = windowSizeClass ) } } } @Test fun wordleScreen_basicControls() { composeRule.waitUntil { !viewModel.uiState.value.loading } composeRule.onNodeWithTag(WordleScreenTestTags.VERIFY_FAB).assertDoesNotExist() composeRule .onNodeWithTag(WordleScreenTestTags.KEYBOARD) .onChildren() .assertCountEquals(WordleScreenUiState.ALL_LETTERS.length) .assertAll(isEnabled()) .assertAll(hasClickAction()) composeRule .onAllNodesWithTag(WordleScreenTestTags.WORDLE_ROW) .assertCountEquals(1) .onFirst() .onChildren() .assertAll(hasText(" ")) .assertAll(hasContentDescription("Item empty")) composeRule .onNodeWithTag(WordleScreenTestTags.KEYBOARD) .onChildren() .onFirst() .performClick() composeRule .onAllNodesWithTag(WordleScreenTestTags.WORDLE_ROW) .onFirst() .onChildren() .filter(hasText(" ")) .assertCountEquals(3) composeRule .onAllNodesWithTag(WordleScreenTestTags.WORDLE_ROW) .onFirst() .onChildren() .onFirst() .assertTextEquals("Q") .assertContentDescriptionEquals("Item Q none") .performClick() .assertTextEquals(" ") .assertContentDescriptionEquals("Item empty") } @Test fun wordleScreen_verifyWord() { composeRule.waitUntil { !viewModel.uiState.value.loading } composeRule.onNodeWithTag(WordleScreenTestTags.VERIFY_FAB).assertDoesNotExist() composeRule .onNodeWithTag(WordleScreenTestTags.KEYBOARD) .onChildren() .assertCountEquals(WordleScreenUiState.ALL_LETTERS.length) .assertAll(isEnabled()) .assertAll(hasClickAction()) composeRule .onAllNodesWithTag(WordleScreenTestTags.WORDLE_ROW) .assertCountEquals(1) .onFirst() .onChildren() .assertAll(hasText(" ")) .assertAll(hasContentDescription("Item empty")) val keyboardSemantics = composeRule .onNodeWithTag(WordleScreenTestTags.KEYBOARD) .onChildren() keyboardSemantics[WordleScreenUiState.ALL_LETTERS.indexOf('A')].performClick() keyboardSemantics[WordleScreenUiState.ALL_LETTERS.indexOf('E')].performClick() keyboardSemantics[WordleScreenUiState.ALL_LETTERS.indexOf('T')].performClick() keyboardSemantics[WordleScreenUiState.ALL_LETTERS.indexOf('T')].performClick() composeRule .onNodeWithTag(WordleScreenTestTags.VERIFY_FAB) .assertIsDisplayed() .performClick() composeRule .onAllNodesWithTag(WordleScreenTestTags.WORDLE_ROW) .onFirst() .onChildren() .filter(hasText(" ")) .assertCountEquals(0) val rowFirstChildrenSemantics = composeRule .onAllNodesWithTag(WordleScreenTestTags.WORDLE_ROW) .onFirst() .onChildren() rowFirstChildrenSemantics .onFirst() .assertTextEquals("A") .assertContentDescriptionEquals("Item A none") rowFirstChildrenSemantics[1] .assertTextEquals("E") .assertContentDescriptionEquals("Item E correct") rowFirstChildrenSemantics[2] .assertTextEquals("T") .assertContentDescriptionEquals("Item T present") rowFirstChildrenSemantics[3] .assertTextEquals("T") .assertContentDescriptionEquals("Item T correct") composeRule .onAllNodesWithTag(WordleScreenTestTags.WORDLE_ROW) .assertCountEquals(2) .onLast() .onChildren() .assertAll(hasText(" ")) .assertAll(hasContentDescription("Item empty")) composeRule .onNodeWithTag(WordleScreenTestTags.KEYBOARD) .onChildren() .assertCountEquals(WordleScreenUiState.ALL_LETTERS.length) .filter(hasText("A")) .assertAll(isNotEnabled()) composeRule.onNodeWithTag(WordleScreenTestTags.VERIFY_FAB).assertDoesNotExist() } @Test fun wordleScreen_rowLimit() { composeRule.waitUntil { !viewModel.uiState.value.loading } composeRule.onNodeWithTag(WordleScreenTestTags.VERIFY_FAB).assertDoesNotExist() composeRule .onAllNodesWithTag(WordleScreenTestTags.WORDLE_ROW) .assertCountEquals(1) .onFirst() .onChildren() .assertAll(hasText(" ")) .assertAll(hasContentDescription("Item empty")) clickFirstKeyWordTimes() composeRule.onNodeWithTag(WordleScreenTestTags.VERIFY_FAB) .assertIsDisplayed() .performClick() .assertDoesNotExist() composeRule .onAllNodesWithTag(WordleScreenTestTags.WORDLE_ROW) .assertCountEquals(2) assert(!viewModel.uiState.value.isGamedEnded) clickFirstKeyWordTimes() composeRule.onNodeWithTag(WordleScreenTestTags.VERIFY_FAB) .assertIsDisplayed() .performClick() .assertDoesNotExist() composeRule .onAllNodesWithTag(WordleScreenTestTags.WORDLE_ROW) .assertCountEquals(3) //assert(!viewModel.uiState.value.isGamedEnded) clickFirstKeyWordTimes() composeRule.onNodeWithTag(WordleScreenTestTags.VERIFY_FAB) .assertIsDisplayed() .performClick() .assertDoesNotExist() composeRule .onAllNodesWithTag(WordleScreenTestTags.WORDLE_ROW) .assertCountEquals(3) composeRule .onNodeWithTag(WordleScreenTestTags.KEYBOARD) .assertDoesNotExist() assert(viewModel.uiState.value.isGamedEnded) } @Test fun wordleScreen_correctWord() { composeRule.waitUntil { !viewModel.uiState.value.loading } composeRule.onNodeWithTag(WordleScreenTestTags.VERIFY_FAB).assertDoesNotExist() composeRule .onNodeWithTag(WordleScreenTestTags.KEYBOARD) .onChildren() .assertCountEquals(WordleScreenUiState.ALL_LETTERS.length) .assertAll(isEnabled()) .assertAll(hasClickAction()) composeRule .onAllNodesWithTag(WordleScreenTestTags.WORDLE_ROW) .assertCountEquals(1) .onFirst() .onChildren() .assertAll(hasText(" ")) .assertAll(hasContentDescription("Item empty")) val keyboardSemantics = composeRule .onNodeWithTag(WordleScreenTestTags.KEYBOARD) .onChildren() keyboardSemantics[WordleScreenUiState.ALL_LETTERS.indexOf('T')].performClick() keyboardSemantics[WordleScreenUiState.ALL_LETTERS.indexOf('E')].performClick() keyboardSemantics[WordleScreenUiState.ALL_LETTERS.indexOf('S')].performClick() keyboardSemantics[WordleScreenUiState.ALL_LETTERS.indexOf('T')].performClick() composeRule .onNodeWithTag(WordleScreenTestTags.VERIFY_FAB) .performClick() .assertDoesNotExist() composeRule .onAllNodesWithTag(WordleScreenTestTags.WORDLE_ROW) .assertCountEquals(1) composeRule .onNodeWithTag(WordleScreenTestTags.KEYBOARD) .assertDoesNotExist() } private fun clickFirstKeyWordTimes() { val tKeyIndex = WordleScreenUiState.ALL_LETTERS.indexOf('T') val firstKey = composeRule .onNodeWithTag(WordleScreenTestTags.KEYBOARD) .onChildren()[tKeyIndex] val wordLength = viewModel.uiState.value.word?.length ?: -1 assert(wordLength > 0) repeat(wordLength) { firstKey.performClick() } } } ================================================ FILE: wordle/src/androidTest/java/com/infinitepower/newquiz/wordle/components/WordleKeyBoardTest.kt ================================================ package com.infinitepower.newquiz.wordle.components import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.infinitepower.newquiz.core.testing.utils.setTestContent import com.infinitepower.newquiz.model.wordle.WordleQuizType import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertTrue @SmallTest @RunWith(AndroidJUnit4::class) internal class WordleKeyBoardTest { @get:Rule val composeTestRule = createComposeRule() @Test @OptIn(ExperimentalLayoutApi::class) fun testWordleKeyBoard_displayCorrectNumberOfItems() { val keys = charArrayOf('a', 'b', 'c') composeTestRule.setTestContent { WordleKeyBoard( keys = keys, disabledKeys = emptySet(), onKeyClick = {}, windowWidthSizeClass = WindowWidthSizeClass.Compact, wordleQuizType = WordleQuizType.TEXT ) } composeTestRule .onAllNodesWithTag(WordleKeyBoardTestingTags.KEY) .assertCountEquals(3) } @Test fun testWordleKeyBoardItem_displayCorrectKey() { composeTestRule.setTestContent { WordleKeyboardKey( key = 'a', disabled = true, onKeyClick = {} ) } composeTestRule .onNodeWithTag(WordleKeyBoardTestingTags.KEY) .assertIsDisplayed() .assertIsNotEnabled() .assertTextContains("a") } @Test fun testWordleKeyBoardItem_disabled() { composeTestRule.setTestContent { WordleKeyboardKey( key = 'a', disabled = true, onKeyClick = {} ) } composeTestRule .onNodeWithTag(WordleKeyBoardTestingTags.KEY) .assertIsDisplayed() .assertIsNotEnabled() .assertTextContains("a") } @Test fun testWordleKeyBoardItem_callsOnClickWhenClicked() { var wasOnClickCalled = false composeTestRule.setTestContent { WordleKeyboardKey( key = 'a', disabled = false, onKeyClick = { wasOnClickCalled = true } ) } composeTestRule .onNodeWithText("a") .assertHasClickAction() .performClick() assertTrue(wasOnClickCalled, "Button as not clicked") } } ================================================ FILE: wordle/src/androidTest/java/com/infinitepower/newquiz/wordle/components/WordleRowComponentTest.kt ================================================ package com.infinitepower.newquiz.wordle.components import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.infinitepower.newquiz.core.testing.utils.setTestContent import com.infinitepower.newquiz.model.wordle.WordleChar import com.infinitepower.newquiz.model.wordle.WordleItem import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @SmallTest @RunWith(AndroidJUnit4::class) internal class WordleRowComponentTest { @get:Rule val composeTestRule = createComposeRule() @Test fun wordleComponent_emptyWordleItem_test() { val item = WordleItem.Empty composeTestRule.setTestContent { WordleComponent( item = item, onClick = {} ) } composeTestRule .onNodeWithTag(WordleRowComponentTestingTags.WORDLE_COMPONENT_SURFACE) .assertExists() .assertIsDisplayed() .assertIsNotEnabled() .assertHasClickAction() .assertTextEquals(" ") .assertContentDescriptionEquals("Item empty") } @Test fun wordleComponent_noneWordleItem_test() { composeTestRule.setTestContent { val (item, setItem) = remember { mutableStateOf(WordleItem.None(WordleChar('A'))) } WordleComponent( item = item, onClick = { setItem(WordleItem.Empty) } ) } composeTestRule .onNodeWithTag(WordleRowComponentTestingTags.WORDLE_COMPONENT_SURFACE) .assertExists() .assertIsDisplayed() .assertIsEnabled() .assertHasClickAction() .assertTextEquals("A") .assertContentDescriptionEquals("Item A none") .performClick() .assertExists() .assertIsDisplayed() .assertIsNotEnabled() .assertHasClickAction() .assertTextEquals(" ") .assertContentDescriptionEquals("Item empty") } @Test fun wordleComponent_presentWordleItem_test() { composeTestRule.setTestContent { WordleComponent( item = WordleItem.Present(WordleChar('A')), onClick = {} ) } composeTestRule .onNodeWithTag(WordleRowComponentTestingTags.WORDLE_COMPONENT_SURFACE) .assertExists() .assertIsDisplayed() .assertIsNotEnabled() .assertHasClickAction() .assertTextEquals("A") .assertContentDescriptionEquals("Item A present") } @Test fun wordleComponent_correctWordleItem_test() { composeTestRule.setTestContent { WordleComponent( item = WordleItem.Correct(WordleChar('A')), onClick = {} ) } composeTestRule .onNodeWithTag(WordleRowComponentTestingTags.WORDLE_COMPONENT_SURFACE) .assertExists() .assertIsDisplayed() .assertIsNotEnabled() .assertHasClickAction() .assertTextEquals("A") .assertContentDescriptionEquals("Item A correct") } } ================================================ FILE: wordle/src/main/AndroidManifest.xml ================================================ ================================================ FILE: wordle/src/main/java/com/infinitepower/newquiz/wordle/WordleScreen.kt ================================================ package com.infinitepower.newquiz.wordle import androidx.annotation.Keep import androidx.annotation.VisibleForTesting 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.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Info import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource 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 com.infinitepower.newquiz.core.navigation.MazeNavigator import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.animationsEnabled import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.core.ui.components.icon.button.BackIconButton import com.infinitepower.newquiz.core.util.asString import com.infinitepower.newquiz.data.util.translation.getWordleTitle import com.infinitepower.newquiz.model.wordle.WordleChar import com.infinitepower.newquiz.model.wordle.WordleItem import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.model.wordle.WordleRowItem import com.infinitepower.newquiz.wordle.components.InfoDialog import com.infinitepower.newquiz.wordle.components.WordleKeyBoard import com.infinitepower.newquiz.wordle.components.WordleRowComponent import com.infinitepower.newquiz.wordle.destinations.WordleScreenDestination import com.ramcosta.composedestinations.annotation.DeepLink import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.delay import com.infinitepower.newquiz.core.R as CoreR @Keep data class WordleScreenNavArgs( val rowLimit: Int? = null, val word: String? = null, val quizType: WordleQuizType = WordleQuizType.TEXT, val mazeItemId: String? = null, val textHelper: String? = null ) @Composable @Destination( navArgsDelegate = WordleScreenNavArgs::class, deepLinks = [ DeepLink(uriPattern = "newquiz://wordleinfinite") ] ) fun WordleScreen( navigator: DestinationsNavigator, mazeNavigator: MazeNavigator, navController: NavController, windowSizeClass: WindowSizeClass, wordleScreenViewModel: WordleScreenViewModel = hiltViewModel() ) { val uiState by wordleScreenViewModel.uiState.collectAsStateWithLifecycle() val mazeItemId = remember(navController) { val backStackEntry = navController.getBackStackEntry(WordleScreenDestination.route) val args = WordleScreenDestination.argsFrom(backStackEntry) args.mazeItemId?.toIntOrNull() } WordleScreenImpl( fromMaze = mazeItemId != null, uiState = uiState, onEvent = wordleScreenViewModel::onEvent, onBackClick = navigator::popBackStack, windowSizeClass = windowSizeClass ) // If the game is over and is from maze, navigate to maze results LaunchedEffect(uiState.isGamedEnded) { if (uiState.isGamedEnded && mazeItemId != null) { if (!uiState.isGameOver) { // Show a delay before moving to maze results delay(NAV_TO_RESULTS_DELAY_MILLIS) mazeNavigator.navigateToMazeResults(mazeItemId) } } } } @Composable @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) private fun WordleScreenImpl( fromMaze: Boolean = false, uiState: WordleScreenUiState, onEvent: (event: WordleScreenUiEvent) -> Unit, onBackClick: () -> Unit, windowSizeClass: WindowSizeClass, animationsEnabled: Boolean = MaterialTheme.animationsEnabled.wordle ) { val spaceMedium = MaterialTheme.spacing.medium val (gameOverPopupVisible, setGameOverPopupVisibility) = remember(uiState.isGameOver && !fromMaze) { mutableStateOf(uiState.isGameOver) } val (infoDialogVisible, setInfoDialogVisibility) = remember { mutableStateOf(false) } val screenTitle = uiState.wordleQuizType.getWordleTitle() val rowLayout = remember(windowSizeClass) { windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact && windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact } val keyboardBottomPadding = if (!rowLayout) { MaterialTheme.spacing.extraLarge } else 0.dp val scrollState = rememberLazyListState() LaunchedEffect(key1 = uiState.currentRowPosition) { if (uiState.currentRowPosition > 0 && uiState.rows.isNotEmpty()) { scrollState.animateScrollToItem(uiState.rows.lastIndex) } } Scaffold( topBar = { TopAppBar( title = { Text(text = screenTitle.asString()) }, navigationIcon = { BackIconButton(onClick = onBackClick) }, actions = { IconButton(onClick = { setInfoDialogVisibility(true) }) { Icon( imageVector = Icons.Rounded.Info, contentDescription = stringResource(id = CoreR.string.info) ) } } ) }, floatingActionButton = { if (uiState.currentRowCompleted && !uiState.isGamedEnded) { ExtendedFloatingActionButton( text = { Text(text = stringResource(id = CoreR.string.verify)) }, icon = { Icon( imageVector = Icons.Rounded.Check, contentDescription = stringResource(id = CoreR.string.verify) ) }, onClick = { onEvent(WordleScreenUiEvent.VerifyRow) }, modifier = Modifier.testTag(WordleScreenTestTags.VERIFY_FAB) ) } } ) { innerPadding -> WordleContainer( modifier = Modifier .fillMaxSize() .padding(innerPadding), rowLayout = rowLayout, wordsScrollState = scrollState, wordleContent = { if (uiState.loading) { item { CircularProgressIndicator( modifier = Modifier.testTag( WordleScreenTestTags.LOADING_PROGRESS_INDICATOR ) ) } } if (uiState.textHelper != null) { item { Text( text = uiState.textHelper, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(horizontal = spaceMedium) ) } } itemsIndexed(items = uiState.rows) { index, rowItem -> val isCurrentRow = uiState.currentRowPosition == index WordleRowComponent( wordleRowItem = rowItem, word = uiState.word.orEmpty(), onItemClick = { itemIndex -> onEvent( WordleScreenUiEvent.OnRemoveKeyClick( itemIndex ) ) }, isColorBlindEnabled = uiState.isColorBlindEnabled, isLetterHintsEnabled = uiState.isLetterHintEnabled, modifier = Modifier.testTag(WordleScreenTestTags.WORDLE_ROW), enabled = isCurrentRow, animationEnabled = animationsEnabled ) } }, keyboardContent = { if (!uiState.loading && !uiState.isGamedEnded) { WordleKeyBoard( modifier = Modifier // .padding(horizontal = MaterialTheme.spacing.small) // .padding(bottom = keyboardBottomPadding) .testTag(WordleScreenTestTags.KEYBOARD), rowLayout = rowLayout, keys = uiState.wordleKeys, disabledKeys = uiState.keysDisabled, onKeyClick = { key -> onEvent(WordleScreenUiEvent.OnKeyClick(key)) }, wordleQuizType = uiState.wordleQuizType ?: WordleQuizType.TEXT, windowWidthSizeClass = windowSizeClass.widthSizeClass, contentPadding = PaddingValues( start = MaterialTheme.spacing.small, end = MaterialTheme.spacing.small, bottom = keyboardBottomPadding ) ) } if (uiState.isGamedEnded && !fromMaze) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spaceMedium), modifier = Modifier.padding(spaceMedium) ) { OutlinedButton( onClick = { onEvent(WordleScreenUiEvent.OnPlayAgainClick) }, modifier = Modifier.weight(1f) ) { Text(text = stringResource(id = CoreR.string.play_again)) } Button( onClick = onBackClick, modifier = Modifier.weight(1f) ) { Text(text = stringResource(id = CoreR.string.back)) } } } } ) } if (gameOverPopupVisible) { AlertDialog( onDismissRequest = { setGameOverPopupVisibility(false) }, title = { Text(text = stringResource(id = CoreR.string.game_over)) }, confirmButton = { TextButton(onClick = { setGameOverPopupVisibility(false) }) { Text(text = stringResource(id = CoreR.string.close)) } } ) } if (infoDialogVisible) { InfoDialog( isColorBlindEnabled = uiState.isColorBlindEnabled, onDismissRequest = { setInfoDialogVisibility(false) } ) } } @Composable private fun WordleContainer( modifier: Modifier = Modifier, rowLayout: Boolean = false, wordsScrollState: LazyListState = rememberLazyListState(), wordleContent: LazyListScope.() -> Unit, keyboardContent: @Composable BoxScope.() -> Unit ) { val spaceMedium = MaterialTheme.spacing.medium if (rowLayout) { Row( modifier = modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spaceMedium) ) { LazyColumn( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(spaceMedium), horizontalAlignment = Alignment.CenterHorizontally, contentPadding = PaddingValues(vertical = spaceMedium), content = wordleContent, state = wordsScrollState ) Box( modifier = Modifier.weight(1f), contentAlignment = Alignment.Center ) { keyboardContent() } } } else { Column( modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally ) { LazyColumn( modifier = Modifier .weight(2f) .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(spaceMedium), horizontalAlignment = Alignment.CenterHorizontally, contentPadding = PaddingValues(bottom = spaceMedium), content = wordleContent, state = wordsScrollState ) Box( modifier = Modifier.weight(1f), contentAlignment = Alignment.Center ) { keyboardContent() } } } } private const val NAV_TO_RESULTS_DELAY_MILLIS = 1000L @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal object WordleScreenTestTags { const val VERIFY_FAB = "VERIFY_FAB" const val LOADING_PROGRESS_INDICATOR = "LOADING_PROGRESS_INDICATOR" const val KEYBOARD = "KEYBOARD" const val WORDLE_ROW = "WORDLE_ROW" } @Composable @PreviewScreenSizes @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) private fun WordleScreenPreview() { val rowItems = listOf( WordleRowItem( items = listOf( WordleItem.Correct(char = WordleChar('Q')), WordleItem.None(char = WordleChar('A')), WordleItem.Present(char = WordleChar('Z')), WordleItem.Present(char = WordleChar('I')), WordleItem.Empty ) ) ) val configuration = LocalConfiguration.current val screenHeight = configuration.screenHeightDp.dp val screenWidth = configuration.screenWidthDp.dp val windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(screenWidth, screenHeight)) NewQuizTheme { WordleScreenImpl( uiState = WordleScreenUiState( word = "QUIZZ", rows = rowItems, currentRowPosition = 0, loading = false, wordleQuizType = WordleQuizType.TEXT, textHelper = "Wordle text helper for word.", ), onEvent = {}, onBackClick = {}, windowSizeClass = windowSizeClass, animationsEnabled = false ) } } ================================================ FILE: wordle/src/main/java/com/infinitepower/newquiz/wordle/WordleScreenUiEvent.kt ================================================ package com.infinitepower.newquiz.wordle sealed interface WordleScreenUiEvent { data class OnKeyClick(val key: Char) : WordleScreenUiEvent data class OnRemoveKeyClick(val index: Int) : WordleScreenUiEvent data object VerifyRow : WordleScreenUiEvent data object OnPlayAgainClick : WordleScreenUiEvent } ================================================ FILE: wordle/src/main/java/com/infinitepower/newquiz/wordle/WordleScreenUiState.kt ================================================ package com.infinitepower.newquiz.wordle import androidx.annotation.Keep import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.model.wordle.WordleRowItem @Keep data class WordleScreenUiState( val loading: Boolean = true, val word: String? = null, val rowLimit: Int = Int.MAX_VALUE, val rows: List = emptyList(), val currentRowPosition: Int = -1, val keysDisabled: Set = emptySet(), val isColorBlindEnabled: Boolean = false, val isLetterHintEnabled: Boolean = false, val isHardModeEnabled: Boolean = false, val wordleQuizType: WordleQuizType? = null, val textHelper: String? = null ) { companion object { const val ALL_LETTERS = "QWERTYUIOPASDFGHJKLZXCVBNM" const val allNumbers = "1234567890" const val mathFormulaKeys = "0123456789+-*/=" } val currentRowCompleted: Boolean get() = rows .lastOrNull() ?.isRowCompleted == true private val currentRowCorrect: Boolean get() = rows .lastOrNull { it.isRowVerified } ?.isRowCorrect == true val isGamedEnded: Boolean get() = !loading && currentRowPosition + 1 > rowLimit || currentRowCorrect val isGameOver: Boolean get() = isGamedEnded && !currentRowCorrect val wordleKeys: CharArray get() = when (wordleQuizType) { WordleQuizType.TEXT -> ALL_LETTERS.toCharArray() WordleQuizType.NUMBER, WordleQuizType.NUMBER_TRIVIA -> allNumbers.toList().toCharArray() WordleQuizType.MATH_FORMULA -> mathFormulaKeys.toCharArray() null -> charArrayOf() } } ================================================ FILE: wordle/src/main/java/com/infinitepower/newquiz/wordle/WordleScreenViewModel.kt ================================================ package com.infinitepower.newquiz.wordle import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.workDataOf import com.infinitepower.newquiz.core.analytics.AnalyticsEvent import com.infinitepower.newquiz.core.analytics.AnalyticsHelper import com.infinitepower.newquiz.core.ui.SnackbarController import com.infinitepower.newquiz.core.util.collections.indexOfFirstOrNull import com.infinitepower.newquiz.data.repository.wordle.InvalidWordError import com.infinitepower.newquiz.data.worker.UpdateGlobalEventDataWorker import com.infinitepower.newquiz.domain.repository.maze.MazeQuizRepository import com.infinitepower.newquiz.domain.repository.wordle.WordleRepository import com.infinitepower.newquiz.model.Resource import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.model.global_event.GameEvent import com.infinitepower.newquiz.model.wordle.WordleItem import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.model.wordle.WordleRowItem import com.infinitepower.newquiz.model.wordle.emptyRowItem import com.infinitepower.newquiz.model.wordle.itemsToString import com.infinitepower.newquiz.wordle.util.asUiText import com.infinitepower.newquiz.wordle.util.word.containsAllLastRevealedHints import com.infinitepower.newquiz.wordle.util.word.getKeysDisabled import com.infinitepower.newquiz.wordle.util.word.verifyFromWord import com.infinitepower.newquiz.wordle.util.worker.WordleEndGameWorker import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import javax.inject.Inject import com.infinitepower.newquiz.core.R as CoreR private const val TAG = "WordleScreenViewModel" @HiltViewModel class WordleScreenViewModel @Inject constructor( private val wordleRepository: WordleRepository, savedStateHandle: SavedStateHandle, private val workManager: WorkManager, private val analyticsHelper: AnalyticsHelper, private val mazeQuizRepository: MazeQuizRepository ) : ViewModel() { private val navArgs: WordleScreenNavArgs = savedStateHandle.navArgs() private var _uiState = MutableStateFlow(WordleScreenUiState()) val uiState = _uiState.asStateFlow() init { viewModelScope.launch { val isColorBlindEnabled = wordleRepository.isColorBlindEnabled() val isLetterHintEnabled = wordleRepository.isLetterHintEnabled() val isHardModeEnabled = wordleRepository.isHardModeEnabled() _uiState.update { currentState -> currentState.copy( isColorBlindEnabled = isColorBlindEnabled, isLetterHintEnabled = isLetterHintEnabled, isHardModeEnabled = isHardModeEnabled ) } } generateGame() } // override fun onCleared() { // endGame() // super.onCleared() // } fun onEvent(event: WordleScreenUiEvent) { when (event) { is WordleScreenUiEvent.OnKeyClick -> addKeyToCurrentRow(event.key) is WordleScreenUiEvent.OnRemoveKeyClick -> removeKeyFromCurrentRow(event.index) is WordleScreenUiEvent.VerifyRow -> verifyRow() is WordleScreenUiEvent.OnPlayAgainClick -> generateGame() } } private fun generateGame() = viewModelScope.launch(Dispatchers.IO) { val quizType = navArgs.quizType // Checks if saved state has an initial word. // If has, generate the rows with the word, if not create a new word. if (navArgs.word != null) { generateRows(navArgs.word, quizType, navArgs.textHelper) return@launch } wordleRepository .generateRandomWord(quizType) .collect { res -> if (res is Resource.Loading) { _uiState.update { currentState -> currentState.copy(loading = true) } } if (res is Resource.Success) { res.data?.let { wordleWord -> generateRows( word = wordleWord.word, quizType = quizType, textHelper = wordleWord.textHelper ) } } } } private suspend fun generateRows( word: String, quizType: WordleQuizType, textHelper: String? = null ) { Log.d(TAG, "Word: $word") val rows = List(1) { emptyRowItem(size = word.length) } // Gets the wordle max rows if the row limit from args is null, if not use the args row limit. val rowLimit = wordleRepository.getWordleMaxRows(navArgs.rowLimit) viewModelScope.launch { UpdateGlobalEventDataWorker.enqueueWork( workManager = workManager, GameEvent.Wordle.PlayWordWithCategory(quizType) ) analyticsHelper.logEvent( AnalyticsEvent.WordleGameStart( wordLength = word.length, maxRows = rowLimit, category = quizType.name, mazeItemId = navArgs.mazeItemId?.toIntOrNull() ) ) } _uiState.update { currentState -> currentState.copy( loading = false, word = word, rowLimit = rowLimit, rows = rows, currentRowPosition = 0, keysDisabled = emptySet(), wordleQuizType = quizType, textHelper = textHelper ) } } private fun addKeyToCurrentRow(key: Char) { _uiState.update { currentState -> val newRows = currentState .rows .toMutableList() .apply { val currentRowItem = this[currentState.currentRowPosition] val newRow = currentRowItem.items.toMutableList().apply { val emptyIndex = indexOfFirstOrNull { item -> item is WordleItem.Empty } ?: return set(emptyIndex, WordleItem.fromChar(key)) } set(currentState.currentRowPosition, WordleRowItem(newRow)) } currentState.copy(rows = newRows) } } private fun removeKeyFromCurrentRow(index: Int) { _uiState.update { currentState -> val newRows = currentState .rows .toMutableList() .apply { val currentRowItem = this[currentState.currentRowPosition] val newRow = currentRowItem.items.toMutableList().apply { set(index, WordleItem.Empty) } set(currentState.currentRowPosition, WordleRowItem(newRow)) } currentState.copy(rows = newRows) } } private fun verifyRow() { val state = _uiState.updateAndGet { currentState -> if (currentState.word == null) return if (currentState.wordleQuizType == null) return if (!currentState.currentRowCompleted) return // Get current row items, if the current row is null stop the verification. // Current row is the last row of the list. val currentItems = currentState.rows.lastOrNull()?.items ?: return wordleRepository.validateWord( currentItems.itemsToString(), currentState.wordleQuizType ).onFailure { exception -> viewModelScope.launch { if (exception is InvalidWordError) { SnackbarController.sendShortMessage(exception.asUiText()) } else { SnackbarController.sendShortMessage( UiText.StringResource(CoreR.string.error_verifying_word) ) } } return@updateAndGet currentState.copy() } // Verifies items with the word and verifies if the word is correct val verifiedItems = currentItems verifyFromWord currentState.word if (currentState.isHardModeEnabled) { val last2RowItems = currentState .rows .getOrNull(currentState.rows.lastIndex - 1) ?.items .orEmpty() .filter { it is WordleItem.Correct || it is WordleItem.Present } val containsAllLastRevealedHints = verifiedItems containsAllLastRevealedHints last2RowItems if (!containsAllLastRevealedHints) { viewModelScope.launch { SnackbarController.sendShortMessage( UiText.StringResource(CoreR.string.need_to_use_all_hints_error) ) } return@updateAndGet currentState.copy() } } val isRowCorrect = verifiedItems.all { it is WordleItem.Correct } val newRowPosition = currentState.currentRowPosition + 1 val newRows = currentState .rows .toMutableList() .apply { // Updates the current row with the verified items set(currentState.currentRowPosition, WordleRowItem(verifiedItems)) // Checks if is game end. // Is game end if new row position >= row limit and current row is correct. // If is not game end add new empty row. val gameEnd = newRowPosition >= currentState.rowLimit || isRowCorrect if (!gameEnd) add(emptyRowItem(currentState.word.length)) } val keysDisabled = verifiedItems.getKeysDisabled() currentState.copy( currentRowPosition = newRowPosition, rows = newRows, keysDisabled = currentState.keysDisabled + keysDisabled, ) } if (state.isGamedEnded) { endGame(state) } } private fun endGame(currentState: WordleScreenUiState) { val isLastRowCorrect = currentState.rows.lastOrNull()?.isRowCorrect == true val mazeItemId = navArgs.mazeItemId if (mazeItemId != null && isLastRowCorrect) { viewModelScope.launch { mazeQuizRepository.completeMazeItem(mazeItemId.toInt()) } } val wordleEndGameWorkRequest = OneTimeWorkRequestBuilder() .setInputData( workDataOf( WordleEndGameWorker.INPUT_WORD to currentState.word, WordleEndGameWorker.INPUT_ROW_LIMIT to currentState.rowLimit, WordleEndGameWorker.INPUT_CURRENT_ROW_POSITION to currentState.currentRowPosition, WordleEndGameWorker.INPUT_IS_LAST_ROW_CORRECT to isLastRowCorrect, WordleEndGameWorker.INPUT_QUIZ_TYPE to currentState.wordleQuizType?.name, WordleEndGameWorker.INPUT_MAZE_TEM_ID to mazeItemId ) ).build() workManager.enqueue(wordleEndGameWorkRequest) } } ================================================ FILE: wordle/src/main/java/com/infinitepower/newquiz/wordle/components/InfoDialog.kt ================================================ package com.infinitepower.newquiz.wordle.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.sp 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.wordle.WordleChar import com.infinitepower.newquiz.model.wordle.WordleItem import com.infinitepower.newquiz.model.wordle.WordleRowItem @Composable internal fun InfoDialog( isColorBlindEnabled: Boolean, onDismissRequest: () -> Unit ) { // Word: QUIZ val rowItem = WordleRowItem( items = listOf( WordleItem.fromChar('Q'), // None WordleItem.Present(WordleChar('U')), WordleItem.Correct(WordleChar('I')), WordleItem.Present(WordleChar('Z')), ) ) AlertDialog( onDismissRequest = onDismissRequest, title = { Text(text = stringResource(id = R.string.info)) }, text = { LazyColumn( horizontalAlignment = Alignment.CenterHorizontally ) { item { WordleRowComponent( wordleRowItem = rowItem, word = "QUIZ", enabled = false, // Disable click animationEnabled = false, isColorBlindEnabled = isColorBlindEnabled, onItemClick = {} ) } item { Spacer(modifier = Modifier.padding(MaterialTheme.spacing.medium)) } item { InfoDialogCard(isColorBlindEnabled = isColorBlindEnabled) } } }, confirmButton = { TextButton(onClick = onDismissRequest) { Text(text = stringResource(id = R.string.close)) } } ) } @Composable private fun InfoDialogCard( isColorBlindEnabled: Boolean ) { val spaceMedium = MaterialTheme.spacing.medium val presentBackgroundColor = getItemRowBackgroundColor( item = WordleItem.Present(WordleChar('C')), isColorBlindEnabled = isColorBlindEnabled ) val presentTextColor = getItemRowTextColor( item = WordleItem.Present(WordleChar('C')), isColorBlindEnabled = isColorBlindEnabled ) val correctBackgroundColor = getItemRowBackgroundColor( item = WordleItem.Correct(WordleChar('C')), isColorBlindEnabled = isColorBlindEnabled ) val correctTextColor = getItemRowTextColor( item = WordleItem.Correct(WordleChar('C')), isColorBlindEnabled = isColorBlindEnabled ) Card { Column( modifier = Modifier.padding(spaceMedium), verticalArrangement = Arrangement.spacedBy(spaceMedium) ) { // Char none: Q Text( buildAnnotatedString { withStyle( style = SpanStyle( fontWeight = FontWeight.Bold, fontSize = ITEM_DESCRIPTION_FONT_SIZE, ) ) { append('Q') } append(stringResource(id = R.string.is_not_in_the_target_word_wordle)) } ) // Chars present: U, Z Text( buildAnnotatedString { withStyle( style = SpanStyle( fontWeight = FontWeight.Bold, background = presentBackgroundColor, color = presentTextColor, fontSize = ITEM_DESCRIPTION_FONT_SIZE, ) ) { append(" U ") } append(" , ") withStyle( style = SpanStyle( fontWeight = FontWeight.Bold, background = presentBackgroundColor, color = presentTextColor, fontSize = ITEM_DESCRIPTION_FONT_SIZE ) ) { append(" Z ") } append(stringResource(id = R.string.is_in_the_word_but_in_the_wrong_spot_wordle)) } ) // Char correct: I Text( buildAnnotatedString { withStyle( style = SpanStyle( fontWeight = FontWeight.Bold, background = correctBackgroundColor, color = correctTextColor, fontSize = ITEM_DESCRIPTION_FONT_SIZE ) ) { append(" I ") } append(stringResource(id = R.string.is_in_the_word_and_in_the_correct_spot_wordle)) } ) } } } private val ITEM_DESCRIPTION_FONT_SIZE = 18.sp @Composable @PreviewLightDark private fun InfoDialogPreview( @PreviewParameter(BooleanPreviewParameterProvider::class) isColorBlindEnabled: Boolean ) { NewQuizTheme { InfoDialog( isColorBlindEnabled = isColorBlindEnabled, onDismissRequest = {} ) } } ================================================ FILE: wordle/src/main/java/com/infinitepower/newquiz/wordle/components/WordleKeyBoard.kt ================================================ package com.infinitepower.newquiz.wordle.components import androidx.annotation.VisibleForTesting import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.DpSize 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.wordle.WordleQuizType /** * A keyboard composed of keys that can be clicked. * * @param modifier the modifier to apply to the keyboard * @param keys the keys to include on the keyboard * @param disabledKeys a set of keys that should be disabled and not clickable * @param onKeyClick a callback to be invoked when a key is clicked */ @Composable @ExperimentalLayoutApi internal fun WordleKeyBoard( modifier: Modifier = Modifier, rowLayout: Boolean = false, windowWidthSizeClass: WindowWidthSizeClass, wordleQuizType: WordleQuizType, keys: CharArray, disabledKeys: Set, contentPadding: PaddingValues = PaddingValues(), onKeyClick: (key: Char) -> Unit ) { val spaceSmall = MaterialTheme.spacing.small val maxWidth = when { windowWidthSizeClass == WindowWidthSizeClass.Medium && !rowLayout -> 0.5f windowWidthSizeClass == WindowWidthSizeClass.Expanded && !rowLayout -> 0.35f else -> 1f } if (wordleQuizType == WordleQuizType.TEXT) { FlowRow( modifier = modifier .padding(contentPadding) .fillMaxWidth(maxWidth), verticalArrangement = Arrangement.spacedBy(spaceSmall), horizontalArrangement = Arrangement.spacedBy(spaceSmall, Alignment.CenterHorizontally) ) { keys.forEach { key -> WordleKeyboardKey( key = key, disabled = key in disabledKeys, onKeyClick = { onKeyClick(key) } ) } } } else { val chuckSize = if (wordleQuizType == WordleQuizType.NUMBER) 3 else 4 val keyList = keys.toList() LazyVerticalGrid( columns = GridCells.Fixed(chuckSize), modifier = modifier.fillMaxWidth(maxWidth), userScrollEnabled = false, horizontalArrangement = Arrangement.spacedBy(spaceSmall, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(spaceSmall, Alignment.CenterVertically), contentPadding = contentPadding ) { items(items = keyList) { key -> WordleKeyboardKey( key = key, disabled = key in disabledKeys, onKeyClick = { onKeyClick(key) }, modifier = Modifier.size(35.dp) ) } } } } /** * A single key on a keyboard. * * @param modifier the modifier to apply to the key * @param key the character displayed on the key * @param disabled whether the key should be disabled and not clickable * @param onKeyClick a callback to be invoked when the key is clicked */ @Composable @OptIn(ExperimentalMaterial3Api::class) internal fun WordleKeyboardKey( modifier: Modifier = Modifier, key: Char, disabled: Boolean, onKeyClick: () -> Unit ) { Card( shape = MaterialTheme.shapes.medium, modifier = modifier .size(35.dp) .testTag(WordleKeyBoardTestingTags.KEY), onClick = onKeyClick, enabled = !disabled ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( text = key.toString(), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center ) } } } @VisibleForTesting internal object WordleKeyBoardTestingTags { const val KEY = "WordleKeyBoardKey" } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalLayoutApi::class) private fun WordKeyBoardPreview() { val configuration = LocalConfiguration.current val screenHeight = configuration.screenHeightDp.dp val screenWidth = configuration.screenWidthDp.dp val windowWidthClass = WindowSizeClass.calculateFromSize(DpSize(screenWidth, screenHeight)) NewQuizTheme { Surface( modifier = Modifier.fillMaxSize() ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { WordleKeyBoard( keys = "QWERTYUIOPASDFGHJKLZXCVBNM".toCharArray(), disabledKeys = "AKDTVMGUI".toSet(), onKeyClick = {}, wordleQuizType = WordleQuizType.TEXT, windowWidthSizeClass = windowWidthClass.widthSizeClass ) } } } } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalLayoutApi::class) private fun WordKeyBoardNumbersPreview() { val allNumbers = "1234567890" val configuration = LocalConfiguration.current val screenHeight = configuration.screenHeightDp.dp val screenWidth = configuration.screenWidthDp.dp val windowWidthClass = WindowSizeClass.calculateFromSize(DpSize(screenWidth, screenHeight)) NewQuizTheme { Surface( modifier = Modifier.fillMaxSize() ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { WordleKeyBoard( keys = allNumbers.toList().toCharArray(), disabledKeys = "136".toSet(), onKeyClick = {}, wordleQuizType = WordleQuizType.NUMBER, windowWidthSizeClass = windowWidthClass.widthSizeClass ) } } } } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalLayoutApi::class) private fun WordKeyBoardMathFormulaPreview() { val allNumbers = "1234567890+-*/=" val configuration = LocalConfiguration.current val screenHeight = configuration.screenHeightDp.dp val screenWidth = configuration.screenWidthDp.dp val windowWidthClass = WindowSizeClass.calculateFromSize(DpSize(screenWidth, screenHeight)) NewQuizTheme { Surface( modifier = Modifier.fillMaxSize() ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { WordleKeyBoard( keys = allNumbers.toList().toCharArray(), disabledKeys = "136".toSet(), onKeyClick = {}, wordleQuizType = WordleQuizType.MATH_FORMULA, windowWidthSizeClass = windowWidthClass.widthSizeClass ) } } } } ================================================ FILE: wordle/src/main/java/com/infinitepower/newquiz/wordle/components/WordleRowComponent.kt ================================================ package com.infinitepower.newquiz.wordle.components import androidx.annotation.VisibleForTesting import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColor import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition import androidx.compose.animation.fadeIn import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.infinitepower.newquiz.core.theme.CustomColor import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.theme.extendedColors import com.infinitepower.newquiz.core.theme.spacing import com.infinitepower.newquiz.model.wordle.WordleChar import com.infinitepower.newquiz.model.wordle.WordleItem import com.infinitepower.newquiz.model.wordle.WordleRowItem import com.infinitepower.newquiz.core.R as CoreR /** * This composable function creates a row of wordle items. * Each item represents a character in the provided [wordleRowItem]. * * @param modifier Modifier to modify the row component * @param word wordle word * @param wordleRowItem An object containing all the items in the row * @param onItemClick called when an item in the row is clicked */ @Composable internal fun WordleRowComponent( modifier: Modifier = Modifier, word: String, wordleRowItem: WordleRowItem, enabled: Boolean = true, isColorBlindEnabled: Boolean = false, isLetterHintsEnabled: Boolean = false, animationEnabled: Boolean = !LocalInspectionMode.current, onItemClick: (index: Int) -> Unit ) { WordleRowContainer( modifier = modifier, word = word, wordleRowItem = wordleRowItem, animationEnabled = animationEnabled ) { item, index, wordCharCount, itemCharCount -> WordleComponent( item = item, enabled = enabled, isColorBlindEnabled = isColorBlindEnabled, onClick = { onItemClick(index) }, charCount = wordCharCount, isLetterHintsEnabled = isLetterHintsEnabled && wordCharCount != itemCharCount ) } } @Composable private fun WordleRowContainer( modifier: Modifier = Modifier, word: String, wordleRowItem: WordleRowItem, animationEnabled: Boolean = true, wordleComponentContent: @Composable ( item: WordleItem, index: Int, wordCharCount: Int, itemCharCount: Int ) -> Unit ) { val presentItems = wordleRowItem .items .filterIsInstance() val state = remember { MutableTransitionState(!animationEnabled).apply { // Start the animation immediately. targetState = true } } Row( horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small), modifier = modifier ) { wordleRowItem.items.forEachIndexed { index, item -> val wordCharCount = word.count { wordChar -> wordChar == item.char.value && item is WordleItem.Present } val itemCharCount = presentItems.count { presentItem -> presentItem.char == item.char && item is WordleItem.Present } if (animationEnabled) { AnimatedVisibility( visibleState = state, enter = fadeIn( animationSpec = tween( durationMillis = 150, delayMillis = 150 * index ) ) ) { wordleComponentContent(item, index, wordCharCount, itemCharCount) } } else { wordleComponentContent(item, index, wordCharCount, itemCharCount) } } } } @Composable internal fun WordleComponent( modifier: Modifier = Modifier, item: WordleItem, enabled: Boolean = true, isColorBlindEnabled: Boolean = false, isLetterHintsEnabled: Boolean = false, charCount: Int = 0, onClick: () -> Unit ) { if (charCount > 1 && isLetterHintsEnabled) { BadgedBox( badge = { Badge { val badgeNumber = charCount.toString() Text( text = badgeNumber, modifier = Modifier.semantics { contentDescription = "Hint: $badgeNumber letter(s) in the word" } ) } } ) { WordleComponentImpl( modifier = modifier, item = item, enabled = enabled, isColorBlindEnabled = isColorBlindEnabled, onClick = onClick ) } } else { WordleComponentImpl( modifier = modifier, item = item, enabled = enabled, isColorBlindEnabled = isColorBlindEnabled, onClick = onClick ) } } @Composable private fun WordleComponentImpl( modifier: Modifier = Modifier, item: WordleItem, enabled: Boolean = true, isColorBlindEnabled: Boolean = false, onClick: () -> Unit ) { val transition = updateTransition( targetState = item, label = "Wordle Component" ) val backgroundColorAnimated by transition.animateColor( label = "Background Color" ) { getItemRowBackgroundColor( item = it, isColorBlindEnabled = isColorBlindEnabled ) } val textColorAnimated by transition.animateColor( label = "Text Color" ) { getItemRowTextColor( item = it, isColorBlindEnabled = isColorBlindEnabled ) } val elevationAnimated by transition.animateDp( label = "Elevation" ) { if (it.char.isEmpty()) 0.dp else 8.dp } val stroke = if (item.char.isEmpty()) { BorderStroke(1.dp, MaterialTheme.colorScheme.outline) } else { null } val itemDescription = item.getItemDescription() Surface( shape = MaterialTheme.shapes.medium, modifier = modifier .size(50.dp) .testTag(WordleRowComponentTestingTags.WORDLE_COMPONENT_SURFACE) .semantics { contentDescription = itemDescription }, color = backgroundColorAnimated, tonalElevation = elevationAnimated, border = stroke, onClick = onClick, enabled = item is WordleItem.None && enabled ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( text = item.char.toString(), style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center, color = textColorAnimated ) } } } @Composable @ReadOnlyComposable internal fun getItemRowBackgroundColor( item: WordleItem, isColorBlindEnabled: Boolean ): Color { return when (item) { is WordleItem.Correct -> MaterialTheme.extendedColors.getColorByKey( key = if (isColorBlindEnabled) { CustomColor.Key.Blue } else { CustomColor.Key.Green } ) is WordleItem.Present -> MaterialTheme.extendedColors.getColorByKey( key = if (isColorBlindEnabled) { CustomColor.Key.Red } else { CustomColor.Key.Yellow } ) else -> MaterialTheme.colorScheme.surface } } @Composable @ReadOnlyComposable internal fun getItemRowTextColor( item: WordleItem, isColorBlindEnabled: Boolean ): Color { return when (item) { is WordleItem.Correct -> MaterialTheme.extendedColors.getOnColorByKey( key = if (isColorBlindEnabled) { CustomColor.Key.Blue } else { CustomColor.Key.Green } ) is WordleItem.Present -> MaterialTheme.extendedColors.getOnColorByKey( key = if (isColorBlindEnabled) { CustomColor.Key.Red } else { CustomColor.Key.Yellow } ) else -> MaterialTheme.colorScheme.onSurface } } @Composable @ReadOnlyComposable private fun WordleItem.getItemDescription() = when (this) { is WordleItem.Empty -> stringResource(id = CoreR.string.item_empty) is WordleItem.None -> stringResource(id = CoreR.string.item_i_none, this.char) is WordleItem.Present -> stringResource(id = CoreR.string.item_i_present, this.char) is WordleItem.Correct -> stringResource(id = CoreR.string.item_i_correct, this.char) } @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal object WordleRowComponentTestingTags { const val WORDLE_COMPONENT_SURFACE = "WORDLE_COMPONENT_SURFACE" } @Composable @PreviewLightDark private fun WordleComponentPreview() { val item = WordleItem.fromChar('A') NewQuizTheme { Surface { WordleComponent( modifier = Modifier.padding(16.dp), item = item, onClick = {} ) } } } @Composable @PreviewLightDark private fun WordleRowComponentPreview() { val item = WordleRowItem( items = listOf( WordleItem.None(char = WordleChar('A')), WordleItem.Present(char = WordleChar('B')), WordleItem.Correct(char = WordleChar('C')), WordleItem.Present(char = WordleChar('U')), WordleItem.Empty, ) ) NewQuizTheme { Surface { WordleRowComponent( modifier = Modifier.padding(16.dp), word = "BUCZB", wordleRowItem = item, onItemClick = {}, isLetterHintsEnabled = true, animationEnabled = false ) } } } ================================================ FILE: wordle/src/main/java/com/infinitepower/newquiz/wordle/list/WordleListScreen.kt ================================================ package com.infinitepower.newquiz.wordle.list 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.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.core.analytics.AnalyticsEvent import com.infinitepower.newquiz.core.analytics.LocalAnalyticsHelper import com.infinitepower.newquiz.core.theme.NewQuizTheme import com.infinitepower.newquiz.core.ui.home.HomeLazyColumn import com.infinitepower.newquiz.core.ui.home.homeCategoriesItems import com.infinitepower.newquiz.core.ui.home_card.components.PlayRandomQuizCard import com.infinitepower.newquiz.data.local.wordle.WordleCategories import com.infinitepower.newquiz.model.wordle.WordleQuizType import com.infinitepower.newquiz.wordle.destinations.WordleScreenDestination import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.infinitepower.newquiz.core.R as CoreR @Composable @Destination @OptIn(ExperimentalMaterial3Api::class) fun WordleListScreen( navigator: DestinationsNavigator, homeViewModel: WordleListScreenViewModel = hiltViewModel() ) { val uiState by homeViewModel.uiState.collectAsStateWithLifecycle() WordleListScreenImpl( uiState = uiState, navigateToWordleQuiz = { wordleQuizType -> navigator.navigate(WordleScreenDestination(quizType = wordleQuizType)) } ) } @Composable @ExperimentalMaterial3Api private fun WordleListScreenImpl( uiState: WordleListUiState, navigateToWordleQuiz: (wordleQuizType: WordleQuizType) -> Unit ) { val analyticsHelper = LocalAnalyticsHelper.current var seeAllCategories by remember { mutableStateOf(false) } HomeLazyColumn { item { PlayRandomQuizCard( modifier = Modifier.fillParentMaxWidth(), title = stringResource(id = CoreR.string.quiz_with_random_categories), buttonTitle = stringResource(id = CoreR.string.random_quiz), containerMainColor = MaterialTheme.colorScheme.primary, onClick = { val randomCategory = WordleCategories.random(uiState.internetConnectionAvailable) navigateToWordleQuiz(randomCategory.wordleQuizType) }, enabled = uiState.internetConnectionAvailable, ) } 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 = { category -> analyticsHelper.logEvent( AnalyticsEvent.CategoryClicked( game = AnalyticsEvent.Game.WORDLE, categoryId = category.id ) ) navigateToWordleQuiz(category.wordleQuizType) }, onSeeAllCategoriesClick = { seeAllCategories = !seeAllCategories }, showConnectionInfo = uiState.showCategoryConnectionInfo ) } } @Composable @PreviewLightDark @OptIn(ExperimentalMaterial3Api::class) private fun WordleListScreenPreview() { NewQuizTheme { Surface { WordleListScreenImpl( uiState = WordleListUiState(), navigateToWordleQuiz = {} ) } } } ================================================ FILE: wordle/src/main/java/com/infinitepower/newquiz/wordle/list/WordleListScreenViewModel.kt ================================================ package com.infinitepower.newquiz.wordle.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.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class WordleListScreenViewModel @Inject constructor( recentCategoriesRepository: RecentCategoriesRepository, networkStatusTracker: NetworkStatusTracker ) : ViewModel() { val uiState = combine( recentCategoriesRepository.getWordleCategories( isInternetAvailable = networkStatusTracker.isCurrentlyConnected() ), recentCategoriesRepository.getShowCategoryConnectionInfoFlow() ) { recentCategories, showCategoryConnectionInfo -> WordleListUiState( homeCategories = recentCategories, internetConnectionAvailable = networkStatusTracker.isCurrentlyConnected(), showCategoryConnectionInfo = showCategoryConnectionInfo ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = WordleListUiState() ) } ================================================ FILE: wordle/src/main/java/com/infinitepower/newquiz/wordle/list/WordleListUiState.kt ================================================ package com.infinitepower.newquiz.wordle.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.wordle.WordleCategory @Keep data class WordleListUiState( val homeCategories: HomeCategories = emptyHomeCategories(), val internetConnectionAvailable: Boolean = true, val showCategoryConnectionInfo: ShowCategoryConnectionInfo = ShowCategoryConnectionInfo.NONE ) ================================================ FILE: wordle/src/main/java/com/infinitepower/newquiz/wordle/util/InvalidWordErrorUiText.kt ================================================ package com.infinitepower.newquiz.wordle.util import com.infinitepower.newquiz.data.repository.wordle.InvalidWordError import com.infinitepower.newquiz.model.UiText import com.infinitepower.newquiz.core.R as CoreR fun InvalidWordError.asUiText(): UiText { return when (this) { InvalidWordError.Empty -> UiText.StringResource(CoreR.string.empty_word) InvalidWordError.NotOnlyLetters -> UiText.StringResource(CoreR.string.word_not_only_letters_error) InvalidWordError.NotOnlyDigits -> UiText.StringResource(CoreR.string.word_not_only_digits_error) InvalidWordError.InvalidMathFormula -> UiText.StringResource(CoreR.string.word_invalid_math_formula_error) } } ================================================ FILE: wordle/src/main/java/com/infinitepower/newquiz/wordle/util/word/WordUtil.kt ================================================ package com.infinitepower.newquiz.wordle.util.word import com.infinitepower.newquiz.model.wordle.WordleItem /** * Verifies the input [WordleItem] list with [originalWord] * * This function will map all [WordleItem] list and return the corresponded verified item * that can be [WordleItem.None], [WordleItem.Present] and [WordleItem.Correct]. * * ### Mapping List * If the item is in the word and in the correct spot returns [WordleItem.Correct]. * If the item is in the word and not in the correct spot returns [WordleItem.Present]. * If the item is not in the word returns [WordleItem.None]. * * * @param originalWord word to verify items * @return verified [WordleItem] items * @see [WordleItem] * @author João Manaia * @since 1.0.0 */ internal infix fun List.verifyFromWord(originalWord: String): List { val removeChars = originalWord.filterIndexed { index, char -> getOrNull(index)?.char?.value != char }.toMutableList() val newList = mapIndexed { index, wordleItem -> val char = wordleItem.char val charCorrect = originalWord[index] == char.value if (charCorrect) return@mapIndexed WordleItem.Correct(char) val charPresent = char.value in originalWord && removeChars.remove(wordleItem.char.value) if (charPresent) return@mapIndexed WordleItem.Present(char) WordleItem.None(char, true) } return newList } /** * Returns `true` if all elements in [lastRevealedHints] are present in this list. * * @param lastRevealedHints the list of elements to check against this list * @return true if all elements in [lastRevealedHints] are present in this list */ internal infix fun List.containsAllLastRevealedHints( lastRevealedHints: List ): Boolean = lastRevealedHints.all { item -> // Check if all elements in lastRevealedHints are present in this list // Check if there is at least one element in this list that has the same character as `item` this.any { it.char == item.char } } /** * Gets keys to disable with list of [WordleItem]. * * If the item is [WordleItem.None] will disable the item key. * If the list contains one item [WordleItem.Correct] or [WordleItem.Present] and one item [WordleItem.None] will * not disable the key. * * @return keys disabled * @see [WordleItem] * @author João Manaia * @since 1.0.0 */ internal fun List.getKeysDisabled(): Set { return filter { item -> val sameItemCount = count { char -> (char is WordleItem.Correct || char is WordleItem.Present) && char.char == item.char } item is WordleItem.None && sameItemCount == 0 }.mapNotNull { item -> val char = item.char.value if (char == ' ') null else char }.toSet() } ================================================ FILE: wordle/src/main/java/com/infinitepower/newquiz/wordle/util/worker/WordleEndGameWorker.kt ================================================ package com.infinitepower.newquiz.wordle.util.worker import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkManager import androidx.work.WorkerParameters 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.data.worker.UpdateGlobalEventDataWorker import com.infinitepower.newquiz.domain.repository.home.RecentCategoriesRepository import com.infinitepower.newquiz.model.global_event.GameEvent import com.infinitepower.newquiz.model.wordle.WordleQuizType import dagger.assisted.Assisted import dagger.assisted.AssistedInject @HiltWorker class WordleEndGameWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted workerParams: WorkerParameters, private val workManager: WorkManager, private val recentCategoriesRepository: RecentCategoriesRepository, private val analyticsHelper: AnalyticsHelper, private val userService: UserService ) : CoroutineWorker(appContext, workerParams) { companion object { const val INPUT_WORD = "word" const val INPUT_ROW_LIMIT = "row_limit" const val INPUT_CURRENT_ROW_POSITION = "current_row_position" const val INPUT_IS_LAST_ROW_CORRECT = "is_last_row_correct" const val INPUT_QUIZ_TYPE = "quiz_type" const val INPUT_MAZE_TEM_ID = "maze_item_id" } override suspend fun doWork(): Result { val word = inputData.getString(INPUT_WORD) ?: return Result.failure() val rowLimit = inputData.getInt(INPUT_ROW_LIMIT, 0) val currentRowPosition = inputData.getInt(INPUT_CURRENT_ROW_POSITION, 0) val isLastRowCorrect = inputData.getBoolean(INPUT_IS_LAST_ROW_CORRECT, false) val quizTypeName = inputData.getString(INPUT_QUIZ_TYPE) ?: WordleQuizType.TEXT.name val mazeItemId = inputData.getString(INPUT_MAZE_TEM_ID) recentCategoriesRepository.addWordleCategory(quizTypeName) if (isLastRowCorrect) { UpdateGlobalEventDataWorker.enqueueWork( workManager = workManager, GameEvent.Wordle.GetWordCorrect ) } analyticsHelper.logEvent( AnalyticsEvent.WordleGameEnd( wordLength = word.length, maxRows = rowLimit, lastRow = currentRowPosition, lastRowCorrect = isLastRowCorrect, category = quizTypeName, mazeItemId = mazeItemId?.toIntOrNull() ) ) if (mazeItemId != null) { analyticsHelper.logEvent(AnalyticsEvent.MazeItemPlayed(isLastRowCorrect)) } if (isLastRowCorrect) { userService.saveWordleGame( wordLength = word.length.toUInt(), rowsUsed = currentRowPosition.toUInt(), maxRows = rowLimit, categoryId = quizTypeName, generateXp = true ) } return Result.success() } } ================================================ FILE: wordle/src/test/java/com/infinitepower/newquiz/wordle/util/word/WordUtilTest.kt ================================================ package com.infinitepower.newquiz.wordle.util.word import com.google.common.truth.Truth.assertThat import com.infinitepower.newquiz.model.wordle.WordleChar import com.infinitepower.newquiz.model.wordle.WordleItem import kotlin.test.Test internal class WordUtilTest { @Test fun `verify from word`() { val originalWord = "DBCKE" val items = listOf( WordleItem.fromChar('A'), WordleItem.fromChar('B'), WordleItem.fromChar('C'), WordleItem.fromChar('D'), WordleItem.fromChar('E'), ) val verifiedItems = items verifyFromWord originalWord val expectedItems = listOf( WordleItem.None(WordleChar('A'), true), WordleItem.Correct(WordleChar('B')), WordleItem.Correct(WordleChar('C')), WordleItem.Present(WordleChar('D')), WordleItem.Correct(WordleChar('E')), ) assertThat(verifiedItems).containsExactlyElementsIn(expectedItems) } @Test fun `verify from word, present chars must be same size as original word`() { val originalWord = "WORDLE" val items = listOf( WordleItem.fromChar('E'), WordleItem.fromChar('E'), WordleItem.fromChar('E'), WordleItem.fromChar('E'), WordleItem.fromChar('E'), WordleItem.fromChar('E'), ) val verifiedItems = items verifyFromWord originalWord val expectedItems = listOf( WordleItem.None(WordleChar('E'), true), WordleItem.None(WordleChar('E'), true), WordleItem.None(WordleChar('E'), true), WordleItem.None(WordleChar('E'), true), WordleItem.None(WordleChar('E'), true), WordleItem.Correct(WordleChar('E')), ) assertThat(verifiedItems).containsExactlyElementsIn(expectedItems) } @Test fun `get keys disabled test`() { val items = listOf( WordleItem.None(WordleChar('A')), WordleItem.Present(WordleChar('B')), WordleItem.Present(WordleChar('C')), WordleItem.None(WordleChar('D')), WordleItem.Empty, WordleItem.Correct(WordleChar('F')), ) val keysDisabled = items.getKeysDisabled() val expectedItems = listOf('A', 'D') assertThat(keysDisabled).containsExactlyElementsIn(expectedItems) } @Test fun `get keys disabled test, multiple same char`() { // Because there are one correct item, we cannot disabled that key val items = listOf( WordleItem.None(WordleChar('F')), WordleItem.Present(WordleChar('F')), WordleItem.Correct(WordleChar('F')), WordleItem.None(WordleChar('F')), WordleItem.Present(WordleChar('F')), WordleItem.Present(WordleChar('F')), ) val keysDisabled = items.getKeysDisabled() assertThat(keysDisabled).isEmpty() val items2 = listOf( WordleItem.None(WordleChar('F')), WordleItem.None(WordleChar('F')), WordleItem.None(WordleChar('F')), WordleItem.None(WordleChar('F')), WordleItem.None(WordleChar('F')), ) val keysDisabled2 = items2.getKeysDisabled() val expectedItems2 = listOf('F') assertThat(keysDisabled2).containsExactlyElementsIn(expectedItems2) } @Test fun `wordle list item contains all last revealed hints, returns true`() { val items = listOf( WordleItem.Present(WordleChar('A')), WordleItem.None(WordleChar('B')), WordleItem.None(WordleChar('B')), WordleItem.Correct(WordleChar('A')), ) val lastRevealedHints = listOf( WordleItem.Present(WordleChar('A')), ) val containsAllLastRevealedHints = items containsAllLastRevealedHints lastRevealedHints assertThat(containsAllLastRevealedHints).isTrue() val items2 = listOf( WordleItem.Correct(WordleChar('E')), WordleItem.None(WordleChar('F')), WordleItem.None(WordleChar('N')), WordleItem.None(WordleChar('F')), WordleItem.Present(WordleChar('I')), ) val lastRevealedHints2 = listOf( WordleItem.Present(WordleChar('E')), WordleItem.Present(WordleChar('I')), ) val containsAllLastRevealedHints2 = items2 containsAllLastRevealedHints lastRevealedHints2 assertThat(containsAllLastRevealedHints2).isTrue() } @Test fun `wordle list item not contains all last revealed hints, returns false`() { val items = listOf( WordleItem.Present(WordleChar('A')), WordleItem.None(WordleChar('B')), WordleItem.None(WordleChar('B')), WordleItem.Correct(WordleChar('A')), ) val lastRevealedHints = listOf( WordleItem.Present(WordleChar('A')), WordleItem.Correct(WordleChar('K')), WordleItem.Present(WordleChar('Z')), ) val containsAllLastRevealedHints = items containsAllLastRevealedHints lastRevealedHints assertThat(containsAllLastRevealedHints).isFalse() } @Test fun `wordle list item contains all empty list last revealed hints, returns true`() { val items = listOf( WordleItem.Present(WordleChar('A')), WordleItem.None(WordleChar('B')), WordleItem.None(WordleChar('B')), WordleItem.Correct(WordleChar('A')), ) val lastRevealedHints = emptyList() val containsAllLastRevealedHints = items containsAllLastRevealedHints lastRevealedHints assertThat(containsAllLastRevealedHints).isTrue() } }