├── .github └── workflows │ ├── androidTests.yml │ ├── build.yml │ ├── refreshVersions.yml │ ├── release-desktop.yml │ └── release.yml ├── .gitignore ├── .idea └── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── RELEASING.md ├── androidApp ├── .gitignore ├── build.gradle.kts ├── google-services.json ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── kotlin │ └── com │ │ └── mr3y │ │ └── ludi │ │ ├── LudiApplication.kt │ │ ├── MainActivity.kt │ │ └── ui │ │ └── preview │ │ └── LudiPreview.kt │ ├── play │ └── release-notes │ │ └── en-US │ │ └── beta.txt │ └── res │ ├── drawable-night │ └── ic_splash.xml │ ├── drawable │ └── ic_splash.xml │ ├── mipmap-anydpi-v26 │ └── ic_launcher.xml │ ├── mipmap-anydpi-v33 │ └── ic_launcher.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ ├── ic_launcher_background.png │ ├── ic_launcher_foreground.png │ └── ic_launcher_monochrome.png │ ├── values-night-v27 │ └── themes.xml │ ├── values-night │ └── themes.xml │ ├── values-v27 │ └── themes.xml │ ├── values │ ├── colors.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ ├── backup_rules.xml │ └── data_extraction_rules.xml ├── build.gradle.kts ├── changelog_config.json ├── convention-plugins ├── gradle.properties ├── plugins │ ├── .gitignore │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ └── com │ │ └── mr3y │ │ └── ludi │ │ └── gradle │ │ ├── AndroidConventionPlugin.kt │ │ └── CommonConventionPlugin.kt └── settings.gradle.kts ├── desktopApp ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── jvmMain │ ├── kotlin │ └── com │ │ └── mr3y │ │ └── ludi │ │ └── desktop │ │ └── Main.kt │ └── resources │ ├── icon_light.ico │ ├── icon_light.png │ └── icon_light.xml ├── docs ├── PrivacyPolicy.md └── README.md ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── index.html ├── lint.xml ├── settings.gradle.kts ├── shared ├── .gitignore ├── build.gradle.kts └── src │ ├── androidInstrumentedTest │ └── kotlin │ │ └── com │ │ └── mr3y │ │ └── ludi │ │ └── shared │ │ └── ui │ │ ├── datastore │ │ └── internal │ │ │ └── DefaultProtoDataStoreMutatorTest.kt │ │ └── presenter │ │ ├── FakeGamesRepository.kt │ │ ├── FakeProtoDataStoreMutator.kt │ │ ├── MainDispatcherRule.kt │ │ ├── OnBoardingViewModelTest.kt │ │ └── SettingsViewModelTest.kt │ ├── androidMain │ ├── AndroidManifest.xml │ └── kotlin │ │ └── com │ │ └── mr3y │ │ └── ludi │ │ └── shared │ │ ├── core │ │ ├── CrashlyticsReporting.kt │ │ └── database │ │ │ └── DatabaseFactory.android.kt │ │ ├── di │ │ ├── AndroidApplicationComponent.kt │ │ ├── AndroidCrashReportingComponent.kt │ │ ├── AndroidSqlDriverComponent.kt │ │ ├── DatabaseDispatcherComponent.android.kt │ │ ├── DealsFeatureComponent.kt │ │ ├── DiscoverFeatureComponent.kt │ │ ├── HostActivityComponent.kt │ │ ├── HostActivityComponentOwner.kt │ │ ├── NewsFeatureComponent.kt │ │ ├── OnboardingFeatureComponent.kt │ │ ├── SettingsFeatureComponent.kt │ │ ├── TimeSourceComponent.android.kt │ │ └── VoyagerIntegration.android.kt │ │ └── ui │ │ ├── components │ │ ├── LudiAsyncImage.android.kt │ │ ├── LudiChromeCustomTab.kt │ │ └── LudiNoInternet.android.kt │ │ ├── lifecycle │ │ ├── ConnectionState.kt │ │ └── ConnectivityStatus.kt │ │ ├── presenter │ │ └── FrameClock.android.kt │ │ ├── resources │ │ └── PlatformCompositionProvider.android.kt │ │ ├── screens │ │ ├── deals │ │ │ └── DealsScreen.android.kt │ │ ├── discover │ │ │ └── DiscoverScreen.android.kt │ │ ├── news │ │ │ └── NewsScreen.android.kt │ │ └── settings │ │ │ └── SettingsScreen.android.kt │ │ └── theme │ │ └── ColorScheme.android.kt │ ├── androidUnitTest │ └── kotlin │ │ └── com │ │ └── mr3y │ │ └── ludi │ │ └── shared │ │ └── ui │ │ └── screens │ │ ├── BaseRobolectricTest.kt │ │ ├── Utils.kt │ │ ├── deals │ │ ├── DealsScreenTest.kt │ │ └── FakeDealsState.kt │ │ └── onboarding │ │ ├── FakeOnboardingState.kt │ │ ├── OnboardingScreenFABTest.kt │ │ ├── Utils.kt │ │ └── accessibility │ │ └── OnboardingScreenAccessibilityTest.kt │ ├── commonMain │ ├── composeResources │ │ └── drawable │ │ │ ├── android.xml │ │ │ ├── brutalgamer_logo.xml │ │ │ ├── eurogamer_logo.xml │ │ │ ├── game_spot_logo.xml │ │ │ ├── gamerant_logo.xml │ │ │ ├── giant_bomb_logo.xml │ │ │ ├── gloriousgaming_logo.xml │ │ │ ├── ign_logo.xml │ │ │ ├── ios.xml │ │ │ ├── linux.xml │ │ │ ├── mmobomb_logo.xml │ │ │ ├── nintendo_switch.xml │ │ │ ├── pc.xml │ │ │ ├── pcgamer_logo.xml │ │ │ ├── pcgamesn_logo.xml │ │ │ ├── pcinvasion_logo.xml │ │ │ ├── placeholder.xml │ │ │ ├── playstation.xml │ │ │ ├── polygon_logo.xml │ │ │ ├── rockpapershotgun_logo.xml │ │ │ ├── tech_radar_logo.xml │ │ │ ├── thegamer_logo.xml │ │ │ ├── venturebeat_logo.xml │ │ │ ├── vg247_logo.xml │ │ │ └── xbox.xml │ ├── kotlin │ │ └── com │ │ │ └── mr3y │ │ │ └── ludi │ │ │ └── shared │ │ │ ├── App.kt │ │ │ ├── LudiSharedState.kt │ │ │ ├── core │ │ │ ├── CrashReporting.kt │ │ │ ├── Logger.kt │ │ │ ├── database │ │ │ │ ├── Adapters.kt │ │ │ │ ├── DatabaseFactory.kt │ │ │ │ ├── Mappers.kt │ │ │ │ ├── Wrapper.kt │ │ │ │ └── dao │ │ │ │ │ └── ArticleEntitiesDao.kt │ │ │ ├── internal │ │ │ │ └── KermitLogger.kt │ │ │ ├── model │ │ │ │ ├── Article.kt │ │ │ │ ├── Deal.kt │ │ │ │ ├── Game.kt │ │ │ │ ├── GiveawayEntry.kt │ │ │ │ ├── MarkupText.kt │ │ │ │ ├── NewReleaseArticle.kt │ │ │ │ ├── NewsArticle.kt │ │ │ │ ├── Result.kt │ │ │ │ ├── ReviewArticle.kt │ │ │ │ └── Source.kt │ │ │ ├── network │ │ │ │ ├── datasources │ │ │ │ │ ├── RSSFeedDataSource.kt │ │ │ │ │ └── internal │ │ │ │ │ │ ├── CheapSharkDataSource.kt │ │ │ │ │ │ ├── DefaultRSSFeedDataSource.kt │ │ │ │ │ │ ├── GamerPowerDataSource.kt │ │ │ │ │ │ └── RAWGDataSource.kt │ │ │ │ ├── model │ │ │ │ │ ├── ApiResult.kt │ │ │ │ │ ├── CheapSharkDeal.kt │ │ │ │ │ ├── GamerPowerGiveaway.kt │ │ │ │ │ ├── RAWGPage.kt │ │ │ │ │ └── TimeConversionsUtils.kt │ │ │ │ ├── rssparser │ │ │ │ │ ├── Parser.kt │ │ │ │ │ └── internal │ │ │ │ │ │ └── DefaultParser.kt │ │ │ │ └── serialization │ │ │ │ │ ├── CheapSharkModelsSerializers.kt │ │ │ │ │ ├── GamerPowerModelsSerializers.kt │ │ │ │ │ └── RAWGModelsSerializers.kt │ │ │ ├── paging │ │ │ │ ├── DealsPagingSource.kt │ │ │ │ └── RAWGGamesPagingSource.kt │ │ │ └── repository │ │ │ │ ├── DealsRepository.kt │ │ │ │ ├── GamesRepository.kt │ │ │ │ ├── NewsRepository.kt │ │ │ │ ├── internal │ │ │ │ ├── DefaultDealsRepository.kt │ │ │ │ ├── DefaultGamesRepository.kt │ │ │ │ └── DefaultNewsRepository.kt │ │ │ │ └── query │ │ │ │ ├── DealsQueryParameters.kt │ │ │ │ ├── GamesQueryParameters.kt │ │ │ │ └── GiveawaysQueryParameters.kt │ │ │ ├── di │ │ │ ├── CoroutinesComponent.kt │ │ │ ├── DataStoreComponent.kt │ │ │ ├── DatabaseDispatcherComponent.kt │ │ │ ├── LoggingComponent.kt │ │ │ ├── NetworkComponent.kt │ │ │ ├── RESTfulDataSourcesComponent.kt │ │ │ ├── RSSFeedDataSourcesComponent.kt │ │ │ ├── RepositoriesComponent.kt │ │ │ ├── SharedApplicationComponent.kt │ │ │ ├── SharedDatabaseComponent.kt │ │ │ ├── SharedDealsFeatureComponent.kt │ │ │ ├── SharedDiscoverFeatureComponent.kt │ │ │ ├── SharedNewsFeatureComponent.kt │ │ │ ├── SharedOnboardingFeatureComponent.kt │ │ │ ├── SharedSettingsFeatureComponent.kt │ │ │ ├── TimeSourceComponent.kt │ │ │ ├── VoyagerIntegration.kt │ │ │ └── annotations │ │ │ │ ├── DealsFeatureScope.kt │ │ │ │ ├── DiscoverFeatureScope.kt │ │ │ │ ├── NewsFeatureScope.kt │ │ │ │ ├── OnboardingFeatureScope.kt │ │ │ │ ├── SettingsFeatureScope.kt │ │ │ │ └── Singleton.kt │ │ │ └── ui │ │ │ ├── adaptive │ │ │ └── LocalWindowSizeClass.kt │ │ │ ├── components │ │ │ ├── LudiAsyncImage.kt │ │ │ ├── LudiChips.kt │ │ │ ├── LudiErrorBox.kt │ │ │ ├── LudiHeaders.kt │ │ │ ├── LudiNavRail.kt │ │ │ ├── LudiNoInternet.kt │ │ │ ├── LudiParallaxAlignment.kt │ │ │ ├── LudiRefreshIconButton.kt │ │ │ └── placeholder │ │ │ │ ├── LudiPlaceholderModifier.kt │ │ │ │ ├── Placeholder.kt │ │ │ │ ├── PlaceholderDefaults.kt │ │ │ │ └── PlaceholderHighlight.kt │ │ │ ├── datastore │ │ │ ├── FavouriteGamesSerializer.kt │ │ │ ├── FavouriteGenresSerializer.kt │ │ │ ├── FollowedNewsDataSourceSerializer.kt │ │ │ ├── PreferencesKeys.kt │ │ │ ├── ProtoDataStoreMutator.kt │ │ │ ├── Wrappers.kt │ │ │ └── internal │ │ │ │ └── DefaultProtoDataStoreMutator.kt │ │ │ ├── navigation │ │ │ ├── BottomBarTab.kt │ │ │ └── PreferencesType.kt │ │ │ ├── presenter │ │ │ ├── DealsViewModel.kt │ │ │ ├── DiscoverPagingFactory.kt │ │ │ ├── DiscoverViewModel.kt │ │ │ ├── EditPreferencesViewModel.kt │ │ │ ├── NewsFeedThrottler.kt │ │ │ ├── NewsViewModel.kt │ │ │ ├── OnBoardingViewModel.kt │ │ │ ├── SettingsViewModel.kt │ │ │ └── model │ │ │ │ ├── DealsState.kt │ │ │ │ ├── DiscoverState.kt │ │ │ │ ├── EditPreferencesState.kt │ │ │ │ ├── NewsState.kt │ │ │ │ ├── OnboardingState.kt │ │ │ │ ├── SettingsState.kt │ │ │ │ └── SupportedNewsDataSources.kt │ │ │ ├── resources │ │ │ ├── EnStrings.kt │ │ │ ├── LudiStrings.kt │ │ │ ├── PlatformCompositionProvider.kt │ │ │ └── format.kt │ │ │ ├── screens │ │ │ ├── deals │ │ │ │ ├── DealAndGiveawayCards.kt │ │ │ │ ├── DealsFilters.kt │ │ │ │ ├── DealsScreen.kt │ │ │ │ ├── DealsTopAppBar.kt │ │ │ │ └── SegmentedTabRow.kt │ │ │ ├── discover │ │ │ │ ├── DiscoverFilters.kt │ │ │ │ ├── DiscoverScreen.kt │ │ │ │ ├── DiscoverTopAppBar.kt │ │ │ │ └── GameCards.kt │ │ │ ├── home │ │ │ │ └── HomeScreen.kt │ │ │ ├── news │ │ │ │ ├── ArticleTile.kt │ │ │ │ ├── NewReleaseTile.kt │ │ │ │ └── NewsScreen.kt │ │ │ ├── onboarding │ │ │ │ ├── DataSourcesPage.kt │ │ │ │ ├── GamesPage.kt │ │ │ │ ├── GenresPage.kt │ │ │ │ └── OnboardingScreen.kt │ │ │ └── settings │ │ │ │ ├── EditPreferencesScreen.kt │ │ │ │ └── SettingsScreen.kt │ │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── ColorScheme.kt │ │ │ ├── Shape.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ ├── proto │ │ ├── favourite_game.proto │ │ ├── favourite_games.proto │ │ ├── favourite_gaming_genre.proto │ │ ├── favourite_gaming_genres.proto │ │ ├── news_data_source.proto │ │ └── news_data_sources.proto │ └── sqldelight │ │ ├── com │ │ └── mr3y │ │ │ └── ludi │ │ │ └── shared │ │ │ ├── Article.sq │ │ │ └── ArticleSearchFTS.sq │ │ ├── databases │ │ ├── 1.db │ │ └── 2.db │ │ └── migrations │ │ └── 1.sqm │ ├── commonTest │ └── kotlin │ │ └── com │ │ └── mr3y │ │ └── ludi │ │ └── shared │ │ ├── MainDispatcherRule.kt │ │ ├── core │ │ ├── FakeCrashReporting.kt │ │ ├── network │ │ │ ├── datasources │ │ │ │ └── internal │ │ │ │ │ ├── CheapSharkDataSourceTest.kt │ │ │ │ │ ├── DefaultRSSFeedDataSourceTest.kt │ │ │ │ │ ├── GamerPowerDataSourceTest.kt │ │ │ │ │ ├── RAWGDataSourceTest.kt │ │ │ │ │ ├── RAWGMockResponses.kt │ │ │ │ │ └── RSSFeedSamples.kt │ │ │ ├── fixtures │ │ │ │ ├── FakeRSSFeedDataSource.kt │ │ │ │ ├── KtorClientForTesting.kt │ │ │ │ └── TestLogger.kt │ │ │ ├── model │ │ │ │ └── ConvertersTest.kt │ │ │ └── rssparser │ │ │ │ └── FakeRSSParser.kt │ │ └── repository │ │ │ └── fixtures │ │ │ └── FakeGamesRepository.kt │ │ └── ui │ │ └── presenter │ │ ├── DiscoverViewModelMockData.kt │ │ ├── DiscoverViewModelTest.kt │ │ └── FakeDiscoverPagingFactory.kt │ ├── desktopAndroidMain │ └── kotlin │ │ └── com │ │ └── mr3y │ │ └── ludi │ │ └── shared │ │ └── ui │ │ └── resources │ │ └── format.shared.kt │ ├── desktopMain │ └── kotlin │ │ └── com │ │ └── mr3y │ │ └── ludi │ │ └── shared │ │ ├── AppCacheDir.kt │ │ ├── AppDir.kt │ │ ├── core │ │ ├── BugsnagReporting.kt │ │ └── database │ │ │ └── DatabaseFactory.desktop.kt │ │ ├── di │ │ ├── DatabaseDispatcherComponent.desktop.kt │ │ ├── DealsFeatureComponent.kt │ │ ├── DesktopApplicationComponent.kt │ │ ├── DesktopCrashReportingComponent.kt │ │ ├── DesktopSqlDriverComponent.kt │ │ ├── DiscoverFeatureComponent.kt │ │ ├── HostWindowComponent.kt │ │ ├── NewsFeatureComponent.kt │ │ ├── OnboardingFeatureComponent.kt │ │ ├── SettingsFeatureComponent.kt │ │ ├── TimeSourceComponent.desktop.kt │ │ └── VoyagerIntegration.desktop.kt │ │ └── ui │ │ ├── components │ │ ├── LudiAsyncImage.desktop.kt │ │ ├── LudiNoInternet.desktop.kt │ │ └── OpenUrls.kt │ │ ├── presenter │ │ └── FrameClock.desktop.kt │ │ ├── resources │ │ └── PlatformCompositionProvider.desktop.kt │ │ ├── screens │ │ ├── deals │ │ │ └── DealsScreen.desktop.kt │ │ ├── discover │ │ │ └── DiscoverScreen.desktop.kt │ │ ├── news │ │ │ └── NewsScreen.desktop.kt │ │ └── settings │ │ │ └── SettingsScreen.desktop.kt │ │ └── theme │ │ └── ColorScheme.desktop.kt │ └── desktopTest │ └── kotlin │ └── com │ └── mr3y │ └── ludi │ └── shared │ └── core │ └── database │ └── dao │ └── ArticleEntitiesDaoTest.kt └── versions.properties /.github/workflows/androidTests.yml: -------------------------------------------------------------------------------- 1 | name: Run android instrumented tests 2 | 3 | # Run Android instrumented tests once every day at 1am UTC. 4 | on: 5 | schedule: 6 | - cron: '0 1 * * *' 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | RAWG_API_KEY: ${{ secrets.RAWG_API_KEY }} 15 | BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }} 16 | 17 | jobs: 18 | androidTest: 19 | runs-on: ubuntu-latest # enables hardware acceleration in the virtual machine 20 | timeout-minutes: 30 21 | strategy: 22 | matrix: 23 | api-level: [ 29, 30, 31, 33, 34 ] 24 | 25 | steps: 26 | - name: Enable KVM group perms 27 | run: | 28 | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules 29 | sudo udevadm control --reload-rules 30 | sudo udevadm trigger --name-match=kvm 31 | ls /dev/kvm 32 | 33 | - name: Checkout 34 | uses: actions/checkout@v3 35 | with: 36 | fetch-depth: 0 37 | 38 | - name: set up JDK 17 39 | uses: actions/setup-java@v3 40 | with: 41 | java-version: '17' 42 | distribution: 'zulu' 43 | 44 | - name: Setup Gradle 45 | uses: gradle/gradle-build-action@v2.7.0 46 | 47 | - name: Grant execute permission for gradlew 48 | run: chmod +x gradlew 49 | 50 | - name: Run instrumentation tests 51 | uses: reactivecircus/android-emulator-runner@v2 52 | with: 53 | api-level: ${{ matrix.api-level }} 54 | arch: x86_64 55 | target: google_apis 56 | disable-animations: true 57 | disk-size: 6000M 58 | heap-size: 600M 59 | script: ./gradlew connectedDebugAndroidTest --stacktrace --daemon 60 | 61 | - name: Upload test reports 62 | if: always() 63 | uses: actions/upload-artifact@v4 64 | with: 65 | name: test-reports-${{ matrix.api-level }} 66 | path: ./**/build/reports 67 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and run local tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags-ignore: 8 | - 'v*' 9 | paths-ignore: 10 | - '**.md' 11 | - '**.txt' 12 | pull_request: 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} 16 | cancel-in-progress: true 17 | 18 | env: 19 | RAWG_API_KEY: ${{ secrets.RAWG_API_KEY }} 20 | BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }} 21 | 22 | jobs: 23 | build: 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v3 29 | 30 | - name: Validate Gradle Wrapper 31 | uses: gradle/wrapper-validation-action@v1 32 | 33 | - name: set up JDK 17 34 | uses: actions/setup-java@v3 35 | with: 36 | java-version: '17' 37 | distribution: 'temurin' 38 | cache: gradle 39 | 40 | - name: Setup Gradle 41 | uses: gradle/gradle-build-action@v2.7.0 42 | 43 | - name: Grant execute permission for gradlew 44 | run: chmod +x gradlew 45 | 46 | - name: Build debug 47 | run: ./gradlew assembleDebug packageDistributionForCurrentOS --stacktrace 48 | 49 | - name: Run local tests 50 | run: ./gradlew testDebug desktopTest --stacktrace 51 | 52 | - name: Apply ktlint formatting to (.kt/s) files. 53 | run: ./gradlew ktlintFormat --stacktrace 54 | 55 | - name: Apply Spotless formatting if not applied. 56 | run: ./gradlew spotlessApply --stacktrace 57 | 58 | - name: Commit and push changes (if any). 59 | if: ${{ github.ref == 'refs/heads/main' }} 60 | uses: EndBug/add-and-commit@v9 61 | with: 62 | author_name: GitHub Actions 63 | author_email: github-actions@github.com 64 | message: Apply style formatting 65 | push: true 66 | 67 | - name: Upload build outputs (APKs & Desktop binaries) 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: build-outputs 71 | path: | 72 | **/build/outputs/* 73 | **/build/compose/* 74 | 75 | - name: Upload build reports 76 | if: always() 77 | uses: actions/upload-artifact@v4 78 | with: 79 | name: build-reports 80 | path: ./**/build/reports 81 | -------------------------------------------------------------------------------- /.github/workflows/refreshVersions.yml: -------------------------------------------------------------------------------- 1 | name: RefreshVersions 2 | 3 | on: 4 | schedule: 5 | - cron: '0 7 * * 1' 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | "Refresh-Versions": 13 | runs-on: "ubuntu-latest" 14 | steps: 15 | - id: step-0 16 | name: check-out 17 | uses: actions/checkout@v3 18 | with: 19 | ref: main 20 | - id: step-1 21 | name: setup-java 22 | uses: actions/setup-java@v3 23 | with: 24 | java-version: 17 25 | distribution: adopt 26 | - id: step-2 27 | name: create-branch 28 | uses: peterjgrainger/action-create-branch@v2.2.0 29 | with: 30 | branch: dependency-update 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | - id: step-3 34 | name: gradle refreshVersions 35 | uses: gradle/gradle-build-action@v2.7.0 36 | with: 37 | arguments: refreshVersions 38 | - id: step-4 39 | name: Commit 40 | uses: EndBug/add-and-commit@v9 41 | with: 42 | author_name: GitHub Actions 43 | author_email: noreply@github.com 44 | message: Refresh versions.properties 45 | new_branch: dependency-update 46 | push: --force --set-upstream origin dependency-update 47 | - id: step-5 48 | name: Pull Request 49 | uses: repo-sync/pull-request@v2 50 | with: 51 | source_branch: dependency-update 52 | destination_branch: main 53 | pr_title: Upgrade gradle dependencies 54 | pr_body: '[refreshVersions](https://github.com/jmfayard/refreshVersions) has found those library updates!' 55 | pr_draft: true 56 | github_token: ${{ secrets.GITHUB_TOKEN }} 57 | -------------------------------------------------------------------------------- /.github/workflows/release-desktop.yml: -------------------------------------------------------------------------------- 1 | name: Release a new Desktop app version 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*-desktop' 7 | 8 | jobs: 9 | release: 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, macos-latest, windows-latest] 13 | runs-on: ${{ matrix.os }} 14 | permissions: 15 | contents: write 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Validate Gradle Wrapper 23 | uses: gradle/wrapper-validation-action@v1 24 | 25 | - name: set up JDK 17 26 | uses: actions/setup-java@v3 27 | with: 28 | java-version: '17' 29 | distribution: 'temurin' 30 | cache: gradle 31 | 32 | - name: Setup Gradle 33 | uses: gradle/gradle-build-action@v2.7.0 34 | 35 | - name: Grant execute permission for gradlew 36 | run: chmod +x gradlew 37 | 38 | - name: Build Desktop release binary 39 | run: ./gradlew packageDistributionForCurrentOS --stacktrace 40 | 41 | - name: Upload Desktop release binary 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: desktopApp-${{ matrix.os }}-release 45 | path: desktopApp/build/compose/binaries/main/ 46 | 47 | - uses: ncipollo/release-action@v1 48 | with: 49 | artifacts: "./desktopApp/build/compose/binaries/main/*/*.*" 50 | generateReleaseNotes: true 51 | allowUpdates: true 52 | omitNameDuringUpdate: true 53 | omitBodyDuringUpdate: true 54 | replacesArtifacts: false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | /.idea/* 11 | !/.idea/codeStyles/ 12 | .DS_Store 13 | /build 14 | /captures 15 | .externalNativeBuild 16 | .cxx 17 | local.properties 18 | keystore.properties 19 | play_config.json 20 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 11 | 12 | 14 | 15 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Development Setup: 2 | - You need JDK 17 or higher. 3 | - Go to https://rawg.io/apidocs to Obtain your API key. 4 | - Add `RAWG_API_KEY=` to local.properties file, and replace `` with your obtained API key. 5 | - Run `./gradlew generateNonAndroidBuildConfig`. 6 | 7 | ## Submit A contribution: 8 | 1. Open an issue describing the bug or the feature request. 9 | 2. Fork the repository. 10 | 3. Make a new branch `git checkout -b new_feature`. 11 | 4. Make your changes. 12 | 5. Commit changes, `git add .` then `git commit -m "Commit Message"`. 13 | 6. push the changes upstream to your fork `git push origin new_feature`. 14 | 7. Open a PR to merge the changes with the original Repository. 15 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | 1. Update google play's release notes located in androidApp/src/main/play/release-notes/en-US/[track].txt. 2 | 2. 3 | - Add new git tag locally `git tag ` & push it to remote origin `git push origin `. 4 | - Add the same tag suffixed with '-desktop' & push it to remote as the previous step. 5 | 6 | That's it. -------------------------------------------------------------------------------- /androidApp/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /release -------------------------------------------------------------------------------- /androidApp/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "807285618590", 4 | "project_id": "ludi-d1698", 5 | "storage_bucket": "ludi-d1698.appspot.com" 6 | }, 7 | "client": [ 8 | { 9 | "client_info": { 10 | "mobilesdk_app_id": "1:807285618590:android:aa1d5f1dbf1bca09b5888f", 11 | "android_client_info": { 12 | "package_name": "com.mr3y.ludi" 13 | } 14 | }, 15 | "oauth_client": [ 16 | { 17 | "client_id": "807285618590-nqrmjqb4o9nscfmlckpibsrmntrbd0kf.apps.googleusercontent.com", 18 | "client_type": 1, 19 | "android_info": { 20 | "package_name": "com.mr3y.ludi", 21 | "certificate_hash": "2763bb5905c1cefac327aeae2a28c2950459b697" 22 | } 23 | }, 24 | { 25 | "client_id": "807285618590-uf86l1m9655g5mlgaapm8qat3e0r4bqc.apps.googleusercontent.com", 26 | "client_type": 3 27 | } 28 | ], 29 | "api_key": [ 30 | { 31 | "current_key": "AIzaSyAl-A_L3jVe8JZ3L0u2ynAW1nbaBvXTVQc" 32 | } 33 | ], 34 | "services": { 35 | "appinvite_service": { 36 | "other_platform_oauth_client": [ 37 | { 38 | "client_id": "807285618590-uf86l1m9655g5mlgaapm8qat3e0r4bqc.apps.googleusercontent.com", 39 | "client_type": 3 40 | } 41 | ] 42 | } 43 | } 44 | } 45 | ], 46 | "configuration_version": "1" 47 | } -------------------------------------------------------------------------------- /androidApp/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | -verbose 9 | -allowaccessmodification 10 | -repackageclasses 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | 19 | # Uncomment this to preserve the line number information for 20 | # debugging stack traces. 21 | -keepattributes SourceFile,LineNumberTable 22 | 23 | -assumenosideeffects class com.mr3y.ludi.shared.core.internal.KermitLogger { 24 | public *** v(...); 25 | public *** d(...); 26 | public *** e(...); 27 | public *** i(...); 28 | public *** w(...); 29 | } 30 | 31 | -dontwarn org.slf4j.impl.StaticLoggerBinder 32 | 33 | -keepclasseswithmembernames class com.mr3y.ludi.datastore.model.** { *; } 34 | 35 | # TODO: remove these rules once https://github.com/square/retrofit/issues/3751 is solved. 36 | #-keep,allowobfuscation,allowshrinking interface retrofit2.Call 37 | #-keep,allowobfuscation,allowshrinking class retrofit2.Response 38 | 39 | -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation 40 | 41 | # Wire 42 | -keep class com.squareup.wire.** { *; } 43 | 44 | # If you keep the line number information, uncomment this to 45 | # hide the original source file name. 46 | -renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /androidApp/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 11 | 12 | 22 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/com/mr3y/ludi/LudiApplication.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi 2 | 3 | import android.app.Application 4 | import com.mr3y.ludi.shared.di.AndroidApplicationComponent 5 | import com.mr3y.ludi.shared.di.create 6 | 7 | class LudiApplication : Application() { 8 | 9 | val component: AndroidApplicationComponent by lazy(LazyThreadSafetyMode.NONE) { 10 | AndroidApplicationComponent::class.create(this) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /androidApp/src/main/kotlin/com/mr3y/ludi/ui/preview/LudiPreview.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.ui.preview 2 | 3 | import android.content.res.Configuration.UI_MODE_NIGHT_NO 4 | import android.content.res.Configuration.UI_MODE_NIGHT_YES 5 | import androidx.compose.ui.tooling.preview.Preview 6 | import androidx.compose.ui.tooling.preview.Wallpapers 7 | 8 | @Preview(name = "Light & Dynamic colors disabled", locale = "en-US", device = "id:pixel_6", uiMode = UI_MODE_NIGHT_NO) 9 | @Preview(name = "Dark & Dynamic colors disabled", locale = "en-US", device = "id:pixel_6", uiMode = UI_MODE_NIGHT_YES) 10 | @Preview(name = "Light & Dynamic Colors enabled", locale = "en-US", device = "id:pixel_6", uiMode = UI_MODE_NIGHT_NO, wallpaper = Wallpapers.GREEN_DOMINATED_EXAMPLE) 11 | @Preview(name = "Dark & Dynamic Colors enabled", locale = "en-US", device = "id:pixel_6", uiMode = UI_MODE_NIGHT_YES, wallpaper = Wallpapers.GREEN_DOMINATED_EXAMPLE) 12 | annotation class LudiPreview 13 | -------------------------------------------------------------------------------- /androidApp/src/main/play/release-notes/en-US/beta.txt: -------------------------------------------------------------------------------- 1 | - Internal changes to support latest android versions. -------------------------------------------------------------------------------- /androidApp/src/main/res/drawable-night/ic_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | 14 | 17 | 18 | -------------------------------------------------------------------------------- /androidApp/src/main/res/drawable/ic_splash.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 16 | 17 | -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-anydpi-v33/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /androidApp/src/main/res/values-night-v27/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /androidApp/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 16 | 17 | 20 | -------------------------------------------------------------------------------- /androidApp/src/main/res/values-v27/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /androidApp/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FF1960A5 4 | #FFFFFFFF 5 | #FF7B5900 6 | #FFFFFFFF 7 | #FFFDFCFF 8 | #FF1A1C1E 9 | #FFA3C9FF 10 | #FF00315D 11 | #FFF8BD39 12 | #FF412D00 13 | #FF1A1C1E 14 | #FFE3E2E6 15 | -------------------------------------------------------------------------------- /androidApp/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Ludi 3 | strikt.api.expectThat 4 | -------------------------------------------------------------------------------- /androidApp/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 17 | 18 | 21 | 22 | 26 | -------------------------------------------------------------------------------- /androidApp/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /androidApp/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | plugins { 3 | // this is necessary to avoid the plugins to be loaded multiple times 4 | // in each subproject's classloader 5 | alias(libs.plugins.kotlin.multiplatform).apply(false) 6 | alias(libs.plugins.kotlin.jvm).apply(false) 7 | alias(libs.plugins.kotlin.android).apply(false) 8 | alias(libs.plugins.android.application).apply(false) 9 | alias(libs.plugins.android.library).apply(false) 10 | alias(libs.plugins.compose.multiplatform).apply(false) 11 | alias(libs.plugins.compose.compiler).apply(false) 12 | alias(libs.plugins.spotless.plugin).apply(false) 13 | alias(libs.plugins.ktlint.plugin).apply(false) 14 | } -------------------------------------------------------------------------------- /changelog_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "categories": [ 3 | { 4 | "title": "## 🚀 Features", 5 | "labels": ["feature", "enhancement"] 6 | }, 7 | { 8 | "title": "## 🐛 Bugs", 9 | "labels": ["bug"] 10 | }, 11 | { 12 | "title": "## 🔧 Internals", 13 | "labels": ["Tech dept"] 14 | } 15 | ], 16 | "ignore_labels": [ 17 | "duplicate", "good first issue", "help wanted", "invalid", "question", "wontfix", "skip release notes", "hold" 18 | ], 19 | "sort": "ASC", 20 | "template": "${{CHANGELOG}}", 21 | "pr_template": "- ${{TITLE}} (#${{NUMBER}})", 22 | "empty_template": "- no changes", 23 | "transformers": [ 24 | { 25 | "pattern": "[\\-\\*] (\\[(...|TEST|CI|SKIP)\\])( )?(.+?)\n(.+?[\\-\\*] )(.+)", 26 | "target": "- $4\n - $6" 27 | } 28 | ], 29 | "max_tags_to_fetch": 200, 30 | "max_pull_requests": 200, 31 | "max_back_track_time_days": 365, 32 | "tag_resolver": { 33 | "method": "semver" 34 | }, 35 | "base_branches": [ 36 | "main" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /convention-plugins/gradle.properties: -------------------------------------------------------------------------------- 1 | # Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534 2 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 3 | kotlin.code.style=official 4 | org.gradle.caching=true 5 | org.gradle.parallel=true -------------------------------------------------------------------------------- /convention-plugins/plugins/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /convention-plugins/plugins/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | `kotlin-dsl` 5 | } 6 | 7 | group = "com.mr3y.ludi.conventionplugins" 8 | 9 | java { 10 | sourceCompatibility = JavaVersion.VERSION_17 11 | targetCompatibility = JavaVersion.VERSION_17 12 | } 13 | 14 | tasks.withType().configureEach { 15 | kotlinOptions { 16 | jvmTarget = JavaVersion.VERSION_17.toString() 17 | } 18 | } 19 | 20 | dependencies { 21 | compileOnly(libs.android.gradlePlugin) 22 | compileOnly(libs.spotless.gradlePlugin) 23 | compileOnly(libs.ktlint.gradlePlugin) 24 | } 25 | 26 | gradlePlugin { 27 | plugins { 28 | register("commonConventionPlugin") { 29 | id = "ludi.common" 30 | implementationClass = "com.mr3y.ludi.gradle.CommonConventionPlugin" 31 | } 32 | register("androidConventionPlugin") { 33 | id = "ludi.android.common" 34 | implementationClass = "com.mr3y.ludi.gradle.AndroidConventionPlugin" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /convention-plugins/plugins/src/main/kotlin/com/mr3y/ludi/gradle/AndroidConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.gradle 2 | 3 | import com.android.build.api.dsl.CommonExtension 4 | import com.android.build.gradle.LibraryExtension 5 | import com.android.build.gradle.internal.dsl.BaseAppModuleExtension 6 | import org.gradle.api.JavaVersion 7 | import org.gradle.api.Plugin 8 | import org.gradle.api.Project 9 | import org.gradle.kotlin.dsl.getByType 10 | 11 | class AndroidConventionPlugin : Plugin { 12 | 13 | override fun apply(target: Project) { 14 | with(target) { 15 | pluginManager.withPlugin("com.android.library") { 16 | val androidLibExtension = extensions.getByType() 17 | androidLibExtension.apply { 18 | applyCommonAndroidConvention() 19 | } 20 | } 21 | pluginManager.withPlugin("com.android.application") { 22 | val androidAppExtension = extensions.getByType() 23 | androidAppExtension.apply { 24 | applyCommonAndroidConvention() 25 | } 26 | } 27 | } 28 | } 29 | 30 | private fun CommonExtension<*, *, *, *, *, *>.applyCommonAndroidConvention() { 31 | compileSdk = 36 32 | 33 | defaultConfig { 34 | minSdk = 26 35 | 36 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 37 | } 38 | 39 | compileOptions { 40 | sourceCompatibility = JavaVersion.VERSION_17 41 | targetCompatibility = JavaVersion.VERSION_17 42 | } 43 | 44 | buildFeatures { 45 | compose = true 46 | aidl = false 47 | buildConfig = false 48 | renderScript = false 49 | shaders = false 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /convention-plugins/plugins/src/main/kotlin/com/mr3y/ludi/gradle/CommonConventionPlugin.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.gradle 2 | 3 | import com.diffplug.gradle.spotless.SpotlessExtension 4 | import org.gradle.api.Plugin 5 | import org.gradle.api.Project 6 | import org.gradle.kotlin.dsl.getByType 7 | import org.jlleitschuh.gradle.ktlint.KtlintExtension 8 | 9 | class CommonConventionPlugin : Plugin { 10 | 11 | override fun apply(target: Project) { 12 | with(target) { 13 | pluginManager.apply("org.jlleitschuh.gradle.ktlint") 14 | pluginManager.apply("com.diffplug.spotless") 15 | 16 | val ktlintExtension = extensions.getByType() 17 | val spotlessExtension = extensions.getByType() 18 | configureKtlintExtension(ktlintExtension) 19 | configureSpotlessExtension(spotlessExtension) 20 | } 21 | } 22 | 23 | private fun Project.configureKtlintExtension(extension: KtlintExtension) { 24 | extension.apply { 25 | filter { 26 | exclude { 27 | it.file.path.contains("build") 28 | } 29 | } 30 | } 31 | } 32 | 33 | private fun Project.configureSpotlessExtension(extension: SpotlessExtension) { 34 | extension.apply { 35 | format("misc") { 36 | // define the files to apply `misc` to 37 | target(listOf("**/*.gradle", "*.md", ".gitignore", "**/*.gradle.kts")) 38 | 39 | // define the steps to apply to those files 40 | trimTrailingWhitespace() 41 | indentWithSpaces(4) 42 | endWithNewline() 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /convention-plugins/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | maven { 6 | url = uri("https://plugins.gradle.org/m2/") 7 | content { 8 | includeGroup("org.jlleitschuh.gradle") 9 | } 10 | } 11 | } 12 | versionCatalogs { 13 | create("libs") { 14 | from(files("../gradle/libs.versions.toml")) 15 | } 16 | } 17 | } 18 | 19 | rootProject.name = "convention-plugins" 20 | include(":plugins") -------------------------------------------------------------------------------- /desktopApp/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /desktopApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat 2 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 3 | 4 | plugins { 5 | alias(libs.plugins.kotlin.multiplatform) 6 | alias(libs.plugins.compose.multiplatform) 7 | alias(libs.plugins.compose.compiler) 8 | alias(libs.plugins.ludi.common) 9 | } 10 | 11 | kotlin { 12 | jvm { 13 | withJava() 14 | } 15 | jvmToolchain(17) 16 | sourceSets { 17 | jvmMain { 18 | dependencies { 19 | implementation(compose.desktop.currentOs) 20 | implementation(compose.desktop.common) 21 | implementation(project(":shared")) 22 | } 23 | } 24 | } 25 | } 26 | 27 | dependencies { 28 | } 29 | 30 | tasks.withType { 31 | compilerOptions { 32 | jvmTarget = JvmTarget.JVM_17 33 | } 34 | } 35 | 36 | java { 37 | sourceCompatibility = JavaVersion.VERSION_17 38 | targetCompatibility = JavaVersion.VERSION_17 39 | } 40 | 41 | compose.desktop { 42 | sourceSets["main"].resources.srcDirs("src/jvmMain/resources") 43 | application { 44 | mainClass = "com.mr3y.ludi.desktop.MainKt" 45 | 46 | nativeDistributions { 47 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) 48 | packageName = "com.mr3y.ludi.desktop" 49 | 50 | description = "Ludi is a Kotlin Multiplatform (Android + Desktop) app to demonstrate best practices & using modern technologies to develop high quality apps" 51 | copyright = "Apache License Version 2.0" 52 | vendor = "mr3y-the-programmer" 53 | 54 | buildTypes.release.proguard { 55 | isEnabled.set(true) 56 | configurationFiles.from("proguard-rules.pro") 57 | } 58 | windows { 59 | shortcut = true 60 | menu = true 61 | menuGroup = "Ludi" 62 | iconFile.set(file("src/jvmMain/resources/icon_light.ico")) 63 | } 64 | macOS { 65 | bundleID = "com.mr3y.ludi" 66 | packageName = "com.mr3y.ludi.desktop" 67 | // TODO: provide .icns icon 68 | iconFile.set(file("src/jvmMain/resources/icon_light.xml")) 69 | } 70 | linux { 71 | iconFile.set(file("src/jvmMain/resources/icon_light.png")) 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /desktopApp/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -verbose 2 | -allowaccessmodification 3 | -repackageclasses 4 | 5 | # Uncomment this to preserve the line number information for 6 | # debugging stack traces. 7 | -keepattributes SourceFile,LineNumberTable 8 | 9 | -assumenosideeffects class com.mr3y.ludi.shared.core.internal.KermitLogger { 10 | public *** v(...); 11 | public *** d(...); 12 | public *** e(...); 13 | public *** i(...); 14 | public *** w(...); 15 | } 16 | 17 | -dontwarn org.slf4j.impl.StaticLoggerBinder 18 | 19 | -keepclasseswithmembernames class com.mr3y.ludi.datastore.model.** { *; } 20 | 21 | -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation 22 | 23 | # Wire 24 | -keep class com.squareup.wire.** { *; } 25 | 26 | # If you keep the line number information, uncomment this to 27 | # hide the original source file name. 28 | -renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /desktopApp/src/jvmMain/kotlin/com/mr3y/ludi/desktop/Main.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.desktop 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.ui.res.painterResource 8 | import androidx.compose.ui.window.Window 9 | import androidx.compose.ui.window.application 10 | import com.mr3y.ludi.shared.App 11 | import com.mr3y.ludi.shared.di.DesktopApplicationComponent 12 | import com.mr3y.ludi.shared.di.HostWindowComponent 13 | import com.mr3y.ludi.shared.di.LocalHostWindowComponent 14 | import com.mr3y.ludi.shared.di.create 15 | import com.mr3y.ludi.shared.ui.presenter.model.Theme 16 | 17 | fun main() { 18 | val appComponent = DesktopApplicationComponent::class.create() 19 | application { 20 | val preferences by appComponent.appState.preferences.collectAsState() 21 | Window( 22 | title = "Ludi", 23 | onCloseRequest = ::exitApplication, 24 | icon = painterResource("icon_light.xml") 25 | ) { 26 | val windowComponent = HostWindowComponent::class.create(window, appComponent) 27 | CompositionLocalProvider( 28 | LocalHostWindowComponent provides windowComponent 29 | ) { 30 | if (preferences != null) { 31 | App( 32 | isDarkTheme = when (preferences!!.theme) { 33 | Theme.Light -> false 34 | Theme.Dark -> true 35 | Theme.SystemDefault -> isSystemInDarkTheme() 36 | }, 37 | useDynamicColor = preferences!!.useDynamicColor, 38 | showOnboardingScreen = preferences!!.showOnBoardingScreen 39 | ) 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /desktopApp/src/jvmMain/resources/icon_light.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/desktopApp/src/jvmMain/resources/icon_light.ico -------------------------------------------------------------------------------- /desktopApp/src/jvmMain/resources/icon_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/desktopApp/src/jvmMain/resources/icon_light.png -------------------------------------------------------------------------------- /desktopApp/src/jvmMain/resources/icon_light.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 16 | 17 | -------------------------------------------------------------------------------- /docs/PrivacyPolicy.md: -------------------------------------------------------------------------------- 1 | # Ludi Privacy Policy - Developer: Abdelrahman Khairy 2 | ## Permissions Ludi has: 3 | Ludi has the following permissions in order to function correctly 4 | - android.permission.INTERNET 5 | - android.permission.ACCESS_NETWORK_STATE 6 | 7 | ## Third-party SDKs Ludi uses: 8 | Ludi uses Google Crashlytics and Analytics to gather data about crashes happening while the app is being used by users, this data is 9 | collected to help me fix Crashes & bugs, and hence Improve the end user experience, All data is anonymous. 10 | 11 | If you think the app is accessing or collecting data that is not supposed to be accessed or collected, 12 | then reach out at 26522145+mr3y-the-programmer@users.noreply.github.com. -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | [PrivacyPolicy](https://github.com/mr3y-the-programmer/Ludi/blob/main/docs/PrivacyPolicy.md) 2 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true 24 | android.enableR8.fullMode=true 25 | org.gradle.caching=true 26 | org.gradle.parallel=true 27 | # kspCommonMainKotlinMetadata is incompatible with configuration caching. 28 | org.gradle.unsafe.configuration-cache=false 29 | 30 | #KMP 31 | kotlin.mpp.androidSourceSetLayoutVersion=2 32 | kotlin.mpp.stability.nowarn=true 33 | kotlin.mpp.androidGradlePluginCompatibility.nowarn=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Feb 26 22:07:05 EET 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip 4 | distributionSha256Sum=61ad310d3c7d3e5da131b76bbf22b5a4c0786e9d892dae8c1658d4b484de3caa 5 | distributionPath=wrapper/dists 6 | zipStorePath=wrapper/dists 7 | zipStoreBase=GRADLE_USER_HOME 8 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Ludi 5 | 6 |

