├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── bump-version.yml │ ├── pull-request.yml │ └── release.yml ├── .gitignore ├── .version ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── build.gradle.kts ├── detekt.yml ├── docs ├── roadmap.md └── screenshot │ ├── banner.png │ ├── linux.png │ └── linux │ ├── 1.png │ ├── 2.png │ ├── 3.png │ ├── 4.png │ └── 5.png ├── gameoutlet.pro ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── script ├── bump-version.sh ├── detekt.gradle ├── gettext.gradle ├── git-hooks.gradle ├── semver └── sqldelight.gradle ├── settings.gradle.kts ├── snap └── snapcraft.yaml ├── src ├── main │ ├── kotlin │ │ └── appoutlet │ │ │ └── gameoutlet │ │ │ ├── Koin.kt │ │ │ ├── Logger.kt │ │ │ ├── LookAndFeel.kt │ │ │ ├── Main.kt │ │ │ ├── MainModule.kt │ │ │ ├── MainOrchestrator.kt │ │ │ ├── core │ │ │ ├── CoreModules.kt │ │ │ ├── database │ │ │ │ └── DatabaseModule.kt │ │ │ ├── network │ │ │ │ └── NetworkModule.kt │ │ │ ├── translation │ │ │ │ ├── TranslationFileProvider.kt │ │ │ │ └── TranslationModule.kt │ │ │ ├── ui │ │ │ │ ├── Color.kt │ │ │ │ ├── GameOutletTheme.kt │ │ │ │ └── Spacing.kt │ │ │ └── util │ │ │ │ ├── DesktopHelper.kt │ │ │ │ ├── StringExtensions.kt │ │ │ │ ├── TimeProvider.kt │ │ │ │ └── UtilModule.kt │ │ │ ├── domain │ │ │ ├── Deal.kt │ │ │ ├── Game.kt │ │ │ ├── Metacritic.kt │ │ │ ├── Steam.kt │ │ │ ├── SteamRating.kt │ │ │ ├── Store.kt │ │ │ └── Theme.kt │ │ │ ├── feature │ │ │ ├── FeatureModules.kt │ │ │ ├── about │ │ │ │ ├── AboutInputEvent.kt │ │ │ │ ├── AboutModule.kt │ │ │ │ ├── AboutUiState.kt │ │ │ │ ├── AboutView.kt │ │ │ │ ├── AboutViewDataMapper.kt │ │ │ │ ├── AboutViewModel.kt │ │ │ │ └── composable │ │ │ │ │ └── AboutScreen.kt │ │ │ ├── common │ │ │ │ ├── InputEvent.kt │ │ │ │ ├── UiState.kt │ │ │ │ ├── View.kt │ │ │ │ ├── ViewModel.kt │ │ │ │ ├── composable │ │ │ │ │ ├── Error.kt │ │ │ │ │ ├── Loading.kt │ │ │ │ │ └── ScreenTitle.kt │ │ │ │ └── util │ │ │ │ │ └── MoneyUtil.kt │ │ │ ├── game │ │ │ │ ├── GameInputEvent.kt │ │ │ │ ├── GameModule.kt │ │ │ │ ├── GameOrchestrator.kt │ │ │ │ ├── GameUiModel.kt │ │ │ │ ├── GameUiModelMapper.kt │ │ │ │ ├── GameUiState.kt │ │ │ │ ├── GameView.kt │ │ │ │ ├── GameViewModel.kt │ │ │ │ ├── GameViewProvider.kt │ │ │ │ └── composable │ │ │ │ │ ├── Deal.kt │ │ │ │ │ ├── GameDetailPreview.kt │ │ │ │ │ ├── GameDetails.kt │ │ │ │ │ ├── GameDetailsFooter.kt │ │ │ │ │ ├── GameDetailsImage.kt │ │ │ │ │ └── GameDetailsTopBar.kt │ │ │ ├── gamesearch │ │ │ │ ├── GameSearchInputEvent.kt │ │ │ │ ├── GameSearchModule.kt │ │ │ │ ├── GameSearchUiModel.kt │ │ │ │ ├── GameSearchUiModelMapper.kt │ │ │ │ ├── GameSearchUiState.kt │ │ │ │ ├── GameSearchView.kt │ │ │ │ ├── GameSearchViewModel.kt │ │ │ │ └── composable │ │ │ │ │ ├── GameSearchContent.kt │ │ │ │ │ └── GameSearchTextField.kt │ │ │ ├── home │ │ │ │ ├── HomeModule.kt │ │ │ │ ├── HomeView.kt │ │ │ │ ├── HomeViewProvider.kt │ │ │ │ └── composable │ │ │ │ │ ├── AboutTab.kt │ │ │ │ │ ├── GameSearchTab.kt │ │ │ │ │ ├── LatestDealsTab.kt │ │ │ │ │ ├── SettingsTab.kt │ │ │ │ │ ├── StoresTab.kt │ │ │ │ │ └── WishlistTab.kt │ │ │ ├── latestdeals │ │ │ │ ├── LatestDealsInputEvent.kt │ │ │ │ ├── LatestDealsModule.kt │ │ │ │ ├── LatestDealsOrchestrator.kt │ │ │ │ ├── LatestDealsUiModelMapper.kt │ │ │ │ ├── LatestDealsUiState.kt │ │ │ │ ├── LatestDealsView.kt │ │ │ │ ├── LatestDealsViewModel.kt │ │ │ │ └── composable │ │ │ │ │ ├── Deal.kt │ │ │ │ │ ├── LatestDealsFooter.kt │ │ │ │ │ ├── LatestDealsItems.kt │ │ │ │ │ └── StoresRow.kt │ │ │ ├── settings │ │ │ │ ├── SettingsInputEvent.kt │ │ │ │ ├── SettingsModule.kt │ │ │ │ ├── SettingsUiState.kt │ │ │ │ ├── SettingsView.kt │ │ │ │ ├── SettingsViewDataMapper.kt │ │ │ │ ├── SettingsViewModel.kt │ │ │ │ └── composable │ │ │ │ │ ├── SettingsScreen.kt │ │ │ │ │ └── ThemeSelector.kt │ │ │ ├── splash │ │ │ │ ├── SplashInputEvent.kt │ │ │ │ ├── SplashModule.kt │ │ │ │ ├── SplashOrchestrator.kt │ │ │ │ ├── SplashUiState.kt │ │ │ │ ├── SplashView.kt │ │ │ │ ├── SplashViewModel.kt │ │ │ │ └── composable │ │ │ │ │ └── SplashLoadingIndicator.kt │ │ │ ├── store │ │ │ │ ├── StoreInputEvent.kt │ │ │ │ ├── StoreModule.kt │ │ │ │ ├── StoreOrchestrator.kt │ │ │ │ ├── StoreUiState.kt │ │ │ │ ├── StoreView.kt │ │ │ │ ├── StoreViewData.kt │ │ │ │ ├── StoreViewDataMapper.kt │ │ │ │ ├── StoreViewModel.kt │ │ │ │ └── composable │ │ │ │ │ ├── StoreContent.kt │ │ │ │ │ ├── StoreDealList.kt │ │ │ │ │ └── StoreTopAppBar.kt │ │ │ ├── storelist │ │ │ │ ├── StoreListInputEvent.kt │ │ │ │ ├── StoreListModule.kt │ │ │ │ ├── StoreListUiModelMapper.kt │ │ │ │ ├── StoreListUiState.kt │ │ │ │ ├── StoreListView.kt │ │ │ │ ├── StoreListViewModel.kt │ │ │ │ └── composable │ │ │ │ │ ├── StoreList.kt │ │ │ │ │ └── StoreListContent.kt │ │ │ └── wishlist │ │ │ │ ├── WishlistInputEvent.kt │ │ │ │ ├── WishlistModule.kt │ │ │ │ ├── WishlistOrchestrator.kt │ │ │ │ ├── WishlistUiState.kt │ │ │ │ ├── WishlistUiStateMapper.kt │ │ │ │ ├── WishlistView.kt │ │ │ │ ├── WishlistViewModel.kt │ │ │ │ └── composable │ │ │ │ ├── WishlistContent.kt │ │ │ │ ├── WishlistEmptyList.kt │ │ │ │ └── WishlistGame.kt │ │ │ └── repository │ │ │ ├── RepositoryModules.kt │ │ │ ├── deals │ │ │ ├── DealGameMapper.kt │ │ │ ├── DealMapper.kt │ │ │ ├── DealRepository.kt │ │ │ ├── DealStoreMapper.kt │ │ │ ├── DealsRepositoryModule.kt │ │ │ ├── GameDealMapper.kt │ │ │ ├── GameMapper.kt │ │ │ └── api │ │ │ │ ├── DealApi.kt │ │ │ │ ├── DealResponse.kt │ │ │ │ ├── GameApi.kt │ │ │ │ ├── GameResponse.kt │ │ │ │ └── GameSearchResponse.kt │ │ │ ├── game │ │ │ ├── GameEntityMapper.kt │ │ │ ├── GameMapper.kt │ │ │ ├── GameRepository.kt │ │ │ └── GameRepositoryModule.kt │ │ │ ├── preference │ │ │ ├── PreferenceRepository.kt │ │ │ └── PreferenceRepositoryModule.kt │ │ │ ├── store │ │ │ ├── StoreCacheRepository.kt │ │ │ ├── StoreEntityMapper.kt │ │ │ ├── StoreMapper.kt │ │ │ ├── StoreRepository.kt │ │ │ ├── StoreRepositoryModule.kt │ │ │ └── api │ │ │ │ ├── StoreApi.kt │ │ │ │ └── model │ │ │ │ ├── StoreImagesResponse.kt │ │ │ │ └── StoreResponse.kt │ │ │ └── theme │ │ │ ├── ThemeRepository.kt │ │ │ └── ThemeRepositoryModule.kt │ ├── resources │ │ ├── i18n │ │ │ ├── en.po │ │ │ ├── es.po │ │ │ ├── it.po │ │ │ ├── nl.po │ │ │ ├── pt.po │ │ │ ├── pt_BR.po │ │ │ └── ru.po │ │ └── image │ │ │ ├── github.svg │ │ │ ├── icon.icns │ │ │ ├── icon.ico │ │ │ ├── icon.png │ │ │ ├── mastodon.svg │ │ │ └── twitter.svg │ └── sqldelight │ │ └── appoutlet │ │ └── gameoutlet │ │ └── core │ │ └── database │ │ ├── Game.sq │ │ ├── Preference.sq │ │ └── Store.sq └── test │ └── kotlin │ └── appoutlet │ └── gameoutlet │ ├── MainOrchestratorTest.kt │ ├── core │ ├── testing │ │ ├── BaseTest.kt │ │ ├── UiTest.kt │ │ ├── UnitTest.kt │ │ └── ViewModelTest.kt │ ├── translation │ │ └── TranslationFileProviderKtTest.kt │ ├── ui │ │ └── GameOutletThemeTest.kt │ └── util │ │ ├── DesktopHelperTest.kt │ │ ├── StringExtensionsKtTest.kt │ │ └── TimeProviderTest.kt │ ├── domain │ └── ThemeTest.kt │ ├── feature │ ├── about │ │ ├── AboutViewDataMapperTest.kt │ │ ├── AboutViewModelTest.kt │ │ ├── AboutViewTest.kt │ │ └── composable │ │ │ └── AboutScreenKtTest.kt │ ├── common │ │ └── composable │ │ │ ├── ErrorKtTest.kt │ │ │ ├── LoadingKtTest.kt │ │ │ └── ScreenTitleKtTest.kt │ ├── game │ │ ├── GameOrchestratorTest.kt │ │ ├── GameUiModelMapperTest.kt │ │ ├── GameViewModelTest.kt │ │ ├── GameViewTest.kt │ │ └── composable │ │ │ ├── DealKtTest.kt │ │ │ ├── GameDetailsKtTest.kt │ │ │ └── GameDetailsTopBarKtTest.kt │ ├── gamesearch │ │ ├── GameSearchUiModelMapperTest.kt │ │ ├── GameSearchViewModelTest.kt │ │ └── composable │ │ │ └── GameSearchContentKtTest.kt │ ├── home │ │ └── HomeViewTest.kt │ ├── latestdeals │ │ ├── LatestDealsOrchestratorTest.kt │ │ ├── LatestDealsUiModelMapperTest.kt │ │ ├── LatestDealsViewModelTest.kt │ │ └── LatestDealsViewTest.kt │ ├── settings │ │ ├── SettingsViewDataMapperTest.kt │ │ └── composable │ │ │ └── SettingsScreenKtTest.kt │ ├── splash │ │ ├── SplashOrchestratorTest.kt │ │ ├── SplashViewModelTest.kt │ │ ├── SplashViewTest.kt │ │ └── composable │ │ │ └── SplashLoadingIndicatorKtTest.kt │ ├── store │ │ ├── StoreOrchestratorTest.kt │ │ ├── StoreViewDataMapperTest.kt │ │ ├── StoreViewModelTest.kt │ │ └── composable │ │ │ └── StoreContentKtTest.kt │ ├── storelist │ │ ├── StoreListUiModelMapperTest.kt │ │ ├── StoreListViewModelTest.kt │ │ └── composable │ │ │ └── StoreListContentKtTest.kt │ └── wishlist │ │ ├── WishlistOrchestratorTest.kt │ │ ├── WishlistUiStateMapperTest.kt │ │ ├── WishlistViewModelTest.kt │ │ └── composable │ │ └── WishlistContentKtTest.kt │ └── repository │ ├── deals │ ├── DealGameMapperTest.kt │ ├── DealMapperTest.kt │ ├── DealRepositoryTest.kt │ ├── DealStoreMapperTest.kt │ ├── GameDealMapperTest.kt │ └── GameMapperTest.kt │ ├── game │ ├── GameEntityMapperTest.kt │ ├── GameMapperTest.kt │ └── GameRepositoryTest.kt │ ├── preference │ └── PreferenceRepositoryTest.kt │ ├── store │ ├── StoreCacheRepositoryTest.kt │ ├── StoreEntityMapperTest.kt │ ├── StoreMapperTest.kt │ └── StoreRepositoryTest.kt │ └── theme │ └── ThemeRepositoryTest.kt └── transifex.yml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [AppOutlet]# Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: appoutlet # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Version [e.g. 22] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Why? 2 | 3 | 4 | ### How? 5 | 6 | 7 | ### How does it look? 8 | 9 | | Before | After | 10 | |------------------------------|-----------------------------| 11 | | Drag the "before" image here | Drag the "after" image here | 12 | 13 | ### Checklist: 14 | - [ ] Unit tests created/updated 15 | - [ ] UI tests verified 16 | - [ ] Uploaded screenshots for design review 17 | 18 | With :heart: 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gradle" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/bump-version.yml: -------------------------------------------------------------------------------- 1 | name: Bump version 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | newVersion: 6 | description: 'New version (optional, defaults to next minor version)' 7 | required: false 8 | 9 | jobs: 10 | bump: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout the code 14 | uses: actions/checkout@v4 15 | - name: Initialize mandatory git config 16 | run: | 17 | git config user.name "AppOutlet team" 18 | git config user.email team.appoutlet@gmail.com 19 | - name: Give execution permission to semver 20 | run: chmod +x script/semver 21 | - name: Give execution permission to the script 22 | run: chmod +x script/bump-version.sh 23 | - name: Bump application version 24 | run: script/bump-version.sh ${{ github.event.inputs.newVersion }} 25 | shell: bash 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull request 2 | run-name: ${{ github.head_ref }} 3 | on: pull_request 4 | jobs: 5 | compile: 6 | name: Compile 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v4 11 | 12 | - name: Setup Java 13 | uses: actions/setup-java@v4 14 | with: 15 | java-version: '17' 16 | distribution: 'adopt' 17 | 18 | - name: Validate Gradle wrapper 19 | uses: gradle/actions/wrapper-validation@v3 20 | 21 | - name: Build with Gradle 22 | uses: gradle/actions/setup-gradle@v3 23 | with: 24 | cache-read-only: false 25 | 26 | - name: Compile kotlin 27 | run: ./gradlew compileKotlin 28 | 29 | unit-test: 30 | name: Unit testing 31 | runs-on: ubuntu-latest 32 | needs: compile 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v4 36 | 37 | - name: Setup Java 38 | uses: actions/setup-java@v4 39 | with: 40 | java-version: '17' 41 | distribution: 'adopt' 42 | 43 | - name: Validate Gradle wrapper 44 | uses: gradle/actions/wrapper-validation@v3 45 | 46 | - name: Build with Gradle 47 | uses: gradle/actions/setup-gradle@v3 48 | with: 49 | cache-read-only: false 50 | 51 | - name: Unit tests 52 | run: ./gradlew test koverVerify 53 | 54 | detekt: 55 | name: Detekt 56 | runs-on: ubuntu-latest 57 | needs: compile 58 | steps: 59 | - name: Checkout 60 | uses: actions/checkout@v4 61 | 62 | - name: Setup Java 63 | uses: actions/setup-java@v4 64 | with: 65 | java-version: '17' 66 | distribution: 'adopt' 67 | 68 | - name: Validate Gradle wrapper 69 | uses: gradle/actions/wrapper-validation@v3 70 | 71 | - name: Build with Gradle 72 | uses: gradle/actions/setup-gradle@v3 73 | with: 74 | cache-read-only: false 75 | 76 | - name: Run Detekt 77 | run: ./gradlew detekt 78 | 79 | - name: Export Detekt summary 80 | if: always() 81 | run: | 82 | cat build/reports/detekt/detekt.md > $GITHUB_STEP_SUMMARY 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Gradle ### 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | local.properties 8 | 9 | ### IntelliJ IDEA ### 10 | .idea/ 11 | *.iws 12 | *.iml 13 | *.ipr 14 | out/ 15 | !**/src/main/**/out/ 16 | !**/src/test/**/out/ 17 | 18 | ### Eclipse ### 19 | .apt_generated 20 | .classpath 21 | .factorypath 22 | .project 23 | .settings 24 | .springBeans 25 | .sts4-cache 26 | bin/ 27 | !**/src/main/**/bin/ 28 | !**/src/test/**/bin/ 29 | 30 | ### NetBeans ### 31 | /nbproject/private/ 32 | /nbbuild/ 33 | /dist/ 34 | /nbdist/ 35 | /.nb-gradle/ 36 | 37 | ### VS Code ### 38 | .vscode/ 39 | 40 | ### Mac OS ### 41 | .DS_Store 42 | 43 | ### Snapcraft ### 44 | *.snap 45 | parts 46 | prime 47 | stage -------------------------------------------------------------------------------- /.version: -------------------------------------------------------------------------------- 1 | 1.4.3 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## v1.3.2 (2023-06-21) 3 | * Fix the version on the About screen 4 | 5 | ## v1.3.1 (2023-06-17) 6 | * Improve language support for ru by @transifex-integration in [#91](https://github.com/AppOutlet/GameOutlet/pull/91) 7 | * Improve language support for pt by @transifex-integration in [#92](https://github.com/AppOutlet/GameOutlet/pull/92) 8 | 9 | ## v1.3.0 (2023-06-16) 10 | ### Added 11 | * Select theme by @MessiasLima in https://github.com/AppOutlet/GameOutlet/pull/87 12 | * About screen by @MessiasLima in https://github.com/AppOutlet/GameOutlet/pull/90 13 | * Enable settings screen by @MessiasLima in https://github.com/AppOutlet/GameOutlet/pull/85 14 | 15 | ### Outro 16 | * Updates for src/commonMain/resources/i18n/en.po in pt by @transifex-integration in https://github.com/AppOutlet/GameOutlet/pull/83 17 | 18 | ## v1.2.0 19 | ### Features 20 | * Create store list by @MessiasLima in [#79](https://github.com/AppOutlet/GameOutlet/pull/79) 21 | * Store deals screen by @MessiasLima in [#81](https://github.com/AppOutlet/GameOutlet/pull/81) 22 | * Enhance language support for ru by @transifex-integration in [#80](https://github.com/AppOutlet/GameOutlet/pull/80) 23 | 24 | ### Fixes 25 | * Set the maximum height to the game image by @MessiasLima in [#78](https://github.com/AppOutlet/GameOutlet/pull/78) 26 | 27 | ### Outro 28 | * Dependency version updates by @github-actions in [#73](https://github.com/AppOutlet/GameOutlet/pull/73) 29 | 30 | ## v1.1.3 (2023-05-25) 31 | ### Added 32 | - Wishlist screen. Now you can save a game as a favorite and see its prices whenever you want easily. 33 | ### Fixed 34 | - [Windows] Window decoration color now matches the application background color 35 | - [Windows] Database permission issue in release builds 36 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 📖 Contributor's guide 2 | Hello dear contributor! First, thank you for being willing to help GameOutlet grow. You can do it in some diffent ways. 3 | Feel free to pick the most suitable for you. 4 | ## 🔎 Reporting bugs and suggesting improvemtns 5 | You can use the Issues section to report bugs and do suggestions to GameOutlet. Feel free to do it. All requests are considered. 6 | ## 🙋‍♂️ Being active in the discussions 7 | Feel free to create discussions, aswer others and make our comunnity alive. 8 | ## 💻 Coding 9 | You are welcome to do code contributions, bug fixes, improvements and more. 10 | You can have a look at the issues, pick one, and open a PR. 11 | You can start with the issues labeled with "good first issue" to get to know the project and gain more confidence. 12 | So what you are waiting for? Clone the repo, open it in your IDE and start playing with it! 13 | _Soon we will release a "quick start" documention in this regard_ 14 | ## 🌍 Translating 15 | If you want to see GameOutlet localized to your mother language, you can contribute with the translations through [our profile in Transifex.](https://app.transifex.com/app-outlet/game-outlet/dashboard/) 16 | We have already several languages covered, we would love to add support for more. 17 | ## 💸 Donating 18 | You can keep this project alive by donating any value through our... 19 | - [Ko-fi profile](https://ko-fi.com/appoutlet) 20 | - [Github Sponsors](https://github.com/sponsors/AppOutlet) 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

GameOutlet

4 |

Find the best prices on PC games

