├── .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 | 
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 | [](https://github.com/AppOutlet/GameOutlet/releases)
25 |
26 |
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 |
--------------------------------------------------------------------------------