7 | Privacy Policy 8 |

9 | 10 | -------------------------------------------------------------------------------- /lint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | import java.net.URI 2 | 3 | pluginManagement { 4 | includeBuild("convention-plugins") 5 | repositories { 6 | google() 7 | mavenCentral() 8 | gradlePluginPortal() 9 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 10 | } 11 | plugins { 12 | id("de.fayard.refreshVersions") version "0.60.5" 13 | } 14 | } 15 | buildscript { 16 | // Workaround for: https://github.com/Splitties/refreshVersions/issues/707 17 | dependencies { 18 | classpath("com.squareup.okio:okio:3.9.0") 19 | } 20 | } 21 | plugins { 22 | id("de.fayard.refreshVersions") 23 | } 24 | 25 | refreshVersions { 26 | rejectVersionIf { 27 | // Recent versions of ktlint gradle plugin changed the default 28 | // code convention style which affects nearly all files in the codebase, 29 | // so, for now we are rejecting updates, as we are fine with the current style. 30 | val blacklist = listOf("org.jlleitschuh.gradle.ktlint") 31 | candidate.stabilityLevel.isLessStableThan(current.stabilityLevel) || moduleId.group in blacklist || candidate.value.endsWith("-jre") || candidate.value.endsWith("-1.8.20") 32 | } 33 | } 34 | 35 | dependencyResolutionManagement { 36 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 37 | repositories { 38 | google() 39 | mavenCentral() 40 | maven { url = URI.create("https://jitpack.io") } 41 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 42 | } 43 | } 44 | rootProject.name = "Ludi" 45 | include(":androidApp") 46 | include(":shared") 47 | include(":desktopApp") -------------------------------------------------------------------------------- /shared/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /shared/src/androidInstrumentedTest/kotlin/com/mr3y/ludi/shared/ui/presenter/MainDispatcherRule.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.presenter 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.test.TestDispatcher 6 | import kotlinx.coroutines.test.UnconfinedTestDispatcher 7 | import kotlinx.coroutines.test.resetMain 8 | import kotlinx.coroutines.test.setMain 9 | import org.junit.rules.TestWatcher 10 | import org.junit.runner.Description 11 | 12 | /** 13 | * A JUnit [TestRule] that sets the Main dispatcher to [testDispatcher] 14 | * for the duration of the test. 15 | */ 16 | @OptIn(ExperimentalCoroutinesApi::class) 17 | class MainDispatcherRule( 18 | val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() 19 | ) : TestWatcher() { 20 | override fun starting(description: Description) { 21 | Dispatchers.setMain(testDispatcher) 22 | } 23 | 24 | override fun finished(description: Description) { 25 | Dispatchers.resetMain() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /shared/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/core/CrashlyticsReporting.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core 2 | 3 | import com.google.firebase.crashlytics.FirebaseCrashlytics 4 | import me.tatarka.inject.annotations.Inject 5 | 6 | @Inject 7 | class CrashlyticsReporting( 8 | private val crashlytics: FirebaseCrashlytics 9 | ) : CrashReporting { 10 | 11 | override fun recordException(throwable: Throwable, logMessage: String?) { 12 | with(crashlytics) { 13 | if (logMessage != null) { 14 | log(logMessage) 15 | } 16 | recordException(throwable) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/core/database/DatabaseFactory.android.kt: -------------------------------------------------------------------------------- 1 | // ktlint-disable filename 2 | package com.mr3y.ludi.shared.core.database 3 | 4 | import android.content.Context 5 | import app.cash.sqldelight.async.coroutines.synchronous 6 | import app.cash.sqldelight.db.SqlDriver 7 | import app.cash.sqldelight.driver.android.AndroidSqliteDriver 8 | import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory 9 | import me.tatarka.inject.annotations.Inject 10 | 11 | @Inject 12 | actual class DriverFactory(private val applicationContext: Context) { 13 | actual fun createDriver(): SqlDriver { 14 | return AndroidSqliteDriver( 15 | schema = LudiDatabase.Schema.synchronous(), 16 | context = applicationContext, 17 | name = "ludi_database.db", 18 | factory = RequerySQLiteOpenHelperFactory() 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/di/AndroidApplicationComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import android.content.Context 4 | import com.mr3y.ludi.shared.di.annotations.Singleton 5 | import me.tatarka.inject.annotations.Component 6 | import me.tatarka.inject.annotations.Provides 7 | import okio.Path 8 | import okio.Path.Companion.toOkioPath 9 | import java.io.File 10 | 11 | @Component 12 | @Singleton 13 | abstract class AndroidApplicationComponent( 14 | @get:Provides val applicationContext: Context 15 | ) : SharedApplicationComponent, AndroidCrashReportingComponent, AndroidSqlDriverComponent { 16 | 17 | override val dataStoreParentDir: Path = applicationContext.filesDir.toOkioPath() 18 | 19 | override val okhttpCacheParentDir: File = applicationContext.cacheDir 20 | 21 | companion object 22 | } 23 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/di/AndroidCrashReportingComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.google.firebase.crashlytics.FirebaseCrashlytics 4 | import com.google.firebase.crashlytics.ktx.crashlytics 5 | import com.google.firebase.ktx.Firebase 6 | import com.mr3y.ludi.shared.core.CrashReporting 7 | import com.mr3y.ludi.shared.core.CrashlyticsReporting 8 | import com.mr3y.ludi.shared.di.annotations.Singleton 9 | import me.tatarka.inject.annotations.Provides 10 | 11 | interface AndroidCrashReportingComponent { 12 | @Singleton 13 | @Provides 14 | fun provideCrashlyticsInstance(): FirebaseCrashlytics { 15 | return Firebase.crashlytics 16 | } 17 | 18 | @Singleton 19 | @Provides 20 | fun provideCrashReportingInstance(crashlytics: FirebaseCrashlytics): CrashReporting { 21 | return CrashlyticsReporting(crashlytics) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/di/AndroidSqlDriverComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import android.content.Context 4 | import com.mr3y.ludi.shared.core.database.DriverFactory 5 | import com.mr3y.ludi.shared.di.annotations.Singleton 6 | import me.tatarka.inject.annotations.Provides 7 | 8 | interface AndroidSqlDriverComponent { 9 | 10 | @Singleton 11 | @Provides 12 | fun provideSqlDriverFactoryInstance(applicationContext: Context): DriverFactory { 13 | return DriverFactory(applicationContext) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/di/DatabaseDispatcherComponent.android.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.Singleton 4 | import kotlinx.coroutines.Dispatchers 5 | import me.tatarka.inject.annotations.Provides 6 | 7 | actual interface DatabaseDispatcherComponent { 8 | 9 | @Singleton 10 | @Provides 11 | fun provideIODatabaseDispatcher(): DatabaseDispatcher { 12 | return DatabaseDispatcher(Dispatchers.IO) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/di/DealsFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.DealsFeatureScope 4 | import me.tatarka.inject.annotations.Component 5 | 6 | @Component 7 | @DealsFeatureScope 8 | abstract class DealsFeatureComponent( 9 | @Component val parent: HostActivityComponent 10 | ) : SharedDealsFeatureComponent 11 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/di/DiscoverFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.DiscoverFeatureScope 4 | import me.tatarka.inject.annotations.Component 5 | 6 | @Component 7 | @DiscoverFeatureScope 8 | abstract class DiscoverFeatureComponent( 9 | @Component val parent: HostActivityComponent 10 | ) : SharedDiscoverFeatureComponent 11 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/di/HostActivityComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import android.app.Activity 4 | import com.mr3y.ludi.shared.ui.navigation.PreferencesType 5 | import com.mr3y.ludi.shared.ui.presenter.EditPreferencesViewModel 6 | import me.tatarka.inject.annotations.Component 7 | import me.tatarka.inject.annotations.Provides 8 | 9 | @Component 10 | abstract class HostActivityComponent( 11 | @get:Provides val activity: Activity, 12 | @Component val parent: AndroidApplicationComponent 13 | ) { 14 | abstract val editPreferencesViewModelFactory: (PreferencesType) -> EditPreferencesViewModel 15 | } 16 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/di/HostActivityComponentOwner.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | interface HostActivityComponentOwner { 4 | 5 | val hostActivityComponent: HostActivityComponent 6 | } 7 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/di/NewsFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.NewsFeatureScope 4 | import me.tatarka.inject.annotations.Component 5 | 6 | @Component 7 | @NewsFeatureScope 8 | abstract class NewsFeatureComponent( 9 | @Component val parent: HostActivityComponent 10 | ) : SharedNewsFeatureComponent 11 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/di/OnboardingFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.OnboardingFeatureScope 4 | import me.tatarka.inject.annotations.Component 5 | 6 | @Component 7 | @OnboardingFeatureScope 8 | abstract class OnboardingFeatureComponent( 9 | @Component val parent: HostActivityComponent 10 | ) : SharedOnboardingFeatureComponent 11 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/di/SettingsFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.SettingsFeatureScope 4 | import me.tatarka.inject.annotations.Component 5 | 6 | @Component 7 | @SettingsFeatureScope 8 | abstract class SettingsFeatureComponent( 9 | @Component val parent: HostActivityComponent 10 | ) : SharedSettingsFeatureComponent 11 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/di/TimeSourceComponent.android.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import android.os.SystemClock 4 | import com.mr3y.ludi.shared.di.annotations.Singleton 5 | import me.tatarka.inject.annotations.Provides 6 | import kotlin.time.AbstractLongTimeSource 7 | import kotlin.time.DurationUnit 8 | import kotlin.time.TimeSource 9 | 10 | actual interface TimeSourceComponent { 11 | 12 | @Provides 13 | @Singleton 14 | fun provideTimeSourceInstance(): TimeSource { 15 | return object : AbstractLongTimeSource(DurationUnit.NANOSECONDS) { 16 | override fun read(): Long = SystemClock.elapsedRealtimeNanos() 17 | 18 | override fun toString(): String = "TimeSource(SystemClock.elapsedRealtimeNanos())" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/ui/components/LudiAsyncImage.android.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.components 2 | 3 | import androidx.compose.ui.graphics.ImageBitmap 4 | import androidx.compose.ui.graphics.asImageBitmap 5 | import coil3.Bitmap 6 | import coil3.request.ImageRequest 7 | import coil3.request.allowHardware 8 | 9 | actual fun ImageRequest.Builder.platformSpecificConfig(allowHardware: Boolean): ImageRequest.Builder = this.allowHardware(allowHardware) 10 | 11 | actual fun Bitmap.asImageBitmap(): ImageBitmap = this.asImageBitmap() 12 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/ui/components/LudiChromeCustomTab.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.components 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import androidx.annotation.ColorInt 6 | import androidx.browser.customtabs.CustomTabColorSchemeParams 7 | import androidx.browser.customtabs.CustomTabsIntent 8 | import androidx.compose.material3.ColorScheme 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.ReadOnlyComposable 11 | import androidx.compose.ui.graphics.toArgb 12 | 13 | val ColorScheme.chromeCustomTabToolbarColor: Int 14 | @Composable 15 | @ReadOnlyComposable 16 | get() = primaryContainer.toArgb() 17 | 18 | fun launchChromeCustomTab(context: Context, url: Uri, @ColorInt toolbarColor: Int) { 19 | val customTabsIntent = CustomTabsIntent.Builder() 20 | .setDefaultColorSchemeParams( 21 | CustomTabColorSchemeParams.Builder() 22 | .setToolbarColor(toolbarColor) 23 | .build() 24 | ) 25 | .build() 26 | 27 | customTabsIntent.launchUrl(context, url) 28 | } 29 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/ui/lifecycle/ConnectionState.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.lifecycle 2 | 3 | enum class ConnectionState { 4 | /** 5 | * Initial [ConnectionState] when we don't know yet about the availability of the internet connectivity on device. 6 | */ 7 | Undefined, 8 | Available, 9 | Unavailable 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/ui/presenter/FrameClock.android.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.presenter 2 | 3 | import androidx.compose.ui.platform.AndroidUiDispatcher 4 | import kotlin.coroutines.CoroutineContext 5 | 6 | internal actual fun frameClock(): CoroutineContext = AndroidUiDispatcher.Main 7 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/ui/resources/PlatformCompositionProvider.android.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.resources 2 | 3 | actual fun isDesktopPlatform() = false 4 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/ui/screens/deals/DealsScreen.android.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.screens.deals 2 | 3 | import android.net.Uri 4 | import androidx.compose.foundation.layout.statusBarsPadding 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.platform.LocalContext 10 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 11 | import com.mr3y.ludi.shared.ui.components.chromeCustomTabToolbarColor 12 | import com.mr3y.ludi.shared.ui.components.launchChromeCustomTab 13 | import com.mr3y.ludi.shared.ui.presenter.DealsViewModel 14 | 15 | @Composable 16 | actual fun DealsScreen( 17 | modifier: Modifier, 18 | viewModel: DealsViewModel 19 | ) { 20 | val dealsState by viewModel.dealsState.collectAsStateWithLifecycle() 21 | val context = LocalContext.current 22 | val tabToolbarColor = MaterialTheme.colorScheme.chromeCustomTabToolbarColor 23 | DealsScreen( 24 | dealsState = dealsState, 25 | searchQuery = viewModel.searchQuery.value, 26 | modifier = modifier.statusBarsPadding(), 27 | onUpdateSearchQuery = viewModel::updateSearchQuery, 28 | onSelectingDealStore = viewModel::addToSelectedDealsStores, 29 | onUnselectingDealStore = viewModel::removeFromSelectedDealsStores, 30 | onSelectingGiveawayStore = viewModel::addToSelectedGiveawaysStores, 31 | onUnselectingGiveawayStore = viewModel::removeFromSelectedGiveawaysStores, 32 | onSelectingGiveawayPlatform = viewModel::addToSelectedGiveawaysPlatforms, 33 | onUnselectingGiveawayPlatform = viewModel::removeFromSelectedGiveawayPlatforms, 34 | onRefreshDeals = viewModel::refreshDeals, 35 | onRefreshDealsFinished = viewModel::refreshDealsComplete, 36 | onRefreshGiveaways = viewModel::refreshGiveaways, 37 | onSelectTab = viewModel::selectTab, 38 | onOpenUrl = { url -> 39 | launchChromeCustomTab(context, Uri.parse(url), tabToolbarColor) 40 | } 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/ui/screens/discover/DiscoverScreen.android.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.screens.discover 2 | 3 | import android.net.Uri 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.platform.LocalContext 9 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 10 | import com.mr3y.ludi.shared.ui.components.chromeCustomTabToolbarColor 11 | import com.mr3y.ludi.shared.ui.components.launchChromeCustomTab 12 | import com.mr3y.ludi.shared.ui.presenter.DiscoverViewModel 13 | 14 | @Composable 15 | actual fun DiscoverScreen( 16 | modifier: Modifier, 17 | viewModel: DiscoverViewModel 18 | ) { 19 | val discoverState by viewModel.discoverState.collectAsStateWithLifecycle() 20 | val context = LocalContext.current 21 | val tabToolbarColor = MaterialTheme.colorScheme.chromeCustomTabToolbarColor 22 | DiscoverScreen( 23 | discoverState = discoverState, 24 | searchQuery = viewModel.searchQuery.value, 25 | onUpdatingSearchQueryText = viewModel::updateSearchQuery, 26 | onSelectingPlatform = viewModel::addToSelectedPlatforms, 27 | onUnselectingPlatform = viewModel::removeFromSelectedPlatforms, 28 | onSelectingStore = viewModel::addToSelectedStores, 29 | onUnselectingStore = viewModel::removeFromSelectedStores, 30 | onSelectingTag = viewModel::addToSelectedTags, 31 | onUnselectingTag = viewModel::removeFromSelectedTags, 32 | onRefresh = viewModel::refresh, 33 | onRefreshFinished = viewModel::refreshComplete, 34 | onOpenUrl = { url -> 35 | launchChromeCustomTab(context, Uri.parse(url), tabToolbarColor) 36 | }, 37 | modifier = modifier 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/ui/screens/news/NewsScreen.android.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.screens.news 2 | 3 | import android.net.Uri 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.platform.LocalContext 9 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 10 | import com.mr3y.ludi.shared.ui.components.chromeCustomTabToolbarColor 11 | import com.mr3y.ludi.shared.ui.components.launchChromeCustomTab 12 | import com.mr3y.ludi.shared.ui.presenter.NewsViewModel 13 | 14 | @Composable 15 | actual fun NewsScreen( 16 | onTuneClick: () -> Unit, 17 | modifier: Modifier, 18 | viewModel: NewsViewModel 19 | ) { 20 | val newsState by viewModel.newsState.collectAsStateWithLifecycle() 21 | val context = LocalContext.current 22 | val tabToolbarColor = MaterialTheme.colorScheme.chromeCustomTabToolbarColor 23 | NewsScreen( 24 | newsState = newsState, 25 | searchQuery = viewModel.searchQuery.value, 26 | onSearchQueryValueChanged = viewModel::updateSearchQuery, 27 | onTuneClick = onTuneClick, 28 | onRefresh = viewModel::refresh, 29 | onOpenUrl = { url -> 30 | launchChromeCustomTab(context, Uri.parse(url), tabToolbarColor) 31 | }, 32 | onConsumeEvent = viewModel::consumeCurrentEvent, 33 | modifier = modifier 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/ui/screens/settings/SettingsScreen.android.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.screens.settings 2 | 3 | import android.net.Uri 4 | import android.os.Build 5 | import androidx.annotation.ChecksSdkIntAtLeast 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.platform.LocalContext 11 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 12 | import com.mr3y.ludi.shared.ui.components.chromeCustomTabToolbarColor 13 | import com.mr3y.ludi.shared.ui.components.launchChromeCustomTab 14 | import com.mr3y.ludi.shared.ui.presenter.SettingsViewModel 15 | import com.mr3y.ludi.shared.ui.theme.isDynamicColorSupported 16 | 17 | @Composable 18 | actual fun SettingsScreen( 19 | onFollowedNewsDataSourcesClick: () -> Unit, 20 | onFavouriteGamesClick: () -> Unit, 21 | onFavouriteGenresClick: () -> Unit, 22 | modifier: Modifier, 23 | viewModel: SettingsViewModel 24 | ) { 25 | val settingsState by viewModel.settingsState.collectAsStateWithLifecycle() 26 | val context = LocalContext.current 27 | val tabToolbarColor = MaterialTheme.colorScheme.chromeCustomTabToolbarColor 28 | SettingsScreen( 29 | settingsState, 30 | modifier = modifier, 31 | onFollowedNewsDataSourcesClick = onFollowedNewsDataSourcesClick, 32 | onFavouriteGamesClick = onFavouriteGamesClick, 33 | onFavouriteGenresClick = onFavouriteGenresClick, 34 | onUpdateTheme = viewModel::setAppTheme, 35 | onToggleDynamicColorValue = viewModel::enableUsingDynamicColor, 36 | onOpenUrl = { url -> 37 | launchChromeCustomTab(context, Uri.parse(url), tabToolbarColor) 38 | } 39 | ) 40 | } 41 | 42 | @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) 43 | actual fun isDynamicColorEnabled(): Boolean { 44 | return isDynamicColorSupported() && Build.VERSION.SDK_INT >= 31 45 | } 46 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/com/mr3y/ludi/shared/ui/theme/ColorScheme.android.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.material3.ColorScheme 5 | import androidx.compose.material3.dynamicDarkColorScheme 6 | import androidx.compose.material3.dynamicLightColorScheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.platform.LocalContext 9 | 10 | @Composable 11 | actual fun colorScheme( 12 | isDarkTheme: Boolean, 13 | useDynamicColors: Boolean 14 | ): ColorScheme { 15 | return when { 16 | Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && useDynamicColors -> { 17 | val context = LocalContext.current 18 | if (isDarkTheme) { 19 | dynamicDarkColorScheme(context) 20 | } else { 21 | dynamicLightColorScheme(context) 22 | } 23 | } 24 | else -> { 25 | if (isDarkTheme) { 26 | DarkColorScheme 27 | } else { 28 | LightColorScheme 29 | } 30 | } 31 | } 32 | } 33 | 34 | actual fun isDynamicColorSupported() = true 35 | -------------------------------------------------------------------------------- /shared/src/androidUnitTest/kotlin/com/mr3y/ludi/shared/ui/screens/BaseRobolectricTest.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.screens 2 | 3 | import androidx.compose.ui.test.junit4.createComposeRule 4 | import org.junit.Before 5 | import org.junit.Rule 6 | import org.robolectric.shadows.ShadowLog 7 | 8 | open class BaseRobolectricTest { 9 | @get:Rule 10 | val composeTestRule = createComposeRule() 11 | 12 | @Before 13 | @Throws(Exception::class) 14 | fun setUp() { 15 | ShadowLog.stream = System.out // Redirect Logcat to console 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /shared/src/androidUnitTest/kotlin/com/mr3y/ludi/shared/ui/screens/Utils.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.screens 2 | 3 | import android.content.Context 4 | import androidx.compose.ui.semantics.Role 5 | import androidx.compose.ui.semantics.SemanticsProperties 6 | import androidx.compose.ui.test.SemanticsMatcher 7 | import androidx.compose.ui.test.SemanticsNodeInteraction 8 | import androidx.compose.ui.test.hasStateDescription 9 | import androidx.compose.ui.test.junit4.ComposeContentTestRule 10 | import androidx.test.core.app.ApplicationProvider 11 | 12 | internal val context: Context = ApplicationProvider.getApplicationContext() 13 | 14 | internal fun ComposeContentTestRule.onNodeWithStateDescription( 15 | stateDescription: String 16 | ): SemanticsNodeInteraction { 17 | return onNode(hasStateDescription(stateDescription)) 18 | } 19 | 20 | internal fun hasRole(role: Role): SemanticsMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Role, role) 21 | -------------------------------------------------------------------------------- /shared/src/androidUnitTest/kotlin/com/mr3y/ludi/shared/ui/screens/onboarding/Utils.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.screens.onboarding 2 | 3 | import androidx.compose.ui.semantics.Role 4 | import androidx.compose.ui.test.SemanticsNodeInteraction 5 | import androidx.compose.ui.test.hasStateDescription 6 | import androidx.compose.ui.test.junit4.ComposeContentTestRule 7 | import com.mr3y.ludi.shared.ui.screens.hasRole 8 | 9 | internal fun ComposeContentTestRule.onGenre(genreStateDesc: String): SemanticsNodeInteraction { 10 | return onNode(hasStateDescription(genreStateDesc) and hasRole(Role.Checkbox)) 11 | } 12 | -------------------------------------------------------------------------------- /shared/src/commonMain/composeResources/drawable/game_spot_logo.xml: -------------------------------------------------------------------------------- 1 | 8 | 13 | 18 | 19 | -------------------------------------------------------------------------------- /shared/src/commonMain/composeResources/drawable/gamerant_logo.xml: -------------------------------------------------------------------------------- 1 | 8 | 13 | 14 | 19 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /shared/src/commonMain/composeResources/drawable/ios.xml: -------------------------------------------------------------------------------- 1 | 8 | 13 | 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/composeResources/drawable/pc.xml: -------------------------------------------------------------------------------- 1 | 8 | 13 | 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/composeResources/drawable/placeholder.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /shared/src/commonMain/composeResources/drawable/xbox.xml: -------------------------------------------------------------------------------- 1 | 8 | 13 | 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/App.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi 5 | import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.CompositionLocalProvider 8 | import androidx.compose.ui.Modifier 9 | import cafe.adriel.voyager.navigator.Navigator 10 | import coil3.ImageLoader 11 | import coil3.annotation.ExperimentalCoilApi 12 | import coil3.compose.setSingletonImageLoaderFactory 13 | import com.mr3y.ludi.shared.ui.adaptive.LocalWindowSizeClass 14 | import com.mr3y.ludi.shared.ui.screens.home.HomeScreen 15 | import com.mr3y.ludi.shared.ui.screens.onboarding.OnboardingScreen 16 | import com.mr3y.ludi.shared.ui.theme.LudiTheme 17 | 18 | @OptIn(ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalCoilApi::class) 19 | @Composable 20 | fun App( 21 | isDarkTheme: Boolean, 22 | useDynamicColor: Boolean, 23 | showOnboardingScreen: Boolean, 24 | modifier: Modifier = Modifier 25 | ) { 26 | LudiTheme( 27 | darkTheme = isDarkTheme, 28 | dynamicColor = useDynamicColor 29 | ) { 30 | setSingletonImageLoaderFactory { context -> 31 | ImageLoader.Builder(context).build() 32 | } 33 | CompositionLocalProvider( 34 | LocalWindowSizeClass provides calculateWindowSizeClass() 35 | ) { 36 | if (showOnboardingScreen) { 37 | Navigator(screen = OnboardingScreen) 38 | } else { 39 | HomeScreen(modifier = modifier.fillMaxSize()) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/CrashReporting.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core 2 | 3 | interface CrashReporting { 4 | 5 | fun recordException(throwable: Throwable, logMessage: String?) 6 | } 7 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/Logger.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core 2 | 3 | interface Logger { 4 | 5 | fun d(throwable: Throwable? = null, tag: String = "", message: () -> String) 6 | 7 | fun i(throwable: Throwable? = null, tag: String = "", message: () -> String) 8 | 9 | fun e(throwable: Throwable? = null, tag: String = "", message: () -> String) 10 | 11 | fun v(throwable: Throwable? = null, tag: String = "", message: () -> String) 12 | 13 | fun w(throwable: Throwable? = null, tag: String = "", message: () -> String) 14 | } 15 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/database/Adapters.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.database 2 | 3 | import app.cash.sqldelight.ColumnAdapter 4 | import com.mr3y.ludi.shared.core.model.MarkupText 5 | import com.mr3y.ludi.shared.core.model.Title 6 | import java.time.ZonedDateTime 7 | 8 | object TitleColumnAdapter : ColumnAdapter { 9 | 10 | override fun encode(value: Title): String { 11 | return when (value) { 12 | is Title.Plain -> "Plain(${value.text})" 13 | is Title.Markup -> "Markup(${value.text})" 14 | } 15 | } 16 | 17 | override fun decode(databaseValue: String): Title { 18 | return when { 19 | databaseValue.startsWith(prefix = "Plain") -> { 20 | Title.Plain(databaseValue.removePrefix("Plain(").removeSuffix(")")) 21 | } 22 | databaseValue.startsWith(prefix = "Markup") -> { 23 | Title.Markup(databaseValue.removePrefix("Markup(").removeSuffix(")")) 24 | } 25 | else -> throw IllegalArgumentException("Unknown Title type discriminator value, Can't decode this title value: $databaseValue") 26 | } 27 | } 28 | } 29 | 30 | object MarkupTextColumnAdapter : ColumnAdapter { 31 | override fun encode(value: MarkupText): String = value.text 32 | 33 | override fun decode(databaseValue: String): MarkupText = MarkupText(databaseValue) 34 | } 35 | 36 | object ZonedDateTimeAdapter : ColumnAdapter { 37 | override fun encode(value: ZonedDateTime): String = value.toString() 38 | 39 | override fun decode(databaseValue: String): ZonedDateTime = ZonedDateTime.parse(databaseValue) 40 | } 41 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/database/DatabaseFactory.kt: -------------------------------------------------------------------------------- 1 | // ktlint-disable filename 2 | package com.mr3y.ludi.shared.core.database 3 | 4 | import app.cash.sqldelight.EnumColumnAdapter 5 | import app.cash.sqldelight.db.SqlDriver 6 | import com.mr3y.ludi.shared.ArticleEntity 7 | 8 | expect class DriverFactory { 9 | fun createDriver(): SqlDriver 10 | } 11 | 12 | fun createDatabase(driver: DriverFactory): LudiDatabase { 13 | return LudiDatabase( 14 | driver = driver.createDriver(), 15 | articleEntityAdapter = ArticleEntity.Adapter( 16 | titleAdapter = TitleColumnAdapter, 17 | descriptionAdapter = MarkupTextColumnAdapter, 18 | sourceAdapter = EnumColumnAdapter(), 19 | contentAdapter = MarkupTextColumnAdapter, 20 | publicationDateAdapter = ZonedDateTimeAdapter 21 | ) 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/database/Wrapper.kt: -------------------------------------------------------------------------------- 1 | // ktlint-disable filename 2 | package com.mr3y.ludi.shared.core.database 3 | 4 | // kotlin-inject fails to resolve types generated by sqlDelight, 5 | // As a workaround we have to wrap those types to make them resolvable. 6 | // See: https://github.com/evant/kotlin-inject/issues/316 7 | 8 | @JvmInline 9 | value class LudiDatabaseWrapper(val value: LudiDatabase) 10 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/internal/KermitLogger.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.internal 2 | 3 | import com.mr3y.ludi.shared.core.Logger 4 | import me.tatarka.inject.annotations.Inject 5 | import co.touchlab.kermit.Logger as DelegatingLogger 6 | 7 | @Inject 8 | class KermitLogger( 9 | private val delegatingLogger: DelegatingLogger 10 | ) : Logger { 11 | 12 | override fun d(throwable: Throwable?, tag: String, message: () -> String) { 13 | delegatingLogger.d(throwable, tag, message) 14 | } 15 | 16 | override fun i(throwable: Throwable?, tag: String, message: () -> String) { 17 | delegatingLogger.i(throwable, tag, message) 18 | } 19 | 20 | override fun e(throwable: Throwable?, tag: String, message: () -> String) { 21 | delegatingLogger.e(throwable, tag, message) 22 | } 23 | 24 | override fun v(throwable: Throwable?, tag: String, message: () -> String) { 25 | delegatingLogger.v(throwable, tag, message) 26 | } 27 | 28 | override fun w(throwable: Throwable?, tag: String, message: () -> String) { 29 | delegatingLogger.w(throwable, tag, message) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/model/Article.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.model 2 | 3 | import java.time.ZonedDateTime 4 | 5 | sealed interface Article { 6 | val title: Title 7 | val description: MarkupText? 8 | val imageUrl: String? 9 | val content: MarkupText? 10 | val author: String? 11 | val source: Source 12 | val sourceLinkUrl: String 13 | val publicationDate: ZonedDateTime? 14 | } 15 | 16 | sealed interface Title { 17 | val text: String 18 | data class Markup(override val text: String) : Title 19 | data class Plain(override val text: String) : Title 20 | } 21 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/model/Deal.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.model 2 | 3 | import java.time.ZonedDateTime 4 | 5 | data class Deal( 6 | val internalName: String, 7 | val name: String, 8 | val metacriticUrl: String?, 9 | val dealID: String, 10 | val storeID: Int, 11 | val gameID: Long, 12 | val salePriceInUsDollar: Float, 13 | val normalPriceInUsDollar: Float, 14 | val isOnSale: Boolean, 15 | val savings: Double, 16 | val metacriticScore: Int, 17 | val steamRatingText: String?, 18 | val steamRatingPercent: Int, 19 | val steamRatingCount: Long, 20 | val steamAppID: Long?, 21 | val releaseDate: ZonedDateTime, 22 | val lastUpdate: ZonedDateTime, 23 | val dealRating: Float, 24 | val thumbnailUrl: String 25 | ) 26 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/model/Game.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.model 2 | 3 | import java.time.ZonedDateTime 4 | 5 | data class GamesPage( 6 | val count: Long, 7 | val nextPageUrl: String?, 8 | val previousPageUrl: String?, 9 | val games: List 10 | ) 11 | 12 | data class GamesGenresPage( 13 | val count: Long, 14 | val nextPageUrl: String?, 15 | val previousPageUrl: String?, 16 | val genres: Set 17 | ) 18 | 19 | data class Game( 20 | val id: Long, 21 | val slug: String?, 22 | val name: String, 23 | val releaseDate: ZonedDateTime?, 24 | val toBeAnnounced: Boolean?, 25 | val imageUrl: String, 26 | val rating: Float, 27 | val metaCriticScore: Int?, 28 | val playtime: Int?, 29 | val suggestionsCount: Int, 30 | val platformsInfo: List?, 31 | val storesInfo: List?, 32 | val tags: List, 33 | val screenshots: List, 34 | val genres: List 35 | ) 36 | 37 | data class PlatformInfo( 38 | val id: Int, 39 | val name: String, 40 | val slug: String, 41 | val imageUrl: String?, 42 | val yearEnd: Int?, 43 | val yearStart: Int?, 44 | val gamesCount: Long?, 45 | val imageBackground: String?, 46 | val releaseDate: ZonedDateTime?, 47 | val gameRequirementsInEnglish: GameRequirements? 48 | ) 49 | 50 | data class GameRequirements( 51 | val minimum: MarkupText, 52 | val recommended: MarkupText 53 | ) 54 | 55 | data class StoreInfo( 56 | val id: Int, 57 | val gameIdOnStore: Long?, 58 | val name: String, 59 | val slug: String?, 60 | val domain: String?, 61 | val gamesCount: Long?, 62 | val imageUrl: String? 63 | ) 64 | 65 | data class GameTag( 66 | val id: Int, 67 | val name: String, 68 | val slug: String?, 69 | val language: String, 70 | val gamesCount: Long?, 71 | val imageUrl: String 72 | ) 73 | 74 | data class GameScreenshot(val id: Long, val imageUrl: String) 75 | 76 | data class GameGenre( 77 | val id: Int, 78 | val name: String, 79 | val slug: String?, 80 | val gamesCount: Long?, 81 | val imageUrl: String? 82 | ) 83 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/model/GiveawayEntry.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.model 2 | 3 | import java.time.ZonedDateTime 4 | 5 | @JvmInline 6 | value class Percent(val value: Int) { 7 | init { 8 | require(value in 0..100) { "Invalid Percentage value $value" } 9 | } 10 | } 11 | 12 | data class GiveawayEntry( 13 | val id: Long, 14 | val title: String, 15 | val worthInUsDollar: Float?, 16 | val thumbnailUrl: String, 17 | val imageUrl: String, 18 | val description: String, 19 | val instructions: String, 20 | val giveawayUrl: String, 21 | val publishedDateTime: ZonedDateTime?, 22 | val type: String, 23 | val platforms: List, 24 | val endDateTime: ZonedDateTime?, 25 | val users: Int, 26 | val status: GiveawayEntryStatus, 27 | val gamerPowerUrl: String 28 | ) 29 | 30 | enum class GiveawayEntryStatus { Active } 31 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/model/MarkupText.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.model 2 | 3 | @JvmInline 4 | value class MarkupText(val text: String) 5 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/model/NewReleaseArticle.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.model 2 | 3 | import java.time.ZonedDateTime 4 | 5 | data class NewReleaseArticle( 6 | override val title: Title, 7 | override val description: MarkupText?, 8 | override val source: Source, 9 | override val sourceLinkUrl: String, 10 | val releaseDate: ZonedDateTime 11 | ) : Article { 12 | override val imageUrl: String? = null 13 | override val content: MarkupText? = null 14 | override val author: String? = null 15 | override val publicationDate: ZonedDateTime = releaseDate 16 | } 17 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/model/NewsArticle.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.model 2 | 3 | import java.time.ZonedDateTime 4 | 5 | data class NewsArticle( 6 | override val title: Title, 7 | override val description: MarkupText?, 8 | override val source: Source, 9 | override val sourceLinkUrl: String, 10 | override val content: MarkupText?, 11 | override val imageUrl: String?, 12 | override val author: String?, 13 | override val publicationDate: ZonedDateTime? 14 | ) : Article { 15 | 16 | override fun toString(): String { 17 | return "NewsArticle(title=\"$title\"," + 18 | " description=\"$description\",\n" + 19 | " source=\"$source\",\n" + 20 | " sourceLinkUrl=\"$sourceLinkUrl\",\n" + 21 | " content=\"$content\",\n" + 22 | " imageUrl=\"$imageUrl\",\n" + 23 | " author=\"$author\",\n" + 24 | " publicationDate=\"$publicationDate\")" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/model/Result.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.model 2 | 3 | import com.mr3y.ludi.shared.core.network.model.ApiResult 4 | 5 | sealed interface Result { 6 | data object Loading : Result 7 | data class Success(val data: T) : Result 8 | data class Error(val exception: Throwable? = null) : Result 9 | } 10 | 11 | /** 12 | * Allow transforming [Result.Success.data] if [Result] is [Result.Success] or return whatever result is otherwise. 13 | */ 14 | inline fun Result.onSuccess(transform: (T) -> R): Result { 15 | return when (this) { 16 | is Result.Loading -> this 17 | is Result.Success -> Result.Success(transform(data)) 18 | is Result.Error -> this 19 | } 20 | } 21 | 22 | val Result.data: R? 23 | get() = (this as? Result.Success)?.data 24 | 25 | val Result.exception: Throwable? 26 | get() = (this as? Result.Error)?.exception 27 | 28 | fun ApiResult.Error.toCoreErrorResult(): Result.Error { 29 | return Result.Error(throwable) 30 | } 31 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/model/ReviewArticle.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.model 2 | 3 | import java.time.ZonedDateTime 4 | 5 | data class ReviewArticle( 6 | override val title: Title, 7 | override val description: MarkupText?, 8 | override val imageUrl: String?, 9 | override val source: Source, 10 | override val content: MarkupText?, 11 | override val sourceLinkUrl: String, 12 | override val author: String?, 13 | override val publicationDate: ZonedDateTime? 14 | ) : Article 15 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/model/Source.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.model 2 | 3 | import ludi.shared.generated.resources.Res 4 | import ludi.shared.generated.resources.brutalgamer_logo 5 | import ludi.shared.generated.resources.eurogamer_logo 6 | import ludi.shared.generated.resources.game_spot_logo 7 | import ludi.shared.generated.resources.gamerant_logo 8 | import ludi.shared.generated.resources.giant_bomb_logo 9 | import ludi.shared.generated.resources.gloriousgaming_logo 10 | import ludi.shared.generated.resources.ign_logo 11 | import ludi.shared.generated.resources.pcgamer_logo 12 | import ludi.shared.generated.resources.pcgamesn_logo 13 | import ludi.shared.generated.resources.pcinvasion_logo 14 | import ludi.shared.generated.resources.polygon_logo 15 | import ludi.shared.generated.resources.rockpapershotgun_logo 16 | import ludi.shared.generated.resources.tech_radar_logo 17 | import ludi.shared.generated.resources.thegamer_logo 18 | import ludi.shared.generated.resources.venturebeat_logo 19 | import ludi.shared.generated.resources.vg247_logo 20 | import org.jetbrains.compose.resources.DrawableResource 21 | 22 | enum class Source { 23 | GiantBomb, 24 | GameSpot, 25 | IGN, 26 | TechRadar, 27 | PCGamesN, 28 | RockPaperShotgun, 29 | PCInvasion, 30 | GloriousGaming, 31 | EuroGamer, 32 | VG247, 33 | TheGamer, 34 | GameRant, 35 | BrutalGamer, 36 | VentureBeat, 37 | Polygon, 38 | PCGamer 39 | } 40 | 41 | internal fun mapTypeToIconRes(type: Source): DrawableResource { 42 | return when (type) { 43 | Source.GiantBomb -> Res.drawable.giant_bomb_logo 44 | Source.GameSpot -> Res.drawable.game_spot_logo 45 | Source.IGN -> Res.drawable.ign_logo 46 | Source.TechRadar -> Res.drawable.tech_radar_logo 47 | Source.PCGamesN -> Res.drawable.pcgamesn_logo 48 | Source.RockPaperShotgun -> Res.drawable.rockpapershotgun_logo 49 | Source.PCInvasion -> Res.drawable.pcinvasion_logo 50 | Source.GloriousGaming -> Res.drawable.gloriousgaming_logo 51 | Source.EuroGamer -> Res.drawable.eurogamer_logo 52 | Source.VG247 -> Res.drawable.vg247_logo 53 | Source.TheGamer -> Res.drawable.thegamer_logo 54 | Source.GameRant -> Res.drawable.gamerant_logo 55 | Source.BrutalGamer -> Res.drawable.brutalgamer_logo 56 | Source.VentureBeat -> Res.drawable.venturebeat_logo 57 | Source.Polygon -> Res.drawable.polygon_logo 58 | Source.PCGamer -> Res.drawable.pcgamer_logo 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/network/datasources/RSSFeedDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.network.datasources 2 | 3 | import com.mr3y.ludi.shared.core.model.NewReleaseArticle 4 | import com.mr3y.ludi.shared.core.model.NewsArticle 5 | import com.mr3y.ludi.shared.core.model.Result 6 | import com.mr3y.ludi.shared.core.model.ReviewArticle 7 | import com.mr3y.ludi.shared.core.model.Source 8 | 9 | interface RSSFeedDataSource { 10 | 11 | suspend fun fetchNewsFeed(source: Source): Result, Throwable>? 12 | 13 | suspend fun fetchReviewsFeed(source: Source): Result, Throwable>? 14 | 15 | suspend fun fetchNewReleasesFeed(source: Source): Result, Throwable>? 16 | } 17 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/network/datasources/internal/CheapSharkDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.network.datasources.internal 2 | 3 | import com.mr3y.ludi.shared.core.network.model.ApiResult 4 | import com.mr3y.ludi.shared.core.network.model.CheapSharkResponse 5 | import com.mr3y.ludi.shared.core.repository.query.DealsQueryParameters 6 | import com.mr3y.ludi.shared.core.repository.query.DealsSortingDirection 7 | import io.ktor.client.HttpClient 8 | import io.ktor.client.call.body 9 | import io.ktor.client.request.get 10 | import io.ktor.client.request.parameter 11 | import io.ktor.http.isSuccess 12 | import me.tatarka.inject.annotations.Inject 13 | 14 | interface CheapSharkDataSource { 15 | 16 | suspend fun queryLatestDeals(queryParameters: DealsQueryParameters, page: Int = 0, pageSize: Int = 60): ApiResult 17 | } 18 | 19 | @Inject 20 | class CheapSharkDataSourceImpl( 21 | private val client: HttpClient 22 | ) : CheapSharkDataSource { 23 | override suspend fun queryLatestDeals(queryParameters: DealsQueryParameters, page: Int, pageSize: Int): ApiResult { 24 | return try { 25 | val response = client.get("https://www.cheapshark.com/api/1.0/deals") { 26 | parameter("pageNumber", page) 27 | parameter("pageSize", pageSize) 28 | parameter("title", queryParameters.searchQuery) 29 | parameter("exact", queryParameters.matchTermsExactly?.let { if (it) 1 else 0 }) 30 | parameter("storeID", queryParameters.stores?.joinToString(separator = ",")) 31 | parameter("sortBy", queryParameters.sorting?.value) 32 | parameter("desc", queryParameters.sortingDirection?.let { if (it == DealsSortingDirection.Descending) 1 else 0 }) 33 | } 34 | if (response.status.isSuccess()) { 35 | ApiResult.Success( 36 | CheapSharkResponse(totalPageCount = response.headers["x-total-page-count"]!!.toInt(), deals = response.body()) 37 | ) 38 | } else { 39 | ApiResult.Error(code = response.status.value) 40 | } 41 | } catch (e: Exception) { 42 | ApiResult.Error(code = null, throwable = e) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/network/model/ApiResult.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.network.model 2 | 3 | sealed interface ApiResult { 4 | data class Success(val data: T) : ApiResult 5 | data class Error(val code: Int?, val throwable: Throwable? = null) : ApiResult 6 | } 7 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/network/model/GamerPowerGiveaway.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.network.model 2 | 3 | import com.mr3y.ludi.shared.core.model.GiveawayEntry 4 | import com.mr3y.ludi.shared.core.model.GiveawayEntryStatus 5 | import com.mr3y.ludi.shared.core.network.serialization.NotAvailableAsNullSerializer 6 | import kotlinx.serialization.SerialName 7 | import kotlinx.serialization.Serializable 8 | 9 | @Serializable 10 | data class GamerPowerGiveawayEntry( 11 | val id: Long, 12 | val title: String, 13 | @Serializable(with = NotAvailableAsNullSerializer::class) 14 | val worth: String?, 15 | @SerialName("thumbnail") 16 | val thumbnailUrl: String, 17 | @SerialName("image") 18 | val imageUrl: String, 19 | val description: String, 20 | val instructions: String, 21 | @SerialName("open_giveaway_url") 22 | val giveawayUrl: String, 23 | @SerialName("published_date") 24 | val publishedDateTime: String, 25 | val type: String, 26 | val platforms: String, 27 | @Serializable(with = NotAvailableAsNullSerializer::class) 28 | @SerialName("end_date") 29 | val endDateTime: String?, 30 | val users: Int, 31 | val status: GamerPowerGiveawayEntryStatus, 32 | @SerialName("gamerpower_url") 33 | val gamerPowerUrl: String 34 | ) 35 | 36 | enum class GamerPowerGiveawayEntryStatus { Active } 37 | 38 | fun GamerPowerGiveawayEntry.toGiveawayEntry(): GiveawayEntry { 39 | return GiveawayEntry( 40 | id = id, 41 | title = title, 42 | worthInUsDollar = worth?.substringAfter('$')?.toFloat(), 43 | thumbnailUrl = thumbnailUrl, 44 | imageUrl = imageUrl, 45 | description = description, 46 | instructions = instructions, 47 | giveawayUrl = giveawayUrl, 48 | publishedDateTime = publishedDateTime.toZonedDateTime(pattern = Pattern.ISO_UTC_DATE_TIME), 49 | type = type, 50 | platforms = platforms.split(',').map { it.trim() }, 51 | endDateTime = endDateTime?.toZonedDateTime(pattern = Pattern.ISO_UTC_DATE_TIME), 52 | users = users, 53 | status = GiveawayEntryStatus.Active, 54 | gamerPowerUrl = gamerPowerUrl 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/network/model/TimeConversionsUtils.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.network.model 2 | 3 | import java.text.ParsePosition 4 | import java.time.Instant 5 | import java.time.ZoneId 6 | import java.time.ZonedDateTime 7 | import java.time.format.DateTimeFormatter 8 | import java.time.format.DateTimeParseException 9 | 10 | internal fun Long.convertEpochSecondToZonedDateTime(): ZonedDateTime { 11 | return ZonedDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneId.systemDefault()) 12 | } 13 | 14 | /** 15 | * Converts the following date format: "yyyy-mm-dd" to ZonedDateTime 16 | */ 17 | internal fun String.toZonedDate(): ZonedDateTime { 18 | val (year, month, dayOfMonth) = split('-').map { it.toInt() } 19 | return ZonedDateTime.of(year, month, dayOfMonth, 0, 0, 0, 0, ZoneId.systemDefault()) 20 | } 21 | 22 | internal fun String.toZonedDateTime(pattern: Pattern = Pattern.RFC_1123): ZonedDateTime? { 23 | return if (DateTimeFormatter.ISO_ZONED_DATE_TIME.parseUnresolved(this, ParsePosition(0)) != null) { 24 | ZonedDateTime.parse(this) 25 | } else { 26 | try { 27 | when (pattern) { 28 | Pattern.RFC_1123 -> ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME) 29 | Pattern.ISO_UTC_DATE_TIME -> ZonedDateTime.parse("${this.replace(' ', 'T').removeSuffix("Z")}Z") 30 | } 31 | } catch (e: DateTimeParseException) { 32 | null 33 | } 34 | } 35 | } 36 | 37 | internal enum class Pattern { 38 | /** 39 | * A date time format pattern for date time strings like: "Thu, 02 Mar 2023 03:00:00 -0800", "Thu, 2 Mar 2023 01:30:02 +0000" 40 | */ 41 | RFC_1123, 42 | 43 | /** 44 | * A date time format pattern for date time strings like: "2023-03-24T15:16:31Z", "2023-03-24 15:16:31" 45 | */ 46 | ISO_UTC_DATE_TIME 47 | } 48 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/network/rssparser/Parser.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.network.rssparser 2 | 3 | import com.mr3y.ludi.shared.core.model.NewReleaseArticle 4 | import com.mr3y.ludi.shared.core.model.NewsArticle 5 | import com.mr3y.ludi.shared.core.model.ReviewArticle 6 | import com.mr3y.ludi.shared.core.model.Source 7 | 8 | interface Parser { 9 | 10 | suspend fun parseNewsArticlesAtUrl(url: String, source: Source): List 11 | 12 | suspend fun parseReviewArticlesAtUrl(url: String, source: Source): List 13 | 14 | suspend fun parseNewReleaseArticlesAtUrl(url: String, source: Source): List 15 | } 16 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/network/serialization/GamerPowerModelsSerializers.kt: -------------------------------------------------------------------------------- 1 | // ktlint-disable filename 2 | package com.mr3y.ludi.shared.core.network.serialization 3 | 4 | import kotlinx.serialization.KSerializer 5 | import kotlinx.serialization.descriptors.PrimitiveKind 6 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 7 | import kotlinx.serialization.descriptors.SerialDescriptor 8 | import kotlinx.serialization.encoding.Decoder 9 | import kotlinx.serialization.encoding.Encoder 10 | 11 | object NotAvailableAsNullSerializer : KSerializer { 12 | override val descriptor: SerialDescriptor 13 | get() = PrimitiveSerialDescriptor(serialName = "String?", kind = PrimitiveKind.STRING) 14 | 15 | override fun deserialize(decoder: Decoder): String? { 16 | return when (val value = decoder.decodeString()) { 17 | "N/A" -> null 18 | else -> value 19 | } 20 | } 21 | 22 | override fun serialize(encoder: Encoder, value: String?) { 23 | encoder.encodeString(value?.toString() ?: "N/A") 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/network/serialization/RAWGModelsSerializers.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.network.serialization 2 | 3 | import com.mr3y.ludi.shared.core.network.model.DetailedRAWGPlatformInfo 4 | import com.mr3y.ludi.shared.core.network.model.RAWGPlatformInfo 5 | import com.mr3y.ludi.shared.core.network.model.RAWGStoreInfo 6 | import com.mr3y.ludi.shared.core.network.model.ShallowRAWGPlatformInfo 7 | import com.mr3y.ludi.shared.core.network.model.ShallowRAWGStoreInfo 8 | import com.mr3y.ludi.shared.core.network.model.ShallowRAWGStoreInfoWithId 9 | import kotlinx.serialization.DeserializationStrategy 10 | import kotlinx.serialization.json.JsonContentPolymorphicSerializer 11 | import kotlinx.serialization.json.JsonElement 12 | import kotlinx.serialization.json.jsonObject 13 | 14 | object RAWGPlatformSerializer : JsonContentPolymorphicSerializer(RAWGPlatformInfo::class) { 15 | override fun selectDeserializer(element: JsonElement): DeserializationStrategy { 16 | return when { 17 | "requirements_en" in element.jsonObject || "released_at" in element.jsonObject -> DetailedRAWGPlatformInfo.serializer() 18 | else -> ShallowRAWGPlatformInfo.serializer() 19 | } 20 | } 21 | } 22 | 23 | object RAWGStoreSerializer : JsonContentPolymorphicSerializer(RAWGStoreInfo::class) { 24 | override fun selectDeserializer(element: JsonElement): DeserializationStrategy { 25 | return when { 26 | "id" in element.jsonObject -> ShallowRAWGStoreInfoWithId.serializer() 27 | else -> ShallowRAWGStoreInfo.serializer() 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/paging/DealsPagingSource.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.paging 2 | 3 | import androidx.paging.PagingState 4 | import app.cash.paging.PagingSource 5 | import com.mr3y.ludi.shared.core.CrashReporting 6 | import com.mr3y.ludi.shared.core.model.Deal 7 | import com.mr3y.ludi.shared.core.network.datasources.internal.CheapSharkDataSource 8 | import com.mr3y.ludi.shared.core.network.model.ApiResult 9 | import com.mr3y.ludi.shared.core.network.model.toDeal 10 | import com.mr3y.ludi.shared.core.repository.query.DealsQueryParameters 11 | 12 | class DealsPagingSource( 13 | private val networkDataSource: CheapSharkDataSource, 14 | private val query: DealsQueryParameters, 15 | private val crashReporting: CrashReporting 16 | ) : PagingSource() { 17 | 18 | override fun getRefreshKey(state: PagingState): Int? { 19 | return state.anchorPosition?.let { anchorPosition -> 20 | val anchorPage = state.closestPageToPosition(anchorPosition) 21 | anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) 22 | } 23 | } 24 | 25 | override suspend fun load(params: LoadParams): LoadResult { 26 | val pageNum = params.key?.coerceAtLeast(0) ?: 0 27 | return when (val response = networkDataSource.queryLatestDeals(query, pageNum, params.loadSize)) { 28 | is ApiResult.Success -> { 29 | LoadResult.Page( 30 | data = response.data.deals.map { it.toDeal() }, 31 | prevKey = if (pageNum > 0) pageNum - 1 else null, 32 | nextKey = if (pageNum < response.data.totalPageCount) pageNum + 1 else null 33 | ) 34 | } 35 | is ApiResult.Error -> { 36 | if (response.throwable != null) { 37 | crashReporting.recordException(response.throwable, logMessage = "Error occurred while querying deals with query $query") 38 | return LoadResult.Error(response.throwable) 39 | } 40 | return LoadResult.Invalid() 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/paging/RAWGGamesPagingSource.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.paging 2 | 3 | import app.cash.paging.PagingSource 4 | import app.cash.paging.PagingState 5 | import com.mr3y.ludi.shared.core.CrashReporting 6 | import com.mr3y.ludi.shared.core.model.Game 7 | import com.mr3y.ludi.shared.core.network.datasources.internal.RAWGDataSource 8 | import com.mr3y.ludi.shared.core.network.model.ApiResult 9 | import com.mr3y.ludi.shared.core.network.model.toGame 10 | import com.mr3y.ludi.shared.core.repository.query.GamesQueryParameters 11 | 12 | class RAWGGamesPagingSource( 13 | private val networkDataSource: RAWGDataSource, 14 | private val query: GamesQueryParameters, 15 | private val crashReporting: CrashReporting 16 | ) : PagingSource() { 17 | 18 | override fun getRefreshKey(state: PagingState): Int? { 19 | return state.anchorPosition?.let { anchorPosition -> 20 | val anchorPage = state.closestPageToPosition(anchorPosition) 21 | anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) 22 | } 23 | } 24 | 25 | override suspend fun load(params: LoadParams): LoadResult { 26 | val pageNum = params.key?.coerceAtLeast(1) ?: 1 27 | return when (val response = networkDataSource.queryGames(query, pageNum, params.loadSize)) { 28 | is ApiResult.Success -> { 29 | LoadResult.Page( 30 | data = response.data.results.mapNotNull { it.toGame() }, 31 | prevKey = if (response.data.previousPageUrl != null) pageNum - 1 else null, 32 | nextKey = if (response.data.nextPageUrl != null) pageNum + 1 else null 33 | ) 34 | } 35 | is ApiResult.Error -> { 36 | if (response.throwable != null) { 37 | crashReporting.recordException(response.throwable, logMessage = "Error occurred while querying RAWG Games with query $query") 38 | return LoadResult.Error(response.throwable) 39 | } 40 | return LoadResult.Invalid() 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/repository/DealsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.repository 2 | 3 | import app.cash.paging.PagingData 4 | import com.mr3y.ludi.shared.core.model.Deal 5 | import com.mr3y.ludi.shared.core.model.GiveawayEntry 6 | import com.mr3y.ludi.shared.core.model.Result 7 | import com.mr3y.ludi.shared.core.repository.query.DealsQueryParameters 8 | import com.mr3y.ludi.shared.core.repository.query.GiveawaysQueryParameters 9 | import kotlinx.coroutines.flow.Flow 10 | 11 | interface DealsRepository { 12 | 13 | fun queryDeals(queryParameters: DealsQueryParameters): Flow> 14 | 15 | suspend fun queryGiveaways(queryParameters: GiveawaysQueryParameters): Result, Throwable> 16 | } 17 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/repository/GamesRepository.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.repository 2 | 3 | import app.cash.paging.PagingData 4 | import com.mr3y.ludi.shared.core.model.Game 5 | import com.mr3y.ludi.shared.core.model.GamesGenresPage 6 | import com.mr3y.ludi.shared.core.model.Result 7 | import com.mr3y.ludi.shared.core.repository.query.GamesQueryParameters 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | interface GamesRepository { 11 | fun queryGames(queryParameters: GamesQueryParameters): Flow> 12 | 13 | suspend fun queryGamesGenres(): Result 14 | } 15 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/repository/NewsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.repository 2 | 3 | import app.cash.paging.PagingData 4 | import com.mr3y.ludi.shared.core.model.NewReleaseArticle 5 | import com.mr3y.ludi.shared.core.model.NewsArticle 6 | import com.mr3y.ludi.shared.core.model.ReviewArticle 7 | import com.mr3y.ludi.shared.core.model.Source 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | interface NewsRepository { 11 | 12 | fun queryLatestGamingNews(searchQuery: String?): Flow> 13 | 14 | fun queryGamesNewReleases(): Flow> 15 | 16 | fun queryGamesReviews(searchQuery: String?): Flow> 17 | 18 | suspend fun updateGamingNews(sources: Set, forceRefresh: Boolean): Boolean 19 | 20 | suspend fun updateGamesNewReleases(sources: Set, forceRefresh: Boolean): Boolean 21 | 22 | suspend fun updateGamesReviews(sources: Set, forceRefresh: Boolean): Boolean 23 | } 24 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/repository/internal/DefaultGamesRepository.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.repository.internal 2 | 3 | import app.cash.paging.Pager 4 | import app.cash.paging.PagingConfig 5 | import app.cash.paging.PagingData 6 | import com.mr3y.ludi.shared.core.CrashReporting 7 | import com.mr3y.ludi.shared.core.model.Game 8 | import com.mr3y.ludi.shared.core.model.GamesGenresPage 9 | import com.mr3y.ludi.shared.core.model.Result 10 | import com.mr3y.ludi.shared.core.model.toCoreErrorResult 11 | import com.mr3y.ludi.shared.core.network.datasources.internal.RAWGDataSource 12 | import com.mr3y.ludi.shared.core.network.model.ApiResult 13 | import com.mr3y.ludi.shared.core.network.model.toGamesGenresPage 14 | import com.mr3y.ludi.shared.core.paging.RAWGGamesPagingSource 15 | import com.mr3y.ludi.shared.core.repository.GamesRepository 16 | import com.mr3y.ludi.shared.core.repository.query.GamesQueryParameters 17 | import kotlinx.coroutines.flow.Flow 18 | import me.tatarka.inject.annotations.Inject 19 | 20 | @Inject 21 | class DefaultGamesRepository( 22 | private val rawgDataSource: RAWGDataSource, 23 | private val crashReporting: CrashReporting 24 | ) : GamesRepository { 25 | 26 | override fun queryGames(queryParameters: GamesQueryParameters): Flow> { 27 | return Pager(DefaultPagingConfig) { 28 | RAWGGamesPagingSource( 29 | rawgDataSource, 30 | queryParameters, 31 | crashReporting 32 | ) 33 | }.flow 34 | } 35 | 36 | override suspend fun queryGamesGenres(): Result { 37 | return when (val result = rawgDataSource.queryGamesGenres()) { 38 | is ApiResult.Success -> { 39 | Result.Success(result.data.toGamesGenresPage()) 40 | } 41 | is ApiResult.Error -> { 42 | val errorResult = result.toCoreErrorResult() 43 | if (errorResult.exception != null) { 44 | crashReporting.recordException(errorResult.exception, logMessage = "Error occurred while querying RAWG game Genres") 45 | } 46 | errorResult 47 | } 48 | } 49 | } 50 | 51 | companion object { 52 | private val DefaultPagingConfig = PagingConfig(pageSize = 20, initialLoadSize = 20) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/repository/query/DealsQueryParameters.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.repository.query 2 | 3 | fun DealsQuery( 4 | searchQuery: String? = null, 5 | matchTermsExactly: Boolean? = null, 6 | stores: List? = null, 7 | sortingCriteria: DealsSorting? = null, 8 | sortingDirection: DealsSortingDirection? = null 9 | ) = DealsQueryParameters(searchQuery, matchTermsExactly, stores, sortingCriteria, sortingDirection) 10 | 11 | data class DealsQueryParameters( 12 | val searchQuery: String?, 13 | val matchTermsExactly: Boolean?, 14 | val stores: List?, 15 | val sorting: DealsSorting?, 16 | val sortingDirection: DealsSortingDirection? 17 | ) 18 | 19 | enum class DealsSorting(val value: String) { 20 | DealRating("Deal Rating"), 21 | Title("Title"), 22 | Savings("Savings"), 23 | Price("Price"), 24 | Metacritic("Metacritic"), 25 | Reviews("Reviews"), 26 | Release("Release"), 27 | Store("Store"), 28 | Recent("Recent") 29 | } 30 | 31 | enum class DealsSortingDirection { 32 | Ascending, 33 | Descending 34 | } 35 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/repository/query/GamesQueryParameters.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.repository.query 2 | 3 | fun GamesQuery( 4 | searchQuery: String? = null, 5 | isFuzzinessEnabled: Boolean? = null, 6 | matchTermsExactly: Boolean? = null, 7 | parentPlatforms: List? = null, 8 | platforms: List? = null, 9 | stores: List? = null, 10 | developers: List? = null, 11 | genres: List? = null, 12 | tags: List? = null, 13 | dates: List? = null, 14 | metaCriticScores: List? = null, 15 | sortingCriteria: GamesSortingCriteria? = null 16 | ) = GamesQueryParameters(searchQuery, isFuzzinessEnabled, matchTermsExactly, parentPlatforms, platforms, stores, developers, genres, tags, dates, metaCriticScores, sortingCriteria) 17 | 18 | data class GamesQueryParameters( 19 | val searchQuery: String?, 20 | val isFuzzinessEnabled: Boolean?, 21 | val matchTermsExactly: Boolean?, 22 | val parentPlatforms: List?, 23 | val platforms: List?, 24 | val stores: List?, 25 | val developers: List?, 26 | val genres: List?, 27 | val tags: List?, 28 | val dates: List?, 29 | val metaCriticScores: List?, 30 | val sortingCriteria: GamesSortingCriteria? 31 | ) { 32 | companion object { 33 | val Empty = GamesQueryParameters( 34 | null, 35 | null, 36 | null, 37 | null, 38 | null, 39 | null, 40 | null, 41 | null, 42 | null, 43 | null, 44 | null, 45 | null 46 | ) 47 | } 48 | } 49 | 50 | enum class GamesSortingCriteria(val apiName: String) { 51 | NameAscending("name"), // A-Z 52 | NameDescending("-name"), // Z-A 53 | ReleasedAscending("released"), 54 | ReleasedDescending("-released"), 55 | DateAddedAscending("added"), 56 | DateAddedDescending("-added"), 57 | DateCreatedAscending("created"), 58 | DateCreatedDescending("-created"), 59 | DateUpdatedAscending("updated"), 60 | DateUpdatedDescending("-updated"), 61 | RatingAscending("rating"), 62 | RatingDescending("-rating"), 63 | MetacriticScoreAscending("metacritic"), 64 | MetacriticScoreDescending("-metacritic") 65 | } 66 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/core/repository/query/GiveawaysQueryParameters.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.repository.query 2 | 3 | data class GiveawaysQueryParameters( 4 | val platforms: List?, 5 | val stores: List?, 6 | val sorting: GiveawaysSorting? 7 | ) 8 | 9 | enum class GiveawayPlatform(val value: String) { 10 | PC("pc"), 11 | Playstation4("ps4"), 12 | Playstation5("ps5"), 13 | XboxOne("xbox-one"), 14 | XboxSeriesXs("xbox-series-xs"), 15 | Xbox360("xbox-360"), 16 | Android("android"), 17 | IOS("ios"), 18 | NintendoSwitch("switch") 19 | } 20 | 21 | enum class GiveawayStore(val value: String) { 22 | Steam("steam"), 23 | EpicGames("epic-games-store"), 24 | Ubisoft("ubisoft"), 25 | GOG("gog"), 26 | Itchio("itchio"), 27 | Origin("origin"), 28 | Battlenet("battlenet") 29 | } 30 | 31 | enum class GiveawaysSorting(val value: String) { 32 | Value("value"), 33 | Popularity("popularity"), 34 | Date("date") 35 | } 36 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/CoroutinesComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.Singleton 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.SupervisorJob 7 | import me.tatarka.inject.annotations.Provides 8 | 9 | @JvmInline 10 | value class ApplicationScope(val value: CoroutineScope) 11 | 12 | interface CoroutinesComponent { 13 | 14 | @Singleton 15 | @Provides 16 | fun providesApplicationCoroutineScope(): ApplicationScope { 17 | return ApplicationScope(CoroutineScope(SupervisorJob() + Dispatchers.IO)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/DatabaseDispatcherComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | 5 | expect interface DatabaseDispatcherComponent 6 | 7 | @JvmInline 8 | value class DatabaseDispatcher(val dispatcher: CoroutineDispatcher) 9 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/LoggingComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import co.touchlab.kermit.Logger 4 | import co.touchlab.kermit.loggerConfigInit 5 | import co.touchlab.kermit.platformLogWriter 6 | import com.mr3y.ludi.shared.core.internal.KermitLogger 7 | import com.mr3y.ludi.shared.di.annotations.Singleton 8 | import me.tatarka.inject.annotations.Provides 9 | 10 | interface LoggingComponent { 11 | 12 | @Singleton 13 | @Provides 14 | fun provideLoggerInstance(): Logger { 15 | return Logger(config = loggerConfigInit(platformLogWriter())) 16 | } 17 | 18 | @Singleton 19 | @Provides 20 | fun provideKermitLoggerInstance(kermitLogger: Logger): com.mr3y.ludi.shared.core.Logger { 21 | return KermitLogger(delegatingLogger = kermitLogger) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/RESTfulDataSourcesComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.core.network.datasources.internal.CheapSharkDataSource 4 | import com.mr3y.ludi.shared.core.network.datasources.internal.CheapSharkDataSourceImpl 5 | import com.mr3y.ludi.shared.core.network.datasources.internal.GamerPowerDataSource 6 | import com.mr3y.ludi.shared.core.network.datasources.internal.GamerPowerDataSourceImpl 7 | import com.mr3y.ludi.shared.core.network.datasources.internal.RAWGDataSource 8 | import com.mr3y.ludi.shared.core.network.datasources.internal.RAWGDataSourceImpl 9 | import com.mr3y.ludi.shared.di.annotations.Singleton 10 | import io.ktor.client.HttpClient 11 | import me.tatarka.inject.annotations.Provides 12 | 13 | interface RESTfulDataSourcesComponent { 14 | 15 | @Provides 16 | @Singleton 17 | fun provideRAWGDataSourceInstance(client: HttpClient): RAWGDataSource { 18 | return RAWGDataSourceImpl(client) 19 | } 20 | 21 | @Provides 22 | @Singleton 23 | fun provideCheapSharkDataSourceInstance(client: HttpClient): CheapSharkDataSource { 24 | return CheapSharkDataSourceImpl(client) 25 | } 26 | 27 | @Provides 28 | @Singleton 29 | fun provideGamerPowerDataSourceInstance(client: HttpClient): GamerPowerDataSource { 30 | return GamerPowerDataSourceImpl(client) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/RSSFeedDataSourcesComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.core.network.datasources.RSSFeedDataSource 4 | import com.mr3y.ludi.shared.core.network.datasources.internal.DefaultRSSFeedDataSource 5 | import com.mr3y.ludi.shared.di.annotations.Singleton 6 | import me.tatarka.inject.annotations.Provides 7 | 8 | interface RSSFeedDataSourcesComponent { 9 | 10 | @Provides 11 | @Singleton 12 | fun DefaultRSSFeedDataSource.bind(): RSSFeedDataSource = this 13 | } 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/RepositoriesComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.core.repository.DealsRepository 4 | import com.mr3y.ludi.shared.core.repository.GamesRepository 5 | import com.mr3y.ludi.shared.core.repository.NewsRepository 6 | import com.mr3y.ludi.shared.core.repository.internal.DefaultDealsRepository 7 | import com.mr3y.ludi.shared.core.repository.internal.DefaultGamesRepository 8 | import com.mr3y.ludi.shared.core.repository.internal.DefaultNewsRepository 9 | import com.mr3y.ludi.shared.di.annotations.Singleton 10 | import me.tatarka.inject.annotations.Provides 11 | 12 | interface RepositoriesComponent { 13 | 14 | @Provides 15 | @Singleton 16 | fun DefaultNewsRepository.bind(): NewsRepository = this 17 | 18 | @Provides 19 | @Singleton 20 | fun DefaultGamesRepository.bind(): GamesRepository = this 21 | 22 | @Provides 23 | @Singleton 24 | fun DefaultDealsRepository.bind(): DealsRepository = this 25 | } 26 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/SharedApplicationComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.LudiSharedState 4 | 5 | interface SharedApplicationComponent : 6 | CoroutinesComponent, 7 | DataStoreComponent, 8 | LoggingComponent, 9 | NetworkComponent, 10 | TimeSourceComponent, 11 | SharedDatabaseComponent, 12 | RepositoriesComponent, 13 | RESTfulDataSourcesComponent, 14 | RSSFeedDataSourcesComponent { 15 | val appState: LudiSharedState 16 | } 17 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/SharedDatabaseComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.core.database.DriverFactory 4 | import com.mr3y.ludi.shared.core.database.LudiDatabaseWrapper 5 | import com.mr3y.ludi.shared.core.database.createDatabase 6 | import com.mr3y.ludi.shared.core.database.dao.ArticleEntitiesDao 7 | import com.mr3y.ludi.shared.core.database.dao.DefaultArticleEntitiesDao 8 | import com.mr3y.ludi.shared.di.annotations.Singleton 9 | import me.tatarka.inject.annotations.Provides 10 | 11 | interface SharedDatabaseComponent : DatabaseDispatcherComponent { 12 | 13 | @Singleton 14 | @Provides 15 | fun provideDatabaseInstance(platformSqlDriverFactory: DriverFactory): LudiDatabaseWrapper { 16 | return LudiDatabaseWrapper(value = createDatabase(platformSqlDriverFactory)) 17 | } 18 | 19 | @Singleton 20 | @Provides 21 | fun provideArticleEntitiesDaoInstance(database: LudiDatabaseWrapper, dispatcher: DatabaseDispatcher): ArticleEntitiesDao { 22 | return DefaultArticleEntitiesDao(database.value, dispatcher) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/SharedDealsFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import cafe.adriel.voyager.core.model.ScreenModel 4 | import com.mr3y.ludi.shared.di.annotations.DealsFeatureScope 5 | import com.mr3y.ludi.shared.ui.presenter.DealsViewModel 6 | import me.tatarka.inject.annotations.Provides 7 | 8 | interface SharedDealsFeatureComponent { 9 | 10 | val bind: ScreenModel 11 | 12 | @Provides 13 | @DealsFeatureScope 14 | fun DealsViewModel.provideDealsViewModelInstance(): ScreenModel = this 15 | } 16 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/SharedDiscoverFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import cafe.adriel.voyager.core.model.ScreenModel 4 | import com.mr3y.ludi.shared.di.annotations.DiscoverFeatureScope 5 | import com.mr3y.ludi.shared.ui.presenter.DefaultDiscoverPagingFactory 6 | import com.mr3y.ludi.shared.ui.presenter.DiscoverPagingFactory 7 | import com.mr3y.ludi.shared.ui.presenter.DiscoverViewModel 8 | import me.tatarka.inject.annotations.Provides 9 | 10 | interface SharedDiscoverFeatureComponent { 11 | 12 | val bind: ScreenModel 13 | 14 | @Provides 15 | @DiscoverFeatureScope 16 | fun DiscoverViewModel.provideDiscoverViewModelInstance(): ScreenModel = this 17 | 18 | @Provides 19 | @DiscoverFeatureScope 20 | fun DefaultDiscoverPagingFactory.bind(): DiscoverPagingFactory = this 21 | } 22 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/SharedNewsFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import cafe.adriel.voyager.core.model.ScreenModel 4 | import com.mr3y.ludi.shared.core.repository.NewsRepository 5 | import com.mr3y.ludi.shared.di.annotations.NewsFeatureScope 6 | import com.mr3y.ludi.shared.ui.datastore.FollowedNewsDataSourcesDataStore 7 | import com.mr3y.ludi.shared.ui.presenter.NewsFeedThrottler 8 | import com.mr3y.ludi.shared.ui.presenter.NewsViewModel 9 | import me.tatarka.inject.annotations.Provides 10 | 11 | interface SharedNewsFeatureComponent { 12 | 13 | val bind: ScreenModel 14 | 15 | @Provides 16 | @NewsFeatureScope 17 | fun provideNewsViewModelInstance( 18 | newsRepository: NewsRepository, 19 | followedNewsDataSourcesDataStore: FollowedNewsDataSourcesDataStore, 20 | throttler: NewsFeedThrottler 21 | ): ScreenModel { 22 | return NewsViewModel(newsRepository, followedNewsDataSourcesDataStore.value, throttler) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/SharedOnboardingFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import cafe.adriel.voyager.core.model.ScreenModel 4 | import com.mr3y.ludi.shared.di.annotations.OnboardingFeatureScope 5 | import com.mr3y.ludi.shared.ui.presenter.OnBoardingViewModel 6 | import me.tatarka.inject.annotations.Provides 7 | 8 | interface SharedOnboardingFeatureComponent { 9 | 10 | val bind: ScreenModel 11 | 12 | @Provides 13 | @OnboardingFeatureScope 14 | fun OnBoardingViewModel.provideOnboardingViewModelInstance(): ScreenModel = this 15 | } 16 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/SharedSettingsFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import cafe.adriel.voyager.core.model.ScreenModel 4 | import com.mr3y.ludi.shared.di.annotations.SettingsFeatureScope 5 | import com.mr3y.ludi.shared.ui.presenter.SettingsViewModel 6 | import me.tatarka.inject.annotations.Provides 7 | 8 | interface SharedSettingsFeatureComponent { 9 | 10 | val bind: ScreenModel 11 | 12 | @Provides 13 | @SettingsFeatureScope 14 | fun SettingsViewModel.provideSettingsViewModelInstance(): ScreenModel = this 15 | } 16 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/TimeSourceComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | expect interface TimeSourceComponent 4 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/VoyagerIntegration.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import androidx.compose.runtime.Composable 4 | import cafe.adriel.voyager.core.model.ScreenModel 5 | import cafe.adriel.voyager.core.screen.Screen 6 | 7 | @Composable 8 | expect inline fun Screen.getScreenModel(tag: String? = null): T 9 | 10 | @Composable 11 | expect inline fun Screen.getScreenModel(tag: String? = null, arg1: P): T 12 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/annotations/DealsFeatureScope.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di.annotations 2 | 3 | import me.tatarka.inject.annotations.Scope 4 | 5 | @Scope 6 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER) 7 | annotation class DealsFeatureScope 8 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/annotations/DiscoverFeatureScope.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di.annotations 2 | 3 | import me.tatarka.inject.annotations.Scope 4 | 5 | @Scope 6 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER) 7 | annotation class DiscoverFeatureScope 8 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/annotations/NewsFeatureScope.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di.annotations 2 | 3 | import me.tatarka.inject.annotations.Scope 4 | 5 | @Scope 6 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER) 7 | annotation class NewsFeatureScope 8 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/annotations/OnboardingFeatureScope.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di.annotations 2 | 3 | import me.tatarka.inject.annotations.Scope 4 | 5 | @Scope 6 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER) 7 | annotation class OnboardingFeatureScope 8 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/annotations/SettingsFeatureScope.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di.annotations 2 | 3 | import me.tatarka.inject.annotations.Scope 4 | 5 | @Scope 6 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER) 7 | annotation class SettingsFeatureScope 8 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/di/annotations/Singleton.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di.annotations 2 | 3 | import me.tatarka.inject.annotations.Scope 4 | 5 | @Scope 6 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER) 7 | annotation class Singleton 8 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/adaptive/LocalWindowSizeClass.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.adaptive 2 | 3 | import androidx.compose.material3.windowsizeclass.WindowSizeClass 4 | import androidx.compose.runtime.staticCompositionLocalOf 5 | 6 | val LocalWindowSizeClass = staticCompositionLocalOf { error("No WindowSizeClass available") } 7 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/components/LudiAsyncImage.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.components 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Alignment 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.graphics.FilterQuality 7 | import androidx.compose.ui.graphics.ImageBitmap 8 | import androidx.compose.ui.graphics.painter.Painter 9 | import androidx.compose.ui.layout.ContentScale 10 | import coil3.Bitmap 11 | import coil3.compose.AsyncImagePainter 12 | import coil3.compose.LocalPlatformContext 13 | import coil3.request.ImageRequest 14 | 15 | @Composable 16 | fun LudiAsyncImage( 17 | url: String?, 18 | contentDescription: String?, 19 | modifier: Modifier = Modifier, 20 | placeholder: Painter? = null, 21 | error: Painter? = null, 22 | onState: ((AsyncImagePainter.State) -> Unit)? = null, 23 | contentScale: ContentScale = ContentScale.Fit, 24 | alignment: Alignment = Alignment.Center, 25 | customMemoryCacheKey: String? = null, 26 | allowBitmapHardware: Boolean = true, 27 | filterQuality: FilterQuality = FilterQuality.Low 28 | ) { 29 | val context = LocalPlatformContext.current 30 | coil3.compose.AsyncImage( 31 | model = ImageRequest.Builder(context) 32 | .data(url) 33 | .memoryCacheKey(customMemoryCacheKey) 34 | .platformSpecificConfig(allowBitmapHardware) 35 | .build(), 36 | contentDescription = contentDescription, 37 | transform = { state -> 38 | when (state) { 39 | is AsyncImagePainter.State.Loading -> AsyncImagePainter.State.Loading(painter = placeholder) 40 | is AsyncImagePainter.State.Error -> AsyncImagePainter.State.Error(painter = error, result = state.result) 41 | else -> state 42 | } 43 | }, 44 | onState = onState, 45 | contentScale = contentScale, 46 | alignment = alignment, 47 | filterQuality = filterQuality, 48 | modifier = modifier 49 | ) 50 | } 51 | 52 | expect fun ImageRequest.Builder.platformSpecificConfig(allowHardware: Boolean): ImageRequest.Builder 53 | 54 | expect fun Bitmap.asImageBitmap(): ImageBitmap 55 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/components/LudiErrorBox.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.Error 9 | import androidx.compose.material3.Icon 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.graphics.vector.rememberVectorPainter 16 | import androidx.compose.ui.unit.dp 17 | import com.mr3y.ludi.shared.ui.resources.isDesktopPlatform 18 | 19 | @Composable 20 | fun LudiErrorBox(modifier: Modifier = Modifier) { 21 | Box( 22 | contentAlignment = Alignment.Center, 23 | modifier = modifier.background(color = MaterialTheme.colorScheme.surface) 24 | ) { 25 | Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { 26 | Icon( 27 | painter = rememberVectorPainter(image = Icons.Filled.Error), 28 | contentDescription = null, 29 | tint = MaterialTheme.colorScheme.error 30 | ) 31 | val errorMessage = if (isDesktopPlatform()) { 32 | "Unexpected Error happened. try to refresh, and see if the problem persists." 33 | } else { 34 | "Unexpected Error happened. pull to refresh, and see if the problem persists." 35 | } 36 | Text( 37 | text = errorMessage, 38 | style = MaterialTheme.typography.titleLarge, 39 | color = MaterialTheme.colorScheme.onSurface 40 | ) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/components/LudiHeaders.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.components 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.semantics.heading 8 | import androidx.compose.ui.semantics.semantics 9 | import androidx.compose.ui.text.style.TextAlign 10 | 11 | @Composable 12 | fun LudiSectionHeader( 13 | text: String, 14 | modifier: Modifier = Modifier 15 | ) { 16 | Text( 17 | text = text, 18 | color = MaterialTheme.colorScheme.onBackground, 19 | style = MaterialTheme.typography.headlineMedium, 20 | modifier = modifier.semantics { 21 | heading() 22 | }, 23 | textAlign = TextAlign.Start 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/components/LudiNavRail.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.ColumnScope 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.WindowInsets 8 | import androidx.compose.foundation.layout.fillMaxHeight 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.widthIn 12 | import androidx.compose.foundation.layout.windowInsetsPadding 13 | import androidx.compose.foundation.selection.selectableGroup 14 | import androidx.compose.material3.NavigationRailDefaults 15 | import androidx.compose.material3.Surface 16 | import androidx.compose.material3.contentColorFor 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.graphics.Color 21 | import androidx.compose.ui.unit.dp 22 | 23 | @Composable 24 | fun LudiNavigationRail( 25 | modifier: Modifier = Modifier, 26 | containerColor: Color = NavigationRailDefaults.ContainerColor, 27 | contentColor: Color = contentColorFor(containerColor), 28 | header: @Composable (ColumnScope.() -> Unit)? = null, 29 | windowInsets: WindowInsets = NavigationRailDefaults.windowInsets, 30 | content: @Composable ColumnScope.() -> Unit 31 | ) { 32 | Surface( 33 | color = containerColor, 34 | contentColor = contentColor, 35 | modifier = modifier 36 | ) { 37 | Column( 38 | Modifier 39 | .fillMaxHeight() 40 | .windowInsetsPadding(windowInsets) 41 | .widthIn(min = 80.dp) 42 | .padding(vertical = 4.dp) 43 | .selectableGroup(), 44 | horizontalAlignment = Alignment.CenterHorizontally, 45 | verticalArrangement = Arrangement.Center 46 | ) { 47 | if (header != null) { 48 | header() 49 | Spacer(Modifier.height(8.dp)) 50 | } 51 | content() 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/components/LudiNoInternet.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.components 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | 6 | @Composable 7 | expect fun AnimatedNoInternetBanner(modifier: Modifier = Modifier) 8 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/components/LudiParallaxAlignment.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.components 2 | 3 | import androidx.compose.foundation.lazy.LazyListState 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.Stable 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.unit.IntOffset 9 | import androidx.compose.ui.unit.IntSize 10 | import androidx.compose.ui.unit.LayoutDirection 11 | import kotlin.math.roundToInt 12 | 13 | @Composable 14 | fun rememberParallaxAlignment( 15 | lazyListState: LazyListState, 16 | key: Any? 17 | ): Alignment { 18 | return remember(lazyListState) { 19 | ParallaxAlignment( 20 | horizontalBias = { 21 | if (key == null) { 22 | return@ParallaxAlignment 0f 23 | } 24 | 25 | // Read the LazyListState layout info 26 | val layoutInfo = lazyListState.layoutInfo 27 | // Find the layout info of this item 28 | val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.key == key } ?: return@ParallaxAlignment 0f 29 | 30 | val adjustedOffset = itemInfo.offset - layoutInfo.viewportStartOffset 31 | (adjustedOffset / itemInfo.size.toFloat()).coerceIn(-1f, 1f) 32 | } 33 | ) 34 | } 35 | } 36 | 37 | @Stable 38 | class ParallaxAlignment( 39 | private val horizontalBias: () -> Float = { 0f }, 40 | private val verticalBias: () -> Float = { 0f } 41 | ) : Alignment { 42 | override fun align( 43 | size: IntSize, 44 | space: IntSize, 45 | layoutDirection: LayoutDirection 46 | ): IntOffset { 47 | // Convert to Px first and only round at the end, to avoid rounding twice while calculating 48 | // the new positions 49 | val centerX = (space.width - size.width).toFloat() / 2f 50 | val centerY = (space.height - size.height).toFloat() / 2f 51 | val resolvedHorizontalBias = if (layoutDirection == LayoutDirection.Ltr) { 52 | horizontalBias() 53 | } else { 54 | -1 * horizontalBias() 55 | } 56 | 57 | val x = centerX * (1 + resolvedHorizontalBias) 58 | val y = centerY * (1 + verticalBias()) 59 | return IntOffset(x.roundToInt(), y.roundToInt()) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/components/LudiRefreshIconButton.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.components 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.foundation.layout.requiredSize 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.Refresh 8 | import androidx.compose.material3.Icon 9 | import androidx.compose.material3.IconButton 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.semantics.contentDescription 15 | import androidx.compose.ui.semantics.semantics 16 | import androidx.compose.ui.unit.dp 17 | import cafe.adriel.lyricist.LocalStrings 18 | 19 | @Composable 20 | fun RefreshIconButton( 21 | onClick: () -> Unit, 22 | modifier: Modifier = Modifier, 23 | tint: Color = MaterialTheme.colorScheme.inverseSurface 24 | ) { 25 | val strings = LocalStrings.current 26 | IconButton( 27 | onClick = onClick, 28 | modifier = modifier 29 | .requiredSize(48.dp) 30 | .semantics { 31 | contentDescription = strings.click_to_refresh 32 | } 33 | ) { 34 | Icon( 35 | imageVector = Icons.Filled.Refresh, 36 | contentDescription = null, 37 | modifier = Modifier 38 | .padding(8.dp) 39 | .fillMaxSize(), 40 | tint = tint 41 | ) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/components/placeholder/LudiPlaceholderModifier.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.components.placeholder 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.composed 6 | import androidx.compose.ui.graphics.Color 7 | 8 | fun Modifier.defaultPlaceholder( 9 | isVisible: Boolean, 10 | highlight: PlaceholderHighlight? = null, 11 | color: Color? = null 12 | ): Modifier = composed { 13 | placeholder( 14 | visible = isVisible, 15 | highlight = highlight ?: PlaceholderHighlight.shimmer(), 16 | color = color ?: MaterialTheme.colorScheme.surfaceVariant, 17 | shape = null 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/datastore/FavouriteGamesSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.datastore 2 | 3 | import androidx.datastore.core.CorruptionException 4 | import androidx.datastore.core.okio.OkioSerializer 5 | import com.mr3y.ludi.datastore.model.UserFavouriteGames 6 | import okio.BufferedSink 7 | import okio.BufferedSource 8 | import okio.IOException 9 | 10 | object FavouriteGamesSerializer : OkioSerializer { 11 | override val defaultValue: UserFavouriteGames = UserFavouriteGames() 12 | 13 | override suspend fun readFrom(source: BufferedSource): UserFavouriteGames { 14 | try { 15 | return UserFavouriteGames.ADAPTER.decode(source) 16 | } catch (exception: IOException) { 17 | throw CorruptionException("Cannot read UserFavouriteGames proto.", exception) 18 | } 19 | } 20 | 21 | override suspend fun writeTo(t: UserFavouriteGames, sink: BufferedSink) { 22 | UserFavouriteGames.ADAPTER.encode(sink, t) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/datastore/FavouriteGenresSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.datastore 2 | 3 | import androidx.datastore.core.CorruptionException 4 | import androidx.datastore.core.okio.OkioSerializer 5 | import com.mr3y.ludi.datastore.model.UserFavouriteGenres 6 | import okio.BufferedSink 7 | import okio.BufferedSource 8 | import okio.IOException 9 | 10 | object FavouriteGenresSerializer : OkioSerializer { 11 | override val defaultValue: UserFavouriteGenres = UserFavouriteGenres() 12 | 13 | override suspend fun readFrom(source: BufferedSource): UserFavouriteGenres { 14 | try { 15 | return UserFavouriteGenres.ADAPTER.decode(source) 16 | } catch (exception: IOException) { 17 | throw CorruptionException("Cannot read UserFavouriteGenres proto.", exception) 18 | } 19 | } 20 | 21 | override suspend fun writeTo(t: UserFavouriteGenres, sink: BufferedSink) { 22 | UserFavouriteGenres.ADAPTER.encode(sink, t) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/datastore/FollowedNewsDataSourceSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.datastore 2 | 3 | import androidx.datastore.core.CorruptionException 4 | import androidx.datastore.core.okio.OkioSerializer 5 | import com.mr3y.ludi.datastore.model.FollowedNewsDataSources 6 | import okio.BufferedSink 7 | import okio.BufferedSource 8 | import okio.IOException 9 | 10 | object FollowedNewsDataSourceSerializer : OkioSerializer { 11 | override val defaultValue: FollowedNewsDataSources = FollowedNewsDataSources() 12 | 13 | override suspend fun readFrom(source: BufferedSource): FollowedNewsDataSources { 14 | try { 15 | return FollowedNewsDataSources.ADAPTER.decode(source) 16 | } catch (exception: IOException) { 17 | throw CorruptionException("Cannot read FollowedNewsDataSources proto.", exception) 18 | } 19 | } 20 | 21 | override suspend fun writeTo(t: FollowedNewsDataSources, sink: BufferedSink) { 22 | FollowedNewsDataSources.ADAPTER.encode(sink, t) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/datastore/PreferencesKeys.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.datastore 2 | 3 | import androidx.datastore.preferences.core.booleanPreferencesKey 4 | import androidx.datastore.preferences.core.stringPreferencesKey 5 | 6 | object PreferencesKeys { 7 | val SelectedThemeKey = stringPreferencesKey("selected_theme") 8 | val DynamicColorKey = booleanPreferencesKey("dynamic_color") 9 | val OnBoardingScreenKey = booleanPreferencesKey("show_onboarding_screen") 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/datastore/ProtoDataStoreMutator.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.datastore 2 | 3 | import com.mr3y.ludi.datastore.model.FollowedNewsDataSource 4 | import com.mr3y.ludi.datastore.model.FollowedNewsDataSources 5 | import com.mr3y.ludi.datastore.model.UserFavouriteGame 6 | import com.mr3y.ludi.datastore.model.UserFavouriteGames 7 | import com.mr3y.ludi.datastore.model.UserFavouriteGenre 8 | import com.mr3y.ludi.datastore.model.UserFavouriteGenres 9 | import kotlinx.coroutines.flow.Flow 10 | 11 | interface ProtoDataStoreMutator { 12 | 13 | val followedNewsDataSources: Flow 14 | 15 | val favouriteGames: Flow 16 | 17 | val favouriteGenres: Flow 18 | 19 | suspend fun followNewsDataSource(source: FollowedNewsDataSource) 20 | 21 | suspend fun unFollowNewsDataSource(source: FollowedNewsDataSource) 22 | 23 | suspend fun addGameToFavourites(game: UserFavouriteGame) 24 | 25 | suspend fun removeGameFromFavourites(game: UserFavouriteGame) 26 | 27 | suspend fun addGenreToFavourites(genre: UserFavouriteGenre) 28 | 29 | suspend fun removeGenreFromFavourites(genre: UserFavouriteGenre) 30 | } 31 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/datastore/Wrappers.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.datastore 2 | 3 | import androidx.datastore.core.DataStore 4 | import com.mr3y.ludi.datastore.model.FollowedNewsDataSources 5 | import com.mr3y.ludi.datastore.model.UserFavouriteGames 6 | import com.mr3y.ludi.datastore.model.UserFavouriteGenres 7 | 8 | // kotlin-inject fails to resolve types generated by wire, 9 | // As a workaround we have to wrap those types to make them resolvable. 10 | // See: https://github.com/evant/kotlin-inject/issues/316 11 | 12 | @JvmInline 13 | value class UserFavoriteGamesDataStore(val value: DataStore) 14 | 15 | @JvmInline 16 | value class FollowedNewsDataSourcesDataStore(val value: DataStore) 17 | 18 | @JvmInline 19 | value class UserFavoriteGenresDataStore(val value: DataStore) 20 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/navigation/BottomBarTab.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.navigation 2 | 3 | import androidx.compose.ui.graphics.vector.ImageVector 4 | 5 | interface BottomBarTab { 6 | 7 | val label: String 8 | 9 | val icon: ImageVector 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/navigation/PreferencesType.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.navigation 2 | 3 | enum class PreferencesType { 4 | NewsDataSources, 5 | Genres, 6 | Games 7 | } 8 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/presenter/NewsFeedThrottler.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.presenter 2 | 3 | import com.mr3y.ludi.shared.di.annotations.Singleton 4 | import me.tatarka.inject.annotations.Inject 5 | import kotlin.time.Duration.Companion.minutes 6 | import kotlin.time.TimeMark 7 | import kotlin.time.TimeSource 8 | 9 | /** 10 | * A simple plain class that has a longer lifetime than our [NewsViewModel] which helps us take correct 11 | * decisions on when to force a data refresh & request updated feed results from network. 12 | * For example, when the NewsViewModel gets popped off the backstack and then the user comes back quickly to NewsScreen, 13 | * we don't want to refresh & request updated data when the previously fetched data is still fresh & valid 14 | */ 15 | @Inject 16 | @Singleton 17 | class NewsFeedThrottler(private val timeSource: TimeSource) { 18 | 19 | private var lastUpdateTimeMark: TimeMark? = null 20 | 21 | fun commitSuccessfulUpdate() { 22 | lastUpdateTimeMark = timeSource.markNow() 23 | } 24 | 25 | fun allowRefreshingData() = lastUpdateTimeMark == null || lastUpdateTimeMark!!.elapsedNow() > 30.minutes 26 | } 27 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/presenter/model/DealsState.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.presenter.model 2 | 3 | import app.cash.paging.PagingData 4 | import com.mr3y.ludi.shared.core.model.Deal 5 | import com.mr3y.ludi.shared.core.model.GiveawayEntry 6 | import com.mr3y.ludi.shared.core.model.Result 7 | import com.mr3y.ludi.shared.core.repository.query.DealsSorting 8 | import com.mr3y.ludi.shared.core.repository.query.DealsSortingDirection 9 | import com.mr3y.ludi.shared.core.repository.query.GiveawayPlatform 10 | import com.mr3y.ludi.shared.core.repository.query.GiveawayStore 11 | import com.mr3y.ludi.shared.core.repository.query.GiveawaysSorting 12 | import kotlinx.coroutines.flow.Flow 13 | 14 | data class DealsState( 15 | val deals: Flow>, 16 | val giveaways: Result, Throwable>, 17 | val dealsFiltersState: DealsFiltersState, 18 | val giveawaysFiltersState: GiveawaysFiltersState, 19 | val selectedTab: Int, 20 | val isRefreshingDeals: Boolean, 21 | val isRefreshingGiveaways: Boolean 22 | ) 23 | 24 | data class DealsFiltersState( 25 | val currentPage: Int, 26 | val allStores: Set, 27 | val selectedStores: Set, 28 | val sortingCriteria: DealsSorting?, 29 | val sortingDirection: DealsSortingDirection? 30 | ) 31 | 32 | data class DealStore( 33 | val id: Int, 34 | val label: String 35 | ) 36 | 37 | data class GiveawaysFiltersState( 38 | val allPlatforms: Set, 39 | val selectedPlatforms: Set, 40 | val allStores: Set, 41 | val selectedStores: Set, 42 | val sortingCriteria: GiveawaysSorting? 43 | ) 44 | 45 | sealed interface DealsUiEvents { 46 | 47 | data class AddToSelectedDealsStores(val store: DealStore) : DealsUiEvents 48 | 49 | data class RemoveFromSelectedDealsStores(val store: DealStore) : DealsUiEvents 50 | 51 | data class AddToSelectedGiveawaysStores(val store: GiveawayStore) : DealsUiEvents 52 | 53 | data class RemoveFromSelectedGiveawaysStores(val store: GiveawayStore) : DealsUiEvents 54 | 55 | data class AddToSelectedGiveawaysPlatforms(val platform: GiveawayPlatform) : DealsUiEvents 56 | 57 | data class RemoveFromSelectedGiveawaysPlatforms(val platform: GiveawayPlatform) : DealsUiEvents 58 | 59 | data object RefreshDeals : DealsUiEvents 60 | 61 | data object RefreshDealsComplete : DealsUiEvents 62 | 63 | data object RefreshGiveaways : DealsUiEvents 64 | 65 | data class SelectTab(val tabIndex: Int) : DealsUiEvents 66 | } 67 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/presenter/model/DiscoverState.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.presenter.model 2 | 3 | import app.cash.paging.PagingData 4 | import com.mr3y.ludi.shared.core.model.Game 5 | import kotlinx.coroutines.flow.Flow 6 | 7 | data class DiscoverState( 8 | val filtersState: DiscoverFiltersState, 9 | val gamesState: DiscoverStateGames, 10 | val isRefreshing: Boolean 11 | ) 12 | 13 | sealed interface DiscoverStateGames { 14 | 15 | data class SuggestedGames( 16 | val trendingGames: Flow>, 17 | val topRatedGames: Flow>, 18 | val favGenresBasedGames: Flow>?, 19 | val multiplayerGames: Flow>, 20 | val freeGames: Flow>, 21 | val storyGames: Flow>, 22 | val boardGames: Flow>, 23 | val eSportsGames: Flow>, 24 | val raceGames: Flow>, 25 | val puzzleGames: Flow>, 26 | val soundtrackGames: Flow> 27 | ) : DiscoverStateGames 28 | 29 | data class SearchQueryBasedGames( 30 | val games: Flow> 31 | ) : DiscoverStateGames 32 | } 33 | 34 | data class DiscoverFiltersState( 35 | val currentPage: Int, 36 | val allPlatforms: Set, 37 | val selectedPlatforms: Set, 38 | val allStores: Set, 39 | val selectedStores: Set, 40 | val allTags: Set, 41 | val selectedTags: Set, 42 | val sortingCriteria: SortingCriteria? 43 | ) 44 | 45 | data class Platform( 46 | val id: Int, 47 | val label: String 48 | ) 49 | 50 | data class Store( 51 | val id: Int, 52 | val label: String 53 | ) 54 | 55 | data class Tag( 56 | val id: Int, 57 | val label: String 58 | ) 59 | 60 | data class SortingCriteria( 61 | val criteria: Criteria, 62 | val order: Order 63 | ) 64 | 65 | enum class Criteria { 66 | Name, 67 | DateReleased, 68 | DateAdded, 69 | DateCreated, 70 | DateUpdated, 71 | Rating, 72 | Metascore 73 | } 74 | enum class Order { 75 | Ascending, 76 | Descending 77 | } 78 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/presenter/model/EditPreferencesState.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.presenter.model 2 | 3 | import com.mr3y.ludi.shared.core.model.GameGenre 4 | import com.mr3y.ludi.shared.core.model.Result 5 | 6 | sealed interface EditPreferencesState { 7 | @JvmInline 8 | value class FavouriteGames(val favouriteGames: List) : EditPreferencesState 9 | data class FollowedNewsDataSources(val allNewsDataSources: List, val followedNewsDataSources: List) : EditPreferencesState 10 | data class FavouriteGenres(val allGenres: Result, Throwable>, val favouriteGenres: Set) : EditPreferencesState 11 | object Undefined : EditPreferencesState 12 | } 13 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/presenter/model/NewsState.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.presenter.model 2 | 3 | import app.cash.paging.PagingData 4 | import com.mr3y.ludi.shared.core.model.NewReleaseArticle 5 | import com.mr3y.ludi.shared.core.model.NewsArticle 6 | import com.mr3y.ludi.shared.core.model.ReviewArticle 7 | import kotlinx.coroutines.flow.Flow 8 | 9 | data class NewsState( 10 | val isRefreshing: Boolean, 11 | val newsFeed: Flow>, 12 | val reviewsFeed: Flow>, 13 | val newReleasesFeed: Flow>, 14 | val currentEvent: NewsStateEvent? = null 15 | ) 16 | 17 | sealed interface NewsStateEvent { 18 | data object FailedToFetchNetworkResults : NewsStateEvent 19 | } 20 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/presenter/model/OnboardingState.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.presenter.model 2 | 3 | import app.cash.paging.PagingData 4 | import com.mr3y.ludi.datastore.model.FollowedNewsDataSource 5 | import com.mr3y.ludi.datastore.model.UserFavouriteGame 6 | import com.mr3y.ludi.datastore.model.UserFavouriteGenre 7 | import com.mr3y.ludi.shared.core.model.Game 8 | import com.mr3y.ludi.shared.core.model.GameGenre 9 | import com.mr3y.ludi.shared.core.model.Result 10 | import com.mr3y.ludi.shared.core.model.Source 11 | import kotlinx.coroutines.flow.Flow 12 | import org.jetbrains.compose.resources.DrawableResource 13 | 14 | data class OnboardingState( 15 | val allNewsDataSources: List, 16 | val followedNewsDataSources: List, 17 | val onboardingGames: OnboardingGames, 18 | val isRefreshingGames: Boolean, 19 | val favouriteGames: List, 20 | val allGamingGenres: Result, Throwable>, 21 | val isRefreshingGenres: Boolean, 22 | val selectedGamingGenres: Set 23 | ) 24 | 25 | sealed interface OnboardingGames { 26 | val games: Flow> 27 | 28 | data class SuggestedGames(override val games: Flow>) : OnboardingGames 29 | data class SearchQueryBasedGames(override val games: Flow>) : OnboardingGames 30 | } 31 | 32 | data class NewsDataSource( 33 | val name: String, 34 | val icon: DrawableResource, 35 | val type: Source 36 | ) 37 | 38 | internal fun NewsDataSource.toFollowedNewsDataSource(): FollowedNewsDataSource { 39 | return FollowedNewsDataSource(name = name, drawableId = 0, type = type.name) 40 | } 41 | 42 | data class FavouriteGame( 43 | val id: Long, 44 | val title: String, 45 | val imageUrl: String, 46 | val rating: Float 47 | ) 48 | 49 | internal fun FavouriteGame.toUserFavouriteGame(): UserFavouriteGame { 50 | return UserFavouriteGame(id = id, name = title, thumbnailUrl = imageUrl, rating = rating) 51 | } 52 | 53 | internal fun GameGenre.toUserFavouriteGenre(): UserFavouriteGenre { 54 | return UserFavouriteGenre(id = id, name = name, imageUrl = imageUrl ?: "", slug = slug ?: "", gamesCount = gamesCount ?: 0L) 55 | } 56 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/presenter/model/SettingsState.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.presenter.model 2 | 3 | data class SettingsState( 4 | val themes: Set, 5 | val selectedTheme: Theme?, 6 | val isUsingDynamicColor: Boolean? 7 | ) 8 | 9 | enum class Theme(val label: String) { 10 | Light("Light"), 11 | Dark("Dark"), 12 | SystemDefault("System Default") 13 | } 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/presenter/model/SupportedNewsDataSources.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.presenter.model 2 | 3 | import com.mr3y.ludi.shared.core.model.Source 4 | import ludi.shared.generated.resources.Res 5 | import ludi.shared.generated.resources.brutalgamer_logo 6 | import ludi.shared.generated.resources.eurogamer_logo 7 | import ludi.shared.generated.resources.game_spot_logo 8 | import ludi.shared.generated.resources.gamerant_logo 9 | import ludi.shared.generated.resources.giant_bomb_logo 10 | import ludi.shared.generated.resources.gloriousgaming_logo 11 | import ludi.shared.generated.resources.ign_logo 12 | import ludi.shared.generated.resources.pcgamer_logo 13 | import ludi.shared.generated.resources.pcgamesn_logo 14 | import ludi.shared.generated.resources.pcinvasion_logo 15 | import ludi.shared.generated.resources.polygon_logo 16 | import ludi.shared.generated.resources.rockpapershotgun_logo 17 | import ludi.shared.generated.resources.tech_radar_logo 18 | import ludi.shared.generated.resources.thegamer_logo 19 | import ludi.shared.generated.resources.venturebeat_logo 20 | import ludi.shared.generated.resources.vg247_logo 21 | 22 | val SupportedNewsDataSources = listOf( 23 | NewsDataSource("Game spot", Res.drawable.game_spot_logo, Source.GameSpot), 24 | NewsDataSource("Giant bomb", Res.drawable.giant_bomb_logo, Source.GiantBomb), 25 | NewsDataSource("IGN", Res.drawable.ign_logo, Source.IGN), 26 | NewsDataSource("Tech Radar", Res.drawable.tech_radar_logo, Source.TechRadar), 27 | NewsDataSource("PCGamesN", Res.drawable.pcgamesn_logo, Source.PCGamesN), 28 | NewsDataSource("PCGamer", Res.drawable.pcgamer_logo, Source.PCGamer), 29 | NewsDataSource("PC Invasion", Res.drawable.pcinvasion_logo, Source.PCInvasion), 30 | NewsDataSource("Euro Gamer", Res.drawable.eurogamer_logo, Source.EuroGamer), 31 | NewsDataSource("VentureBeat", Res.drawable.venturebeat_logo, Source.VentureBeat), 32 | NewsDataSource("VG247", Res.drawable.vg247_logo, Source.VG247), 33 | NewsDataSource("Glorious Gaming", Res.drawable.gloriousgaming_logo, Source.GloriousGaming), 34 | NewsDataSource("Rock Paper Shotgun", Res.drawable.rockpapershotgun_logo, Source.RockPaperShotgun), 35 | NewsDataSource("Game Rant", Res.drawable.gamerant_logo, Source.GameRant), 36 | NewsDataSource("The Gamer", Res.drawable.thegamer_logo, Source.TheGamer), 37 | NewsDataSource("Polygon", Res.drawable.polygon_logo, Source.Polygon), 38 | NewsDataSource("Brutal Gamer", Res.drawable.brutalgamer_logo, Source.BrutalGamer) 39 | ) 40 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/resources/PlatformCompositionProvider.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.resources 2 | 3 | expect fun isDesktopPlatform(): Boolean 4 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/resources/format.kt: -------------------------------------------------------------------------------- 1 | // ktlint-disable filename 2 | package com.mr3y.ludi.shared.ui.resources 3 | 4 | expect fun Float.roundToTwoDecimalDigits(): Float 5 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/theme/ColorScheme.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.theme 2 | 3 | import androidx.compose.material3.ColorScheme 4 | import androidx.compose.runtime.Composable 5 | 6 | @Composable 7 | expect fun colorScheme( 8 | isDarkTheme: Boolean, 9 | useDynamicColors: Boolean 10 | ): ColorScheme 11 | 12 | expect fun isDynamicColorSupported(): Boolean 13 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material3.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(8.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) 12 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/com/mr3y/ludi/shared/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp 15 | ) 16 | /* Other default text styles to override 17 | labelLarge = TextStyle( 18 | fontFamily = FontFamily.Default, 19 | fontWeight = FontWeight.W500, 20 | fontSize = 14.sp 21 | ), 22 | bodySmall = TextStyle( 23 | fontFamily = FontFamily.Default, 24 | fontWeight = FontWeight.Normal, 25 | fontSize = 12.sp 26 | ) 27 | */ 28 | ) 29 | -------------------------------------------------------------------------------- /shared/src/commonMain/proto/favourite_game.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "com.mr3y.ludi.datastore.model"; 4 | option java_multiple_files = true; 5 | 6 | message UserFavouriteGame { 7 | int64 id = 1; 8 | string name = 2; 9 | string thumbnailUrl = 3; 10 | float rating = 4; 11 | } -------------------------------------------------------------------------------- /shared/src/commonMain/proto/favourite_games.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | import "favourite_game.proto"; 3 | 4 | option java_package = "com.mr3y.ludi.datastore.model"; 5 | option java_multiple_files = true; 6 | 7 | message UserFavouriteGames { 8 | repeated UserFavouriteGame favGame = 1; 9 | } -------------------------------------------------------------------------------- /shared/src/commonMain/proto/favourite_gaming_genre.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "com.mr3y.ludi.datastore.model"; 4 | option java_multiple_files = true; 5 | 6 | message UserFavouriteGenre { 7 | int32 id = 1; 8 | string name = 2; 9 | string imageUrl = 3; 10 | int64 gamesCount = 4; 11 | string slug = 5; 12 | } -------------------------------------------------------------------------------- /shared/src/commonMain/proto/favourite_gaming_genres.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | import "favourite_gaming_genre.proto"; 3 | 4 | option java_package = "com.mr3y.ludi.datastore.model"; 5 | option java_multiple_files = true; 6 | 7 | message UserFavouriteGenres { 8 | repeated UserFavouriteGenre favGenre = 1; 9 | } -------------------------------------------------------------------------------- /shared/src/commonMain/proto/news_data_source.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "com.mr3y.ludi.datastore.model"; 4 | option java_multiple_files = true; 5 | 6 | message FollowedNewsDataSource { 7 | string name = 1; 8 | int32 drawableId = 2; 9 | string type = 3; 10 | } -------------------------------------------------------------------------------- /shared/src/commonMain/proto/news_data_sources.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | import "news_data_source.proto"; 3 | 4 | option java_package = "com.mr3y.ludi.datastore.model"; 5 | option java_multiple_files = true; 6 | 7 | message FollowedNewsDataSources { 8 | repeated FollowedNewsDataSource newsDataSource = 1; 9 | } -------------------------------------------------------------------------------- /shared/src/commonMain/sqldelight/com/mr3y/ludi/shared/Article.sq: -------------------------------------------------------------------------------- 1 | import com.mr3y.ludi.shared.core.model.MarkupText; 2 | import com.mr3y.ludi.shared.core.model.Source; 3 | import com.mr3y.ludi.shared.core.model.Title; 4 | import java.time.ZonedDateTime; 5 | 6 | CREATE TABLE IF NOT EXISTS articleEntity ( 7 | title TEXT AS Title PRIMARY KEY NOT NULL, 8 | description TEXT AS MarkupText, 9 | source TEXT AS Source NOT NULL, 10 | sourceLinkUrl TEXT NOT NULL, 11 | content TEXT AS MarkupText, 12 | imageUrl TEXT, 13 | author TEXT, 14 | publicationDate TEXT AS ZonedDateTime, 15 | type TEXT NOT NULL 16 | ); 17 | 18 | CREATE INDEX IF NOT EXISTS idx_type ON articleEntity (type); 19 | 20 | queryArticles: 21 | SELECT * FROM articleEntity WHERE type == :type ORDER BY 22 | CASE WHEN type == "new_releases" THEN publicationDate END ASC, 23 | CASE WHEN type == "news" OR type == "reviews" THEN publicationDate END DESC, 24 | title ASC LIMIT :limit OFFSET :offset; 25 | 26 | countArticles: 27 | SELECT count(*) FROM articleEntity WHERE type == :type; 28 | 29 | insertArticle: 30 | INSERT OR REPLACE INTO articleEntity(title, description, source, sourceLinkUrl, content, imageUrl, author, publicationDate, type) VALUES ?; 31 | 32 | deleteArticlesByType: 33 | DELETE FROM articleEntity WHERE type = :type; 34 | -------------------------------------------------------------------------------- /shared/src/commonMain/sqldelight/com/mr3y/ludi/shared/ArticleSearchFTS.sq: -------------------------------------------------------------------------------- 1 | CREATE VIRTUAL TABLE IF NOT EXISTS articleEntitySearch USING FTS5( 2 | title TEXT NOT NULL, 3 | description TEXT, 4 | source TEXT NOT NULL, 5 | sourceLinkUrl TEXT NOT NULL UNINDEXED, 6 | content TEXT, 7 | author TEXT, 8 | type TEXT NOT NULL UNINDEXED, 9 | tokenize="trigram" 10 | ); 11 | 12 | CREATE TRIGGER IF NOT EXISTS 13 | articleEntitySearch_BEFORE_DELETE 14 | BEFORE DELETE ON articleEntity BEGIN DELETE FROM articleEntitySearch 15 | WHERE sourceLinkUrl = old.sourceLinkUrl; 16 | END; 17 | 18 | CREATE TRIGGER IF NOT EXISTS 19 | articleEntitySearch_AFTER_INSERT 20 | AFTER INSERT ON articleEntity 21 | BEGIN INSERT OR REPLACE INTO articleEntitySearch(title, description, source, sourceLinkUrl, content, author, type) VALUES (new.title, new.description, new.source, new.sourceLinkUrl, new.content, new.author, new.type); 22 | END; 23 | 24 | countSearchResults: 25 | SELECT count(*) FROM articleEntitySearch WHERE articleEntitySearch MATCH :searchQuery AND type == :type; 26 | 27 | search: 28 | SELECT 29 | articleEntitySearch.title, 30 | articleEntitySearch.description, 31 | articleEntitySearch.source, 32 | articleEntitySearch.content, 33 | articleEntitySearch.author, 34 | articleEntity.sourceLinkUrl, 35 | articleEntity.imageUrl, 36 | articleEntity.publicationDate, 37 | articleEntity.type 38 | FROM articleEntitySearch 39 | INNER JOIN articleEntity ON articleEntity.sourceLinkUrl == articleEntitySearch.sourceLinkUrl 40 | WHERE articleEntitySearch MATCH :searchQuery AND articleEntity.type == :type 41 | ORDER BY bm25(articleEntitySearch) DESC 42 | LIMIT :limit OFFSET :offset; -------------------------------------------------------------------------------- /shared/src/commonMain/sqldelight/databases/1.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/shared/src/commonMain/sqldelight/databases/1.db -------------------------------------------------------------------------------- /shared/src/commonMain/sqldelight/databases/2.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mr3y-the-programmer/Ludi/2b10921a04f3639ccd31d491849f8e05b36e65aa/shared/src/commonMain/sqldelight/databases/2.db -------------------------------------------------------------------------------- /shared/src/commonMain/sqldelight/migrations/1.sqm: -------------------------------------------------------------------------------- 1 | CREATE VIRTUAL TABLE IF NOT EXISTS articleEntitySearch USING FTS5( 2 | title TEXT NOT NULL, 3 | description TEXT, 4 | source TEXT NOT NULL, 5 | sourceLinkUrl TEXT NOT NULL UNINDEXED, 6 | content TEXT, 7 | author TEXT, 8 | type TEXT NOT NULL UNINDEXED, 9 | tokenize="trigram" 10 | ); 11 | 12 | INSERT INTO articleEntitySearch SELECT title, description, source, sourceLinkUrl, content, author, type FROM articleEntity; 13 | 14 | CREATE TRIGGER IF NOT EXISTS 15 | articleEntitySearch_BEFORE_DELETE 16 | BEFORE DELETE ON articleEntity BEGIN DELETE FROM articleEntitySearch 17 | WHERE sourceLinkUrl = old.sourceLinkUrl; 18 | END; 19 | 20 | CREATE TRIGGER IF NOT EXISTS 21 | articleEntitySearch_AFTER_INSERT 22 | AFTER INSERT ON articleEntity 23 | BEGIN INSERT OR REPLACE INTO articleEntitySearch(title, description, source, sourceLinkUrl, content, author, type) VALUES (new.title, new.description, new.source, new.sourceLinkUrl, new.content, new.author, new.type); 24 | END; -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/com/mr3y/ludi/shared/MainDispatcherRule.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.test.TestDispatcher 6 | import kotlinx.coroutines.test.UnconfinedTestDispatcher 7 | import kotlinx.coroutines.test.resetMain 8 | import kotlinx.coroutines.test.setMain 9 | import org.junit.rules.TestWatcher 10 | import org.junit.runner.Description 11 | 12 | /** 13 | * A JUnit [TestRule] that sets the Main dispatcher to [testDispatcher] 14 | * for the duration of the test. 15 | */ 16 | @OptIn(ExperimentalCoroutinesApi::class) 17 | class MainDispatcherRule( 18 | val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() 19 | ) : TestWatcher() { 20 | override fun starting(description: Description) { 21 | Dispatchers.setMain(testDispatcher) 22 | } 23 | 24 | override fun finished(description: Description) { 25 | Dispatchers.resetMain() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/com/mr3y/ludi/shared/core/FakeCrashReporting.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core 2 | 3 | class FakeCrashReporting : CrashReporting { 4 | 5 | private val exceptions = mutableListOf() 6 | 7 | override fun recordException(throwable: Throwable, logMessage: String?) { 8 | println(logMessage) 9 | exceptions += throwable 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/com/mr3y/ludi/shared/core/network/fixtures/TestLogger.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.network.fixtures 2 | 3 | import com.mr3y.ludi.shared.core.Logger 4 | 5 | class TestLogger : Logger { 6 | 7 | private val _logs = mutableListOf() 8 | val logs: List 9 | get() = _logs 10 | 11 | override fun d(throwable: Throwable?, tag: String, message: () -> String) { 12 | val evaluatedMessage = message() 13 | _logs += evaluatedMessage 14 | println("$tag: $evaluatedMessage") 15 | } 16 | 17 | override fun i(throwable: Throwable?, tag: String, message: () -> String) { 18 | val evaluatedMessage = message() 19 | _logs += evaluatedMessage 20 | println("$tag: $evaluatedMessage") 21 | } 22 | 23 | override fun e(throwable: Throwable?, tag: String, message: () -> String) { 24 | val evaluatedMessage = message() 25 | _logs += evaluatedMessage 26 | println("$tag: $evaluatedMessage - $throwable") 27 | } 28 | 29 | override fun v(throwable: Throwable?, tag: String, message: () -> String) { 30 | val evaluatedMessage = message() 31 | _logs += evaluatedMessage 32 | println("$tag: $evaluatedMessage") 33 | } 34 | 35 | override fun w(throwable: Throwable?, tag: String, message: () -> String) { 36 | val evaluatedMessage = message() 37 | _logs += evaluatedMessage 38 | println("$tag: $evaluatedMessage - $throwable") 39 | } 40 | 41 | fun reset() { 42 | _logs.clear() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/com/mr3y/ludi/shared/core/network/rssparser/FakeRSSParser.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core.network.rssparser 2 | 3 | import com.mr3y.ludi.shared.core.model.Article 4 | import com.mr3y.ludi.shared.core.model.NewReleaseArticle 5 | import com.mr3y.ludi.shared.core.model.NewsArticle 6 | import com.mr3y.ludi.shared.core.model.ReviewArticle 7 | import com.mr3y.ludi.shared.core.model.Source 8 | 9 | class FakeRSSParser : Parser { 10 | 11 | private val cache = hashMapOf>() 12 | private var fail = false 13 | 14 | fun setNewsArticlesAtUrl(url: String, articles: () -> List) { 15 | cache[url] = articles() 16 | } 17 | 18 | fun setReviewArticlesAtUrl(url: String, articles: () -> List) { 19 | cache[url] = articles() 20 | } 21 | 22 | fun setNewReleaseArticlesAtUrl(url: String, articles: () -> List) { 23 | cache[url] = articles() 24 | } 25 | 26 | override suspend fun parseNewsArticlesAtUrl(url: String, source: Source): List { 27 | if (fail) { 28 | throw Exception("Simulated Failure") 29 | } 30 | return cache[url] as? List ?: emptyList() 31 | } 32 | 33 | override suspend fun parseReviewArticlesAtUrl(url: String, source: Source): List { 34 | if (fail) { 35 | throw Exception("Simulated Failure") 36 | } 37 | return cache[url] as? List ?: emptyList() 38 | } 39 | 40 | override suspend fun parseNewReleaseArticlesAtUrl(url: String, source: Source): List { 41 | if (fail) { 42 | throw Exception("Simulated Failure") 43 | } 44 | return cache[url] as? List ?: emptyList() 45 | } 46 | 47 | fun simulateFailure() { 48 | fail = true 49 | } 50 | 51 | fun reset() { 52 | fail = false 53 | cache.clear() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/com/mr3y/ludi/shared/ui/presenter/FakeDiscoverPagingFactory.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.presenter 2 | 3 | import app.cash.paging.PagingData 4 | import com.mr3y.ludi.shared.core.model.Game 5 | import com.mr3y.ludi.shared.core.repository.fixtures.FakeGamesRepository 6 | import com.mr3y.ludi.shared.core.repository.query.GamesQuery 7 | import com.mr3y.ludi.shared.ui.presenter.model.DiscoverFiltersState 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | class FakeDiscoverPagingFactory(private val repo: FakeGamesRepository) : DiscoverPagingFactory { 11 | override val trendingGamesPager: Flow> = repo.queryGames(GamesQuery()) 12 | 13 | override val topRatedGamesPager: Flow> = repo.queryGames(GamesQuery()) 14 | 15 | override val favGenresBasedGamesPager: Flow> = repo.queryGames(GamesQuery()) 16 | 17 | override val multiplayerGamesPager: Flow> = repo.queryGames(GamesQuery()) 18 | 19 | override val freeGamesPager: Flow> = repo.queryGames(GamesQuery()) 20 | 21 | override val storyGamesPager: Flow> = repo.queryGames(GamesQuery()) 22 | 23 | override val boardGamesPager: Flow> = repo.queryGames(GamesQuery()) 24 | 25 | override val esportsGamesPager: Flow> = repo.queryGames(GamesQuery()) 26 | 27 | override val raceGamesPager: Flow> = repo.queryGames(GamesQuery()) 28 | 29 | override val puzzleGamesPager: Flow> = repo.queryGames(GamesQuery()) 30 | 31 | override val soundtrackGamesPager: Flow> = repo.queryGames(GamesQuery()) 32 | 33 | override fun searchQueryBasedGamesPager( 34 | searchQuery: String, 35 | filters: DiscoverFiltersState 36 | ): Flow> { 37 | return repo.queryGames(GamesQuery(searchQuery = searchQuery)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /shared/src/desktopAndroidMain/kotlin/com/mr3y/ludi/shared/ui/resources/format.shared.kt: -------------------------------------------------------------------------------- 1 | // ktlint-disable filename 2 | package com.mr3y.ludi.shared.ui.resources 3 | 4 | import java.math.RoundingMode 5 | 6 | actual fun Float.roundToTwoDecimalDigits(): Float = toBigDecimal().setScale(2, RoundingMode.HALF_UP).toFloat() 7 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/AppCacheDir.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared 2 | 3 | import java.io.File 4 | 5 | fun getCacheDir() = when (currentOperatingSystem) { 6 | OperatingSystem.Windows -> File(System.getenv("AppData"), "Ludi/cache") 7 | OperatingSystem.Linux -> File(System.getProperty("user.home"), ".cache/Ludi") 8 | OperatingSystem.MacOS -> File(System.getProperty("user.home"), "Library/Caches/Ludi") 9 | else -> throw IllegalStateException("Unsupported operating system") 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/AppDir.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared 2 | 3 | import java.io.File 4 | 5 | enum class OperatingSystem { 6 | Windows, Linux, MacOS, Unknown 7 | } 8 | 9 | val currentOperatingSystem: OperatingSystem 10 | get() { 11 | val operSys = System.getProperty("os.name").lowercase() 12 | return if (operSys.contains("win")) { 13 | OperatingSystem.Windows 14 | } else if (operSys.contains("nix") || operSys.contains("nux") || 15 | operSys.contains("aix") 16 | ) { 17 | OperatingSystem.Linux 18 | } else if (operSys.contains("mac")) { 19 | OperatingSystem.MacOS 20 | } else { 21 | OperatingSystem.Unknown 22 | } 23 | } 24 | 25 | fun getAppDir() = when (currentOperatingSystem) { 26 | OperatingSystem.Windows -> File(System.getenv("AppData"), "LudiApp/Ludi") 27 | OperatingSystem.Linux -> File(System.getProperty("user.home"), ".local/share/Ludi") 28 | OperatingSystem.MacOS -> File(System.getProperty("user.home"), "Library/Application Support/Ludi") 29 | else -> throw IllegalStateException("Unsupported operating system") 30 | } 31 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/core/BugsnagReporting.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.core 2 | 3 | import com.bugsnag.Bugsnag 4 | import me.tatarka.inject.annotations.Inject 5 | 6 | @Inject 7 | class BugsnagReporting( 8 | private val bugsnagClient: Bugsnag 9 | ) : CrashReporting { 10 | 11 | override fun recordException(throwable: Throwable, logMessage: String?) { 12 | bugsnagClient.notify(throwable) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/core/database/DatabaseFactory.desktop.kt: -------------------------------------------------------------------------------- 1 | // ktlint-disable filename 2 | package com.mr3y.ludi.shared.core.database 3 | 4 | import app.cash.sqldelight.async.coroutines.synchronous 5 | import app.cash.sqldelight.db.SqlDriver 6 | import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver 7 | import com.mr3y.ludi.shared.getAppDir 8 | import me.tatarka.inject.annotations.Inject 9 | import java.io.File 10 | 11 | @Inject 12 | actual class DriverFactory { 13 | actual fun createDriver(): SqlDriver { 14 | val databaseFile = File(getAppDir(), "ludi_database.db") 15 | val driver: SqlDriver = JdbcSqliteDriver(url = "jdbc:sqlite:${databaseFile.path}") 16 | LudiDatabase.Schema.synchronous().create(driver) 17 | return driver 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/di/DatabaseDispatcherComponent.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.Singleton 4 | import kotlinx.coroutines.Dispatchers 5 | import me.tatarka.inject.annotations.Provides 6 | 7 | actual interface DatabaseDispatcherComponent { 8 | 9 | @Singleton 10 | @Provides 11 | fun provideMainDispatcher(): DatabaseDispatcher { 12 | return DatabaseDispatcher(Dispatchers.Main) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/di/DealsFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.DealsFeatureScope 4 | import me.tatarka.inject.annotations.Component 5 | 6 | @Component 7 | @DealsFeatureScope 8 | abstract class DealsFeatureComponent( 9 | @Component val parent: HostWindowComponent 10 | ) : SharedDealsFeatureComponent 11 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/di/DesktopApplicationComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.Singleton 4 | import com.mr3y.ludi.shared.getAppDir 5 | import com.mr3y.ludi.shared.getCacheDir 6 | import me.tatarka.inject.annotations.Component 7 | import okio.Path 8 | import okio.Path.Companion.toOkioPath 9 | import java.io.File 10 | 11 | @Component 12 | @Singleton 13 | abstract class DesktopApplicationComponent : SharedApplicationComponent, DesktopCrashReportingComponent, DesktopSqlDriverComponent { 14 | 15 | override val dataStoreParentDir: Path = getAppDir().toOkioPath() 16 | 17 | override val okhttpCacheParentDir: File = getCacheDir() 18 | 19 | companion object 20 | } 21 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/di/DesktopCrashReportingComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.bugsnag.Bugsnag 4 | import com.mr3y.ludi.shared.DesktopMainBuildConfig 5 | import com.mr3y.ludi.shared.core.BugsnagReporting 6 | import com.mr3y.ludi.shared.core.CrashReporting 7 | import com.mr3y.ludi.shared.di.annotations.Singleton 8 | import me.tatarka.inject.annotations.Provides 9 | 10 | interface DesktopCrashReportingComponent { 11 | 12 | @Singleton 13 | @Provides 14 | fun provideBugsnagClientInstance(): Bugsnag { 15 | return Bugsnag(DesktopMainBuildConfig.BUGSNAG_API_KEY) 16 | } 17 | 18 | @Singleton 19 | @Provides 20 | fun provideCrashReportingInstance(bugsnagClient: Bugsnag): CrashReporting { 21 | return BugsnagReporting(bugsnagClient) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/di/DesktopSqlDriverComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.core.database.DriverFactory 4 | import com.mr3y.ludi.shared.di.annotations.Singleton 5 | import me.tatarka.inject.annotations.Provides 6 | 7 | interface DesktopSqlDriverComponent { 8 | 9 | @Singleton 10 | @Provides 11 | fun provideJdbcSqlDriverFactoryInstance(): DriverFactory { 12 | return DriverFactory() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/di/DiscoverFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.DiscoverFeatureScope 4 | import me.tatarka.inject.annotations.Component 5 | 6 | @Component 7 | @DiscoverFeatureScope 8 | abstract class DiscoverFeatureComponent( 9 | @Component val parent: HostWindowComponent 10 | ) : SharedDiscoverFeatureComponent 11 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/di/HostWindowComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import androidx.compose.runtime.staticCompositionLocalOf 4 | import androidx.compose.ui.awt.ComposeWindow 5 | import com.mr3y.ludi.shared.ui.navigation.PreferencesType 6 | import com.mr3y.ludi.shared.ui.presenter.EditPreferencesViewModel 7 | import me.tatarka.inject.annotations.Component 8 | import me.tatarka.inject.annotations.Provides 9 | 10 | @Component 11 | abstract class HostWindowComponent( 12 | @get:Provides val window: ComposeWindow, 13 | @Component val parent: DesktopApplicationComponent 14 | ) { 15 | abstract val editPreferencesViewModelFactory: (PreferencesType) -> EditPreferencesViewModel 16 | } 17 | 18 | val LocalHostWindowComponent = staticCompositionLocalOf { error("HostWindowComponent isn't provided!") } 19 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/di/NewsFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.NewsFeatureScope 4 | import me.tatarka.inject.annotations.Component 5 | 6 | @Component 7 | @NewsFeatureScope 8 | abstract class NewsFeatureComponent( 9 | @Component val parent: HostWindowComponent 10 | ) : SharedNewsFeatureComponent 11 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/di/OnboardingFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.OnboardingFeatureScope 4 | import me.tatarka.inject.annotations.Component 5 | 6 | @Component 7 | @OnboardingFeatureScope 8 | abstract class OnboardingFeatureComponent( 9 | @Component val parent: HostWindowComponent 10 | ) : SharedOnboardingFeatureComponent 11 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/di/SettingsFeatureComponent.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.SettingsFeatureScope 4 | import me.tatarka.inject.annotations.Component 5 | 6 | @Component 7 | @SettingsFeatureScope 8 | abstract class SettingsFeatureComponent( 9 | @Component val parent: HostWindowComponent 10 | ) : SharedSettingsFeatureComponent 11 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/di/TimeSourceComponent.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.di 2 | 3 | import com.mr3y.ludi.shared.di.annotations.Singleton 4 | import me.tatarka.inject.annotations.Provides 5 | import kotlin.time.TimeSource 6 | 7 | actual interface TimeSourceComponent { 8 | @Provides 9 | @Singleton 10 | fun provideTimeSourceInstance(): TimeSource = TimeSource.Monotonic 11 | } 12 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/ui/components/LudiAsyncImage.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.components 2 | 3 | import androidx.compose.ui.graphics.ImageBitmap 4 | import androidx.compose.ui.graphics.asComposeImageBitmap 5 | import coil3.Bitmap 6 | import coil3.request.ImageRequest 7 | 8 | actual fun ImageRequest.Builder.platformSpecificConfig(allowHardware: Boolean): ImageRequest.Builder = this 9 | 10 | actual fun Bitmap.asImageBitmap(): ImageBitmap = this.asComposeImageBitmap() 11 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/ui/components/LudiNoInternet.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.components 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | 6 | @Composable 7 | actual fun AnimatedNoInternetBanner(modifier: Modifier) { } 8 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/ui/components/OpenUrls.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.components 2 | 3 | import java.awt.Desktop 4 | import java.net.URI 5 | 6 | fun openUrlInBrowser(url: String) { 7 | Desktop.getDesktop().browse(URI.create(url)) 8 | } 9 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/ui/presenter/FrameClock.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.presenter 2 | 3 | import androidx.compose.runtime.BroadcastFrameClock 4 | import kotlin.coroutines.CoroutineContext 5 | 6 | internal actual fun frameClock(): CoroutineContext = BroadcastFrameClock() 7 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/ui/resources/PlatformCompositionProvider.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.resources 2 | 3 | actual fun isDesktopPlatform() = true 4 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/ui/screens/deals/DealsScreen.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.screens.deals 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.ui.Modifier 7 | import com.mr3y.ludi.shared.ui.components.openUrlInBrowser 8 | import com.mr3y.ludi.shared.ui.presenter.DealsViewModel 9 | 10 | @Composable 11 | actual fun DealsScreen( 12 | modifier: Modifier, 13 | viewModel: DealsViewModel 14 | ) { 15 | val dealsState by viewModel.dealsState.collectAsState() 16 | DealsScreen( 17 | dealsState = dealsState, 18 | searchQuery = viewModel.searchQuery.value, 19 | modifier = modifier, 20 | onUpdateSearchQuery = viewModel::updateSearchQuery, 21 | onSelectingDealStore = viewModel::addToSelectedDealsStores, 22 | onUnselectingDealStore = viewModel::removeFromSelectedDealsStores, 23 | onSelectingGiveawayStore = viewModel::addToSelectedGiveawaysStores, 24 | onUnselectingGiveawayStore = viewModel::removeFromSelectedGiveawaysStores, 25 | onSelectingGiveawayPlatform = viewModel::addToSelectedGiveawaysPlatforms, 26 | onUnselectingGiveawayPlatform = viewModel::removeFromSelectedGiveawayPlatforms, 27 | onRefreshDeals = viewModel::refreshDeals, 28 | onRefreshDealsFinished = viewModel::refreshDealsComplete, 29 | onRefreshGiveaways = viewModel::refreshGiveaways, 30 | onSelectTab = viewModel::selectTab, 31 | onOpenUrl = ::openUrlInBrowser 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/ui/screens/discover/DiscoverScreen.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.screens.discover 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.ui.Modifier 7 | import com.mr3y.ludi.shared.ui.components.openUrlInBrowser 8 | import com.mr3y.ludi.shared.ui.presenter.DiscoverViewModel 9 | 10 | @Composable 11 | actual fun DiscoverScreen( 12 | modifier: Modifier, 13 | viewModel: DiscoverViewModel 14 | ) { 15 | val discoverState by viewModel.discoverState.collectAsState() 16 | DiscoverScreen( 17 | discoverState = discoverState, 18 | searchQuery = viewModel.searchQuery.value, 19 | onUpdatingSearchQueryText = viewModel::updateSearchQuery, 20 | onSelectingPlatform = viewModel::addToSelectedPlatforms, 21 | onUnselectingPlatform = viewModel::removeFromSelectedPlatforms, 22 | onSelectingStore = viewModel::addToSelectedStores, 23 | onUnselectingStore = viewModel::removeFromSelectedStores, 24 | onSelectingTag = viewModel::addToSelectedTags, 25 | onUnselectingTag = viewModel::removeFromSelectedTags, 26 | onRefresh = viewModel::refresh, 27 | onRefreshFinished = viewModel::refreshComplete, 28 | onOpenUrl = ::openUrlInBrowser, 29 | modifier = modifier 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/ui/screens/news/NewsScreen.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.screens.news 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.ui.Modifier 7 | import com.mr3y.ludi.shared.ui.components.openUrlInBrowser 8 | import com.mr3y.ludi.shared.ui.presenter.NewsViewModel 9 | 10 | @Composable 11 | actual fun NewsScreen( 12 | onTuneClick: () -> Unit, 13 | modifier: Modifier, 14 | viewModel: NewsViewModel 15 | ) { 16 | val newsState by viewModel.newsState.collectAsState() 17 | NewsScreen( 18 | newsState = newsState, 19 | searchQuery = viewModel.searchQuery.value, 20 | onSearchQueryValueChanged = viewModel::updateSearchQuery, 21 | onTuneClick = onTuneClick, 22 | onRefresh = viewModel::refresh, 23 | onConsumeEvent = viewModel::consumeCurrentEvent, 24 | onOpenUrl = ::openUrlInBrowser, 25 | modifier = modifier 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/ui/screens/settings/SettingsScreen.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.screens.settings 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.ui.Modifier 7 | import com.mr3y.ludi.shared.ui.components.openUrlInBrowser 8 | import com.mr3y.ludi.shared.ui.presenter.SettingsViewModel 9 | 10 | @Composable 11 | actual fun SettingsScreen( 12 | onFollowedNewsDataSourcesClick: () -> Unit, 13 | onFavouriteGamesClick: () -> Unit, 14 | onFavouriteGenresClick: () -> Unit, 15 | modifier: Modifier, 16 | viewModel: SettingsViewModel 17 | ) { 18 | val settingsState by viewModel.settingsState.collectAsState() 19 | SettingsScreen( 20 | settingsState, 21 | modifier = modifier, 22 | onFollowedNewsDataSourcesClick = onFollowedNewsDataSourcesClick, 23 | onFavouriteGamesClick = onFavouriteGamesClick, 24 | onFavouriteGenresClick = onFavouriteGenresClick, 25 | onUpdateTheme = viewModel::setAppTheme, 26 | onToggleDynamicColorValue = {}, 27 | onOpenUrl = ::openUrlInBrowser 28 | ) 29 | } 30 | 31 | actual fun isDynamicColorEnabled(): Boolean = false 32 | -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/com/mr3y/ludi/shared/ui/theme/ColorScheme.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.mr3y.ludi.shared.ui.theme 2 | 3 | import androidx.compose.material3.ColorScheme 4 | import androidx.compose.runtime.Composable 5 | 6 | @Composable 7 | actual fun colorScheme( 8 | isDarkTheme: Boolean, 9 | useDynamicColors: Boolean 10 | ): ColorScheme { 11 | return if (isDarkTheme) { 12 | DarkColorScheme 13 | } else { 14 | LightColorScheme 15 | } 16 | } 17 | 18 | actual fun isDynamicColorSupported() = false 19 | -------------------------------------------------------------------------------- /versions.properties: -------------------------------------------------------------------------------- 1 | #### Dependencies and Plugin versions with their available updates. 2 | #### Generated by `./gradlew refreshVersions` version 0.60.5 3 | #### 4 | #### Don't manually edit or split the comments that start with four hashtags (####), 5 | #### they will be overwritten by refreshVersions. 6 | #### 7 | #### suppress inspection "SpellCheckingInspection" for whole file 8 | #### suppress inspection "UnusedProperty" for whole file 9 | #### 10 | #### NOTE: Some versions are filtered by the rejectVersionIf predicate. See the settings.gradle.kts file. 11 | --------------------------------------------------------------------------------