5 |
6 | 7 | ![screenshot](https://github.com/AppOutlet/GameOutlet/assets/10220064/1ed76971-5516-4ff0-b912-47e192a8749e) 8 | 9 | The GameOutlet put together the latest deals in games for PC. It puts the deals for the most popular online game stores in one single place. It allows you to easily find the best deals and search for your favorite games 10 | 11 | #### Features 12 | - See the latest deals from the most popular game stores 13 | - Search by a game title to see its prices on these stores 14 | - Save your favorite games to a wishlist 15 | - See all deals from the online game stores 16 | - Light and dark theme 17 | - Available for Windows, Linux, and macOS 18 | 19 | To see the next steps, please refer to [our roadmap.](https://github.com/AppOutlet/GameOutlet/blob/main/docs/roadmap.md) 20 | 21 | ## Download 22 | You can download the latest version GameOutlet by clicking on the button below 23 | 24 | [![GitHub all releases](https://img.shields.io/github/downloads/AppOutlet/GameOutlet/total?color=%231B6D00&style=for-the-badge)](https://github.com/AppOutlet/GameOutlet/releases) 25 | 26 | Download on Flathub 27 | 28 | GameOutlet is also available in ... 29 | - [OpenDesktop.org](https://www.opendesktop.org/p/2025451) 30 | 31 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Due to the evolving nature of this project, always prefer the latest version available in the [Releases](https://github.com/AppOutlet/GameOutlet/releases) section. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | To report a vulnerability as well as other issues, please use the [Issues](https://github.com/AppOutlet/GameOutlet/issues) section in this repository. 10 | -------------------------------------------------------------------------------- /detekt.yml: -------------------------------------------------------------------------------- 1 | naming: 2 | FunctionNaming: 3 | ignoreAnnotated: 4 | - 'Composable' 5 | style: 6 | UnusedPrivateMember: 7 | ignoreAnnotated: 8 | - 'Composable' 9 | -------------------------------------------------------------------------------- /docs/roadmap.md: -------------------------------------------------------------------------------- 1 | # 🛤️ Roadmap 2 | 3 | We have several improvements and features to be added to the GameOutlet. Here are the things that we want to implement 4 | in the near future: 5 | - [x] **Latest deals:** Show the latest deals on the home screen. 6 | - [x] **Game-search:** Search by your favourite games 7 | - [x] **Windows version:** Game Outlet fully functional in windows hosts 8 | - [x] **Wishlist:** Mark a game as a favorite and easily get back to it to see its prices 9 | - [x] **Stores:** Show the list of available stores and show the deals from this store 10 | - [x] **Theme selection:** Select users preferred theme from the settings section 11 | - [ ] **Alerts:** Get a notification when a favorite game's price drops 12 | - [ ] **Currency conversion:** Convert the prices to a local currency 13 | 14 | New ideas are always welcome. Feel free to use the [discussions section](https://github.com/AppOutlet/GameOutlet/discussions) to suggest more! 15 | -------------------------------------------------------------------------------- /docs/screenshot/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppOutlet/GameOutlet/42f9aa74d31b20ff75535f722c952ef5b6896e6b/docs/screenshot/banner.png -------------------------------------------------------------------------------- /docs/screenshot/linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppOutlet/GameOutlet/42f9aa74d31b20ff75535f722c952ef5b6896e6b/docs/screenshot/linux.png -------------------------------------------------------------------------------- /docs/screenshot/linux/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppOutlet/GameOutlet/42f9aa74d31b20ff75535f722c952ef5b6896e6b/docs/screenshot/linux/1.png -------------------------------------------------------------------------------- /docs/screenshot/linux/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppOutlet/GameOutlet/42f9aa74d31b20ff75535f722c952ef5b6896e6b/docs/screenshot/linux/2.png -------------------------------------------------------------------------------- /docs/screenshot/linux/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppOutlet/GameOutlet/42f9aa74d31b20ff75535f722c952ef5b6896e6b/docs/screenshot/linux/3.png -------------------------------------------------------------------------------- /docs/screenshot/linux/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppOutlet/GameOutlet/42f9aa74d31b20ff75535f722c952ef5b6896e6b/docs/screenshot/linux/4.png -------------------------------------------------------------------------------- /docs/screenshot/linux/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppOutlet/GameOutlet/42f9aa74d31b20ff75535f722c952ef5b6896e6b/docs/screenshot/linux/5.png -------------------------------------------------------------------------------- /gameoutlet.pro: -------------------------------------------------------------------------------- 1 | # internal classes 2 | -keep class appoutlet.gameoutlet.repository.deals.api.** { *; } 3 | -keep class appoutlet.gameoutlet.repository.store.api.** { *; } 4 | -keep class appoutlet.gameoutlet.core.database.*.** { *; } 5 | 6 | # dependencies 7 | -keep class app.cash.sqldelight.driver.jdbc.sqlite.*.** { *; } 8 | -keep interface org.osgi.framework.* { *; } 9 | -keep class org.sqlite.** { *; } 10 | -keep class org.javamoney.moneta.spi.** { *; } 11 | -keep class io.ktor.client.engine.java.** { *; } 12 | -keep class com.sun.jna.** { *; } 13 | -keep class com.formdev.flatlaf.ui.** { *; } 14 | -keep class com.formdev.flatlaf.icons.** { *; } 15 | -keep class oshi.** { *; } 16 | -keep class com.jthemedetecor.** { *; } 17 | -keep class de.jangassen.jfa.foundation.** { *; } 18 | 19 | -ignorewarnings 20 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | kotlin.mpp.stability.nowarn=true 3 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | detekt = "1.23.7" 3 | kotlin = "1.9.23" 4 | kotlinx-gettext = "0.6.1" 5 | retrofit = "2.11.0" 6 | sqldelight = "2.0.2" 7 | voyager = "1.0.1" 8 | 9 | [libraries] 10 | appdirs = "ca.gosyer:kotlin-multiplatform-appdirs:1.2.0" 11 | detekt-compose = "io.nlopez.compose.rules:detekt:0.4.12" 12 | detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } 13 | flatlaf = "com.formdev:flatlaf:3.5.4" 14 | joda-time = "joda-time:joda-time:2.13.1" 15 | kamel = "com.alialbaali.kamel:kamel-image:0.4.1" 16 | koin = "io.insert-koin:koin-core:4.0.2" 17 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 18 | kotlinFixture = "com.appmattus.fixture:fixture:1.2.0" 19 | kotlinx-gettext = { module = "name.kropp.kotlinx-gettext:kotlinx-gettext", version.ref = "kotlinx-gettext" } 20 | ktor-client = "io.ktor:ktor-client-java:3.0.3" 21 | mockk = "io.mockk:mockk:1.13.14" 22 | moneta = "org.javamoney:moneta:1.4.4" 23 | moshi-kotlin = "com.squareup.moshi:moshi-kotlin:1.15.2" 24 | napier = "io.github.aakira:napier:2.7.1" 25 | retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } 26 | retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" } 27 | slf4j-simple = "org.slf4j:slf4j-simple:2.0.16" 28 | sqldelight-coroutinesExtensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } 29 | sqldelight-sqliteDriver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } 30 | truth = "com.google.truth:truth:1.4.4" 31 | themeDetector = "com.github.Dansoftowner:jSystemThemeDetector:3.9.1" 32 | voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } 33 | voyager-tabNavigation = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } 34 | voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } 35 | 36 | [plugins] 37 | commitlint = "ru.netris.commitlint:1.4.3" 38 | compose = "org.jetbrains.compose:1.8.0+" 39 | detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } 40 | gitHooks = "com.star-zero.gradle.githook:1.2.1" 41 | kotlinx-gettext = { id = "name.kropp.kotlinx-gettext", version.ref = "kotlinx-gettext" } 42 | kover = "org.jetbrains.kotlinx.kover:0.9.1" 43 | jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 44 | sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } 45 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppOutlet/GameOutlet/42f9aa74d31b20ff75535f722c952ef5b6896e6b/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /script/bump-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PATH=$PATH:$PWD/script 3 | CURRENT_VERSION=`cat .version` 4 | 5 | if [ -z "$CURRENT_VERSION" ]; then 6 | echo "No old version found!" 7 | exit 1; 8 | fi 9 | 10 | if [ -z "$1" ]; then 11 | NEW_VERSION=`semver bump minor $CURRENT_VERSION` 12 | else 13 | NEW_VERSION=$1 14 | fi 15 | 16 | if ! grep -q "$CURRENT_VERSION" build.gradle.kts; then 17 | echo "Failed to find existing version in build.gradle.kts!" 18 | exit 1; 19 | fi 20 | 21 | if ! grep -q "$CURRENT_VERSION" snap/snapcraft.yaml; then 22 | echo "Failed to find existing version in snapcraft.yaml" 23 | exit 1; 24 | fi 25 | 26 | echo "Updating snapcraft version" 27 | cat snap/snapcraft.yaml|sed -r "s/version: $CURRENT_VERSION$/version: $NEW_VERSION/g" > snap/snapcraft.yaml.tmp 28 | mv snap/snapcraft.yaml.tmp snap/snapcraft.yaml 29 | 30 | echo "Updating build.gradle version" 31 | cat build.gradle.kts|sed -r "s/version = \"$CURRENT_VERSION\"$/version = \"$NEW_VERSION\"/g" > build.gradle.kts.tmp 32 | mv build.gradle.kts.tmp build.gradle.kts 33 | 34 | echo "Updating build.gradle version" 35 | cat build.gradle.kts|sed -r "s/packageVersion = \"$CURRENT_VERSION\"$/packageVersion = \"$NEW_VERSION\"/g" > build.gradle.kts.tmp 36 | mv build.gradle.kts.tmp build.gradle.kts 37 | 38 | echo "Updating AboutScreen version" 39 | cat src/main/kotlin/appoutlet/gameoutlet/feature/about/composable/AboutScreen.kt|sed -r "s/const val VERSION = \"$CURRENT_VERSION\"$/const val VERSION = \"$NEW_VERSION\"/g" > src/main/kotlin/appoutlet/gameoutlet/feature/about/composable/AboutScreen.kt.tmp 40 | mv src/main/kotlin/appoutlet/gameoutlet/feature/about/composable/AboutScreen.kt.tmp src/main/kotlin/appoutlet/gameoutlet/feature/about/composable/AboutScreen.kt 41 | 42 | echo "$NEW_VERSION" > .version 43 | 44 | git add . 45 | git commit --no-verify -am "chore: bump to version $NEW_VERSION" 46 | git push 47 | git tag v$NEW_VERSION 48 | git push origin v$NEW_VERSION -------------------------------------------------------------------------------- /script/detekt.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'io.gitlab.arturbosch.detekt' 2 | 3 | detekt { 4 | autoCorrect true 5 | config = files("$rootDir/detekt.yml") 6 | source = files( 7 | "src/main/kotlin", 8 | "src/test/kotlin", 9 | ) 10 | parallel true 11 | buildUponDefaultConfig true 12 | } 13 | -------------------------------------------------------------------------------- /script/gettext.gradle: -------------------------------------------------------------------------------- 1 | gettext { 2 | potFile = layout.projectDirectory.file("src/main/resources/i18n/en.po") 3 | } 4 | -------------------------------------------------------------------------------- /script/git-hooks.gradle: -------------------------------------------------------------------------------- 1 | githook { 2 | failOnMissingHooksDir = true 3 | createHooksDirIfNotExist = true 4 | hooks { 5 | "commit-msg" { 6 | task = "commitlint -Dmsgfile=\$1" 7 | } 8 | 9 | "pre-push" { 10 | task = "detekt" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /script/sqldelight.gradle: -------------------------------------------------------------------------------- 1 | sqldelight { 2 | databases { 3 | GameOutletDatabase { 4 | packageName = "appoutlet.gameoutlet.core.database" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | fun shouldEnableFlatDir(): Boolean { 4 | val context = providers.environmentVariable("CONTEXT").orNull 5 | return context == "FLATPAK" 6 | } 7 | 8 | pluginManagement { 9 | repositories { 10 | google() 11 | gradlePluginPortal() 12 | mavenCentral() 13 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 14 | } 15 | } 16 | 17 | dependencyResolutionManagement { 18 | repositories { 19 | google() 20 | mavenCentral() 21 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 22 | maven("https://jitpack.io") 23 | if(shouldEnableFlatDir()) { 24 | flatDir { 25 | dir(file("dependencies")) 26 | } 27 | } 28 | } 29 | } 30 | 31 | rootProject.name = "GameOutlet" 32 | 33 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/Koin.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet 2 | 3 | import appoutlet.gameoutlet.core.coreModules 4 | import appoutlet.gameoutlet.feature.featureModules 5 | import appoutlet.gameoutlet.repository.repositoryModules 6 | import org.koin.core.Koin 7 | import org.koin.core.context.startKoin 8 | import org.koin.core.logger.Level 9 | import org.koin.core.logger.PrintLogger 10 | 11 | @Suppress("SpreadOperator") 12 | fun initKoin(): Koin { 13 | return startKoin { 14 | logger(PrintLogger(level = Level.WARNING)) 15 | modules( 16 | * coreModules, 17 | * featureModules, 18 | * repositoryModules, 19 | mainModule, 20 | ) 21 | }.koin 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/Logger.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet 2 | 3 | import io.github.aakira.napier.DebugAntilog 4 | import io.github.aakira.napier.Napier 5 | 6 | fun initLogger() { 7 | Napier.base(DebugAntilog()) 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/MainModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet 2 | 3 | import org.koin.dsl.module 4 | 5 | val mainModule = module { 6 | factory { MainOrchestrator(get()) } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/MainOrchestrator.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet 2 | 3 | import appoutlet.gameoutlet.domain.Theme 4 | import appoutlet.gameoutlet.repository.theme.ThemeRepository 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.asStateFlow 8 | import kotlinx.coroutines.flow.map 9 | 10 | class MainOrchestrator(themeRepository: ThemeRepository) { 11 | private val _theme = MutableStateFlow(themeRepository.getTheme()) 12 | 13 | init { 14 | themeRepository.observeTheme { currentTheme -> 15 | _theme.value = currentTheme 16 | } 17 | } 18 | 19 | fun isDarkTheme(systemDefault: Boolean): Flow { 20 | return _theme.asStateFlow() 21 | .map { 22 | when (it) { 23 | Theme.LIGHT -> false 24 | Theme.DARK -> true 25 | Theme.SYSTEM -> systemDefault 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/core/CoreModules.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core 2 | 3 | import appoutlet.gameoutlet.core.database.databaseModule 4 | import appoutlet.gameoutlet.core.network.networkModule 5 | import appoutlet.gameoutlet.core.translation.translationModule 6 | import appoutlet.gameoutlet.core.util.utilModule 7 | 8 | val coreModules = arrayOf( 9 | translationModule, 10 | databaseModule, 11 | networkModule, 12 | utilModule, 13 | ) 14 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/core/database/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.database 2 | 3 | import app.cash.sqldelight.db.SqlDriver 4 | import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver 5 | import appoutlet.gameoutlet.OS 6 | import appoutlet.gameoutlet.getOS 7 | import ca.gosyer.appdirs.AppDirs 8 | import io.github.aakira.napier.Napier 9 | import org.koin.core.qualifier.named 10 | import org.koin.dsl.module 11 | import java.io.File 12 | 13 | private const val QUALIFIER_DATABASE_FOLDER = "databaseFolder" 14 | 15 | val databaseModule = module { 16 | factory(named(QUALIFIER_DATABASE_FOLDER)) { 17 | val databaseFolder = getDatabaseFolder() 18 | Napier.i("Database folder $databaseFolder") 19 | createDatabaseFolderIfItDoesntExists(databaseFolder) 20 | "jdbc:sqlite:$databaseFolder/GameOutlet.db" 21 | } 22 | 23 | single { 24 | val driver = JdbcSqliteDriver(get(named(QUALIFIER_DATABASE_FOLDER))) 25 | GameOutletDatabase.Schema.create(driver) 26 | driver 27 | } 28 | 29 | single { GameOutletDatabase(get()) } 30 | } 31 | 32 | private fun getDatabaseFolder(): String { 33 | return when (getOS()) { 34 | OS.MAC, 35 | OS.WINDOWS -> { 36 | val appDirs = AppDirs(appName = "GameOutlet", appAuthor = "AppOutlet") 37 | appDirs.getUserDataDir() 38 | } 39 | OS.LINUX -> { 40 | val home = System.getenv("HOME") 41 | return "$home/.config/AppOutlet/GameOutlet/database" 42 | } 43 | } 44 | } 45 | 46 | private fun createDatabaseFolderIfItDoesntExists(databaseFolder: String) { 47 | val file = File(databaseFolder) 48 | if (!file.exists()) { 49 | file.mkdirs() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/core/network/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.network 2 | 3 | import com.squareup.moshi.Moshi 4 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory 5 | import org.koin.dsl.module 6 | import retrofit2.Retrofit 7 | import retrofit2.converter.moshi.MoshiConverterFactory 8 | 9 | private const val CHEAP_SHARK_BASE_URL = "https://www.cheapshark.com/api/1.0/" 10 | 11 | val networkModule = module { 12 | single { 13 | 14 | val moshi = Moshi.Builder() 15 | .add(KotlinJsonAdapterFactory()) 16 | .build() 17 | 18 | Retrofit.Builder() 19 | .baseUrl(CHEAP_SHARK_BASE_URL) 20 | .addConverterFactory(MoshiConverterFactory.create(moshi)) 21 | .build() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/core/translation/TranslationFileProvider.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.translation 2 | 3 | import java.io.InputStream 4 | 5 | private fun ClassLoader.getStream(fileName: String): InputStream? { 6 | return getResourceAsStream("i18n/$fileName.po") 7 | } 8 | 9 | fun getTranslationFile(classLoader: ClassLoader, languageTag: String, language: String): InputStream { 10 | return classLoader.getStream(languageTag) ?: classLoader.getStream(language) ?: classLoader.getStream("en")!! 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/core/translation/TranslationModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.translation 2 | 3 | import name.kropp.kotlinx.gettext.Gettext 4 | import name.kropp.kotlinx.gettext.I18n 5 | import name.kropp.kotlinx.gettext.Locale 6 | import name.kropp.kotlinx.gettext.load 7 | import org.koin.dsl.module 8 | 9 | val translationModule = module { 10 | single { i18n } 11 | } 12 | 13 | val i18n: I18n by lazy { 14 | initGettext() 15 | } 16 | 17 | private fun initGettext(): I18n { 18 | val locale = Locale.getDefault() 19 | val languageTag = locale.toLanguageTag().replace("-", "_") // Needed because of the transifex language mapping 20 | val language = locale.language 21 | val classLoader = Thread.currentThread().contextClassLoader 22 | 23 | val translationFileInputStream = getTranslationFile( 24 | classLoader = classLoader, 25 | languageTag = languageTag, 26 | language = language, 27 | ) 28 | 29 | return Gettext.load(locale = locale, s = translationFileInputStream) 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/core/ui/Spacing.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.ui 2 | 3 | import androidx.compose.ui.unit.dp 4 | 5 | object Spacing { 6 | val extraSmall = 4.dp 7 | val small = 8.dp 8 | val medium = 12.dp 9 | val large = 24.dp 10 | val extraLarge = 48.dp 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/core/util/DesktopHelper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.util 2 | 3 | import io.github.aakira.napier.Napier 4 | import java.awt.Desktop 5 | import java.net.URI 6 | 7 | class DesktopHelper(private val desktop: Desktop, private val runtime: Runtime) { 8 | fun openLink(url: String) { 9 | if (desktop.isSupported(Desktop.Action.BROWSE)) { 10 | desktop.browse(URI(url)) 11 | } else { 12 | Napier.w("Desktop utils not supported. Trying with xdg-open") 13 | runtime.exec(arrayOf("xdg-open", url)) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/core/util/StringExtensions.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.util 2 | 3 | import org.javamoney.moneta.Money 4 | import java.math.BigDecimal 5 | 6 | fun String.asMoney(): Money { 7 | return Money.of(BigDecimal(this), "USD") 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/core/util/TimeProvider.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.util 2 | 3 | import java.time.Instant 4 | import java.time.LocalDateTime 5 | import java.time.ZoneId 6 | 7 | class TimeProvider { 8 | fun now(): LocalDateTime = LocalDateTime.now() 9 | fun fromEpochMillis(millis: Long): LocalDateTime { 10 | val instant = Instant.ofEpochMilli(millis) 11 | return LocalDateTime.ofInstant(instant, ZoneId.systemDefault()) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/core/util/UtilModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.util 2 | 3 | import org.koin.core.module.dsl.factoryOf 4 | import org.koin.dsl.module 5 | import java.awt.Desktop 6 | 7 | val utilModule = module { 8 | factory { TimeProvider() } 9 | factoryOf(::DesktopHelper) 10 | factory { Desktop.getDesktop() } 11 | factory { Runtime.getRuntime() } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/domain/Deal.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.domain 2 | 3 | import org.javamoney.moneta.Money 4 | import java.time.LocalDateTime 5 | 6 | data class Deal( 7 | val id: String, 8 | val game: Game, 9 | val store: Store, 10 | val salePrice: Money, 11 | val normalPrice: Money, 12 | val savings: Float, 13 | val releaseDate: LocalDateTime, 14 | val lastChange: LocalDateTime, 15 | val rating: Float, 16 | ) 17 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/domain/Game.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.domain 2 | 3 | data class Game( 4 | val id: Long, 5 | val title: String, 6 | val image: String, 7 | val metacritic: Metacritic? = null, 8 | val steam: Steam? = null, 9 | ) { 10 | companion object { 11 | val UNSET = Game( 12 | id = Long.MIN_VALUE, 13 | title = "", 14 | image = "" 15 | ) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/domain/Metacritic.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.domain 2 | 3 | data class Metacritic( 4 | val link: String, 5 | val score: String, 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/domain/Steam.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.domain 2 | 3 | data class Steam( 4 | val appId: Long, 5 | val rating: SteamRating?, 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/domain/SteamRating.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.domain 2 | 3 | data class SteamRating( 4 | val text: String, 5 | val percent: Float, 6 | val count: Long, 7 | ) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/domain/Store.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.domain 2 | 3 | data class Store( 4 | val id: Int, 5 | val name: String = "", 6 | val bannerUrl: String? = null, 7 | val logoUrl: String? = null, 8 | val iconUrl: String? = null, 9 | ) 10 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/domain/Theme.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.domain 2 | 3 | enum class Theme { 4 | LIGHT, DARK, SYSTEM; 5 | 6 | companion object { 7 | fun fromString(themeString: String?): Theme { 8 | return values().firstOrNull { it.name == themeString } ?: SYSTEM 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/FeatureModules.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature 2 | 3 | import appoutlet.gameoutlet.feature.about.aboutModule 4 | import appoutlet.gameoutlet.feature.game.gameModule 5 | import appoutlet.gameoutlet.feature.gamesearch.gameSearchModule 6 | import appoutlet.gameoutlet.feature.home.homeModule 7 | import appoutlet.gameoutlet.feature.latestdeals.latestDealsModule 8 | import appoutlet.gameoutlet.feature.settings.settingsModule 9 | import appoutlet.gameoutlet.feature.splash.splashModule 10 | import appoutlet.gameoutlet.feature.store.storeModule 11 | import appoutlet.gameoutlet.feature.storelist.storeListModule 12 | import appoutlet.gameoutlet.feature.wishlist.wishlistModule 13 | 14 | val featureModules = arrayOf( 15 | splashModule, 16 | homeModule, 17 | latestDealsModule, 18 | wishlistModule, 19 | storeListModule, 20 | settingsModule, 21 | gameModule, 22 | gameSearchModule, 23 | storeModule, 24 | aboutModule, 25 | ) 26 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/about/AboutInputEvent.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.about 2 | 3 | import appoutlet.gameoutlet.feature.common.InputEvent 4 | 5 | sealed interface AboutInputEvent : InputEvent { 6 | object Load : AboutInputEvent 7 | data class OpenLink(val url: String) : AboutInputEvent 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/about/AboutModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.about 2 | 3 | import org.koin.dsl.module 4 | 5 | val aboutModule = module { 6 | factory { AboutViewModel(get(), get()) } 7 | factory { AboutViewDataMapper() } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/about/AboutUiState.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.about 2 | 3 | import appoutlet.gameoutlet.feature.common.UiState 4 | 5 | sealed interface AboutUiState : UiState { 6 | object Idle : AboutUiState 7 | 8 | data class Loaded( 9 | val contributeEvent: AboutInputEvent?, 10 | val donationEvent: AboutInputEvent?, 11 | val websiteEvent: AboutInputEvent?, 12 | val twitterEvent: AboutInputEvent?, 13 | val mastodonEvent: AboutInputEvent?, 14 | val githubEvent: AboutInputEvent?, 15 | ) : AboutUiState 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/about/AboutView.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.about 2 | 3 | import androidx.compose.runtime.Composable 4 | import appoutlet.gameoutlet.feature.about.composable.AboutScreen 5 | import appoutlet.gameoutlet.feature.common.View 6 | import org.koin.core.component.get 7 | 8 | class AboutView : View() { 9 | override val viewModel = get() 10 | 11 | @Composable 12 | override fun ViewContent(uiState: AboutUiState, onInputEvent: (AboutInputEvent) -> Unit) { 13 | AboutScreen(uiState, onInputEvent) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/about/AboutViewDataMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.about 2 | 3 | import appoutlet.gameoutlet.feature.about.AboutInputEvent.OpenLink 4 | import appoutlet.gameoutlet.feature.about.AboutUiState.Loaded 5 | 6 | class AboutViewDataMapper { 7 | operator fun invoke() = Loaded( 8 | contributeEvent = OpenLink( 9 | "https://github.com/AppOutlet/GameOutlet/blob/main/CONTRIBUTING.md#-contributors-guide" 10 | ), 11 | donationEvent = OpenLink("https://ko-fi.com/appoutlet"), 12 | websiteEvent = OpenLink("https://appoutlet.github.io/"), 13 | twitterEvent = OpenLink("https://twitter.com/AppOutletTeam"), 14 | mastodonEvent = OpenLink("https://mastodon.social/@AppOutlet"), 15 | githubEvent = OpenLink("https://github.com/AppOutlet/GameOutlet"), 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/about/AboutViewModel.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.about 2 | 3 | import appoutlet.gameoutlet.core.util.DesktopHelper 4 | import appoutlet.gameoutlet.feature.common.ViewModel 5 | 6 | class AboutViewModel( 7 | private val desktopHelper: DesktopHelper, 8 | private val aboutViewDataMapper: AboutViewDataMapper 9 | ) : ViewModel(initialState = AboutUiState.Idle) { 10 | override fun onInputEvent(inputEvent: AboutInputEvent) { 11 | when (inputEvent) { 12 | is AboutInputEvent.OpenLink -> { 13 | desktopHelper.openLink(inputEvent.url) 14 | } 15 | 16 | AboutInputEvent.Load -> { 17 | mutableUiState.value = aboutViewDataMapper() 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/common/InputEvent.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.common 2 | 3 | interface InputEvent 4 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/common/UiState.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.common 2 | 3 | interface UiState 4 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/common/View.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.common 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.rememberCoroutineScope 7 | import cafe.adriel.voyager.core.screen.Screen 8 | import cafe.adriel.voyager.navigator.LocalNavigator 9 | import org.koin.core.component.KoinComponent 10 | 11 | abstract class View : Screen, KoinComponent { 12 | abstract val viewModel: ViewModel 13 | 14 | @Composable 15 | override fun Content() { 16 | val navigator = LocalNavigator.current 17 | val coroutineScope = rememberCoroutineScope() 18 | requireNotNull(navigator) { "Navigator is not available" } 19 | viewModel.init(coroutineScope, navigator) 20 | InternalContent() 21 | } 22 | 23 | @Composable 24 | private fun InternalContent() { 25 | val uiState by viewModel.uiState.collectAsState() 26 | ViewContent(uiState = uiState, onInputEvent = viewModel::onInputEvent) 27 | } 28 | 29 | @Composable 30 | abstract fun ViewContent(uiState: State, onInputEvent: (Event) -> Unit) 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/common/ViewModel.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.common 2 | 3 | import cafe.adriel.voyager.navigator.Navigator 4 | import io.github.aakira.napier.Napier 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.Job 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.asStateFlow 9 | 10 | abstract class ViewModel(initialState: State) { 11 | protected lateinit var viewModelScope: CoroutineScope 12 | protected val mutableUiState = MutableStateFlow(initialState) 13 | protected lateinit var navigator: Navigator 14 | val uiState = mutableUiState.asStateFlow() 15 | var viewModelJob: Job? = null 16 | 17 | fun init(scope: CoroutineScope, navigator: Navigator) { 18 | viewModelScope = scope 19 | this.navigator = navigator 20 | Napier.v(message = "ViewModel initialized") 21 | afterViewModelInitialization() 22 | } 23 | 24 | abstract fun onInputEvent(inputEvent: Event) 25 | 26 | open fun afterViewModelInitialization() {} 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/common/composable/Error.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.common.composable 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.material3.Button 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.semantics.semantics 15 | import androidx.compose.ui.semantics.testTag 16 | import androidx.compose.ui.text.style.TextAlign 17 | import appoutlet.gameoutlet.core.translation.i18n 18 | import appoutlet.gameoutlet.core.ui.GameOutletTheme 19 | import appoutlet.gameoutlet.core.ui.spacing 20 | 21 | @Composable 22 | fun Error( 23 | modifier: Modifier = Modifier, 24 | title: String = i18n.tr("Something went wrong"), 25 | message: String = i18n.tr("An unexpected error occurred"), 26 | buttonText: String = i18n.tr("Try again"), 27 | onTryAgain: (() -> Unit)? = null 28 | ) { 29 | Column( 30 | modifier = modifier.semantics { testTag = "errorLayout" }, 31 | horizontalAlignment = Alignment.CenterHorizontally, 32 | verticalArrangement = Arrangement.Center, 33 | ) { 34 | Text( 35 | modifier = Modifier.semantics { testTag = "title" }, 36 | text = title, 37 | style = MaterialTheme.typography.titleLarge 38 | ) 39 | 40 | Spacer(modifier = Modifier.height(MaterialTheme.spacing.medium)) 41 | 42 | Text( 43 | modifier = Modifier.semantics { testTag = "message" }, 44 | text = message, 45 | style = MaterialTheme.typography.bodyMedium, 46 | textAlign = TextAlign.Center 47 | ) 48 | 49 | Spacer(modifier = Modifier.height(MaterialTheme.spacing.large)) 50 | 51 | onTryAgain?.let { 52 | Button(modifier = Modifier.semantics { testTag = "button" }, onClick = onTryAgain) { 53 | Text(buttonText) 54 | } 55 | } 56 | } 57 | } 58 | 59 | @Composable 60 | @Preview 61 | private fun ErrorPreview() { 62 | GameOutletTheme { 63 | Error { 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/common/composable/Loading.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.common.composable 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.layout.width 9 | import androidx.compose.material3.LinearProgressIndicator 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.semantics.semantics 16 | import androidx.compose.ui.semantics.testTag 17 | import androidx.compose.ui.unit.dp 18 | import appoutlet.gameoutlet.core.translation.i18n 19 | import appoutlet.gameoutlet.core.ui.spacing 20 | 21 | @Composable 22 | fun Loading(modifier: Modifier = Modifier, text: String = i18n.tr("Loading")) { 23 | Column( 24 | modifier = modifier.fillMaxSize(), 25 | verticalArrangement = Arrangement.Center, 26 | horizontalAlignment = Alignment.CenterHorizontally 27 | ) { 28 | LinearProgressIndicator(modifier = Modifier.width(256.dp).semantics { testTag = "loadingIndicator" }) 29 | Spacer(modifier = Modifier.height(MaterialTheme.spacing.small)) 30 | Text(text = text, style = MaterialTheme.typography.bodyMedium) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/common/composable/ScreenTitle.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.common.composable 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.semantics.semantics 9 | import androidx.compose.ui.semantics.testTag 10 | import androidx.compose.ui.text.font.FontWeight 11 | import appoutlet.gameoutlet.core.ui.spacing 12 | 13 | @Composable 14 | fun ScreenTitle(text: String, modifier: Modifier = Modifier) { 15 | Text( 16 | modifier = modifier.semantics { testTag = "screenTitle" } 17 | .padding( 18 | bottom = MaterialTheme.spacing.small, 19 | top = MaterialTheme.spacing.large, 20 | start = MaterialTheme.spacing.small 21 | ), 22 | text = text, 23 | style = MaterialTheme.typography.headlineLarge, 24 | fontWeight = FontWeight.SemiBold, 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/common/util/MoneyUtil.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.common.util 2 | 3 | import appoutlet.gameoutlet.core.translation.i18n 4 | import org.javamoney.moneta.Money 5 | import org.javamoney.moneta.format.CurrencyStyle 6 | import java.util.Locale 7 | import javax.money.format.AmountFormatQueryBuilder 8 | import javax.money.format.MonetaryFormats 9 | 10 | private val amountFormatQuery = AmountFormatQueryBuilder.of(Locale.US) 11 | .set(CurrencyStyle.SYMBOL) 12 | .build() 13 | 14 | private val amountFormat = MonetaryFormats.getAmountFormat(amountFormatQuery) 15 | 16 | fun Money.asString(): String { 17 | return if (this.isZero) { 18 | i18n.tr("FREE") 19 | } else { 20 | amountFormat.format(this) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/game/GameInputEvent.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.game 2 | 3 | import appoutlet.gameoutlet.domain.Game 4 | import appoutlet.gameoutlet.feature.common.InputEvent 5 | 6 | sealed interface GameInputEvent : InputEvent { 7 | object NavigateBack : GameInputEvent 8 | data class Load(val gameNavArgs: GameNavArgs) : GameInputEvent 9 | data class DealClicked(val deal: GameDealUiModel) : GameInputEvent 10 | data class SaveGame(val game: Game) : GameInputEvent 11 | data class RemoveGameFromFavorites(val game: Game) : GameInputEvent 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/game/GameModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.game 2 | 3 | import org.koin.dsl.module 4 | 5 | val gameModule = module { 6 | factory { GameViewModel(get(), get(), get()) } 7 | factory { GameViewProvider() } 8 | factory { GameOrchestrator(get(), get(), get()) } 9 | factory { GameUiModelMapper(get()) } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/game/GameOrchestrator.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.game 2 | 3 | import appoutlet.gameoutlet.domain.Deal 4 | import appoutlet.gameoutlet.domain.Game 5 | import appoutlet.gameoutlet.repository.deals.DealRepository 6 | import appoutlet.gameoutlet.repository.game.GameRepository 7 | import appoutlet.gameoutlet.repository.store.StoreRepository 8 | import kotlinx.coroutines.async 9 | import kotlinx.coroutines.awaitAll 10 | import kotlinx.coroutines.coroutineScope 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlinx.coroutines.flow.flow 13 | import kotlinx.coroutines.flow.map 14 | 15 | class GameOrchestrator( 16 | private val storeRepository: StoreRepository, 17 | private val dealRepository: DealRepository, 18 | private val gameRepository: GameRepository, 19 | ) { 20 | fun findDealsByGame(game: Game): Flow> = flow { 21 | val deals = dealRepository.findDealsByGame(game) 22 | emit(deals) 23 | }.map { addStoreDetails(it) } 24 | 25 | private suspend fun addStoreDetails(deals: List): List = coroutineScope { 26 | val deferredDeals = deals.map { deal -> 27 | async { 28 | val store = storeRepository.findById(deal.store.id) 29 | store?.let { 30 | deal.copy(store = it) 31 | } 32 | } 33 | } 34 | 35 | deferredDeals.awaitAll().filterNotNull() 36 | } 37 | 38 | fun save(game: Game) { 39 | gameRepository.save(game) 40 | } 41 | 42 | fun checkIfGameIsSaved(game: Game): Boolean { 43 | val savedGame = gameRepository.findById(game.id) 44 | return savedGame?.let { true } ?: false 45 | } 46 | 47 | fun removeGame(game: Game) { 48 | gameRepository.deleteById(gameId = game.id) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/game/GameUiModel.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.game 2 | 3 | import androidx.compose.runtime.Immutable 4 | 5 | @Immutable 6 | data class GameUiModel( 7 | val title: String, 8 | val image: String, 9 | val deals: List, 10 | val favouriteButton: GameFavouriteButton, 11 | val snackBarMessage: String? 12 | ) 13 | 14 | data class GameDealUiModel( 15 | val id: String, 16 | val store: GameDealStoreUiModel, 17 | val salePrice: String, 18 | val normalPrice: String, 19 | val savings: String, 20 | val showNormalPrice: Boolean, 21 | ) 22 | 23 | data class GameDealStoreUiModel( 24 | val name: String, 25 | val icon: String, 26 | ) 27 | 28 | data class GameFavouriteButton( 29 | val isSaved: Boolean, 30 | val inputEvent: GameInputEvent 31 | ) 32 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/game/GameUiState.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.game 2 | 3 | import appoutlet.gameoutlet.feature.common.UiState 4 | 5 | sealed interface GameUiState : UiState { 6 | object Idle : GameUiState 7 | object Loading : GameUiState 8 | object Error : GameUiState 9 | data class Loaded(val gameUiModel: GameUiModel) : GameUiState 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/game/GameView.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.game 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import appoutlet.gameoutlet.core.translation.i18n 7 | import appoutlet.gameoutlet.feature.common.View 8 | import appoutlet.gameoutlet.feature.common.composable.Error 9 | import appoutlet.gameoutlet.feature.common.composable.Loading 10 | import appoutlet.gameoutlet.feature.game.composable.GameDetails 11 | import org.koin.core.component.inject 12 | 13 | class GameView(private val navArgs: GameNavArgs) : View() { 14 | override val viewModel by inject() 15 | 16 | @Composable 17 | override fun ViewContent(uiState: GameUiState, onInputEvent: (GameInputEvent) -> Unit) { 18 | GameViewContent(navArgs, uiState, onInputEvent) 19 | } 20 | } 21 | 22 | @Suppress("ModifierMissing") 23 | @Composable 24 | fun GameViewContent( 25 | navArgs: GameNavArgs, 26 | uiState: GameUiState, 27 | onInputEvent: (GameInputEvent) -> Unit 28 | ) { 29 | when (uiState) { 30 | GameUiState.Idle -> onInputEvent(GameInputEvent.Load(navArgs)) 31 | 32 | GameUiState.Error -> Error( 33 | modifier = Modifier.fillMaxSize(), 34 | message = i18n.tr("We could not load the data from the selected game"), 35 | onTryAgain = { onInputEvent(GameInputEvent.Load(navArgs)) } 36 | ) 37 | 38 | GameUiState.Loading -> Loading( 39 | modifier = Modifier.fillMaxSize(), 40 | text = i18n.tr("We are fetching the deals from the selected game") 41 | ) 42 | 43 | is GameUiState.Loaded -> GameDetails(uiState.gameUiModel, onInputEvent = onInputEvent) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/game/GameViewProvider.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.game 2 | 3 | class GameViewProvider { 4 | fun getGameView(navArgs: GameNavArgs) = GameView(navArgs) 5 | } 6 | 7 | data class GameNavArgs(val gameId: Long, val gameTitle: String, val gameImage: String) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/game/composable/GameDetailPreview.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.game.composable 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.runtime.Composable 5 | import appoutlet.gameoutlet.core.ui.GameOutletTheme 6 | import appoutlet.gameoutlet.feature.game.GameDealStoreUiModel 7 | import appoutlet.gameoutlet.feature.game.GameDealUiModel 8 | import appoutlet.gameoutlet.feature.game.GameFavouriteButton 9 | import appoutlet.gameoutlet.feature.game.GameInputEvent 10 | import appoutlet.gameoutlet.feature.game.GameUiModel 11 | 12 | @Composable 13 | @Preview 14 | private fun GameDetailPreview() { 15 | GameOutletTheme { 16 | val uiModel = GameUiModel( 17 | title = "Some amazing game", 18 | image = "Some image", 19 | deals = listOf( 20 | GameDealUiModel( 21 | id = "id", 22 | store = GameDealStoreUiModel( 23 | name = "Some store", 24 | icon = "Some store logo", 25 | ), 26 | salePrice = "$ 12", 27 | savings = "- 20%", 28 | normalPrice = "$ 100", 29 | showNormalPrice = true 30 | ) 31 | ), 32 | favouriteButton = GameFavouriteButton( 33 | isSaved = false, 34 | inputEvent = GameInputEvent.NavigateBack 35 | ), 36 | snackBarMessage = null 37 | ) 38 | GameDetails(uiState = uiModel, onInputEvent = {}) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/game/composable/GameDetailsFooter.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.game.composable 2 | 3 | import androidx.compose.foundation.layout.Row 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.layout.width 8 | import androidx.compose.foundation.layout.widthIn 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.outlined.Warning 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.unit.dp 17 | import appoutlet.gameoutlet.core.translation.i18n 18 | import appoutlet.gameoutlet.core.ui.spacing 19 | 20 | @Composable 21 | fun GameDetailsFooter(modifier: Modifier = Modifier) { 22 | Row( 23 | modifier = modifier 24 | .widthIn(max = 500.dp) 25 | .padding(vertical = MaterialTheme.spacing.large, horizontal = MaterialTheme.spacing.medium) 26 | .fillMaxWidth(), 27 | ) { 28 | Icon(imageVector = Icons.Outlined.Warning, contentDescription = null) 29 | 30 | Spacer(modifier = Modifier.width(MaterialTheme.spacing.medium)) 31 | 32 | Text( 33 | text = i18n.tr( 34 | "The prices are in US dollar (USD). Note that the value can change depending on your location" 35 | ), 36 | style = MaterialTheme.typography.bodyLarge, 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/game/composable/GameDetailsImage.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.game.composable 2 | 3 | import androidx.compose.animation.core.tween 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.draw.blur 12 | import androidx.compose.ui.graphics.BlendMode 13 | import androidx.compose.ui.graphics.ColorFilter 14 | import androidx.compose.ui.layout.ContentScale 15 | import androidx.compose.ui.unit.dp 16 | import appoutlet.gameoutlet.core.ui.spacing 17 | import appoutlet.gameoutlet.feature.game.GameUiModel 18 | import io.kamel.image.KamelImage 19 | import io.kamel.image.lazyPainterResource 20 | 21 | @Composable 22 | fun GameDetailsImage(uiState: GameUiModel, modifier: Modifier = Modifier) { 23 | Box(modifier = modifier.fillMaxWidth().height(256.dp)) { 24 | KamelImage( 25 | modifier = Modifier 26 | .matchParentSize() 27 | .blur(10.dp), 28 | resource = lazyPainterResource(data = uiState.image), 29 | contentDescription = null, 30 | contentScale = ContentScale.Crop, 31 | colorFilter = ColorFilter.tint( 32 | color = MaterialTheme.colorScheme.background.copy(alpha = .5f), 33 | blendMode = BlendMode.Lighten 34 | ) 35 | ) 36 | 37 | KamelImage( 38 | modifier = Modifier.padding(MaterialTheme.spacing.medium), 39 | resource = lazyPainterResource(data = uiState.image), 40 | contentDescription = null, 41 | animationSpec = tween(), 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/game/composable/GameDetailsTopBar.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.game.composable 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.outlined.ArrowBack 5 | import androidx.compose.material.icons.outlined.Favorite 6 | import androidx.compose.material.icons.outlined.FavoriteBorder 7 | import androidx.compose.material3.ExperimentalMaterial3Api 8 | import androidx.compose.material3.Icon 9 | import androidx.compose.material3.IconButton 10 | import androidx.compose.material3.LocalContentColor 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.Text 13 | import androidx.compose.material3.TopAppBar 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.semantics.contentDescription 17 | import androidx.compose.ui.semantics.semantics 18 | import androidx.compose.ui.semantics.testTag 19 | import appoutlet.gameoutlet.feature.game.GameInputEvent 20 | import appoutlet.gameoutlet.feature.game.GameUiModel 21 | 22 | @OptIn(ExperimentalMaterial3Api::class) 23 | @Composable 24 | fun GameDetailsTopBar( 25 | uiState: GameUiModel, 26 | onInputEvent: (GameInputEvent) -> Unit, 27 | modifier: Modifier = Modifier 28 | ) { 29 | TopAppBar( 30 | modifier = modifier, 31 | title = { 32 | Text(text = uiState.title) 33 | }, 34 | navigationIcon = { 35 | IconButton( 36 | modifier = Modifier.semantics { testTag = "navigation icon" }, 37 | onClick = { onInputEvent(GameInputEvent.NavigateBack) }, 38 | content = { 39 | Icon(Icons.Outlined.ArrowBack, null) 40 | } 41 | ) 42 | }, 43 | actions = { 44 | val (icon, color) = if (uiState.favouriteButton.isSaved) { 45 | Icons.Outlined.Favorite to MaterialTheme.colorScheme.error 46 | } else { 47 | Icons.Outlined.FavoriteBorder to LocalContentColor.current 48 | } 49 | 50 | IconButton( 51 | modifier = Modifier.semantics { 52 | contentDescription = if (uiState.favouriteButton.isSaved) { 53 | "remove game icon" 54 | } else { 55 | "save game icon" 56 | } 57 | }, 58 | onClick = { onInputEvent(uiState.favouriteButton.inputEvent) }, 59 | content = { Icon(icon, null, tint = color) } 60 | ) 61 | } 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/gamesearch/GameSearchInputEvent.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.gamesearch 2 | 3 | import appoutlet.gameoutlet.feature.common.InputEvent 4 | 5 | sealed interface GameSearchInputEvent : InputEvent { 6 | data class Search(val title: String) : GameSearchInputEvent 7 | data class GameClicked(val game: GameSearchUiModel) : GameSearchInputEvent 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/gamesearch/GameSearchModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.gamesearch 2 | 3 | import org.koin.dsl.module 4 | 5 | val gameSearchModule = module { 6 | factory { GameSearchViewModel(get(), get(), get()) } 7 | factory { GameSearchUiModelMapper() } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/gamesearch/GameSearchUiModel.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.gamesearch 2 | 3 | data class GameSearchUiModel( 4 | val id: Long, 5 | val title: String, 6 | val image: String, 7 | ) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/gamesearch/GameSearchUiModelMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.gamesearch 2 | 3 | import appoutlet.gameoutlet.domain.Game 4 | 5 | class GameSearchUiModelMapper { 6 | operator fun invoke(game: Game): GameSearchUiModel { 7 | return GameSearchUiModel( 8 | id = game.id, 9 | title = game.title, 10 | image = game.image, 11 | ) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/gamesearch/GameSearchUiState.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.gamesearch 2 | 3 | import androidx.compose.runtime.Immutable 4 | import appoutlet.gameoutlet.feature.common.UiState 5 | 6 | @Immutable 7 | sealed interface GameSearchUiState : UiState { 8 | val searchTerm: String 9 | val games: List 10 | 11 | @Immutable 12 | data class Idle(override val searchTerm: String) : GameSearchUiState { 13 | override val games = emptyList() 14 | } 15 | 16 | @Immutable 17 | data class Loading(override val searchTerm: String, override val games: List) : GameSearchUiState 18 | 19 | @Immutable 20 | data class Loaded(override val searchTerm: String, override val games: List) : GameSearchUiState 21 | 22 | @Immutable 23 | data class Error(override val searchTerm: String, override val games: List) : GameSearchUiState 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/gamesearch/GameSearchView.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.gamesearch 2 | 3 | import androidx.compose.runtime.Composable 4 | import appoutlet.gameoutlet.feature.common.View 5 | import appoutlet.gameoutlet.feature.gamesearch.composable.GameSearchContent 6 | import org.koin.core.component.inject 7 | 8 | class GameSearchView : View() { 9 | override val viewModel by inject() 10 | 11 | @Composable 12 | override fun ViewContent(uiState: GameSearchUiState, onInputEvent: (GameSearchInputEvent) -> Unit) { 13 | GameSearchContent(uiState = uiState, onInputEvent = onInputEvent) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/home/HomeModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.home 2 | 3 | import org.koin.dsl.module 4 | 5 | val homeModule = module { 6 | factory { HomeViewProvider() } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/home/HomeViewProvider.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.home 2 | 3 | class HomeViewProvider { 4 | fun getView() = HomeView() 5 | } 6 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/home/composable/AboutTab.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.home.composable 2 | 3 | import androidx.compose.animation.ExperimentalAnimationApi 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.outlined.Info 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 8 | import appoutlet.gameoutlet.core.translation.i18n 9 | import appoutlet.gameoutlet.feature.about.AboutView 10 | import cafe.adriel.voyager.navigator.Navigator 11 | import cafe.adriel.voyager.navigator.tab.Tab 12 | import cafe.adriel.voyager.navigator.tab.TabOptions 13 | import cafe.adriel.voyager.transitions.SlideTransition 14 | 15 | object AboutTab : Tab { 16 | override val options: TabOptions 17 | @Composable 18 | get() = TabOptions( 19 | index = 0u, 20 | title = i18n.tr("About"), 21 | icon = rememberVectorPainter(Icons.Outlined.Info) 22 | ) 23 | 24 | @OptIn(ExperimentalAnimationApi::class) 25 | @Composable 26 | override fun Content() { 27 | Navigator(AboutView()) { 28 | SlideTransition(it) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/home/composable/GameSearchTab.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.home.composable 2 | 3 | import androidx.compose.animation.ExperimentalAnimationApi 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.outlined.Search 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 8 | import appoutlet.gameoutlet.core.translation.i18n 9 | import appoutlet.gameoutlet.feature.gamesearch.GameSearchView 10 | import cafe.adriel.voyager.navigator.Navigator 11 | import cafe.adriel.voyager.navigator.tab.Tab 12 | import cafe.adriel.voyager.navigator.tab.TabOptions 13 | import cafe.adriel.voyager.transitions.SlideTransition 14 | 15 | object GameSearchTab : Tab { 16 | override val options: TabOptions 17 | @Composable 18 | get() = TabOptions( 19 | index = 0u, 20 | title = i18n.tr("Search"), 21 | icon = rememberVectorPainter(Icons.Outlined.Search) 22 | ) 23 | 24 | @OptIn(ExperimentalAnimationApi::class) 25 | @Composable 26 | override fun Content() { 27 | Navigator(GameSearchView()) { 28 | SlideTransition(it) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/home/composable/LatestDealsTab.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.home.composable 2 | 3 | import androidx.compose.animation.ExperimentalAnimationApi 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.outlined.ShoppingBasket 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 8 | import appoutlet.gameoutlet.core.translation.i18n 9 | import appoutlet.gameoutlet.feature.latestdeals.LatestDealsView 10 | import cafe.adriel.voyager.navigator.Navigator 11 | import cafe.adriel.voyager.navigator.tab.Tab 12 | import cafe.adriel.voyager.navigator.tab.TabOptions 13 | import cafe.adriel.voyager.transitions.SlideTransition 14 | 15 | object LatestDealsTab : Tab { 16 | override val options: TabOptions 17 | @Composable 18 | get() = TabOptions( 19 | index = 0u, 20 | title = i18n.tr("Latest deals"), 21 | icon = rememberVectorPainter(Icons.Outlined.ShoppingBasket) 22 | ) 23 | 24 | @OptIn(ExperimentalAnimationApi::class) 25 | @Composable 26 | override fun Content() { 27 | Navigator(LatestDealsView()) { 28 | SlideTransition(it) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/home/composable/SettingsTab.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.home.composable 2 | 3 | import androidx.compose.animation.ExperimentalAnimationApi 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.outlined.Settings 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 8 | import appoutlet.gameoutlet.core.translation.i18n 9 | import appoutlet.gameoutlet.feature.settings.SettingsView 10 | import cafe.adriel.voyager.navigator.Navigator 11 | import cafe.adriel.voyager.navigator.tab.Tab 12 | import cafe.adriel.voyager.navigator.tab.TabOptions 13 | import cafe.adriel.voyager.transitions.SlideTransition 14 | 15 | @OptIn(ExperimentalAnimationApi::class) 16 | object SettingsTab : Tab { 17 | override val options: TabOptions 18 | @Composable 19 | get() = TabOptions( 20 | index = 3u, 21 | title = i18n.tr("Settings"), 22 | icon = rememberVectorPainter(Icons.Outlined.Settings) 23 | ) 24 | 25 | @Composable 26 | override fun Content() { 27 | Navigator(SettingsView()) { 28 | SlideTransition(it) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/home/composable/StoresTab.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.home.composable 2 | 3 | import androidx.compose.animation.ExperimentalAnimationApi 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.outlined.Store 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 8 | import appoutlet.gameoutlet.core.translation.i18n 9 | import appoutlet.gameoutlet.feature.storelist.StoreListView 10 | import cafe.adriel.voyager.navigator.Navigator 11 | import cafe.adriel.voyager.navigator.tab.Tab 12 | import cafe.adriel.voyager.navigator.tab.TabOptions 13 | import cafe.adriel.voyager.transitions.SlideTransition 14 | 15 | @OptIn(ExperimentalAnimationApi::class) 16 | object StoresTab : Tab { 17 | override val options: TabOptions 18 | @Composable 19 | get() = TabOptions( 20 | index = 2u, 21 | title = i18n.tr("Stores"), 22 | icon = rememberVectorPainter(Icons.Outlined.Store) 23 | ) 24 | 25 | @Composable 26 | override fun Content() { 27 | Navigator(StoreListView()) { 28 | SlideTransition(it) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/home/composable/WishlistTab.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.home.composable 2 | 3 | import androidx.compose.animation.ExperimentalAnimationApi 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.outlined.FavoriteBorder 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 8 | import appoutlet.gameoutlet.core.translation.i18n 9 | import appoutlet.gameoutlet.feature.wishlist.WishlistView 10 | import cafe.adriel.voyager.navigator.Navigator 11 | import cafe.adriel.voyager.navigator.tab.Tab 12 | import cafe.adriel.voyager.navigator.tab.TabOptions 13 | import cafe.adriel.voyager.transitions.SlideTransition 14 | 15 | @OptIn(ExperimentalAnimationApi::class) 16 | object WishlistTab : Tab { 17 | override val options: TabOptions 18 | @Composable 19 | get() = TabOptions( 20 | index = 1u, 21 | title = i18n.tr("Wishlist"), 22 | icon = rememberVectorPainter(Icons.Outlined.FavoriteBorder) 23 | ) 24 | 25 | @Composable 26 | override fun Content() { 27 | Navigator(WishlistView()) { 28 | SlideTransition(it) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/latestdeals/LatestDealsInputEvent.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.latestdeals 2 | 3 | import appoutlet.gameoutlet.feature.common.InputEvent 4 | import appoutlet.gameoutlet.feature.latestdeals.composable.DealUiModel 5 | 6 | sealed interface LatestDealsInputEvent : InputEvent { 7 | object Load : LatestDealsInputEvent 8 | object ToSearch : LatestDealsInputEvent 9 | data class DealClicked(val deal: DealUiModel) : LatestDealsInputEvent 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/latestdeals/LatestDealsModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.latestdeals 2 | 3 | import org.koin.dsl.module 4 | 5 | val latestDealsModule = module { 6 | factory { LatestDealsViewModel(get(), get(), get()) } 7 | factory { LatestDealsOrchestrator(get(), get()) } 8 | factory { LatestDealsUiModelMapper() } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/latestdeals/LatestDealsOrchestrator.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.latestdeals 2 | 3 | import appoutlet.gameoutlet.domain.Deal 4 | import appoutlet.gameoutlet.repository.deals.DealRepository 5 | import appoutlet.gameoutlet.repository.store.StoreRepository 6 | import kotlinx.coroutines.async 7 | import kotlinx.coroutines.awaitAll 8 | import kotlinx.coroutines.coroutineScope 9 | import kotlinx.coroutines.flow.flow 10 | import kotlinx.coroutines.flow.map 11 | 12 | class LatestDealsOrchestrator( 13 | private val dealRepository: DealRepository, 14 | private val storeRepository: StoreRepository, 15 | ) { 16 | fun findLatestDeals() = flow { emit(dealRepository.findLatestDeals()) } 17 | .map { deals -> appendStores(deals) } 18 | 19 | private suspend fun appendStores(deals: List): List = coroutineScope { 20 | val deferredDeals = deals.map { deal -> 21 | async { appendStore(deal) } 22 | } 23 | 24 | deferredDeals.awaitAll().filterNotNull() 25 | } 26 | 27 | private fun appendStore(deal: Deal): Deal? { 28 | val store = storeRepository.findById(deal.store.id) 29 | return store?.let { deal.copy(store = it) } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/latestdeals/LatestDealsUiModelMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.latestdeals 2 | 3 | import appoutlet.gameoutlet.domain.Deal 4 | import appoutlet.gameoutlet.domain.Game 5 | import appoutlet.gameoutlet.feature.common.util.asString 6 | import appoutlet.gameoutlet.feature.latestdeals.composable.DealStoreUiModel 7 | import appoutlet.gameoutlet.feature.latestdeals.composable.DealUiModel 8 | import kotlin.math.roundToInt 9 | 10 | class LatestDealsUiModelMapper { 11 | operator fun invoke(deals: List): List { 12 | val dealsMap = deals.groupBy { it.game } 13 | return dealsMap.map { (game, deals) -> 14 | mapDealUiModel(game, deals) 15 | } 16 | } 17 | 18 | private fun mapDealUiModel(game: Game, deals: List): DealUiModel { 19 | val cheapestDeal = deals.minBy { it.salePrice } 20 | 21 | return DealUiModel( 22 | gameTitle = game.title, 23 | gameId = game.id, 24 | gameImage = game.image, 25 | currentPrice = cheapestDeal.salePrice.asString(), 26 | oldPrice = cheapestDeal.normalPrice.asString(), 27 | stores = mapStore(deals), 28 | savings = "- ${cheapestDeal.savings.roundToInt()}%" 29 | ) 30 | } 31 | 32 | private fun mapStore(deals: List) = deals.mapNotNull { deal -> 33 | val logoUrl = deal.store.iconUrl 34 | logoUrl?.let { logo -> 35 | DealStoreUiModel( 36 | icon = logo, 37 | name = deal.store.name, 38 | ) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/latestdeals/LatestDealsUiState.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.latestdeals 2 | 3 | import androidx.compose.runtime.Immutable 4 | import appoutlet.gameoutlet.feature.common.UiState 5 | import appoutlet.gameoutlet.feature.latestdeals.composable.DealUiModel 6 | 7 | sealed interface LatestDealsUiState : UiState { 8 | object Idle : LatestDealsUiState 9 | object Error : LatestDealsUiState 10 | object Loading : LatestDealsUiState 11 | 12 | @Immutable 13 | data class Loaded(val uiModels: List) : LatestDealsUiState 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/latestdeals/LatestDealsView.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.latestdeals 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.semantics.semantics 8 | import androidx.compose.ui.semantics.testTag 9 | import appoutlet.gameoutlet.core.translation.i18n 10 | import appoutlet.gameoutlet.core.ui.GameOutletTheme 11 | import appoutlet.gameoutlet.feature.common.View 12 | import appoutlet.gameoutlet.feature.common.composable.Error 13 | import appoutlet.gameoutlet.feature.common.composable.Loading 14 | import appoutlet.gameoutlet.feature.latestdeals.composable.LatestDealsItems 15 | import org.koin.core.component.inject 16 | 17 | class LatestDealsView : View() { 18 | override val viewModel by inject() 19 | 20 | @Composable 21 | override fun ViewContent(uiState: LatestDealsUiState, onInputEvent: (LatestDealsInputEvent) -> Unit) { 22 | LatestDealsViewContent(uiState, onInputEvent) 23 | } 24 | } 25 | 26 | @Composable 27 | private fun LatestDealsViewContent(uiState: LatestDealsUiState, onInputEvent: (LatestDealsInputEvent) -> Unit) { 28 | when (uiState) { 29 | LatestDealsUiState.Idle -> onInputEvent(LatestDealsInputEvent.Load) 30 | 31 | LatestDealsUiState.Error -> Error( 32 | modifier = Modifier.fillMaxSize().semantics { testTag = "errorIndicator" }, 33 | message = i18n.tr("We were unable to get the latest deals"), 34 | onTryAgain = { onInputEvent(LatestDealsInputEvent.Load) }, 35 | ) 36 | 37 | LatestDealsUiState.Loading -> Loading(text = i18n.tr("Fetching the latest deals for you")) 38 | 39 | is LatestDealsUiState.Loaded -> LatestDealsItems(uiState = uiState, onInputEvent = onInputEvent) 40 | } 41 | } 42 | 43 | @Composable 44 | @Preview 45 | private fun LatestDealsViewContentPreview() { 46 | GameOutletTheme { 47 | LatestDealsViewContent(LatestDealsUiState.Idle) {} 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/latestdeals/LatestDealsViewModel.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.latestdeals 2 | 3 | import appoutlet.gameoutlet.feature.common.ViewModel 4 | import appoutlet.gameoutlet.feature.game.GameNavArgs 5 | import appoutlet.gameoutlet.feature.game.GameViewProvider 6 | import appoutlet.gameoutlet.feature.home.composable.GameSearchTab 7 | import appoutlet.gameoutlet.feature.latestdeals.composable.DealUiModel 8 | import io.github.aakira.napier.Napier 9 | import kotlinx.coroutines.flow.catch 10 | import kotlinx.coroutines.flow.launchIn 11 | import kotlinx.coroutines.flow.map 12 | import kotlinx.coroutines.flow.onEach 13 | import kotlinx.coroutines.flow.onStart 14 | 15 | class LatestDealsViewModel( 16 | private val latestDealsOrchestrator: LatestDealsOrchestrator, 17 | private val latestDealsUiModelMapper: LatestDealsUiModelMapper, 18 | private val gameViewProvider: GameViewProvider, 19 | ) : ViewModel(initialState = LatestDealsUiState.Idle) { 20 | 21 | override fun onInputEvent(inputEvent: LatestDealsInputEvent) { 22 | when (inputEvent) { 23 | LatestDealsInputEvent.Load -> loadLatestDeals() 24 | LatestDealsInputEvent.ToSearch -> goToSearchScreen() 25 | is LatestDealsInputEvent.DealClicked -> onDealClicked(inputEvent.deal) 26 | } 27 | } 28 | 29 | private fun loadLatestDeals() { 30 | latestDealsOrchestrator.findLatestDeals() 31 | .map { latestDealsUiModelMapper(it) } 32 | .catch { 33 | Napier.e(message = "Error when loading latest deals", throwable = it) 34 | mutableUiState.value = LatestDealsUiState.Error 35 | } 36 | .onStart { mutableUiState.value = LatestDealsUiState.Loading } 37 | .onEach { mutableUiState.value = LatestDealsUiState.Loaded(it) } 38 | .launchIn(viewModelScope) 39 | } 40 | 41 | private fun onDealClicked(dealUiModel: DealUiModel) { 42 | val gameNavArgs = GameNavArgs( 43 | gameId = dealUiModel.gameId, 44 | gameTitle = dealUiModel.gameTitle, 45 | gameImage = dealUiModel.gameImage, 46 | ) 47 | 48 | navigator.push(gameViewProvider.getGameView(gameNavArgs)) 49 | } 50 | 51 | private fun goToSearchScreen() { 52 | navigator.parent?.replaceAll(GameSearchTab) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/latestdeals/composable/LatestDealsItems.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.latestdeals.composable 2 | 3 | import androidx.compose.foundation.lazy.grid.GridCells 4 | import androidx.compose.foundation.lazy.grid.GridItemSpan 5 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 6 | import androidx.compose.foundation.lazy.grid.items 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.unit.dp 10 | import appoutlet.gameoutlet.core.translation.i18n 11 | import appoutlet.gameoutlet.feature.common.composable.ScreenTitle 12 | import appoutlet.gameoutlet.feature.latestdeals.LatestDealsInputEvent 13 | import appoutlet.gameoutlet.feature.latestdeals.LatestDealsUiState 14 | 15 | @Composable 16 | fun LatestDealsItems( 17 | uiState: LatestDealsUiState.Loaded, 18 | onInputEvent: (LatestDealsInputEvent) -> Unit, 19 | modifier: Modifier = Modifier, 20 | ) { 21 | LazyVerticalGrid(modifier = modifier, columns = GridCells.Adaptive(minSize = 256.dp)) { 22 | item(span = { 23 | GridItemSpan(maxLineSpan) 24 | }) { 25 | ScreenTitle(i18n.tr("Latest deals")) 26 | } 27 | 28 | items(uiState.uiModels) { deal -> 29 | Deal(deal = deal, onInputEvent = onInputEvent) 30 | } 31 | 32 | item(span = { 33 | GridItemSpan(maxLineSpan) 34 | }) { 35 | LatestDealsFooter(onInputEvent = onInputEvent) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/settings/SettingsInputEvent.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.settings 2 | 3 | import appoutlet.gameoutlet.domain.Theme 4 | import appoutlet.gameoutlet.feature.common.InputEvent 5 | 6 | sealed interface SettingsInputEvent : InputEvent { 7 | data class UpdateThemePreference(val theme: Theme) : SettingsInputEvent 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/settings/SettingsModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.settings 2 | 3 | import org.koin.dsl.module 4 | 5 | val settingsModule = module { 6 | factory { SettingsViewModel(get(), get()) } 7 | factory { SettingsViewDataMapper(get()) } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/settings/SettingsUiState.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.settings 2 | 3 | import appoutlet.gameoutlet.feature.common.UiState 4 | 5 | sealed interface SettingsUiState : UiState { 6 | object Idle : SettingsUiState 7 | data class Loaded(val settingsViewData: SettingsViewData) : SettingsUiState 8 | } 9 | 10 | data class SettingsViewData( 11 | val themeViewData: ThemeViewData 12 | ) 13 | 14 | data class ThemeViewData( 15 | val lightButton: ThemeButtonViewData, 16 | val darkButton: ThemeButtonViewData, 17 | val systemThemeButton: ThemeButtonViewData, 18 | ) { 19 | data class ThemeButtonViewData( 20 | val name: String, 21 | val isSelected: Boolean, 22 | val inputEvent: SettingsInputEvent, 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/settings/SettingsView.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.settings 2 | 3 | import androidx.compose.runtime.Composable 4 | import appoutlet.gameoutlet.feature.common.View 5 | import appoutlet.gameoutlet.feature.settings.composable.SettingsScreen 6 | import org.koin.core.component.inject 7 | 8 | class SettingsView : View() { 9 | override val viewModel by inject() 10 | 11 | @Composable 12 | override fun ViewContent(uiState: SettingsUiState, onInputEvent: (SettingsInputEvent) -> Unit) { 13 | SettingsScreen(uiState, onInputEvent) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/settings/SettingsViewDataMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.settings 2 | 3 | import appoutlet.gameoutlet.domain.Theme 4 | import name.kropp.kotlinx.gettext.I18n 5 | import name.kropp.kotlinx.gettext.tr 6 | 7 | class SettingsViewDataMapper( 8 | private val i18n: I18n, 9 | ) { 10 | operator fun invoke(theme: Theme): SettingsViewData { 11 | return SettingsViewData( 12 | themeViewData = mapTheme(theme) 13 | ) 14 | } 15 | 16 | private fun mapTheme(theme: Theme): ThemeViewData { 17 | return ThemeViewData( 18 | lightButton = ThemeViewData.ThemeButtonViewData( 19 | name = i18n.tr("Light"), 20 | isSelected = theme == Theme.LIGHT, 21 | inputEvent = SettingsInputEvent.UpdateThemePreference(Theme.LIGHT) 22 | ), 23 | darkButton = ThemeViewData.ThemeButtonViewData( 24 | name = i18n.tr("Dark"), 25 | isSelected = theme == Theme.DARK, 26 | inputEvent = SettingsInputEvent.UpdateThemePreference(Theme.DARK) 27 | ), 28 | systemThemeButton = ThemeViewData.ThemeButtonViewData( 29 | name = i18n.tr("System default"), 30 | isSelected = theme == Theme.SYSTEM, 31 | inputEvent = SettingsInputEvent.UpdateThemePreference(Theme.SYSTEM) 32 | ), 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/settings/SettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.settings 2 | 3 | import appoutlet.gameoutlet.domain.Theme 4 | import appoutlet.gameoutlet.feature.common.ViewModel 5 | import appoutlet.gameoutlet.repository.theme.ThemeRepository 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.launchIn 8 | import kotlinx.coroutines.flow.map 9 | import kotlinx.coroutines.flow.onEach 10 | 11 | class SettingsViewModel( 12 | private val themeRepository: ThemeRepository, 13 | private val settingsViewDataMapper: SettingsViewDataMapper, 14 | ) : ViewModel(initialState = SettingsUiState.Idle) { 15 | private val theme = MutableStateFlow(themeRepository.getTheme()) 16 | 17 | override fun afterViewModelInitialization() { 18 | super.afterViewModelInitialization() 19 | 20 | themeRepository.observeTheme { currentTheme -> 21 | theme.value = currentTheme 22 | } 23 | 24 | viewModelJob = theme 25 | .map { settingsViewDataMapper(it) } 26 | .onEach { mutableUiState.value = SettingsUiState.Loaded(it) } 27 | .launchIn(viewModelScope) 28 | } 29 | 30 | override fun onInputEvent(inputEvent: SettingsInputEvent) { 31 | when (inputEvent) { 32 | is SettingsInputEvent.UpdateThemePreference -> updateTheme(inputEvent.theme) 33 | } 34 | } 35 | 36 | private fun updateTheme(theme: Theme) { 37 | themeRepository.setTheme(theme) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/settings/composable/SettingsScreen.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.settings.composable 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.widthIn 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.dp 12 | import appoutlet.gameoutlet.core.translation.i18n 13 | import appoutlet.gameoutlet.core.ui.spacing 14 | import appoutlet.gameoutlet.feature.common.composable.ScreenTitle 15 | import appoutlet.gameoutlet.feature.settings.SettingsInputEvent 16 | import appoutlet.gameoutlet.feature.settings.SettingsUiState 17 | 18 | @Suppress("UnusedParameter") 19 | @Composable 20 | fun SettingsScreen( 21 | uiState: SettingsUiState, 22 | onInputEvent: (SettingsInputEvent) -> Unit, 23 | modifier: Modifier = Modifier 24 | ) { 25 | Column(modifier = modifier.fillMaxWidth()) { 26 | ScreenTitle(i18n.tr("Settings")) 27 | when (uiState) { 28 | is SettingsUiState.Loaded -> SettingsControls( 29 | uiState = uiState, 30 | onInputEvent = onInputEvent, 31 | modifier = Modifier.align(Alignment.CenterHorizontally) 32 | ) 33 | 34 | else -> {} 35 | } 36 | } 37 | } 38 | 39 | @Composable 40 | private fun SettingsControls( 41 | uiState: SettingsUiState.Loaded, 42 | onInputEvent: (SettingsInputEvent) -> Unit, 43 | modifier: Modifier = Modifier 44 | ) { 45 | Column( 46 | modifier = modifier.widthIn(max = 576.dp) 47 | .padding(all = MaterialTheme.spacing.small) 48 | ) { 49 | ThemeSelector(viewData = uiState.settingsViewData.themeViewData, onInputEvent = onInputEvent) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/splash/SplashInputEvent.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.splash 2 | 3 | import appoutlet.gameoutlet.feature.common.InputEvent 4 | 5 | sealed interface SplashInputEvent : InputEvent { 6 | object Load : SplashInputEvent 7 | } 8 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/splash/SplashModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.splash 2 | 3 | import org.koin.dsl.module 4 | 5 | val splashModule = module { 6 | factory { 7 | SplashViewModel(get(), get()) 8 | } 9 | 10 | factory { SplashOrchestrator(get()) } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/splash/SplashOrchestrator.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.splash 2 | 3 | import appoutlet.gameoutlet.repository.store.StoreRepository 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.flow 6 | 7 | class SplashOrchestrator( 8 | private val storeRepository: StoreRepository 9 | ) { 10 | fun synchronizeStoreData(): Flow = flow { 11 | storeRepository.findAll() 12 | emit(Unit) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/splash/SplashUiState.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.splash 2 | 3 | import androidx.compose.runtime.Stable 4 | import appoutlet.gameoutlet.feature.common.UiState 5 | 6 | @Stable 7 | sealed interface SplashUiState : UiState { 8 | object Idle : SplashUiState 9 | object Loading : SplashUiState 10 | object Error : SplashUiState 11 | object Loaded : SplashUiState 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/splash/SplashViewModel.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.splash 2 | 3 | import appoutlet.gameoutlet.feature.common.ViewModel 4 | import appoutlet.gameoutlet.feature.home.HomeViewProvider 5 | import io.github.aakira.napier.Napier 6 | import kotlinx.coroutines.flow.catch 7 | import kotlinx.coroutines.flow.launchIn 8 | import kotlinx.coroutines.flow.onEach 9 | import kotlinx.coroutines.flow.onStart 10 | 11 | class SplashViewModel( 12 | private val splashOrchestrator: SplashOrchestrator, 13 | private val homeViewProvider: HomeViewProvider, 14 | ) : ViewModel(initialState = SplashUiState.Idle) { 15 | 16 | override fun onInputEvent(inputEvent: SplashInputEvent) { 17 | when (inputEvent) { 18 | SplashInputEvent.Load -> loadStores() 19 | } 20 | } 21 | 22 | private fun loadStores() { 23 | splashOrchestrator.synchronizeStoreData() 24 | .onStart { 25 | mutableUiState.value = SplashUiState.Loading 26 | } 27 | .onEach { 28 | mutableUiState.value = SplashUiState.Loaded 29 | navigator.replaceAll(homeViewProvider.getView()) 30 | } 31 | .catch { 32 | Napier.e(message = "Error when synchronizing stores", throwable = it) 33 | mutableUiState.value = SplashUiState.Error 34 | } 35 | .launchIn(viewModelScope) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/splash/composable/SplashLoadingIndicator.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.splash.composable 2 | 3 | import androidx.compose.desktop.ui.tooling.preview.Preview 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.material3.LinearProgressIndicator 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.semantics.semantics 14 | import androidx.compose.ui.semantics.testTag 15 | import appoutlet.gameoutlet.core.translation.i18n 16 | import appoutlet.gameoutlet.core.ui.GameOutletTheme 17 | import appoutlet.gameoutlet.core.ui.spacing 18 | 19 | @Composable 20 | fun SplashLoadingIndicator(modifier: Modifier = Modifier) { 21 | Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { 22 | LinearProgressIndicator(modifier = Modifier.semantics { testTag = "splashLoadingIndicator" }) 23 | Spacer(modifier = Modifier.height(MaterialTheme.spacing.small)) 24 | Text(i18n.tr("Loading"), style = MaterialTheme.typography.bodyMedium) 25 | } 26 | } 27 | 28 | @Composable 29 | @Preview 30 | private fun SplashLoadingIndicatorPreview() { 31 | GameOutletTheme { 32 | SplashLoadingIndicator() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/store/StoreInputEvent.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.store 2 | 3 | import appoutlet.gameoutlet.domain.Store 4 | import appoutlet.gameoutlet.feature.common.InputEvent 5 | import appoutlet.gameoutlet.feature.game.GameNavArgs 6 | 7 | sealed interface StoreInputEvent : InputEvent { 8 | data class Load(val store: Store) : StoreInputEvent 9 | object NavigateBack : StoreInputEvent 10 | data class SelectDeal(val gameNavArgs: GameNavArgs) : StoreInputEvent 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/store/StoreModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.store 2 | 3 | import org.koin.dsl.module 4 | 5 | val storeModule = module { 6 | factory { StoreView.Provider() } 7 | factory { StoreViewModel(get(), get(), get()) } 8 | factory { StoreOrchestrator(get()) } 9 | factory { StoreViewDataMapper() } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/store/StoreOrchestrator.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.store 2 | 3 | import appoutlet.gameoutlet.domain.Store 4 | import appoutlet.gameoutlet.repository.deals.DealRepository 5 | import kotlinx.coroutines.flow.flow 6 | 7 | class StoreOrchestrator( 8 | private val dealRepository: DealRepository 9 | ) { 10 | fun loadStore(store: Store) = flow { emit(dealRepository.findDealsByStore(store)) } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/store/StoreUiState.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.store 2 | 3 | import appoutlet.gameoutlet.feature.common.UiState 4 | 5 | sealed interface StoreUiState : UiState { 6 | object Idle : StoreUiState 7 | object Loading : StoreUiState 8 | object Error : StoreUiState 9 | data class Loaded(val viewData: StoreViewData) : StoreUiState 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/store/StoreView.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.store 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import appoutlet.gameoutlet.domain.Store 7 | import appoutlet.gameoutlet.feature.common.View 8 | import appoutlet.gameoutlet.feature.store.composable.StoreContent 9 | import org.koin.core.component.inject 10 | 11 | class StoreView(private val store: Store) : View() { 12 | override val viewModel by inject() 13 | 14 | @Composable 15 | override fun ViewContent(uiState: StoreUiState, onInputEvent: (StoreInputEvent) -> Unit) { 16 | StoreContent( 17 | store = store, 18 | modifier = Modifier.fillMaxSize(), 19 | uiState = uiState, 20 | onInputEvent = onInputEvent 21 | ) 22 | } 23 | 24 | class Provider { 25 | fun getStoreView(store: Store) = StoreView(store) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/store/StoreViewData.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.store 2 | 3 | data class StoreViewData(val deals: List) 4 | 5 | data class DealViewData( 6 | val title: String, 7 | val currentPrice: String, 8 | val normalPrice: String?, 9 | val savings: String, 10 | val image: String, 11 | val inputEvent: StoreInputEvent 12 | ) 13 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/store/StoreViewDataMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.store 2 | 3 | import appoutlet.gameoutlet.domain.Deal 4 | import appoutlet.gameoutlet.domain.Game 5 | import appoutlet.gameoutlet.feature.common.util.asString 6 | import appoutlet.gameoutlet.feature.game.GameNavArgs 7 | import kotlin.math.roundToInt 8 | 9 | class StoreViewDataMapper { 10 | operator fun invoke(deals: List): StoreViewData { 11 | return StoreViewData( 12 | deals = deals.map(this::mapDealViewData) 13 | ) 14 | } 15 | 16 | private fun mapDealViewData(deal: Deal): DealViewData { 17 | val normalPrice = if (deal.salePrice == deal.normalPrice) { 18 | null 19 | } else { 20 | deal.normalPrice.asString() 21 | } 22 | 23 | return DealViewData( 24 | title = deal.game.title, 25 | currentPrice = deal.salePrice.asString(), 26 | normalPrice = normalPrice, 27 | savings = "- ${deal.savings.roundToInt()}%", 28 | image = deal.game.image, 29 | inputEvent = mapInputEvent(deal.game), 30 | ) 31 | } 32 | 33 | private fun mapInputEvent(game: Game): StoreInputEvent { 34 | val gameNavArgs = GameNavArgs( 35 | gameId = game.id, 36 | gameTitle = game.title, 37 | gameImage = game.image, 38 | ) 39 | 40 | return StoreInputEvent.SelectDeal(gameNavArgs) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/store/StoreViewModel.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.store 2 | 3 | import appoutlet.gameoutlet.domain.Store 4 | import appoutlet.gameoutlet.feature.common.ViewModel 5 | import appoutlet.gameoutlet.feature.game.GameNavArgs 6 | import appoutlet.gameoutlet.feature.game.GameViewProvider 7 | import kotlinx.coroutines.flow.catch 8 | import kotlinx.coroutines.flow.launchIn 9 | import kotlinx.coroutines.flow.map 10 | import kotlinx.coroutines.flow.onEach 11 | import kotlinx.coroutines.flow.onStart 12 | 13 | class StoreViewModel( 14 | private val storeOrchestrator: StoreOrchestrator, 15 | private val storeViewDataMapper: StoreViewDataMapper, 16 | private val gameViewProvider: GameViewProvider, 17 | ) : ViewModel(initialState = StoreUiState.Idle) { 18 | override fun onInputEvent(inputEvent: StoreInputEvent) { 19 | when (inputEvent) { 20 | is StoreInputEvent.Load -> loadStore(inputEvent.store) 21 | is StoreInputEvent.SelectDeal -> goToGame(inputEvent.gameNavArgs) 22 | StoreInputEvent.NavigateBack -> navigator.pop() 23 | } 24 | } 25 | 26 | private fun loadStore(store: Store) { 27 | storeOrchestrator.loadStore(store) 28 | .map { storeViewDataMapper(it) } 29 | .onEach { mutableUiState.value = StoreUiState.Loaded(it) } 30 | .onStart { mutableUiState.value = StoreUiState.Loading } 31 | .catch { mutableUiState.value = StoreUiState.Error } 32 | .launchIn(viewModelScope) 33 | } 34 | 35 | private fun goToGame(gameNavArgs: GameNavArgs) { 36 | val gameView = gameViewProvider.getGameView(gameNavArgs) 37 | navigator.push(gameView) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/store/composable/StoreContent.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.store.composable 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.platform.testTag 9 | import appoutlet.gameoutlet.core.translation.i18n 10 | import appoutlet.gameoutlet.domain.Store 11 | import appoutlet.gameoutlet.feature.common.composable.Error 12 | import appoutlet.gameoutlet.feature.common.composable.Loading 13 | import appoutlet.gameoutlet.feature.store.StoreInputEvent 14 | import appoutlet.gameoutlet.feature.store.StoreUiState 15 | 16 | @Composable 17 | fun StoreContent( 18 | store: Store, 19 | uiState: StoreUiState, 20 | onInputEvent: (StoreInputEvent) -> Unit, 21 | modifier: Modifier = Modifier 22 | ) { 23 | Column(modifier = modifier) { 24 | StoreTopAppBar( 25 | modifier = Modifier.fillMaxWidth().testTag("storeTopBar"), 26 | store = store, 27 | onInputEvent = onInputEvent 28 | ) 29 | 30 | when (uiState) { 31 | StoreUiState.Idle -> onInputEvent(StoreInputEvent.Load(store)) 32 | 33 | StoreUiState.Error -> Error( 34 | modifier = Modifier.fillMaxSize(), 35 | message = i18n.tr( 36 | "We could not get the list of deals from {{storeName}}", 37 | "storeName" to store.name 38 | ), 39 | onTryAgain = { onInputEvent(StoreInputEvent.Load(store)) } 40 | ) 41 | 42 | StoreUiState.Loading -> Loading( 43 | modifier = Modifier.fillMaxSize(), 44 | text = i18n.tr( 45 | "We are fetching the list of deals from {{storeName}}", 46 | "storeName" to store.name 47 | ) 48 | ) 49 | 50 | is StoreUiState.Loaded -> StoreDealList( 51 | modifier = Modifier.fillMaxWidth(), 52 | viewData = uiState.viewData, 53 | onInputEvent = onInputEvent 54 | ) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/storelist/StoreListInputEvent.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.storelist 2 | 3 | import appoutlet.gameoutlet.domain.Store 4 | import appoutlet.gameoutlet.feature.common.InputEvent 5 | 6 | sealed interface StoreListInputEvent : InputEvent { 7 | object Load : StoreListInputEvent 8 | data class SelectStore(val store: Store) : StoreListInputEvent 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/storelist/StoreListModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.storelist 2 | 3 | import org.koin.dsl.module 4 | 5 | val storeListModule = module { 6 | factory { StoreListViewModel(get(), get(), get()) } 7 | factory { StoreListUiModelMapper() } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/storelist/StoreListUiModelMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.storelist 2 | 3 | import appoutlet.gameoutlet.domain.Store 4 | 5 | class StoreListUiModelMapper { 6 | operator fun invoke(entities: List): List { 7 | return entities.map(this::mapStore) 8 | } 9 | 10 | private fun mapStore(store: Store) = StoreUiModel( 11 | icon = store.logoUrl, 12 | name = store.name, 13 | inputEvent = StoreListInputEvent.SelectStore(store), 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/storelist/StoreListUiState.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.storelist 2 | 3 | import appoutlet.gameoutlet.feature.common.UiState 4 | 5 | sealed interface StoreListUiState : UiState { 6 | object Idle : StoreListUiState 7 | object Loading : StoreListUiState 8 | object Error : StoreListUiState 9 | data class Loaded(val stores: List) : StoreListUiState 10 | } 11 | 12 | data class StoreUiModel( 13 | val icon: String?, 14 | val name: String, 15 | val inputEvent: StoreListInputEvent 16 | ) 17 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/storelist/StoreListView.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.storelist 2 | 3 | import androidx.compose.runtime.Composable 4 | import appoutlet.gameoutlet.feature.common.View 5 | import appoutlet.gameoutlet.feature.storelist.composable.StoreListContent 6 | import org.koin.core.component.inject 7 | 8 | class StoreListView : View() { 9 | override val viewModel by inject() 10 | 11 | @Composable 12 | override fun ViewContent(uiState: StoreListUiState, onInputEvent: (StoreListInputEvent) -> Unit) { 13 | StoreListContent(uiState = uiState, onInputEvent = onInputEvent) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/storelist/StoreListViewModel.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.storelist 2 | 3 | import appoutlet.gameoutlet.domain.Store 4 | import appoutlet.gameoutlet.feature.common.ViewModel 5 | import appoutlet.gameoutlet.feature.store.StoreView 6 | import appoutlet.gameoutlet.repository.store.StoreRepository 7 | import io.github.aakira.napier.Napier 8 | import kotlinx.coroutines.launch 9 | 10 | class StoreListViewModel( 11 | private val storeRepository: StoreRepository, 12 | private val storeListUiModelMapper: StoreListUiModelMapper, 13 | private val storeViewProvider: StoreView.Provider 14 | ) : ViewModel(initialState = StoreListUiState.Idle) { 15 | override fun onInputEvent(inputEvent: StoreListInputEvent) { 16 | when (inputEvent) { 17 | StoreListInputEvent.Load -> loadStores() 18 | is StoreListInputEvent.SelectStore -> goToStore(inputEvent.store) 19 | } 20 | } 21 | 22 | @Suppress("TooGenericExceptionCaught") 23 | private fun loadStores() { 24 | viewModelScope.launch { 25 | try { 26 | mutableUiState.value = StoreListUiState.Loading 27 | 28 | val storesEntity = storeRepository.findAll() 29 | val uiModels = storeListUiModelMapper(storesEntity) 30 | 31 | mutableUiState.value = StoreListUiState.Loaded(uiModels) 32 | } catch (throwable: Throwable) { 33 | Napier.e("Error getting the stores list", throwable) 34 | mutableUiState.value = StoreListUiState.Error 35 | } 36 | } 37 | } 38 | 39 | private fun goToStore(store: Store) { 40 | navigator.push(storeViewProvider.getStoreView(store)) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/storelist/composable/StoreListContent.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.storelist.composable 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import appoutlet.gameoutlet.core.translation.i18n 9 | import appoutlet.gameoutlet.feature.common.composable.Error 10 | import appoutlet.gameoutlet.feature.common.composable.Loading 11 | import appoutlet.gameoutlet.feature.common.composable.ScreenTitle 12 | import appoutlet.gameoutlet.feature.storelist.StoreListInputEvent 13 | import appoutlet.gameoutlet.feature.storelist.StoreListUiState 14 | 15 | @Composable 16 | fun StoreListContent( 17 | uiState: StoreListUiState, 18 | onInputEvent: (StoreListInputEvent) -> Unit, 19 | modifier: Modifier = Modifier 20 | ) { 21 | Column(modifier = modifier.fillMaxWidth()) { 22 | ScreenTitle(text = i18n.tr("Stores")) 23 | when (uiState) { 24 | StoreListUiState.Idle -> onInputEvent(StoreListInputEvent.Load) 25 | StoreListUiState.Error -> Error( 26 | modifier = Modifier.fillMaxSize(), 27 | message = i18n.tr("We could not get the list of Stores"), 28 | onTryAgain = { onInputEvent(StoreListInputEvent.Load) } 29 | ) 30 | 31 | StoreListUiState.Loading -> Loading( 32 | modifier = Modifier.fillMaxSize(), 33 | text = i18n.tr("We are fetching the list of stores") 34 | ) 35 | 36 | is StoreListUiState.Loaded -> StoreList( 37 | uiState = uiState, 38 | onInputEvent = onInputEvent, 39 | ) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/wishlist/WishlistInputEvent.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.wishlist 2 | 3 | import appoutlet.gameoutlet.feature.common.InputEvent 4 | 5 | sealed interface WishlistInputEvent : InputEvent { 6 | object Load : WishlistInputEvent 7 | data class GameClicked( 8 | val game: WishlistGameUiModel, 9 | ) : WishlistInputEvent 10 | 11 | object GoToSearch : WishlistInputEvent 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/wishlist/WishlistModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.wishlist 2 | 3 | import org.koin.dsl.module 4 | 5 | val wishlistModule = module { 6 | factory { WishlistViewModel(get(), get(), get(), get()) } 7 | factory { WishlistOrchestrator(get()) } 8 | factory { WishlistUiStateMapper() } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/wishlist/WishlistOrchestrator.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.wishlist 2 | 3 | import appoutlet.gameoutlet.repository.game.GameRepository 4 | 5 | class WishlistOrchestrator( 6 | private val gameRepository: GameRepository, 7 | ) { 8 | fun findAll() = gameRepository.findAll() 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/wishlist/WishlistUiState.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.wishlist 2 | 3 | import appoutlet.gameoutlet.feature.common.UiState 4 | 5 | sealed interface WishlistUiState : UiState { 6 | object Idle : WishlistUiState 7 | object Loading : WishlistUiState 8 | object Error : WishlistUiState 9 | data class Loaded(val list: List) : WishlistUiState 10 | } 11 | 12 | data class WishlistGameUiModel( 13 | val id: Long, 14 | val title: String, 15 | val image: String 16 | ) 17 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/wishlist/WishlistUiStateMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.wishlist 2 | 3 | import appoutlet.gameoutlet.domain.Game 4 | 5 | class WishlistUiStateMapper { 6 | operator fun invoke(games: List) = games.map(this::mapWishlistGame) 7 | 8 | private fun mapWishlistGame(game: Game) = WishlistGameUiModel( 9 | id = game.id, 10 | title = game.title, 11 | image = game.image, 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/wishlist/WishlistView.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.wishlist 2 | 3 | import androidx.compose.runtime.Composable 4 | import appoutlet.gameoutlet.feature.common.View 5 | import appoutlet.gameoutlet.feature.wishlist.composable.WishlistContent 6 | import org.koin.core.component.inject 7 | 8 | class WishlistView : View() { 9 | override val viewModel by inject() 10 | 11 | @Composable 12 | override fun ViewContent(uiState: WishlistUiState, onInputEvent: (WishlistInputEvent) -> Unit) { 13 | WishlistContent(uiState = uiState, onInputEvent = onInputEvent) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/wishlist/WishlistViewModel.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.wishlist 2 | 3 | import appoutlet.gameoutlet.core.database.GameQueries 4 | import appoutlet.gameoutlet.feature.common.ViewModel 5 | import appoutlet.gameoutlet.feature.game.GameNavArgs 6 | import appoutlet.gameoutlet.feature.game.GameViewProvider 7 | import appoutlet.gameoutlet.feature.home.composable.GameSearchTab 8 | 9 | class WishlistViewModel( 10 | private val wishlistOrchestrator: WishlistOrchestrator, 11 | private val wishlistUiStateMapper: WishlistUiStateMapper, 12 | private val gameViewProvider: GameViewProvider, 13 | private val gameQueries: GameQueries, 14 | ) : ViewModel(initialState = WishlistUiState.Idle) { 15 | init { 16 | // remove when sqlite was updated 17 | gameQueries.findAll().addListener { 18 | loadSavedGames() 19 | } 20 | } 21 | 22 | override fun onInputEvent(inputEvent: WishlistInputEvent) { 23 | when (inputEvent) { 24 | WishlistInputEvent.Load -> loadSavedGames() 25 | is WishlistInputEvent.GameClicked -> navigateToGameDetail(inputEvent.game) 26 | WishlistInputEvent.GoToSearch -> { 27 | navigator.parent?.replaceAll(GameSearchTab) 28 | } 29 | } 30 | } 31 | 32 | private fun loadSavedGames() { 33 | val games = wishlistOrchestrator.findAll() 34 | val uiModels = wishlistUiStateMapper(games) 35 | mutableUiState.value = WishlistUiState.Loaded(uiModels) 36 | } 37 | 38 | private fun navigateToGameDetail(gameUiModel: WishlistGameUiModel) { 39 | val gameNavArgs = GameNavArgs( 40 | gameId = gameUiModel.id, 41 | gameTitle = gameUiModel.title, 42 | gameImage = gameUiModel.image, 43 | ) 44 | 45 | navigator.push(gameViewProvider.getGameView(gameNavArgs)) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/feature/wishlist/composable/WishlistEmptyList.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.wishlist.composable 2 | 3 | import androidx.compose.foundation.layout.fillMaxHeight 4 | import androidx.compose.foundation.layout.widthIn 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.unit.dp 8 | import appoutlet.gameoutlet.core.translation.i18n 9 | import appoutlet.gameoutlet.feature.common.composable.Error 10 | import appoutlet.gameoutlet.feature.wishlist.WishlistInputEvent 11 | 12 | @Suppress("MaxLineLength") 13 | @Composable 14 | fun WishlistEmptyList(onInputEvent: (WishlistInputEvent) -> Unit, modifier: Modifier = Modifier) { 15 | Error( 16 | modifier = modifier.fillMaxHeight().widthIn(max = 500.dp), 17 | title = i18n.tr("You didn't save any game yet"), 18 | message = i18n.tr( 19 | "Your saved games will appear here. See the latest deals or search by your favourite game and then click on the favorite button." 20 | ), 21 | buttonText = i18n.tr("Take me to search screen"), 22 | onTryAgain = { onInputEvent(WishlistInputEvent.GoToSearch) }, 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/RepositoryModules.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository 2 | 3 | import appoutlet.gameoutlet.repository.deals.dealRepositoryModule 4 | import appoutlet.gameoutlet.repository.game.gameRepositoryModule 5 | import appoutlet.gameoutlet.repository.preference.preferenceRepositoryModule 6 | import appoutlet.gameoutlet.repository.store.storeRepositoryModule 7 | import appoutlet.gameoutlet.repository.theme.themeRepositoryModule 8 | 9 | val repositoryModules = arrayOf( 10 | storeRepositoryModule, 11 | preferenceRepositoryModule, 12 | dealRepositoryModule, 13 | gameRepositoryModule, 14 | themeRepositoryModule, 15 | ) 16 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/deals/DealGameMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals 2 | 3 | import appoutlet.gameoutlet.domain.Game 4 | import appoutlet.gameoutlet.repository.deals.api.DealResponse 5 | 6 | class DealGameMapper { 7 | operator fun invoke(dealResponse: DealResponse): Game? { 8 | val id = dealResponse.gameID.toLongOrNull() 9 | 10 | return id?.let { gameId -> 11 | Game( 12 | id = gameId, 13 | title = dealResponse.title, 14 | image = dealResponse.thumb.replace("capsule_sm_120", "header"), 15 | metacritic = null, 16 | steam = null, 17 | ) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/deals/DealMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals 2 | 3 | import appoutlet.gameoutlet.core.util.TimeProvider 4 | import appoutlet.gameoutlet.core.util.asMoney 5 | import appoutlet.gameoutlet.domain.Deal 6 | import appoutlet.gameoutlet.domain.Game 7 | import appoutlet.gameoutlet.domain.Store 8 | import appoutlet.gameoutlet.repository.deals.api.DealResponse 9 | import kotlinx.coroutines.async 10 | import kotlinx.coroutines.awaitAll 11 | import kotlinx.coroutines.coroutineScope 12 | 13 | class DealMapper( 14 | private val dealGameMapper: DealGameMapper, 15 | private val dealStoreMapper: DealStoreMapper, 16 | private val timeProvider: TimeProvider 17 | ) { 18 | suspend operator fun invoke(dealsResponse: List) = coroutineScope { 19 | val deferredDeals = dealsResponse.map { dealsResponse -> 20 | async { map(dealsResponse) } 21 | } 22 | 23 | deferredDeals.awaitAll().filterNotNull() 24 | } 25 | 26 | private fun map(response: DealResponse): Deal? { 27 | val game = dealGameMapper(response) 28 | val store = dealStoreMapper(response) 29 | 30 | return if (game != null && store != null) { 31 | mapDeal(response, game, store) 32 | } else { 33 | null 34 | } 35 | } 36 | 37 | private fun mapDeal(response: DealResponse, game: Game, store: Store): Deal { 38 | return Deal( 39 | id = response.dealID, 40 | game = game, 41 | store = store, 42 | salePrice = response.salePrice.asMoney(), 43 | normalPrice = response.normalPrice.asMoney(), 44 | savings = response.savings.toFloatOrNull() ?: 0f, 45 | releaseDate = timeProvider.fromEpochMillis(response.releaseDate), 46 | lastChange = timeProvider.fromEpochMillis(response.lastChange), 47 | rating = response.dealRating?.toFloatOrNull() ?: 0f, 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/deals/DealRepository.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals 2 | 3 | import appoutlet.gameoutlet.domain.Deal 4 | import appoutlet.gameoutlet.domain.Game 5 | import appoutlet.gameoutlet.domain.Store 6 | import appoutlet.gameoutlet.repository.deals.api.DealApi 7 | import appoutlet.gameoutlet.repository.deals.api.GameApi 8 | 9 | class DealRepository( 10 | private val dealApi: DealApi, 11 | private val dealMapper: DealMapper, 12 | private val gameApi: GameApi, 13 | private val gameDealMapper: GameDealMapper, 14 | private val gameMapper: GameMapper, 15 | ) { 16 | suspend fun findLatestDeals(): List { 17 | val dealsResponse = dealApi.findLatestDeals() 18 | return dealMapper(dealsResponse) 19 | } 20 | 21 | suspend fun findDealsByGame(game: Game): List { 22 | val gameResponse = gameApi.findById(game.id) 23 | return gameResponse.deals.mapNotNull { dealResponse -> gameDealMapper(game, dealResponse) } 24 | } 25 | 26 | suspend fun findGamesByTitle(title: String): List { 27 | val gameResponses = gameApi.findByTitle(title) 28 | return gameResponses.mapNotNull { gameMapper(it) } 29 | } 30 | 31 | suspend fun findDealsByStore(store: Store): List { 32 | val dealsResponse = dealApi.findDealsByStore(store.id) 33 | return dealMapper(dealsResponse) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/deals/DealStoreMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals 2 | 3 | import appoutlet.gameoutlet.domain.Store 4 | import appoutlet.gameoutlet.repository.deals.api.DealResponse 5 | 6 | class DealStoreMapper { 7 | operator fun invoke(dealResponse: DealResponse): Store? { 8 | val storeId = dealResponse.storeID.toIntOrNull() 9 | 10 | return storeId?.let { id -> 11 | Store(id = id) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/deals/DealsRepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals 2 | 3 | import appoutlet.gameoutlet.repository.deals.api.DealApi 4 | import appoutlet.gameoutlet.repository.deals.api.GameApi 5 | import org.koin.dsl.module 6 | import retrofit2.Retrofit 7 | 8 | val dealRepositoryModule = module { 9 | factory { DealRepository(get(), get(), get(), get(), get()) } 10 | 11 | factory { 12 | val retrofit by inject() 13 | retrofit.create(DealApi::class.java) 14 | } 15 | 16 | factory { DealGameMapper() } 17 | 18 | factory { DealStoreMapper() } 19 | 20 | factory { DealMapper(get(), get(), get()) } 21 | 22 | factory { 23 | val retrofit by inject() 24 | retrofit.create(GameApi::class.java) 25 | } 26 | 27 | factory { GameDealMapper(get()) } 28 | 29 | factory { GameMapper() } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/deals/GameDealMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals 2 | 3 | import appoutlet.gameoutlet.core.util.TimeProvider 4 | import appoutlet.gameoutlet.core.util.asMoney 5 | import appoutlet.gameoutlet.domain.Deal 6 | import appoutlet.gameoutlet.domain.Game 7 | import appoutlet.gameoutlet.domain.Store 8 | import appoutlet.gameoutlet.repository.deals.api.GameDealResponse 9 | 10 | class GameDealMapper(private val timeProvider: TimeProvider) { 11 | operator fun invoke(game: Game, gameDealResponse: GameDealResponse): Deal? { 12 | val store = mapStore(gameDealResponse.storeID) ?: return null 13 | 14 | return Deal( 15 | id = gameDealResponse.dealID, 16 | game = game, 17 | store = store, 18 | salePrice = gameDealResponse.price.asMoney(), 19 | normalPrice = gameDealResponse.retailPrice.asMoney(), 20 | savings = gameDealResponse.savings.toFloatOrNull() ?: 0f, 21 | releaseDate = timeProvider.now(), 22 | lastChange = timeProvider.now(), 23 | rating = 0f 24 | ) 25 | } 26 | 27 | private fun mapStore(storeId: String): Store? { 28 | return storeId.toIntOrNull()?.let { Store(it) } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/deals/GameMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals 2 | 3 | import appoutlet.gameoutlet.domain.Game 4 | import appoutlet.gameoutlet.repository.deals.api.GameSearchResponse 5 | 6 | class GameMapper { 7 | operator fun invoke(gameSearchResponse: GameSearchResponse): Game? { 8 | val gameId = gameSearchResponse.gameID.toLongOrNull() ?: return null 9 | return Game( 10 | id = gameId, 11 | title = gameSearchResponse.external, 12 | image = gameSearchResponse.thumb.replace("capsule_sm_120", "header") 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/deals/api/DealApi.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals.api 2 | 3 | import retrofit2.http.GET 4 | import retrofit2.http.Query 5 | 6 | interface DealApi { 7 | @GET("deals?onSale=1") 8 | suspend fun findLatestDeals(): List 9 | 10 | @GET("deals?onSale=1") 11 | suspend fun findDealsByStore(@Query("storeID") storeId: Int): List 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/deals/api/DealResponse.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals.api 2 | 3 | data class DealResponse( 4 | val internalName: String, 5 | val title: String, 6 | val metacriticLink: String?, 7 | val metacriticScore: String?, 8 | val dealID: String, 9 | val storeID: String, 10 | val gameID: String, 11 | val salePrice: String, 12 | val normalPrice: String, 13 | val isOnSale: String, 14 | val savings: String, 15 | val steamRatingText: String?, 16 | val steamRatingPercent: String?, 17 | val steamRatingCount: String?, 18 | val steamAppID: String?, 19 | val releaseDate: Long, 20 | val lastChange: Long, 21 | val dealRating: String?, 22 | val thumb: String, 23 | ) 24 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/deals/api/GameApi.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals.api 2 | 3 | import retrofit2.http.GET 4 | import retrofit2.http.Query 5 | 6 | interface GameApi { 7 | @GET("games") 8 | suspend fun findById(@Query("id") id: Long): GameResponse 9 | 10 | @GET("games") 11 | suspend fun findByTitle(@Query("title") title: String): List 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/deals/api/GameResponse.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals.api 2 | 3 | data class GameResponse( 4 | val info: GameInfoResponse, 5 | val cheapestPriceEver: CheapestPriceEverResponse, 6 | val deals: List, 7 | ) 8 | 9 | data class GameInfoResponse( 10 | val title: String, 11 | val steamAppId: String?, 12 | val thumb: String, 13 | ) 14 | 15 | data class CheapestPriceEverResponse( 16 | val price: String, 17 | val date: Long, 18 | ) 19 | 20 | data class GameDealResponse( 21 | val storeID: String, 22 | val dealID: String, 23 | val price: String, 24 | val retailPrice: String, 25 | val savings: String, 26 | ) 27 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/deals/api/GameSearchResponse.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals.api 2 | 3 | data class GameSearchResponse( 4 | val gameID: String, 5 | val steamAppID: String?, 6 | val cheapest: String, 7 | val cheapestDealID: String, 8 | val external: String, 9 | val internalName: String, 10 | val thumb: String, 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/game/GameEntityMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.game 2 | 3 | import appoutlet.gameoutlet.core.database.GameEntity 4 | import appoutlet.gameoutlet.domain.Game 5 | 6 | class GameEntityMapper { 7 | operator fun invoke(game: Game) = GameEntity( 8 | id = game.id, 9 | title = game.title, 10 | imageUrl = game.image, 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/game/GameMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.game 2 | 3 | import appoutlet.gameoutlet.core.database.GameEntity 4 | import appoutlet.gameoutlet.domain.Game 5 | 6 | class GameMapper { 7 | operator fun invoke(gameEntity: GameEntity) = Game( 8 | id = gameEntity.id, 9 | title = gameEntity.title, 10 | image = gameEntity.imageUrl, 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/game/GameRepository.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.game 2 | 3 | import appoutlet.gameoutlet.core.database.GameQueries 4 | import appoutlet.gameoutlet.domain.Game 5 | 6 | class GameRepository( 7 | private val gameQueries: GameQueries, 8 | private val gameEntityMapper: GameEntityMapper, 9 | private val gameMapper: GameMapper, 10 | ) { 11 | fun save(game: Game) { 12 | val gameEntity = gameEntityMapper(game) 13 | gameQueries.save(gameEntity) 14 | } 15 | 16 | fun findById(gameId: Long): Game? { 17 | val entity = gameQueries.findById(gameId).executeAsOneOrNull() 18 | return entity?.let(gameMapper::invoke) 19 | } 20 | 21 | fun deleteById(gameId: Long) { 22 | gameQueries.deleteById(gameId) 23 | } 24 | 25 | fun findAll(): List { 26 | val entities = gameQueries.findAll().executeAsList() 27 | return entities.map(gameMapper::invoke) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/game/GameRepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.game 2 | 3 | import appoutlet.gameoutlet.core.database.GameOutletDatabase 4 | import org.koin.dsl.module 5 | 6 | val gameRepositoryModule = module { 7 | factory { get().gameQueries } 8 | factory { GameRepository(get(), get(), get()) } 9 | factory { GameEntityMapper() } 10 | factory { GameMapper() } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/preference/PreferenceRepository.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.preference 2 | 3 | import appoutlet.gameoutlet.core.database.PreferenceQueries 4 | 5 | class PreferenceRepository( 6 | private val preferenceQueries: PreferenceQueries 7 | ) { 8 | fun setPreference(key: String, value: String) { 9 | preferenceQueries.save(key, value) 10 | } 11 | 12 | fun getPreference(key: String): String? { 13 | return preferenceQueries.findByKey(key).executeAsOneOrNull()?.value_ 14 | } 15 | 16 | fun observePreference(key: String, block: (String?) -> Unit) { 17 | preferenceQueries.findByKey(key).addListener { 18 | block(getPreference(key)) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/preference/PreferenceRepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.preference 2 | 3 | import appoutlet.gameoutlet.core.database.GameOutletDatabase 4 | import org.koin.dsl.module 5 | 6 | val preferenceRepositoryModule = module { 7 | factory { PreferenceRepository(get()) } 8 | factory { get().preferenceQueries } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/store/StoreCacheRepository.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.store 2 | 3 | import appoutlet.gameoutlet.core.util.TimeProvider 4 | import appoutlet.gameoutlet.repository.preference.PreferenceRepository 5 | import java.time.LocalDateTime 6 | 7 | class StoreCacheRepository( 8 | private val preferenceRepository: PreferenceRepository, 9 | private val timeProvider: TimeProvider 10 | ) { 11 | fun isStoreListCacheValid(): Boolean { 12 | val lastUpdateString = preferenceRepository.getPreference(KEY_STORE_LIST_CACHE) 13 | val lastUpdate = lastUpdateString?.let(LocalDateTime::parse) 14 | val cacheExpirationDate = lastUpdate?.plusDays(STORE_CACHE_VALIDITY_IN_DAYS) 15 | return cacheExpirationDate?.let { expirationDate -> 16 | expirationDate.isAfter(timeProvider.now()) 17 | } ?: false 18 | } 19 | 20 | fun registerStoreListCached() { 21 | preferenceRepository.setPreference(KEY_STORE_LIST_CACHE, timeProvider.now().toString()) 22 | } 23 | 24 | companion object { 25 | const val KEY_STORE_LIST_CACHE = "storeListCache" 26 | const val STORE_CACHE_VALIDITY_IN_DAYS = 7L 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/store/StoreEntityMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.store 2 | 3 | import appoutlet.gameoutlet.core.database.StoreEntity 4 | import appoutlet.gameoutlet.domain.Store 5 | 6 | class StoreEntityMapper { 7 | operator fun invoke(store: Store) = StoreEntity( 8 | id = store.id.toLong(), 9 | name = store.name, 10 | bannerUrl = store.bannerUrl, 11 | logoUrl = store.logoUrl, 12 | iconUrl = store.iconUrl, 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/store/StoreMapper.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.store 2 | 3 | import appoutlet.gameoutlet.core.database.StoreEntity 4 | import appoutlet.gameoutlet.domain.Store 5 | import appoutlet.gameoutlet.repository.store.api.model.StoreResponse 6 | 7 | class StoreMapper { 8 | operator fun invoke(storeEntity: StoreEntity) = Store( 9 | id = storeEntity.id.toInt(), 10 | name = storeEntity.name, 11 | bannerUrl = storeEntity.bannerUrl, 12 | logoUrl = storeEntity.logoUrl, 13 | iconUrl = storeEntity.iconUrl, 14 | ) 15 | 16 | operator fun invoke(storeResponse: StoreResponse) = Store( 17 | id = storeResponse.storeID.toInt(), 18 | name = storeResponse.storeName, 19 | bannerUrl = parseStoreImage(storeResponse.images.banner), 20 | logoUrl = parseStoreImage(storeResponse.images.logo), 21 | iconUrl = parseStoreImage(storeResponse.images.icon), 22 | ) 23 | 24 | private fun parseStoreImage(uri: String) = CHEAP_SHARK_IMAGE_BASE_URL + uri 25 | 26 | companion object { 27 | const val CHEAP_SHARK_IMAGE_BASE_URL = "https://www.cheapshark.com" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/store/StoreRepository.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.store 2 | 3 | import appoutlet.gameoutlet.core.database.StoreQueries 4 | import appoutlet.gameoutlet.domain.Store 5 | import appoutlet.gameoutlet.repository.store.api.StoreApi 6 | 7 | class StoreRepository( 8 | private val storeQueries: StoreQueries, 9 | private val storeApi: StoreApi, 10 | private val storeCacheRepository: StoreCacheRepository, 11 | private val storeMapper: StoreMapper, 12 | private val storeEntityMapper: StoreEntityMapper, 13 | ) { 14 | suspend fun findAll(): List { 15 | return if (storeCacheRepository.isStoreListCacheValid()) { 16 | findAllFromCache() 17 | } else { 18 | findAllFromApi() 19 | } 20 | } 21 | 22 | private fun findAllFromCache(): List { 23 | return storeQueries.findAll().executeAsList().map(storeMapper::invoke) 24 | } 25 | 26 | private suspend fun findAllFromApi(): List { 27 | val stores = storeApi.findAll() 28 | .asSequence() 29 | .filter { it.isActive == 1 } 30 | .map(storeMapper::invoke) 31 | .onEach { store -> 32 | val entity = storeEntityMapper(store) 33 | storeQueries.save(entity) 34 | } 35 | .toList() 36 | 37 | storeCacheRepository.registerStoreListCached() 38 | 39 | return stores 40 | } 41 | 42 | fun findById(id: Int): Store? { 43 | val storeEntity = storeQueries.findById(id.toLong()).executeAsOneOrNull() 44 | return storeEntity?.let { storeMapper(it) } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/store/StoreRepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.store 2 | 3 | import appoutlet.gameoutlet.core.database.GameOutletDatabase 4 | import appoutlet.gameoutlet.repository.store.api.StoreApi 5 | import org.koin.dsl.module 6 | import retrofit2.Retrofit 7 | 8 | val storeRepositoryModule = module { 9 | factory { StoreRepository(get(), get(), get(), get(), get()) } 10 | 11 | factory { 12 | val retrofit by inject() 13 | retrofit.create(StoreApi::class.java) 14 | } 15 | 16 | factory { get().storeQueries } 17 | 18 | factory { StoreCacheRepository(get(), get()) } 19 | 20 | factory { StoreMapper() } 21 | 22 | factory { StoreEntityMapper() } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/store/api/StoreApi.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.store.api 2 | 3 | import appoutlet.gameoutlet.repository.store.api.model.StoreResponse 4 | import retrofit2.http.GET 5 | 6 | interface StoreApi { 7 | @GET("stores") 8 | suspend fun findAll(): List 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/store/api/model/StoreImagesResponse.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.store.api.model 2 | 3 | data class StoreImagesResponse( 4 | val banner: String, 5 | val logo: String, 6 | val icon: String 7 | ) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/store/api/model/StoreResponse.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.store.api.model 2 | 3 | data class StoreResponse( 4 | val storeID: String, 5 | val storeName: String, 6 | val isActive: Int, 7 | val images: StoreImagesResponse, 8 | ) 9 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/theme/ThemeRepository.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.theme 2 | 3 | import appoutlet.gameoutlet.domain.Theme 4 | import appoutlet.gameoutlet.repository.preference.PreferenceRepository 5 | 6 | class ThemeRepository(private val preferenceRepository: PreferenceRepository) { 7 | fun setTheme(theme: Theme) { 8 | preferenceRepository.setPreference(PREFERENCE_THEME, theme.name) 9 | } 10 | 11 | fun getTheme(): Theme { 12 | val themeString = preferenceRepository.getPreference(PREFERENCE_THEME) 13 | return Theme.fromString(themeString) 14 | } 15 | 16 | fun observeTheme( 17 | block: (Theme) -> Unit, 18 | ) = preferenceRepository.observePreference(PREFERENCE_THEME) { themeString -> 19 | block(Theme.fromString(themeString)) 20 | } 21 | 22 | companion object { 23 | const val PREFERENCE_THEME = "THEME" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/appoutlet/gameoutlet/repository/theme/ThemeRepositoryModule.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.theme 2 | 3 | import org.koin.dsl.module 4 | 5 | val themeRepositoryModule = module { 6 | factory { ThemeRepository(get()) } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/resources/i18n/es.po: -------------------------------------------------------------------------------- 1 | # GameOutlet. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | # Translators: 7 | # brandon galvis, 2023 8 | # 9 | #, fuzzy 10 | msgid "" 11 | msgstr "" 12 | "Project-Id-Version: PACKAGE VERSION\n" 13 | "Report-Msgid-Bugs-To: \n" 14 | "\n" 15 | "PO-Revision-Date: 2023-03-20 11:26+0000\n" 16 | "Last-Translator: brandon galvis, 2023\n" 17 | "Language-Team: Spanish (https://www.transifex.com/app-outlet/teams/121024/es/)\n" 18 | "MIME-Version: 1.0\n" 19 | "Content-Type: text/plain; charset=UTF-8\n" 20 | "Content-Transfer-Encoding: 8bit\n" 21 | "Language: es\n" 22 | "Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n" 23 | 24 | #: src/commonMain/kotlin/appoutlet/gameoutlet/feature/splash/SplashView.kt:82 25 | msgid "Powered by App Outlet" 26 | msgstr "Desarrollado por App Outlet" 27 | 28 | #: src/commonMain/kotlin/appoutlet/gameoutlet/feature/splash/SplashView.kt:74 29 | msgid "Occurred an error when loading the store data" 30 | msgstr "Ocurrió un error al cargar los datos de la tienda" 31 | 32 | #: src/commonMain/kotlin/appoutlet/gameoutlet/feature/splash/composable/SplashLoadingIndicator.kt:22 33 | msgid "Loading" 34 | msgstr "Cargando" 35 | 36 | #: src/commonMain/kotlin/appoutlet/gameoutlet/feature/common/composable/ErrorView.kt:20 37 | msgid "Something went wrong" 38 | msgstr "Algo salió mal" 39 | 40 | #: src/commonMain/kotlin/appoutlet/gameoutlet/feature/common/composable/ErrorView.kt:21 41 | msgid "An unexpected error occurred" 42 | msgstr "Ha ocurrido un error inesperado" 43 | 44 | #: src/commonMain/kotlin/appoutlet/gameoutlet/feature/common/composable/ErrorView.kt:22 45 | msgid "Try again" 46 | msgstr "Vuelve a intentarlo" 47 | 48 | msgid "GameOutlet logo" 49 | msgstr "Logo de GameOutlet" 50 | -------------------------------------------------------------------------------- /src/main/resources/i18n/pt_BR.po: -------------------------------------------------------------------------------- 1 | # GameOutlet. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | # Translators: 7 | # App Outlet Linux , 2023 8 | # 9 | #, fuzzy 10 | msgid "" 11 | msgstr "" 12 | "Project-Id-Version: PACKAGE VERSION\n" 13 | "Report-Msgid-Bugs-To: \n" 14 | "\n" 15 | "PO-Revision-Date: 2023-03-20 11:26+0000\n" 16 | "Last-Translator: App Outlet Linux , 2023\n" 17 | "Language-Team: Portuguese (Brazil) (https://www.transifex.com/app-outlet/teams/121024/pt_BR/)\n" 18 | "MIME-Version: 1.0\n" 19 | "Content-Type: text/plain; charset=UTF-8\n" 20 | "Content-Transfer-Encoding: 8bit\n" 21 | "Language: pt_BR\n" 22 | "Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n" 23 | 24 | #: src/commonMain/kotlin/appoutlet/gameoutlet/feature/splash/SplashView.kt:82 25 | msgid "Powered by App Outlet" 26 | msgstr "Desenvolvido por App Outlet" 27 | 28 | #: src/commonMain/kotlin/appoutlet/gameoutlet/feature/splash/SplashView.kt:74 29 | msgid "Occurred an error when loading the store data" 30 | msgstr "Ocorreu um erro ao carregar os dados da loja" 31 | 32 | #: src/commonMain/kotlin/appoutlet/gameoutlet/feature/splash/composable/SplashLoadingIndicator.kt:22 33 | msgid "Loading" 34 | msgstr "Carregando" 35 | 36 | #: src/commonMain/kotlin/appoutlet/gameoutlet/feature/common/composable/ErrorView.kt:20 37 | msgid "Something went wrong" 38 | msgstr "Alguma coisa deu errado" 39 | 40 | #: src/commonMain/kotlin/appoutlet/gameoutlet/feature/common/composable/ErrorView.kt:21 41 | msgid "An unexpected error occurred" 42 | msgstr "Ocorreu um erro inesperado" 43 | 44 | #: src/commonMain/kotlin/appoutlet/gameoutlet/feature/common/composable/ErrorView.kt:22 45 | msgid "Try again" 46 | msgstr "Tente novamente" 47 | 48 | msgid "GameOutlet logo" 49 | msgstr "Logomarca do GameOutlet" 50 | -------------------------------------------------------------------------------- /src/main/resources/image/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main/resources/image/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppOutlet/GameOutlet/42f9aa74d31b20ff75535f722c952ef5b6896e6b/src/main/resources/image/icon.icns -------------------------------------------------------------------------------- /src/main/resources/image/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppOutlet/GameOutlet/42f9aa74d31b20ff75535f722c952ef5b6896e6b/src/main/resources/image/icon.ico -------------------------------------------------------------------------------- /src/main/resources/image/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AppOutlet/GameOutlet/42f9aa74d31b20ff75535f722c952ef5b6896e6b/src/main/resources/image/icon.png -------------------------------------------------------------------------------- /src/main/resources/image/mastodon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/image/twitter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main/sqldelight/appoutlet/gameoutlet/core/database/Game.sq: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS GameEntity ( 2 | id INTEGER PRIMARY KEY NOT NULL, 3 | title TEXT NOT NULL, 4 | imageUrl TEXT NOT NULL 5 | ); 6 | 7 | CREATE INDEX IF NOT EXISTS GameEntity_id ON GameEntity(id); 8 | 9 | findAll: 10 | SELECT * FROM GameEntity; 11 | 12 | findById: 13 | SELECT * FROM GameEntity WHERE id = ?; 14 | 15 | save: 16 | INSERT OR REPLACE INTO GameEntity(id, title, imageUrl) VALUES ?; 17 | 18 | deleteById: 19 | DELETE FROM GameEntity WHERE id = ?; -------------------------------------------------------------------------------- /src/main/sqldelight/appoutlet/gameoutlet/core/database/Preference.sq: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS PreferenceEntity ( 2 | key TEXT NOT NULL PRIMARY KEY, 3 | value TEXT 4 | ); 5 | 6 | CREATE INDEX IF NOT EXISTS PreferenceEntity_id ON PreferenceEntity(key); 7 | 8 | save: 9 | INSERT OR REPLACE INTO PreferenceEntity(key, value) VALUES (?,?); 10 | 11 | findByKey: 12 | SELECT * FROM PreferenceEntity WHERE key = ?; 13 | -------------------------------------------------------------------------------- /src/main/sqldelight/appoutlet/gameoutlet/core/database/Store.sq: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS StoreEntity ( 2 | id INTEGER PRIMARY KEY NOT NULL, 3 | name TEXT NOT NULL, 4 | bannerUrl TEXT, 5 | logoUrl TEXT, 6 | iconUrl TEXT 7 | ); 8 | 9 | CREATE INDEX IF NOT EXISTS StoreEntity_id ON StoreEntity(id); 10 | 11 | save: 12 | INSERT OR REPLACE INTO StoreEntity(id, name, bannerUrl, logoUrl, iconUrl) 13 | VALUES ?; 14 | 15 | findAll: 16 | SELECT * 17 | FROM StoreEntity; 18 | 19 | findById: 20 | SELECT * 21 | FROM StoreEntity 22 | WHERE id == ?; 23 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/MainOrchestratorTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet 2 | 3 | import appoutlet.gameoutlet.domain.Theme 4 | import appoutlet.gameoutlet.repository.theme.ThemeRepository 5 | import com.google.common.truth.Truth.assertThat 6 | import io.mockk.every 7 | import io.mockk.mockk 8 | import kotlinx.coroutines.flow.first 9 | import kotlinx.coroutines.test.runTest 10 | import org.junit.Test 11 | 12 | class MainOrchestratorTest { 13 | @Test 14 | fun `observe theme - light`() = runTest { 15 | val mockThemeRepository = mockk() 16 | 17 | every { mockThemeRepository.getTheme() } returns Theme.SYSTEM 18 | 19 | every { mockThemeRepository.observeTheme(any()) } answers { 20 | (firstArg() as ((Theme) -> Unit))(Theme.LIGHT) 21 | } 22 | 23 | val subject = MainOrchestrator(mockThemeRepository) 24 | 25 | val actual = subject.isDarkTheme(true) 26 | .first() 27 | 28 | assertThat(actual).isFalse() 29 | } 30 | 31 | @Test 32 | fun `observe theme - dark`() = runTest { 33 | val mockThemeRepository = mockk() 34 | 35 | every { mockThemeRepository.getTheme() } returns Theme.SYSTEM 36 | 37 | every { mockThemeRepository.observeTheme(any()) } answers { 38 | (firstArg() as ((Theme) -> Unit))(Theme.DARK) 39 | } 40 | 41 | val subject = MainOrchestrator(mockThemeRepository) 42 | 43 | val actual = subject.isDarkTheme(false) 44 | .first() 45 | 46 | assertThat(actual).isTrue() 47 | } 48 | 49 | @Test 50 | fun `observe theme - system`() = runTest { 51 | val mockThemeRepository = mockk() 52 | 53 | every { mockThemeRepository.getTheme() } returns Theme.LIGHT 54 | 55 | every { mockThemeRepository.observeTheme(any()) } answers { 56 | (firstArg() as ((Theme) -> Unit))(Theme.SYSTEM) 57 | } 58 | 59 | val subject = MainOrchestrator(mockThemeRepository) 60 | 61 | val actual = subject.isDarkTheme(true) 62 | .first() 63 | 64 | assertThat(actual).isTrue() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/core/testing/BaseTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.testing 2 | 3 | import com.appmattus.kotlinfixture.decorator.nullability.NeverNullStrategy 4 | import com.appmattus.kotlinfixture.decorator.nullability.nullabilityStrategy 5 | import com.appmattus.kotlinfixture.kotlinFixture 6 | import org.javamoney.moneta.Money 7 | import java.math.BigDecimal 8 | import java.time.LocalDateTime 9 | 10 | @Suppress("UnnecessaryAbstractClass") 11 | abstract class BaseTest { 12 | protected val fixture = kotlinFixture { 13 | nullabilityStrategy(NeverNullStrategy) 14 | factory { 15 | Money.of(BigDecimal((1..100).random()), "USD") 16 | } 17 | factory { 18 | LocalDateTime.now() 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/core/testing/UiTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.testing 2 | 3 | import androidx.compose.ui.test.junit4.ComposeContentTestRule 4 | import androidx.compose.ui.test.junit4.createComposeRule 5 | import androidx.compose.ui.test.onRoot 6 | import androidx.compose.ui.test.printToString 7 | import org.junit.Rule 8 | import org.koin.core.context.startKoin 9 | import org.koin.core.context.stopKoin 10 | import org.koin.core.module.Module 11 | import org.koin.dsl.module 12 | import kotlin.test.AfterTest 13 | import kotlin.test.BeforeTest 14 | 15 | abstract class UiTest : BaseTest() { 16 | @get:Rule 17 | val composeTestRule = createComposeRule() 18 | open val koinTestModule: Module = module { } 19 | 20 | @BeforeTest 21 | fun setup() { 22 | startKoin { 23 | modules(koinTestModule) 24 | } 25 | } 26 | 27 | @AfterTest 28 | fun tearDown() { 29 | stopKoin() 30 | } 31 | 32 | protected fun ComposeContentTestRule.print() { 33 | println(onRoot().printToString()) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/core/testing/UnitTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.testing 2 | 3 | import kotlin.test.BeforeTest 4 | 5 | abstract class UnitTest : BaseTest() { 6 | protected lateinit var sut: SubjectUnderTest 7 | 8 | abstract fun buildSut(): SubjectUnderTest 9 | 10 | @BeforeTest 11 | fun setup() { 12 | sut = buildSut() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/core/testing/ViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.testing 2 | 3 | import appoutlet.gameoutlet.feature.common.ViewModel 4 | import cafe.adriel.voyager.navigator.Navigator 5 | import io.mockk.mockk 6 | import kotlinx.coroutines.test.TestScope 7 | import kotlinx.coroutines.test.runTest 8 | 9 | abstract class ViewModelTest : UnitTest() { 10 | protected val mockNavigator = mockk(relaxed = true) 11 | 12 | fun runViewModelTest(testBlock: TestScope.() -> Unit) = runTest { 13 | (sut as? ViewModel<*, *>)?.init(this, mockNavigator) 14 | testBlock() 15 | (sut as? ViewModel<*, *>)?.viewModelJob?.cancel() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/core/ui/GameOutletThemeTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.ui 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import appoutlet.gameoutlet.core.testing.UiTest 5 | import com.google.common.truth.Truth.assertThat 6 | import kotlin.test.Test 7 | 8 | class GameOutletThemeTest : UiTest() { 9 | @Test 10 | fun `should render light theme`() { 11 | composeTestRule.setContent { 12 | GameOutletTheme( 13 | useDarkTheme = false, 14 | content = { 15 | assertThat(MaterialTheme.colorScheme).isEqualTo(LightColors) 16 | } 17 | ) 18 | } 19 | } 20 | 21 | @Test 22 | fun `should render dark theme`() { 23 | composeTestRule.setContent { 24 | GameOutletTheme( 25 | useDarkTheme = true, 26 | content = { 27 | assertThat(MaterialTheme.colorScheme).isEqualTo(DarkColors) 28 | } 29 | ) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/core/util/DesktopHelperTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.util 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import io.mockk.every 5 | import io.mockk.mockk 6 | import io.mockk.verify 7 | import java.awt.Desktop 8 | import java.net.URI 9 | import kotlin.test.Test 10 | 11 | class DesktopHelperTest : UnitTest() { 12 | private val mockDesktop = mockk(relaxUnitFun = true) 13 | private val mockRuntime = mockk(relaxed = true) 14 | 15 | override fun buildSut() = DesktopHelper(mockDesktop, mockRuntime) 16 | 17 | @Test 18 | fun `openLink - browse supported`() { 19 | val fixtureLink = fixture() 20 | 21 | every { mockDesktop.isSupported(Desktop.Action.BROWSE) } returns true 22 | 23 | sut.openLink(fixtureLink) 24 | 25 | verify { 26 | mockDesktop.browse(URI(fixtureLink)) 27 | } 28 | } 29 | 30 | @Test 31 | fun `openLink - browse not supported`() { 32 | val fixtureLink = fixture() 33 | 34 | every { mockDesktop.isSupported(Desktop.Action.BROWSE) } returns false 35 | 36 | sut.openLink(fixtureLink) 37 | 38 | verify { 39 | mockRuntime.exec(arrayOf("xdg-open", fixtureLink)) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/core/util/StringExtensionsKtTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.util 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import org.javamoney.moneta.Money 5 | import kotlin.test.Test 6 | 7 | class StringExtensionsKtTest { 8 | @Test 9 | fun `should parse string to money`() { 10 | val actual = "1".asMoney() 11 | assertThat(actual).isInstanceOf(Money::class.java) 12 | assertThat(actual.number.intValueExact()).isEqualTo(1) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/core/util/TimeProviderTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.core.util 2 | 3 | import com.google.common.truth.Truth.assertThat 4 | import io.mockk.every 5 | import io.mockk.mockkStatic 6 | import java.time.Instant 7 | import java.time.LocalDateTime 8 | import java.time.ZoneOffset 9 | import kotlin.test.Test 10 | 11 | class TimeProviderTest { 12 | 13 | private val sut = TimeProvider() 14 | 15 | @Test 16 | fun now() { 17 | val fixtureNow = LocalDateTime.now() 18 | 19 | mockkStatic(LocalDateTime::class) 20 | 21 | every { LocalDateTime.now() } returns fixtureNow 22 | 23 | assertThat(sut.now()).isEqualTo(fixtureNow) 24 | } 25 | 26 | @Test 27 | fun fromEpochMillis() { 28 | val fixtureNow = Instant.now() 29 | val fixtureNowEpochMillis = fixtureNow.toEpochMilli() 30 | val fixtureLocalDateTime = LocalDateTime.ofInstant(fixtureNow, ZoneOffset.systemDefault()) 31 | 32 | val actual = sut.fromEpochMillis(fixtureNowEpochMillis) 33 | 34 | assertThat(actual.getMillis()).isEqualTo(fixtureLocalDateTime.getMillis()) 35 | } 36 | 37 | private fun LocalDateTime.getMillis() = atZone(ZoneOffset.systemDefault()).toInstant().toEpochMilli() 38 | } 39 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/domain/ThemeTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.domain 2 | 3 | import appoutlet.gameoutlet.core.testing.BaseTest 4 | import com.google.common.truth.Truth.assertThat 5 | import kotlin.test.Test 6 | 7 | class ThemeTest : BaseTest() { 8 | @Test 9 | fun `should map theme from string - LIGHT`() { 10 | val actual = Theme.fromString("LIGHT") 11 | 12 | assertThat(actual).isEqualTo(Theme.LIGHT) 13 | } 14 | 15 | @Test 16 | fun `should map theme from string - DARK`() { 17 | val actual = Theme.fromString("DARK") 18 | 19 | assertThat(actual).isEqualTo(Theme.DARK) 20 | } 21 | 22 | @Test 23 | fun `should map theme from string - SYSTEM`() { 24 | val actual = Theme.fromString("SYSTEM") 25 | 26 | assertThat(actual).isEqualTo(Theme.SYSTEM) 27 | } 28 | 29 | @Test 30 | fun `should map theme from string - null`() { 31 | val actual = Theme.fromString(null) 32 | 33 | assertThat(actual).isEqualTo(Theme.SYSTEM) 34 | } 35 | 36 | @Test 37 | fun `should map theme from string - random string`() { 38 | val actual = Theme.fromString(fixture()) 39 | 40 | assertThat(actual).isEqualTo(Theme.SYSTEM) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/about/AboutViewDataMapperTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.about 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import com.google.common.truth.Truth.assertThat 5 | import kotlin.test.Test 6 | 7 | class AboutViewDataMapperTest : UnitTest() { 8 | override fun buildSut() = AboutViewDataMapper() 9 | 10 | @Test 11 | fun shouldReturnData() { 12 | val actual = sut.invoke() 13 | 14 | with(actual) { 15 | assertThat(contributeEvent).isNotNull() 16 | assertThat(donationEvent).isNotNull() 17 | assertThat(websiteEvent).isNotNull() 18 | assertThat(twitterEvent).isNotNull() 19 | assertThat(mastodonEvent).isNotNull() 20 | assertThat(githubEvent).isNotNull() 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/about/AboutViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.about 2 | 3 | import appoutlet.gameoutlet.core.testing.ViewModelTest 4 | import appoutlet.gameoutlet.core.util.DesktopHelper 5 | import com.google.common.truth.Truth.assertThat 6 | import io.mockk.every 7 | import io.mockk.mockk 8 | import io.mockk.verify 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.test.advanceUntilIdle 11 | import kotlin.test.Test 12 | 13 | @OptIn(ExperimentalCoroutinesApi::class) 14 | class AboutViewModelTest : ViewModelTest() { 15 | private val mockDesktopHelper = mockk(relaxUnitFun = true) 16 | private val mockAboutViewDataMapper = mockk() 17 | 18 | override fun buildSut() = AboutViewModel( 19 | desktopHelper = mockDesktopHelper, 20 | aboutViewDataMapper = mockAboutViewDataMapper, 21 | ) 22 | 23 | @Test 24 | fun `should load data`() = runViewModelTest { 25 | val fixtureData = fixture() 26 | 27 | every { mockAboutViewDataMapper.invoke() } returns fixtureData 28 | 29 | sut.onInputEvent(AboutInputEvent.Load) 30 | 31 | advanceUntilIdle() 32 | 33 | assertThat(sut.uiState.value).isEqualTo(fixtureData) 34 | } 35 | 36 | @Test 37 | fun `should open link`() = runViewModelTest { 38 | val fixtureEvent = fixture() 39 | 40 | sut.onInputEvent(fixtureEvent) 41 | 42 | verify { mockDesktopHelper.openLink(fixtureEvent.url) } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/about/AboutViewTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.about 2 | 3 | import androidx.compose.ui.test.assertIsDisplayed 4 | import androidx.compose.ui.test.onNodeWithText 5 | import appoutlet.gameoutlet.core.testing.UiTest 6 | import io.mockk.mockk 7 | import org.koin.core.module.Module 8 | import org.koin.dsl.module 9 | import kotlin.test.Test 10 | 11 | class AboutViewTest : UiTest() { 12 | 13 | override val koinTestModule: Module 14 | get() = module { 15 | single { mockk(relaxed = true) } 16 | } 17 | 18 | @Test 19 | fun `should show about view`() { 20 | val fixtureUiState = fixture() 21 | 22 | composeTestRule.setContent { AboutView().ViewContent(fixtureUiState, {}) } 23 | 24 | composeTestRule.onNodeWithText("GameOutlet") 25 | .assertIsDisplayed() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/common/composable/ErrorKtTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.common.composable 2 | 3 | import androidx.compose.ui.test.assert 4 | import androidx.compose.ui.test.assertIsDisplayed 5 | import androidx.compose.ui.test.hasTestTag 6 | import androidx.compose.ui.test.hasText 7 | import androidx.compose.ui.test.performClick 8 | import appoutlet.gameoutlet.core.testing.UiTest 9 | import appoutlet.gameoutlet.core.translation.i18n 10 | import appoutlet.gameoutlet.core.ui.GameOutletTheme 11 | import io.mockk.every 12 | import io.mockk.mockk 13 | import io.mockk.verify 14 | import kotlin.test.Test 15 | 16 | class ErrorKtTest : UiTest() { 17 | private val mockOnTryAgain = mockk<() -> Unit>() 18 | 19 | @Test 20 | fun `should show error composable - default values`() { 21 | composeTestRule.setContent { 22 | GameOutletTheme { Error() } 23 | } 24 | 25 | composeTestRule.onNode(hasTestTag("title")) 26 | .assertIsDisplayed() 27 | .assert(hasText(i18n.tr("Something went wrong"))) 28 | 29 | composeTestRule.onNode(hasTestTag("message")) 30 | .assertIsDisplayed() 31 | .assert(hasText(i18n.tr("An unexpected error occurred"))) 32 | 33 | composeTestRule.onNode(hasTestTag("button")) 34 | .assertDoesNotExist() 35 | } 36 | 37 | @Test 38 | fun `should show error composable - custom values`() { 39 | val titleFixture = fixture() 40 | val messageFixture = fixture() 41 | val buttonTextFixture = fixture() 42 | 43 | every { mockOnTryAgain.invoke() } returns Unit 44 | 45 | composeTestRule.setContent { 46 | Error( 47 | title = titleFixture, 48 | message = messageFixture, 49 | buttonText = buttonTextFixture, 50 | onTryAgain = mockOnTryAgain 51 | ) 52 | } 53 | 54 | composeTestRule.onNode(hasTestTag("title")) 55 | .assertIsDisplayed() 56 | .assert(hasText(titleFixture)) 57 | 58 | composeTestRule.onNode(hasTestTag("message")) 59 | .assertIsDisplayed() 60 | .assert(hasText(messageFixture)) 61 | 62 | composeTestRule.onNode(hasTestTag("button")) 63 | .assertIsDisplayed() 64 | .assert(hasText(buttonTextFixture)) 65 | .performClick() 66 | 67 | verify(exactly = 1) { mockOnTryAgain.invoke() } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/common/composable/LoadingKtTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.common.composable 2 | 3 | import androidx.compose.ui.test.assertIsDisplayed 4 | import androidx.compose.ui.test.onNodeWithTag 5 | import androidx.compose.ui.test.onNodeWithText 6 | import appoutlet.gameoutlet.core.testing.UiTest 7 | import kotlin.test.Test 8 | 9 | class LoadingKtTest : UiTest() { 10 | @Test 11 | fun `should show loading view`() { 12 | val textFixture = fixture() 13 | 14 | composeTestRule.setContent { 15 | Loading(text = textFixture) 16 | } 17 | 18 | composeTestRule.onNodeWithText(textFixture) 19 | .assertIsDisplayed() 20 | 21 | composeTestRule.onNodeWithTag("loadingIndicator") 22 | .assertIsDisplayed() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/common/composable/ScreenTitleKtTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.common.composable 2 | 3 | import androidx.compose.ui.test.assert 4 | import androidx.compose.ui.test.hasText 5 | import androidx.compose.ui.test.onNodeWithTag 6 | import appoutlet.gameoutlet.core.testing.UiTest 7 | import kotlin.test.Test 8 | 9 | class ScreenTitleKtTest : UiTest() { 10 | @Test 11 | fun `should show page title`() { 12 | val screenTitleFixture = fixture() 13 | 14 | composeTestRule.setContent { ScreenTitle(text = screenTitleFixture) } 15 | 16 | composeTestRule.onNodeWithTag("screenTitle") 17 | .assert(hasText(screenTitleFixture)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/game/composable/DealKtTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.game.composable 2 | 3 | import androidx.compose.ui.test.assertIsDisplayed 4 | import androidx.compose.ui.test.onNodeWithTag 5 | import androidx.compose.ui.test.onNodeWithText 6 | import androidx.compose.ui.test.performClick 7 | import appoutlet.gameoutlet.core.testing.UiTest 8 | import appoutlet.gameoutlet.feature.game.GameDealUiModel 9 | import appoutlet.gameoutlet.feature.game.GameInputEvent 10 | import io.mockk.mockk 11 | import io.mockk.verify 12 | import kotlin.test.Test 13 | 14 | internal class DealKtTest : UiTest() { 15 | private val mockOnInputEvent = mockk<(GameInputEvent) -> Unit>(relaxed = true) 16 | 17 | @Test 18 | fun `should show deal`() { 19 | val fixtureGameDealUiModel = fixture().copy(showNormalPrice = true) 20 | 21 | composeTestRule.setContent { 22 | Deal(item = fixtureGameDealUiModel, onInputEvent = mockOnInputEvent) 23 | } 24 | 25 | // Show store name 26 | composeTestRule.onNodeWithText(fixtureGameDealUiModel.store.name) 27 | .assertIsDisplayed() 28 | 29 | composeTestRule.onNodeWithText(fixtureGameDealUiModel.salePrice) 30 | .assertIsDisplayed() 31 | 32 | composeTestRule.onNodeWithText(fixtureGameDealUiModel.normalPrice) 33 | .assertIsDisplayed() 34 | } 35 | 36 | @Test 37 | fun `should show deal - no normal price`() { 38 | val fixtureGameDealUiModel = fixture().copy(showNormalPrice = false) 39 | 40 | composeTestRule.setContent { 41 | Deal(item = fixtureGameDealUiModel, onInputEvent = mockOnInputEvent) 42 | } 43 | 44 | composeTestRule.onNodeWithText(fixtureGameDealUiModel.normalPrice) 45 | .assertDoesNotExist() 46 | } 47 | 48 | @Test 49 | fun `should emit event when click`() { 50 | val fixtureGameDealUiModel = fixture() 51 | 52 | composeTestRule.setContent { 53 | Deal(item = fixtureGameDealUiModel, onInputEvent = mockOnInputEvent) 54 | } 55 | 56 | // Show store name 57 | composeTestRule.onNodeWithTag(fixtureGameDealUiModel.id) 58 | .performClick() 59 | 60 | verify { mockOnInputEvent.invoke(GameInputEvent.DealClicked(fixtureGameDealUiModel)) } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/game/composable/GameDetailsKtTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.game.composable 2 | 3 | import androidx.compose.ui.test.assertIsDisplayed 4 | import androidx.compose.ui.test.onNodeWithText 5 | import androidx.compose.ui.test.performScrollTo 6 | import appoutlet.gameoutlet.core.testing.UiTest 7 | import appoutlet.gameoutlet.core.translation.i18n 8 | import appoutlet.gameoutlet.feature.game.GameInputEvent 9 | import appoutlet.gameoutlet.feature.game.GameUiModel 10 | import io.mockk.mockk 11 | import kotlin.test.Test 12 | 13 | internal class GameDetailsKtTest : UiTest() { 14 | private val mockOnInputEvent = mockk<(GameInputEvent) -> Unit>(relaxed = true) 15 | 16 | @Test 17 | fun `should show game details`() { 18 | val fixtureUiModel = fixture().copy(deals = listOf(fixture())) 19 | 20 | composeTestRule.setContent { 21 | GameDetails(uiState = fixtureUiModel, onInputEvent = mockOnInputEvent) 22 | } 23 | 24 | composeTestRule.onNodeWithText(fixtureUiModel.title) 25 | .assertIsDisplayed() 26 | 27 | composeTestRule.onNodeWithText(i18n.tr("Deals")) 28 | .assertIsDisplayed() 29 | 30 | fixtureUiModel.deals.forEach { deal -> 31 | val node = composeTestRule.onNodeWithText(deal.store.name) 32 | node.performScrollTo() 33 | composeTestRule.waitForIdle() 34 | node.assertIsDisplayed() 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/game/composable/GameDetailsTopBarKtTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.game.composable 2 | 3 | import androidx.compose.ui.test.assertIsDisplayed 4 | import androidx.compose.ui.test.onNodeWithContentDescription 5 | import androidx.compose.ui.test.onNodeWithTag 6 | import androidx.compose.ui.test.onNodeWithText 7 | import androidx.compose.ui.test.performClick 8 | import appoutlet.gameoutlet.core.testing.UiTest 9 | import appoutlet.gameoutlet.feature.game.GameFavouriteButton 10 | import appoutlet.gameoutlet.feature.game.GameInputEvent 11 | import appoutlet.gameoutlet.feature.game.GameUiModel 12 | import io.mockk.mockk 13 | import io.mockk.verify 14 | import kotlin.test.Test 15 | 16 | internal class GameDetailsTopBarKtTest : UiTest() { 17 | private val mockOnInputEvent = mockk<(GameInputEvent) -> Unit>(relaxed = true) 18 | 19 | @Test 20 | fun `should navigate back`() { 21 | val fixtureGameUiModel = fixture() 22 | 23 | composeTestRule.setContent { 24 | GameDetailsTopBar(uiState = fixtureGameUiModel, onInputEvent = mockOnInputEvent) 25 | } 26 | 27 | composeTestRule.onNodeWithText(fixtureGameUiModel.title) 28 | .assertIsDisplayed() 29 | 30 | composeTestRule.onNodeWithTag("navigation icon") 31 | .performClick() 32 | 33 | verify { mockOnInputEvent.invoke(GameInputEvent.NavigateBack) } 34 | } 35 | 36 | @Test 37 | fun `should save game`() { 38 | val fixtureFavButton = fixture().copy(isSaved = false) 39 | val fixtureGameUiModel = fixture().copy(favouriteButton = fixtureFavButton) 40 | 41 | composeTestRule.setContent { 42 | GameDetailsTopBar(uiState = fixtureGameUiModel, onInputEvent = mockOnInputEvent) 43 | } 44 | 45 | composeTestRule.onNodeWithContentDescription("save game icon") 46 | .performClick() 47 | 48 | verify { mockOnInputEvent.invoke(fixtureFavButton.inputEvent) } 49 | } 50 | 51 | @Test 52 | fun `should remove game`() { 53 | val fixtureFavButton = fixture().copy(isSaved = true) 54 | val fixtureGameUiModel = fixture().copy(favouriteButton = fixtureFavButton) 55 | 56 | composeTestRule.setContent { 57 | GameDetailsTopBar(uiState = fixtureGameUiModel, onInputEvent = mockOnInputEvent) 58 | } 59 | 60 | composeTestRule.onNodeWithContentDescription("remove game icon") 61 | .performClick() 62 | 63 | verify { mockOnInputEvent.invoke(fixtureFavButton.inputEvent) } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/gamesearch/GameSearchUiModelMapperTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.gamesearch 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import appoutlet.gameoutlet.domain.Game 5 | import com.google.common.truth.Truth.assertThat 6 | import kotlin.test.Test 7 | 8 | class GameSearchUiModelMapperTest : UnitTest() { 9 | override fun buildSut() = GameSearchUiModelMapper() 10 | 11 | @Test 12 | fun `should map ui model`() { 13 | val fixtureGame = fixture() 14 | 15 | val actual = sut(fixtureGame) 16 | 17 | assertThat(actual.id).isEqualTo(fixtureGame.id) 18 | assertThat(actual.image).isEqualTo(fixtureGame.image) 19 | assertThat(actual.title).isEqualTo(fixtureGame.title) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/latestdeals/LatestDealsOrchestratorTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.latestdeals 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import appoutlet.gameoutlet.domain.Deal 5 | import appoutlet.gameoutlet.domain.Store 6 | import appoutlet.gameoutlet.repository.deals.DealRepository 7 | import appoutlet.gameoutlet.repository.store.StoreRepository 8 | import com.google.common.truth.Truth.assertThat 9 | import io.mockk.coEvery 10 | import io.mockk.every 11 | import io.mockk.mockk 12 | import kotlinx.coroutines.ExperimentalCoroutinesApi 13 | import kotlinx.coroutines.flow.first 14 | import kotlinx.coroutines.test.runTest 15 | import kotlin.test.Test 16 | 17 | @OptIn(ExperimentalCoroutinesApi::class) 18 | class LatestDealsOrchestratorTest : UnitTest() { 19 | private val mockDealRepository = mockk() 20 | private val mockStoreRepository = mockk() 21 | override fun buildSut() = LatestDealsOrchestrator( 22 | dealRepository = mockDealRepository, 23 | storeRepository = mockStoreRepository, 24 | ) 25 | 26 | @Test 27 | fun `should find latest deals`() = runTest { 28 | val dealsFixture = fixture>() 29 | val storesFixture = fixture>() 30 | 31 | coEvery { mockDealRepository.findLatestDeals() } returns dealsFixture 32 | dealsFixture.forEachIndexed { index, deal -> 33 | every { mockStoreRepository.findById(deal.store.id) } returns storesFixture[index] 34 | } 35 | 36 | val actual = sut.findLatestDeals().first() 37 | 38 | actual.forEachIndexed { index, deal -> 39 | assertThat(deal.id).isEqualTo(dealsFixture[index].id) 40 | assertThat(deal.store).isEqualTo(storesFixture[index]) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/settings/composable/SettingsScreenKtTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.settings.composable 2 | 3 | import androidx.compose.ui.test.assertIsDisplayed 4 | import androidx.compose.ui.test.onNodeWithTag 5 | import androidx.compose.ui.test.onNodeWithText 6 | import androidx.compose.ui.test.performClick 7 | import appoutlet.gameoutlet.core.testing.UiTest 8 | import appoutlet.gameoutlet.feature.settings.SettingsInputEvent 9 | import appoutlet.gameoutlet.feature.settings.SettingsUiState 10 | import io.mockk.mockk 11 | import io.mockk.verify 12 | import kotlin.test.Test 13 | 14 | class SettingsScreenKtTest : UiTest() { 15 | private val mockOnInputEvent = mockk<(SettingsInputEvent) -> Unit>(relaxed = true) 16 | 17 | @Test 18 | fun `should show screen title`() { 19 | val fixtureUiState = fixture() 20 | 21 | composeTestRule.setContent { 22 | SettingsScreen(uiState = fixtureUiState, onInputEvent = mockOnInputEvent) 23 | } 24 | 25 | composeTestRule.onNodeWithTag("screenTitle") 26 | .assertIsDisplayed() 27 | } 28 | 29 | @Test 30 | fun `should show theme button`() { 31 | val fixtureUiState = fixture() 32 | 33 | composeTestRule.setContent { 34 | SettingsScreen(uiState = fixtureUiState, onInputEvent = mockOnInputEvent) 35 | } 36 | 37 | with(fixtureUiState.settingsViewData.themeViewData.lightButton) { 38 | composeTestRule.onNodeWithText(name) 39 | .assertIsDisplayed() 40 | .performClick() 41 | 42 | verify { mockOnInputEvent.invoke(inputEvent) } 43 | } 44 | 45 | with(fixtureUiState.settingsViewData.themeViewData.darkButton) { 46 | composeTestRule.onNodeWithText(name) 47 | .assertIsDisplayed() 48 | .performClick() 49 | 50 | verify { mockOnInputEvent.invoke(inputEvent) } 51 | } 52 | 53 | with(fixtureUiState.settingsViewData.themeViewData.systemThemeButton) { 54 | composeTestRule.onNodeWithText(name) 55 | .assertIsDisplayed() 56 | .performClick() 57 | 58 | verify { mockOnInputEvent.invoke(inputEvent) } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/splash/SplashOrchestratorTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.splash 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import appoutlet.gameoutlet.domain.Store 5 | import appoutlet.gameoutlet.repository.store.StoreRepository 6 | import com.google.common.truth.Truth.assertThat 7 | import io.mockk.coEvery 8 | import io.mockk.coVerify 9 | import io.mockk.mockk 10 | import kotlinx.coroutines.ExperimentalCoroutinesApi 11 | import kotlinx.coroutines.flow.first 12 | import kotlinx.coroutines.test.runTest 13 | import kotlin.test.Test 14 | 15 | @OptIn(ExperimentalCoroutinesApi::class) 16 | class SplashOrchestratorTest : UnitTest() { 17 | private val mockStoreRepository = mockk() 18 | 19 | override fun buildSut() = SplashOrchestrator(storeRepository = mockStoreRepository) 20 | 21 | @Test 22 | fun `should synchronize stores`() = runTest { 23 | val storeListFixture = fixture>() 24 | 25 | coEvery { mockStoreRepository.findAll() } returns storeListFixture 26 | 27 | val actual = sut.synchronizeStoreData().first() 28 | 29 | assertThat(actual).isEqualTo(Unit) 30 | 31 | coVerify(exactly = 1) { mockStoreRepository.findAll() } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/splash/SplashViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.splash 2 | 3 | import appoutlet.gameoutlet.core.testing.ViewModelTest 4 | import appoutlet.gameoutlet.feature.home.HomeView 5 | import appoutlet.gameoutlet.feature.home.HomeViewProvider 6 | import com.google.common.truth.Truth.assertThat 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import io.mockk.verify 10 | import kotlinx.coroutines.delay 11 | import kotlinx.coroutines.flow.flow 12 | import kotlinx.coroutines.test.advanceTimeBy 13 | import kotlinx.coroutines.test.advanceUntilIdle 14 | import kotlinx.coroutines.test.runTest 15 | import kotlin.test.Test 16 | 17 | @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) 18 | class SplashViewModelTest : ViewModelTest() { 19 | private val mockSplashOrchestrator = mockk() 20 | private val mockHomeViewProvider = mockk() 21 | private val mockHomeView = mockk() 22 | 23 | override fun buildSut() = SplashViewModel( 24 | splashOrchestrator = mockSplashOrchestrator, 25 | homeViewProvider = mockHomeViewProvider 26 | ) 27 | 28 | @Test 29 | fun `should sync stores`() = runViewModelTest { 30 | every { mockSplashOrchestrator.synchronizeStoreData() } returns flow { 31 | delay(3) 32 | emit(Unit) 33 | } 34 | 35 | every { mockHomeViewProvider.getView() } returns mockHomeView 36 | 37 | assertThat(sut.uiState.value).isEqualTo(SplashUiState.Idle) 38 | 39 | sut.onInputEvent(SplashInputEvent.Load) 40 | 41 | advanceTimeBy(2) 42 | 43 | assertThat(sut.uiState.value).isEqualTo(SplashUiState.Loading) 44 | 45 | advanceUntilIdle() 46 | 47 | assertThat(sut.uiState.value).isEqualTo(SplashUiState.Loaded) 48 | 49 | verify { mockNavigator.replaceAll(mockHomeView) } 50 | } 51 | 52 | @Test 53 | fun `should show error screen`() = runTest { 54 | sut.init(this, mockNavigator) 55 | 56 | every { mockSplashOrchestrator.synchronizeStoreData() } returns flow { 57 | throw IllegalStateException() 58 | } 59 | 60 | sut.onInputEvent(SplashInputEvent.Load) 61 | 62 | advanceUntilIdle() 63 | 64 | assertThat(sut.uiState.value).isEqualTo(SplashUiState.Error) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/splash/composable/SplashLoadingIndicatorKtTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.splash.composable 2 | 3 | import androidx.compose.ui.test.assertIsDisplayed 4 | import androidx.compose.ui.test.hasTestTag 5 | import androidx.compose.ui.test.hasText 6 | import appoutlet.gameoutlet.core.testing.UiTest 7 | import appoutlet.gameoutlet.core.translation.i18n 8 | import kotlin.test.Test 9 | 10 | class SplashLoadingIndicatorKtTest : UiTest() { 11 | @Test 12 | fun `should show loading indicator`() { 13 | composeTestRule.setContent { 14 | SplashLoadingIndicator() 15 | } 16 | 17 | composeTestRule.onNode(hasTestTag("splashLoadingIndicator")) 18 | .assertIsDisplayed() 19 | 20 | composeTestRule.onNode(hasText(i18n.tr("Loading"))) 21 | .assertIsDisplayed() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/store/StoreOrchestratorTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.store 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import appoutlet.gameoutlet.domain.Deal 5 | import appoutlet.gameoutlet.domain.Store 6 | import appoutlet.gameoutlet.repository.deals.DealRepository 7 | import com.google.common.truth.Truth.assertThat 8 | import io.mockk.coEvery 9 | import io.mockk.mockk 10 | import kotlinx.coroutines.ExperimentalCoroutinesApi 11 | import kotlinx.coroutines.flow.first 12 | import kotlinx.coroutines.test.runTest 13 | import kotlin.test.Test 14 | 15 | @OptIn(ExperimentalCoroutinesApi::class) 16 | class StoreOrchestratorTest : UnitTest() { 17 | private val mockDealRepository = mockk() 18 | 19 | override fun buildSut() = StoreOrchestrator(mockDealRepository) 20 | 21 | @Test 22 | fun `should load stores`() = runTest { 23 | val fixtureStore = fixture() 24 | val fixtureDeals = fixture>() 25 | 26 | coEvery { mockDealRepository.findDealsByStore(fixtureStore) } returns fixtureDeals 27 | 28 | val actual = sut.loadStore(fixtureStore).first() 29 | 30 | assertThat(actual).isEqualTo(fixtureDeals) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/store/StoreViewDataMapperTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.store 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import appoutlet.gameoutlet.domain.Deal 5 | import appoutlet.gameoutlet.feature.common.util.asString 6 | import com.google.common.truth.Truth.assertThat 7 | import org.javamoney.moneta.Money 8 | import kotlin.test.Test 9 | 10 | class StoreViewDataMapperTest : UnitTest() { 11 | override fun buildSut() = StoreViewDataMapper() 12 | 13 | @Test 14 | fun `should map`() { 15 | val fixtureDeals = fixture>() 16 | 17 | val actual = sut(fixtureDeals) 18 | 19 | actual.deals.forEachIndexed { index, dealViewData -> 20 | val deal = fixtureDeals[index] 21 | 22 | assertThat(dealViewData.title).isEqualTo(deal.game.title) 23 | assertThat(dealViewData.currentPrice).isEqualTo(deal.salePrice.asString()) 24 | assertThat(dealViewData.normalPrice).isEqualTo(deal.normalPrice.asString()) 25 | assertThat(dealViewData.image).isEqualTo(deal.game.image) 26 | 27 | with(dealViewData.inputEvent) { 28 | val event = this as StoreInputEvent.SelectDeal 29 | 30 | assertThat(event.gameNavArgs.gameId).isEqualTo(deal.game.id) 31 | assertThat(event.gameNavArgs.gameImage).isEqualTo(deal.game.image) 32 | assertThat(event.gameNavArgs.gameTitle).isEqualTo(deal.game.title) 33 | } 34 | } 35 | } 36 | 37 | @Test 38 | fun `should map - same prices`() { 39 | val fixturePrice = fixture() 40 | val fixtureDeals = listOf( 41 | fixture().copy(normalPrice = fixturePrice, salePrice = fixturePrice), 42 | ) 43 | 44 | val actual = sut(fixtureDeals) 45 | 46 | actual.deals.forEach { dealViewData -> 47 | assertThat(dealViewData.normalPrice).isNull() 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/store/StoreViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.store 2 | 3 | import appoutlet.gameoutlet.core.testing.ViewModelTest 4 | import appoutlet.gameoutlet.domain.Deal 5 | import appoutlet.gameoutlet.domain.Store 6 | import appoutlet.gameoutlet.feature.game.GameView 7 | import appoutlet.gameoutlet.feature.game.GameViewProvider 8 | import com.google.common.truth.Truth.assertThat 9 | import io.mockk.every 10 | import io.mockk.mockk 11 | import io.mockk.verify 12 | import kotlinx.coroutines.ExperimentalCoroutinesApi 13 | import kotlinx.coroutines.flow.flowOf 14 | import kotlinx.coroutines.test.advanceUntilIdle 15 | import kotlin.test.Test 16 | 17 | @OptIn(ExperimentalCoroutinesApi::class) 18 | class StoreViewModelTest : ViewModelTest() { 19 | private val mockStoreOrchestrator = mockk() 20 | private val mockStoreViewDataMapper = mockk() 21 | private val mockGameViewProvider = mockk() 22 | 23 | override fun buildSut() = StoreViewModel( 24 | storeOrchestrator = mockStoreOrchestrator, 25 | storeViewDataMapper = mockStoreViewDataMapper, 26 | gameViewProvider = mockGameViewProvider, 27 | ) 28 | 29 | @Test 30 | fun `should load store`() = runViewModelTest { 31 | val fixtureStore = fixture() 32 | val fixtureDeals = fixture>() 33 | val fixtureViewData = fixture() 34 | 35 | every { mockStoreOrchestrator.loadStore(fixtureStore) } returns flowOf(fixtureDeals) 36 | every { mockStoreViewDataMapper.invoke(fixtureDeals) } returns fixtureViewData 37 | 38 | sut.onInputEvent(StoreInputEvent.Load(fixtureStore)) 39 | 40 | advanceUntilIdle() 41 | 42 | assertThat(sut.uiState.value).isEqualTo(StoreUiState.Loaded(fixtureViewData)) 43 | } 44 | 45 | @Test 46 | fun `should select deal`() = runViewModelTest { 47 | val fixtureInputEvent = fixture() 48 | val mockGameView = mockk() 49 | 50 | every { mockGameViewProvider.getGameView(fixtureInputEvent.gameNavArgs) } returns mockGameView 51 | 52 | sut.onInputEvent(fixtureInputEvent) 53 | 54 | verify { mockNavigator.push(mockGameView) } 55 | } 56 | 57 | @Test 58 | fun `should navigate back`() = runViewModelTest { 59 | sut.onInputEvent(StoreInputEvent.NavigateBack) 60 | 61 | verify { mockNavigator.pop() } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/storelist/StoreListUiModelMapperTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.storelist 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import appoutlet.gameoutlet.domain.Store 5 | import com.google.common.truth.Truth.assertThat 6 | import kotlin.test.Test 7 | 8 | class StoreListUiModelMapperTest : UnitTest() { 9 | 10 | override fun buildSut() = StoreListUiModelMapper() 11 | 12 | @Test 13 | fun `should map`() { 14 | val fixtureEntities = fixture>() 15 | 16 | val uiModels = sut.invoke(entities = fixtureEntities) 17 | 18 | uiModels.forEachIndexed { index, uiModel -> 19 | val entity = fixtureEntities[index] 20 | 21 | assertThat(uiModel.icon).isEqualTo(entity.logoUrl) 22 | assertThat(uiModel.name).isEqualTo(entity.name) 23 | assertThat(uiModel.inputEvent).isEqualTo(StoreListInputEvent.SelectStore(entity)) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/storelist/StoreListViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.storelist 2 | 3 | import appoutlet.gameoutlet.core.testing.ViewModelTest 4 | import appoutlet.gameoutlet.domain.Store 5 | import appoutlet.gameoutlet.feature.store.StoreView 6 | import appoutlet.gameoutlet.repository.store.StoreRepository 7 | import com.google.common.truth.Truth.assertThat 8 | import io.mockk.coEvery 9 | import io.mockk.every 10 | import io.mockk.mockk 11 | import io.mockk.verify 12 | import kotlinx.coroutines.ExperimentalCoroutinesApi 13 | import kotlinx.coroutines.test.advanceUntilIdle 14 | import kotlin.test.Test 15 | 16 | @OptIn(ExperimentalCoroutinesApi::class) 17 | class StoreListViewModelTest : ViewModelTest() { 18 | private val mockStoreRepository = mockk() 19 | private val mockStoreListUiModelMapper = mockk() 20 | private val mockStoreViewProvider = mockk() 21 | 22 | override fun buildSut() = StoreListViewModel( 23 | storeRepository = mockStoreRepository, 24 | storeListUiModelMapper = mockStoreListUiModelMapper, 25 | storeViewProvider = mockStoreViewProvider, 26 | ) 27 | 28 | @Test 29 | fun `should load stores`() = runViewModelTest { 30 | val fixtureEntities = fixture>() 31 | val fixtureUiModels = fixture>() 32 | 33 | coEvery { mockStoreRepository.findAll() } returns fixtureEntities 34 | every { mockStoreListUiModelMapper.invoke(fixtureEntities) } returns fixtureUiModels 35 | 36 | sut.onInputEvent(StoreListInputEvent.Load) 37 | 38 | advanceUntilIdle() 39 | 40 | assertThat(sut.uiState.value).isEqualTo(StoreListUiState.Loaded(fixtureUiModels)) 41 | } 42 | 43 | @Test 44 | fun `should load stores - error state`() = runViewModelTest { 45 | coEvery { mockStoreRepository.findAll() } throws RuntimeException() 46 | 47 | sut.onInputEvent(StoreListInputEvent.Load) 48 | 49 | advanceUntilIdle() 50 | 51 | assertThat(sut.uiState.value).isEqualTo(StoreListUiState.Error) 52 | } 53 | 54 | @Test 55 | fun `should select stores`() = runViewModelTest { 56 | val fixtureStore = fixture() 57 | val mockStoreView = mockk() 58 | 59 | every { mockStoreViewProvider.getStoreView(fixtureStore) } returns mockStoreView 60 | 61 | sut.onInputEvent(StoreListInputEvent.SelectStore(fixtureStore)) 62 | 63 | verify { mockNavigator.push(mockStoreView) } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/wishlist/WishlistOrchestratorTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.wishlist 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import appoutlet.gameoutlet.domain.Game 5 | import appoutlet.gameoutlet.repository.game.GameRepository 6 | import com.google.common.truth.Truth.assertThat 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import kotlin.test.Test 10 | 11 | internal class WishlistOrchestratorTest : UnitTest() { 12 | private val mockGameRepository = mockk() 13 | 14 | override fun buildSut() = WishlistOrchestrator(gameRepository = mockGameRepository) 15 | 16 | @Test 17 | fun `should find all games`() { 18 | val fixtureGames = fixture>() 19 | 20 | every { mockGameRepository.findAll() } returns fixtureGames 21 | 22 | val actual = sut.findAll() 23 | 24 | assertThat(actual).isEqualTo(fixtureGames) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/feature/wishlist/WishlistUiStateMapperTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.feature.wishlist 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import appoutlet.gameoutlet.domain.Game 5 | import com.google.common.truth.Truth.assertThat 6 | import kotlin.test.Test 7 | 8 | internal class WishlistUiStateMapperTest : UnitTest() { 9 | override fun buildSut() = WishlistUiStateMapper() 10 | 11 | @Test 12 | fun `should map wishlist ui state`() { 13 | val fixtureGames = fixture>() 14 | 15 | val actual = sut(fixtureGames) 16 | 17 | actual.forEachIndexed { index, wishlistGameUiModel -> 18 | val game = fixtureGames[index] 19 | 20 | with(wishlistGameUiModel) { 21 | assertThat(id).isEqualTo(game.id) 22 | assertThat(title).isEqualTo(game.title) 23 | assertThat(image).isEqualTo(game.image) 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/repository/deals/DealGameMapperTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import appoutlet.gameoutlet.repository.deals.api.DealResponse 5 | import com.google.common.truth.Truth.assertThat 6 | import kotlin.test.Test 7 | 8 | class DealGameMapperTest : UnitTest() { 9 | override fun buildSut() = DealGameMapper() 10 | 11 | @Test 12 | fun `should map a game`() { 13 | val responseFixture = fixture().copy(gameID = "1") 14 | 15 | val actual = sut.invoke(dealResponse = responseFixture) 16 | 17 | assertThat(actual).isNotNull() 18 | with(actual!!) { 19 | assertThat(id).isEqualTo(1) 20 | assertThat(title).isEqualTo(responseFixture.title) 21 | assertThat(image).isEqualTo(responseFixture.thumb) 22 | assertThat(metacritic).isNull() 23 | assertThat(steam).isNull() 24 | } 25 | } 26 | 27 | @Test 28 | fun `should return null if game id is not a number`() { 29 | val responseFixture = fixture().copy(gameID = "sdfasd") 30 | 31 | val actual = sut.invoke(dealResponse = responseFixture) 32 | 33 | assertThat(actual).isNull() 34 | } 35 | 36 | @Test 37 | fun `should replace image url fragment`() { 38 | val responseFixture = fixture().copy(gameID = "1", thumb = "capsule_sm_120") 39 | 40 | val actual = sut.invoke(dealResponse = responseFixture) 41 | 42 | assertThat(actual).isNotNull() 43 | assertThat(actual!!.image).isEqualTo("header") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/repository/deals/DealStoreMapperTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import appoutlet.gameoutlet.domain.Store 5 | import appoutlet.gameoutlet.repository.deals.api.DealResponse 6 | import com.google.common.truth.Truth.assertThat 7 | import kotlin.test.Test 8 | 9 | class DealStoreMapperTest : UnitTest() { 10 | override fun buildSut() = DealStoreMapper() 11 | 12 | @Test 13 | fun `should map store`() { 14 | val fixtureResponse = fixture().copy(storeID = "1") 15 | 16 | val actual = sut.invoke(fixtureResponse) 17 | 18 | assertThat(actual).isEqualTo(Store(1)) 19 | } 20 | 21 | @Test 22 | fun `should return null if the store id is not a valid int`() { 23 | val fixtureResponse = fixture() 24 | 25 | val actual = sut.invoke(fixtureResponse) 26 | 27 | assertThat(actual).isNull() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/repository/deals/GameMapperTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.deals 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import appoutlet.gameoutlet.repository.deals.api.GameSearchResponse 5 | import com.google.common.truth.Truth.assertThat 6 | import kotlin.test.Test 7 | 8 | internal class GameMapperTest : UnitTest() { 9 | override fun buildSut() = GameMapper() 10 | 11 | @Test 12 | fun `should map game`() { 13 | val fixtureGameId = fixture() 14 | val fixtureThumb = fixture() 15 | 16 | val fixtureGameResponse = fixture().copy( 17 | gameID = fixtureGameId.toString(), 18 | thumb = fixtureThumb + "capsule_sm_120" 19 | ) 20 | 21 | val actual = sut(fixtureGameResponse) 22 | 23 | assertThat(actual).isNotNull() 24 | actual?.run { 25 | assertThat(id).isEqualTo(fixtureGameId) 26 | assertThat(title).isEqualTo(fixtureGameResponse.external) 27 | assertThat(image).isEqualTo(fixtureThumb + "header") 28 | } 29 | } 30 | 31 | @Test 32 | fun `should map game - invalid id`() { 33 | val fixtureGameResponse = fixture() 34 | 35 | val actual = sut(fixtureGameResponse) 36 | 37 | assertThat(actual).isNull() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/repository/game/GameEntityMapperTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.game 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import appoutlet.gameoutlet.domain.Game 5 | import com.google.common.truth.Truth.assertThat 6 | import kotlin.test.Test 7 | 8 | internal class GameEntityMapperTest : UnitTest() { 9 | override fun buildSut() = GameEntityMapper() 10 | 11 | @Test 12 | fun `should map game entity`() { 13 | val fixtureGame = fixture() 14 | 15 | val actual = sut(fixtureGame) 16 | 17 | with(actual) { 18 | assertThat(id).isEqualTo(fixtureGame.id) 19 | assertThat(title).isEqualTo(fixtureGame.title) 20 | assertThat(imageUrl).isEqualTo(fixtureGame.image) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/repository/game/GameMapperTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.game 2 | 3 | import appoutlet.gameoutlet.core.database.GameEntity 4 | import appoutlet.gameoutlet.core.testing.UnitTest 5 | import com.google.common.truth.Truth 6 | import kotlin.test.Test 7 | 8 | internal class GameMapperTest : UnitTest() { 9 | override fun buildSut() = GameMapper() 10 | 11 | @Test 12 | fun `should map game`() { 13 | val fixtureGameEntity = fixture() 14 | 15 | val actual = sut(fixtureGameEntity) 16 | 17 | with(actual) { 18 | Truth.assertThat(id).isEqualTo(fixtureGameEntity.id) 19 | Truth.assertThat(title).isEqualTo(fixtureGameEntity.title) 20 | Truth.assertThat(image).isEqualTo(fixtureGameEntity.imageUrl) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/repository/preference/PreferenceRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.preference 2 | 3 | import app.cash.sqldelight.Query 4 | import appoutlet.gameoutlet.core.database.PreferenceEntity 5 | import appoutlet.gameoutlet.core.database.PreferenceQueries 6 | import appoutlet.gameoutlet.core.testing.UnitTest 7 | import com.google.common.truth.Truth.assertThat 8 | import io.mockk.every 9 | import io.mockk.mockk 10 | import io.mockk.verify 11 | import kotlin.test.Test 12 | 13 | class PreferenceRepositoryTest : UnitTest() { 14 | private val mockPreferenceQueries = mockk(relaxUnitFun = true) 15 | 16 | override fun buildSut() = PreferenceRepository(mockPreferenceQueries) 17 | 18 | @Test 19 | fun `should set preference`() { 20 | val fixtureKey = fixture() 21 | val fixtureValue = fixture() 22 | 23 | sut.setPreference(fixtureKey, fixtureValue) 24 | 25 | verify { mockPreferenceQueries.save(fixtureKey, fixtureValue) } 26 | } 27 | 28 | @Test 29 | fun `should get preference`() { 30 | val fixtureKey = fixture() 31 | val fixtureValue = fixture() 32 | val mockQueryPreference = mockk>() 33 | 34 | every { mockPreferenceQueries.findByKey(fixtureKey) } returns mockQueryPreference 35 | every { mockQueryPreference.executeAsOneOrNull() } returns PreferenceEntity(fixtureKey, fixtureValue) 36 | 37 | val actual = sut.getPreference(fixtureKey) 38 | 39 | assertThat(actual).isEqualTo(fixtureValue) 40 | } 41 | 42 | @Test 43 | fun `should get preference - execution null`() { 44 | val fixtureKey = fixture() 45 | val mockQueryPreference = mockk>() 46 | 47 | every { mockPreferenceQueries.findByKey(fixtureKey) } returns mockQueryPreference 48 | every { mockQueryPreference.executeAsOneOrNull() } returns null 49 | 50 | val actual = sut.getPreference(fixtureKey) 51 | 52 | assertThat(actual).isNull() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/repository/store/StoreEntityMapperTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.store 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import appoutlet.gameoutlet.domain.Store 5 | import com.google.common.truth.Truth.assertThat 6 | import kotlin.test.Test 7 | 8 | class StoreEntityMapperTest : UnitTest() { 9 | override fun buildSut() = StoreEntityMapper() 10 | 11 | @Test 12 | fun `should map store entity`() { 13 | val fixtureStore = fixture() 14 | 15 | val actual = sut.invoke(fixtureStore) 16 | 17 | assertThat(actual.id).isEqualTo(fixtureStore.id) 18 | assertThat(actual.name).isEqualTo(fixtureStore.name) 19 | assertThat(actual.bannerUrl).isEqualTo(fixtureStore.bannerUrl) 20 | assertThat(actual.logoUrl).isEqualTo(fixtureStore.logoUrl) 21 | assertThat(actual.iconUrl).isEqualTo(fixtureStore.iconUrl) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/repository/store/StoreMapperTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.store 2 | 3 | import appoutlet.gameoutlet.core.database.StoreEntity 4 | import appoutlet.gameoutlet.core.testing.UnitTest 5 | import appoutlet.gameoutlet.repository.store.StoreMapper.Companion.CHEAP_SHARK_IMAGE_BASE_URL 6 | import appoutlet.gameoutlet.repository.store.api.model.StoreResponse 7 | import com.google.common.truth.Truth.assertThat 8 | import kotlin.test.Test 9 | 10 | class StoreMapperTest : UnitTest() { 11 | override fun buildSut() = StoreMapper() 12 | 13 | @Test 14 | fun `should map from StoreEntity`() { 15 | val fixtureStoreEntity = fixture().copy(id = fixture().toLong()) 16 | 17 | val actual = sut.invoke(fixtureStoreEntity) 18 | 19 | assertThat(actual.id).isEqualTo(fixtureStoreEntity.id) 20 | assertThat(actual.name).isEqualTo(fixtureStoreEntity.name) 21 | assertThat(actual.bannerUrl).isEqualTo(fixtureStoreEntity.bannerUrl) 22 | assertThat(actual.logoUrl).isEqualTo(fixtureStoreEntity.logoUrl) 23 | assertThat(actual.iconUrl).isEqualTo(fixtureStoreEntity.iconUrl) 24 | } 25 | 26 | @Test 27 | fun `should map from StoreResponse`() { 28 | val fixtureStoreEntity = fixture().copy(storeID = fixture().toString()) 29 | 30 | val actual = sut.invoke(fixtureStoreEntity) 31 | 32 | assertThat(actual.id).isEqualTo(fixtureStoreEntity.storeID.toInt()) 33 | assertThat(actual.name).isEqualTo(fixtureStoreEntity.storeName) 34 | assertThat(actual.bannerUrl).isEqualTo(CHEAP_SHARK_IMAGE_BASE_URL + fixtureStoreEntity.images.banner) 35 | assertThat(actual.logoUrl).isEqualTo(CHEAP_SHARK_IMAGE_BASE_URL + fixtureStoreEntity.images.logo) 36 | assertThat(actual.iconUrl).isEqualTo(CHEAP_SHARK_IMAGE_BASE_URL + fixtureStoreEntity.images.icon) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/test/kotlin/appoutlet/gameoutlet/repository/theme/ThemeRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package appoutlet.gameoutlet.repository.theme 2 | 3 | import appoutlet.gameoutlet.core.testing.UnitTest 4 | import appoutlet.gameoutlet.domain.Theme 5 | import appoutlet.gameoutlet.repository.preference.PreferenceRepository 6 | import appoutlet.gameoutlet.repository.theme.ThemeRepository.Companion.PREFERENCE_THEME 7 | import com.google.common.truth.Truth.assertThat 8 | import io.mockk.every 9 | import io.mockk.mockk 10 | import io.mockk.verify 11 | import kotlin.test.Test 12 | 13 | class ThemeRepositoryTest : UnitTest() { 14 | private val mockPreferenceRepository = mockk(relaxUnitFun = true) 15 | 16 | override fun buildSut() = ThemeRepository(mockPreferenceRepository) 17 | 18 | @Test 19 | fun `should set theme`() { 20 | val fixtureTheme = fixture() 21 | 22 | sut.setTheme(fixtureTheme) 23 | 24 | verify { mockPreferenceRepository.setPreference(PREFERENCE_THEME, fixtureTheme.name) } 25 | } 26 | 27 | @Test 28 | fun `should get theme`() { 29 | val fixtureTheme = fixture() 30 | 31 | every { mockPreferenceRepository.getPreference(PREFERENCE_THEME) } returns fixtureTheme.name 32 | 33 | val actual = sut.getTheme() 34 | 35 | assertThat(actual).isEqualTo(fixtureTheme) 36 | } 37 | 38 | @Test 39 | fun `should observe theme`() { 40 | every { mockPreferenceRepository.observePreference(PREFERENCE_THEME, any()) } answers { 41 | (it.invocation.args[1] as (String) -> Unit).invoke(Theme.DARK.name) 42 | } 43 | 44 | sut.observeTheme { 45 | assertThat(it).isEqualTo(Theme.DARK) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /transifex.yml: -------------------------------------------------------------------------------- 1 | filters: 2 | - filter_type: file 3 | source_file: src/commonMain/resources/i18n/en.po 4 | file_format: PO 5 | source_language: en 6 | translation_files_expression: 'src/commonMain/resources/i18n/.po' 7 | --------------------------------------------------------------------------------