Repository: thomaskioko/tv-maniac
Branch: main
Commit: fd3340db192b
Files: 1716
Total size: 9.6 MB
Directory structure:
gitextract_xytv9ppo/
├── .editorconfig
├── .geminiignore
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.yml
│ │ ├── config.yml
│ │ └── feature_request.yml
│ ├── actions/
│ │ ├── setup-android-release/
│ │ │ └── action.yml
│ │ ├── setup-gradle/
│ │ │ └── action.yml
│ │ ├── setup-ios/
│ │ │ └── action.yml
│ │ └── setup-ios-release/
│ │ └── action.yml
│ ├── release.yml
│ ├── renovate.json
│ └── workflows/
│ ├── baseline-profile.yml
│ ├── beta-release.yml
│ ├── ci.yml
│ ├── compare-screenshot.yml
│ ├── daily-build.yml
│ ├── nightly-integration-tests.yml
│ ├── promote-release.yml
│ ├── release.yml
│ └── store-screenshot.yml
├── .gitignore
├── .idea/
│ ├── codeStyles/
│ │ ├── Project.xml
│ │ └── codeStyleConfig.xml
│ └── dictionaries/
│ └── project.xml
├── .ruby-version
├── .swiftformat
├── .swiftlint.yml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── GEMINI.md
├── Gemfile
├── LICENSE
├── README.md
├── android-designsystem/
│ ├── build.gradle.kts
│ └── src/
│ ├── debug/
│ │ └── res/
│ │ └── values/
│ │ └── strings.xml
│ ├── main/
│ │ ├── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── compose/
│ │ │ ├── components/
│ │ │ │ ├── Background.kt
│ │ │ │ ├── BadgeChip.kt
│ │ │ │ ├── Buttons.kt
│ │ │ │ ├── Card.kt
│ │ │ │ ├── Chip.kt
│ │ │ │ ├── Dialogs.kt
│ │ │ │ ├── EmptyLayout.kt
│ │ │ │ ├── ErrorLayout.kt
│ │ │ │ ├── FilterChipSection.kt
│ │ │ │ ├── GradientScrim.kt
│ │ │ │ ├── Image.kt
│ │ │ │ ├── NavigationBar.kt
│ │ │ │ ├── NotificationRationaleContent.kt
│ │ │ │ ├── PosterPlaceholder.kt
│ │ │ │ ├── ProgressIndicator.kt
│ │ │ │ ├── ScanlineOverlay.kt
│ │ │ │ ├── SearchTextField.kt
│ │ │ │ ├── SegmentedProgressBar.kt
│ │ │ │ ├── SheetDragHandle.kt
│ │ │ │ ├── ShowLinearProgressIndicator.kt
│ │ │ │ ├── Snackbar.kt
│ │ │ │ ├── Text.kt
│ │ │ │ ├── TextTitlePill.kt
│ │ │ │ ├── TopBar.kt
│ │ │ │ ├── TvManiacBottomSheet.kt
│ │ │ │ └── TvManiacPreviewWrapperProvider.kt
│ │ │ ├── extensions/
│ │ │ │ ├── GradientExtensions.kt
│ │ │ │ ├── LazyListExtensions.kt
│ │ │ │ ├── PaddingValuesExtentions.kt
│ │ │ │ └── ScrimExtentions.kt
│ │ │ ├── theme/
│ │ │ │ ├── Background.kt
│ │ │ │ ├── Colors.kt
│ │ │ │ ├── Shape.kt
│ │ │ │ ├── Theme.kt
│ │ │ │ └── Type.kt
│ │ │ └── util/
│ │ │ ├── AutoAdvanceLocal.kt
│ │ │ └── DynamicTheming.kt
│ │ └── res/
│ │ └── values/
│ │ └── strings.xml
│ └── test/
│ └── kotlin/
│ └── com/
│ └── thomaskioko/
│ └── tvmaniac/
│ └── compose/
│ └── roborazzi/
│ ├── NotificationRationaleContentScreenshotTest.kt
│ └── TvManiacSnackBarScreenshotTest.kt
├── api/
│ ├── tmdb/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── tmdb/
│ │ │ └── api/
│ │ │ ├── TmdbConfig.kt
│ │ │ ├── TmdbSeasonDetailsNetworkDataSource.kt
│ │ │ ├── TmdbShowDetailsNetworkDataSource.kt
│ │ │ ├── TmdbShowsNetworkDataSource.kt
│ │ │ └── model/
│ │ │ ├── CreditsResponse.kt
│ │ │ ├── EpisodesResponse.kt
│ │ │ ├── GenreResponse.kt
│ │ │ ├── ImagesResponse.kt
│ │ │ ├── LastEpisodeToAirResponse.kt
│ │ │ ├── NetworksResponse.kt
│ │ │ ├── NextEpisodeToAirResponse.kt
│ │ │ ├── SeasonsResponse.kt
│ │ │ ├── TmdbGenreResult.kt
│ │ │ ├── TmdbSeasonDetailsResponse.kt
│ │ │ ├── TmdbShowDetailsResponse.kt
│ │ │ ├── TmdbShowResponse.kt
│ │ │ ├── VideosResponse.kt
│ │ │ └── WatchProvidersResult.kt
│ │ └── implementation/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── androidMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── tmdb/
│ │ │ └── implementation/
│ │ │ └── TmdbPlatformBindingContainer.kt
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── tmdb/
│ │ │ └── implementation/
│ │ │ ├── DefaultTmdbSeasonDetailsNetworkDataSource.kt
│ │ │ ├── DefaultTmdbShowDetailsNetworkDataSource.kt
│ │ │ ├── DefaultTmdbShowsNetworkDataSource.kt
│ │ │ ├── TmdbBindingContainer.kt
│ │ │ └── TmdbClient.kt
│ │ └── iosMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── tmdb/
│ │ └── implementation/
│ │ └── TmdbPlatformBindingContainer.kt
│ └── trakt/
│ ├── api/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── trakt/
│ │ └── api/
│ │ ├── TraktCalendarRemoteDataSource.kt
│ │ ├── TraktConfig.kt
│ │ ├── TraktEpisodeHistoryRemoteDataSource.kt
│ │ ├── TraktListRemoteDataSource.kt
│ │ ├── TraktShowsRemoteDataSource.kt
│ │ ├── TraktSyncRemoteDataSource.kt
│ │ ├── TraktTokenRemoteDataSource.kt
│ │ ├── TraktUserRemoteDataSource.kt
│ │ └── model/
│ │ ├── AccessTokenBody.kt
│ │ ├── RefreshAccessTokenBody.kt
│ │ ├── TraktAccessRefreshTokenResponse.kt
│ │ ├── TraktAccessTokenResponse.kt
│ │ ├── TraktAddShowRequest.kt
│ │ ├── TraktAddShowToListResponse.kt
│ │ ├── TraktCalendarResponse.kt
│ │ ├── TraktCreateListRequest.kt
│ │ ├── TraktCreateListResponse.kt
│ │ ├── TraktFollowedShowResponse.kt
│ │ ├── TraktGenreResponse.kt
│ │ ├── TraktLastActivitiesResponse.kt
│ │ ├── TraktNextEpisodeResponse.kt
│ │ ├── TraktPeopleResponse.kt
│ │ ├── TraktPersonalListsResponse.kt
│ │ ├── TraktRemoveShowFromListResponse.kt
│ │ ├── TraktSeasonEpisodesResponse.kt
│ │ ├── TraktSeasonsResponse.kt
│ │ ├── TraktShowsResponse.kt
│ │ ├── TraktSyncModels.kt
│ │ ├── TraktUserResponse.kt
│ │ ├── TraktUserStatsResponse.kt
│ │ ├── TraktVideosResponse.kt
│ │ └── TraktWatchedProgressResponse.kt
│ └── implementation/
│ ├── build.gradle.kts
│ └── src/
│ ├── androidMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── trakt/
│ │ └── service/
│ │ └── implementation/
│ │ └── TraktPlatformBindingContainer.kt
│ ├── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── trakt/
│ │ └── service/
│ │ └── implementation/
│ │ ├── TraktAuthPlugin.kt
│ │ ├── TraktBindingContainer.kt
│ │ ├── TraktHttpClient.kt
│ │ └── api/
│ │ ├── DefaultTraktCalendarRemoteDataSource.kt
│ │ ├── DefaultTraktEpisodeRemoteDataSource.kt
│ │ ├── DefaultTraktListRemoteDataSource.kt
│ │ ├── DefaultTraktShowsRemoteDataSource.kt
│ │ ├── DefaultTraktSyncRemoteDataSource.kt
│ │ ├── DefaultTraktTokenRemoteDataSource.kt
│ │ └── DefaultTraktUserRemoteDataSource.kt
│ ├── commonTest/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── trakt/
│ │ └── service/
│ │ └── implementation/
│ │ └── TraktAuthGuardPluginTest.kt
│ ├── iosMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── trakt/
│ │ └── service/
│ │ └── implementation/
│ │ └── TraktPlatformBindingContainer.kt
│ └── jvmTest/
│ ├── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── trakt/
│ │ └── service/
│ │ └── implementation/
│ │ ├── TestResourceLoader.jvm.kt
│ │ └── api/
│ │ └── DefaultTraktListRemoteDataSourceTest.kt
│ └── resources/
│ ├── trakt_add_show_response.json
│ ├── trakt_error_response.json
│ └── trakt_user_response.json
├── app/
│ ├── benchmark-rules.pro
│ ├── build.gradle.kts
│ ├── lint-baseline.xml
│ ├── proguard-rules.pro
│ └── src/
│ ├── androidTest/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── app/
│ │ └── test/
│ │ └── runner/
│ │ └── TvManiacInstrumentationRunner.kt
│ ├── debug/
│ │ ├── AndroidManifest.xml
│ │ ├── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── app/
│ │ │ └── debug/
│ │ │ ├── DebugNotificationIconProvider.kt
│ │ │ ├── DebugNotificationInitializer.kt
│ │ │ └── di/
│ │ │ └── DebugNotificationInitializerBindingContainer.kt
│ │ └── res/
│ │ └── drawable/
│ │ ├── ic_app_launcher.xml
│ │ ├── ic_debug_bug.xml
│ │ └── ic_launcher_foreground.xml
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── generated/
│ │ │ └── baselineProfiles/
│ │ │ ├── baseline-prof.txt
│ │ │ └── startup-prof.txt
│ │ ├── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── app/
│ │ │ ├── MainActivity.kt
│ │ │ ├── TvManicApplication.kt
│ │ │ ├── di/
│ │ │ │ ├── ActivityGraph.kt
│ │ │ │ └── ApplicationGraph.kt
│ │ │ └── util/
│ │ │ ├── AppNotificationIconProvider.kt
│ │ │ └── TvManiacWorkerFactory.kt
│ │ └── res/
│ │ ├── drawable/
│ │ │ ├── ic_app_launcher.xml
│ │ │ ├── ic_launcher_background.xml
│ │ │ ├── ic_launcher_foreground.xml
│ │ │ └── ic_launcher_monochrome.xml
│ │ ├── mipmap-anydpi-v26/
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ ├── values/
│ │ │ ├── colors.xml
│ │ │ └── themes.xml
│ │ ├── values-night/
│ │ │ ├── colors.xml
│ │ │ └── themes.xml
│ │ └── xml/
│ │ ├── backup_rules.xml
│ │ └── data_extraction_rules.xml
│ ├── sharedTest/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── app/
│ │ └── test/
│ │ ├── BaseAppFlowTest.kt
│ │ ├── TestAppComponent.kt
│ │ ├── TvManiacTestApplication.kt
│ │ └── compose/
│ │ ├── TvManiacTestActivity.kt
│ │ ├── flows/
│ │ │ ├── calendar/
│ │ │ │ └── CalendarFlowTest.kt
│ │ │ ├── discover/
│ │ │ │ ├── DiscoverToSeasonDetailsFlowTest.kt
│ │ │ │ └── DiscoverToShowDetailsFollowFlowTest.kt
│ │ │ ├── library/
│ │ │ │ └── LibraryFlowTest.kt
│ │ │ ├── search/
│ │ │ │ └── SearchFlowTest.kt
│ │ │ ├── seasons/
│ │ │ │ └── SeasonFlowTest.kt
│ │ │ ├── settings/
│ │ │ │ └── SettingsFlowTest.kt
│ │ │ ├── sheet/
│ │ │ │ └── EpisodeSheetFlowTest.kt
│ │ │ ├── showdetails/
│ │ │ │ └── ShowDetailsFeaturesFlowTest.kt
│ │ │ ├── upnext/
│ │ │ │ └── UpNextFlowTests.kt
│ │ │ └── userlists/
│ │ │ └── UserListFlowTests.kt
│ │ ├── journey/
│ │ │ ├── AuthenticatedUserJourneyTest.kt
│ │ │ └── UnauthenticatedUserJourneyTest.kt
│ │ ├── robot/
│ │ │ ├── CalendarRobot.kt
│ │ │ ├── DiscoverRobot.kt
│ │ │ ├── EpisodeSheetRobot.kt
│ │ │ ├── HomeRobot.kt
│ │ │ ├── LibraryRobot.kt
│ │ │ ├── ProfileRobot.kt
│ │ │ ├── ProgressRobot.kt
│ │ │ ├── RootRobot.kt
│ │ │ ├── SearchRobot.kt
│ │ │ ├── SeasonDetailsRobot.kt
│ │ │ ├── SettingsRobot.kt
│ │ │ └── ShowDetailsRobot.kt
│ │ └── stubs/
│ │ └── Scenarios.kt
│ └── test/
│ └── kotlin/
│ └── com/
│ └── thomaskioko/
│ └── tvmaniac/
│ └── app/
│ └── test/
│ └── graph/
│ ├── GraphFactories.kt
│ └── NavigationRouteTest.kt
├── benchmark/
│ ├── build.gradle.kts
│ └── src/
│ └── main/
│ ├── AndroidManifest.xml
│ └── kotlin/
│ └── com/
│ └── thomaskioko/
│ └── tvmaniac/
│ └── benchmark/
│ ├── Common.kt
│ ├── baselineprofile/
│ │ └── BaselineProfileGenerator.kt
│ └── benchmark/
│ └── StartupBenchmarks.kt
├── build.gradle.kts
├── cliff.toml
├── compose-stability.conf
├── core/
│ ├── appconfig/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── appconfig/
│ │ │ └── ApplicationInfo.kt
│ │ └── implementation/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── androidMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── appconfig/
│ │ │ └── AndroidAppConfigBindingContainer.kt
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── appconfig/
│ │ │ ├── DefaultTmdbConfig.kt
│ │ │ └── DefaultTraktConfig.kt
│ │ └── iosMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── appconfig/
│ │ └── IosAppConfigBindingContainer.kt
│ ├── base/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── androidMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── core/
│ │ │ └── base/
│ │ │ └── di/
│ │ │ └── BaseAndroidBindingContainer.kt
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── core/
│ │ └── base/
│ │ ├── ActivityScope.kt
│ │ ├── AppInitializers.kt
│ │ ├── Initializer.kt
│ │ ├── Qualifiers.kt
│ │ ├── di/
│ │ │ ├── BaseBindingContainer.kt
│ │ │ └── InitializerMultibindings.kt
│ │ ├── extensions/
│ │ │ ├── Combine.kt
│ │ │ ├── DecomposeUtils.kt
│ │ │ ├── Lazy.kt
│ │ │ └── ParallelUtils.kt
│ │ ├── interactor/
│ │ │ └── Interactor.kt
│ │ └── model/
│ │ └── AppCoroutineDispatchers.kt
│ ├── connectivity/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── core/
│ │ │ └── connectivity/
│ │ │ └── api/
│ │ │ └── InternetConnectionChecker.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── androidMain/
│ │ │ │ ├── AndroidManifest.xml
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── core/
│ │ │ │ └── connectivity/
│ │ │ │ └── implementation/
│ │ │ │ └── PlatformInternetConnectionChecker.android.kt
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── core/
│ │ │ │ └── connectivity/
│ │ │ │ └── implementation/
│ │ │ │ └── PlatformInternetConnectionChecker.kt
│ │ │ ├── iosMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── core/
│ │ │ │ └── connectivity/
│ │ │ │ └── implementation/
│ │ │ │ └── PlatformInternetConnectionChecker.ios.kt
│ │ │ └── jvmMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── core/
│ │ │ └── connectivity/
│ │ │ └── implementation/
│ │ │ └── PlatformInternetConnectionChecker.jvm.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── core/
│ │ └── connectivity/
│ │ └── testing/
│ │ └── FakeInternetConnectionChecker.kt
│ ├── imageloading/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── imageloading/
│ │ │ └── api/
│ │ │ └── ImageQualityProvider.kt
│ │ └── implementation/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── main/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── imageloading/
│ │ └── implementation/
│ │ ├── CoilImageLoaderFactory.kt
│ │ ├── CoilImageLoaderInitializer.kt
│ │ ├── DefaultImageQualityProvider.kt
│ │ ├── di/
│ │ │ ├── CoilImageLoaderInitializerBindingContainer.kt
│ │ │ └── ImageLoadingBindingContainer.kt
│ │ └── interceptors/
│ │ └── TmdbInterceptor.kt
│ ├── integration/
│ │ ├── infra/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── androidHostTest/
│ │ │ │ ├── AndroidManifest.xml
│ │ │ │ ├── kotlin/
│ │ │ │ │ └── com/
│ │ │ │ │ └── thomaskioko/
│ │ │ │ │ └── tvmaniac/
│ │ │ │ │ └── testing/
│ │ │ │ │ └── integration/
│ │ │ │ │ ├── EndpointsCatalogTest.kt
│ │ │ │ │ ├── FixtureLoaderTest.kt
│ │ │ │ │ └── MockEngineHandlerTest.kt
│ │ │ │ └── resources/
│ │ │ │ └── fixtures/
│ │ │ │ └── test/
│ │ │ │ └── hello.json
│ │ │ ├── androidMain/
│ │ │ │ ├── kotlin/
│ │ │ │ │ └── com/
│ │ │ │ │ └── thomaskioko/
│ │ │ │ │ └── tvmaniac/
│ │ │ │ │ └── testing/
│ │ │ │ │ └── integration/
│ │ │ │ │ ├── Endpoints.kt
│ │ │ │ │ ├── MockEngineHandler.kt
│ │ │ │ │ ├── SearchStubs.kt
│ │ │ │ │ ├── ShowFixtures.kt
│ │ │ │ │ ├── bindings/
│ │ │ │ │ │ ├── TestAuthBindingContainer.kt
│ │ │ │ │ │ ├── TestConnectivityBindingContainer.kt
│ │ │ │ │ │ ├── TestDateTimeBindingContainer.kt
│ │ │ │ │ │ ├── TestDispatcherBindingContainer.kt
│ │ │ │ │ │ ├── TestImageLoaderBindingContainer.kt
│ │ │ │ │ │ ├── TestInitializerBindingContainer.kt
│ │ │ │ │ │ ├── TestLoggerBindingContainer.kt
│ │ │ │ │ │ ├── TestNotificationBindingContainer.kt
│ │ │ │ │ │ ├── TestTmdbBindingContainer.kt
│ │ │ │ │ │ ├── TestTraktAuthManagerBindingContainer.kt
│ │ │ │ │ │ ├── TestTraktBindingContainer.kt
│ │ │ │ │ │ └── TestWorkerBindingContainer.kt
│ │ │ │ │ └── util/
│ │ │ │ │ └── FixtureLoader.kt
│ │ │ │ └── resources/
│ │ │ │ └── fixtures/
│ │ │ │ ├── empty_array.json
│ │ │ │ ├── tmdb/
│ │ │ │ │ ├── credits/
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ └── success.json
│ │ │ │ │ ├── details/
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ └── success.json
│ │ │ │ │ ├── discover/
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ └── success.json
│ │ │ │ │ └── watchproviders/
│ │ │ │ │ ├── error.json
│ │ │ │ │ └── success.json
│ │ │ │ └── trakt/
│ │ │ │ ├── calendar/
│ │ │ │ │ ├── error.json
│ │ │ │ │ └── success.json
│ │ │ │ ├── episodes/
│ │ │ │ │ ├── season1/
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ └── success.json
│ │ │ │ │ └── season2/
│ │ │ │ │ ├── error.json
│ │ │ │ │ └── success.json
│ │ │ │ ├── genres/
│ │ │ │ │ ├── error.json
│ │ │ │ │ └── success.json
│ │ │ │ ├── search/
│ │ │ │ │ ├── error.json
│ │ │ │ │ └── success.json
│ │ │ │ ├── seasons/
│ │ │ │ │ ├── error.json
│ │ │ │ │ └── success.json
│ │ │ │ ├── shows/
│ │ │ │ │ ├── details/
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ └── success.json
│ │ │ │ │ ├── favorite/
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ └── success.json
│ │ │ │ │ ├── people/
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ └── success.json
│ │ │ │ │ ├── popular/
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ └── success.json
│ │ │ │ │ ├── progress/
│ │ │ │ │ │ ├── refreshed/
│ │ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ │ └── success.json
│ │ │ │ │ │ └── watched/
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ └── success.json
│ │ │ │ │ ├── related/
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ └── success.json
│ │ │ │ │ ├── trending/
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ └── success.json
│ │ │ │ │ └── videos/
│ │ │ │ │ ├── error.json
│ │ │ │ │ └── success.json
│ │ │ │ ├── sync/
│ │ │ │ │ ├── error.json
│ │ │ │ │ ├── history/
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ └── success.json
│ │ │ │ │ └── success.json
│ │ │ │ └── users/
│ │ │ │ ├── lists/
│ │ │ │ │ ├── create/
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ └── success.json
│ │ │ │ │ ├── error.json
│ │ │ │ │ ├── items/
│ │ │ │ │ │ ├── add/
│ │ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ │ └── success.json
│ │ │ │ │ │ └── remove/
│ │ │ │ │ │ ├── error.json
│ │ │ │ │ │ └── success.json
│ │ │ │ │ └── success.json
│ │ │ │ ├── me/
│ │ │ │ │ ├── error.json
│ │ │ │ │ └── success.json
│ │ │ │ ├── stats/
│ │ │ │ │ ├── error.json
│ │ │ │ │ └── success.json
│ │ │ │ └── watchlist/
│ │ │ │ ├── error.json
│ │ │ │ └── success.json
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── testing/
│ │ │ │ └── di/
│ │ │ │ ├── FakeAppConfigBindingContainer.kt
│ │ │ │ └── TestScope.kt
│ │ │ ├── iosMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── testing/
│ │ │ │ └── di/
│ │ │ │ ├── FakeIosPlatformBindingContainer.kt
│ │ │ │ ├── RunTestWithGraph.kt
│ │ │ │ └── TestGraph.kt
│ │ │ ├── jvmAndIosMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── testing/
│ │ │ │ └── di/
│ │ │ │ └── FakeAppBindingContainer.kt
│ │ │ ├── jvmMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── testing/
│ │ │ │ └── di/
│ │ │ │ ├── RunTestWithGraph.kt
│ │ │ │ ├── TestGraph.kt
│ │ │ │ └── TestJvmPlatformBindingContainer.kt
│ │ │ └── jvmTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── testing/
│ │ │ └── di/
│ │ │ └── TestJvmGraphTest.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── androidMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── testing/
│ │ └── integration/
│ │ └── ui/
│ │ ├── BaseRobot.kt
│ │ └── SystemDialogUtil.kt
│ ├── locale/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── locale/
│ │ │ └── api/
│ │ │ ├── Language.kt
│ │ │ └── LocaleProvider.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── androidDeviceTest/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── locale/
│ │ │ │ └── implementation/
│ │ │ │ └── PlatformLocaleProviderAndroidTest.kt
│ │ │ ├── androidMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── locale/
│ │ │ │ └── implementation/
│ │ │ │ └── PlatformLocaleProvider.android.kt
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── locale/
│ │ │ │ └── implementation/
│ │ │ │ ├── DefaultLocaleProvider.kt
│ │ │ │ └── PlatformLocaleProvider.kt
│ │ │ ├── commonTest/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── locale/
│ │ │ │ └── implementation/
│ │ │ │ └── PlatformLocaleProviderTest.kt
│ │ │ ├── iosMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── locale/
│ │ │ │ └── implementation/
│ │ │ │ └── PlatformLocaleProvider.ios.kt
│ │ │ ├── iosTest/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── locale/
│ │ │ │ └── implementation/
│ │ │ │ └── PlatformLocaleProviderIosTest.kt
│ │ │ ├── jvmMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── locale/
│ │ │ │ └── implementation/
│ │ │ │ └── PlatformLocaleProvider.jvm.kt
│ │ │ └── jvmTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── locale/
│ │ │ └── implementation/
│ │ │ └── PlatformLocaleProviderJvmTest.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── locale/
│ │ └── testing/
│ │ └── FakeLocaleProvider.kt
│ ├── logger/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── core/
│ │ │ │ └── logger/
│ │ │ │ ├── CrashReporter.kt
│ │ │ │ └── Logger.kt
│ │ │ └── iosMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── core/
│ │ │ └── logger/
│ │ │ ├── CrashReportingBridge.kt
│ │ │ └── CrashReportingBridgeHolder.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── androidMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── core/
│ │ │ │ └── logger/
│ │ │ │ ├── AndroidCrashReporter.kt
│ │ │ │ └── AndroidLoggerBindingContainer.kt
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── core/
│ │ │ │ └── logger/
│ │ │ │ ├── CompositeLogger.kt
│ │ │ │ ├── FirebaseCrashLogger.kt
│ │ │ │ ├── KermitLogger.kt
│ │ │ │ └── LoggingInitializer.kt
│ │ │ └── iosMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── core/
│ │ │ └── logger/
│ │ │ ├── IosCrashReporter.kt
│ │ │ ├── IosCrashReporterBindingContainer.kt
│ │ │ └── NoOpCrashReportingBridge.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── core/
│ │ └── logger/
│ │ └── fixture/
│ │ ├── FakeCrashReporter.kt
│ │ └── FakeLogger.kt
│ ├── network-util/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── core/
│ │ │ │ └── networkutil/
│ │ │ │ └── api/
│ │ │ │ ├── ApiRateLimiter.kt
│ │ │ │ ├── extensions/
│ │ │ │ │ ├── ApiRateLimiterExtensions.kt
│ │ │ │ │ ├── ApiResponseExtensions.kt
│ │ │ │ │ ├── InternetConnectionPlugin.kt
│ │ │ │ │ └── StoreExtensions.kt
│ │ │ │ └── model/
│ │ │ │ ├── ApiExceptions.kt
│ │ │ │ ├── ApiResponse.kt
│ │ │ │ ├── AuthenticationException.kt
│ │ │ │ ├── HttpExceptions.kt
│ │ │ │ ├── NoInternetException.kt
│ │ │ │ ├── SyncError.kt
│ │ │ │ └── SyncException.kt
│ │ │ ├── commonTest/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── core/
│ │ │ │ └── networkutil/
│ │ │ │ └── api/
│ │ │ │ └── model/
│ │ │ │ └── ThrowableToSyncErrorTest.kt
│ │ │ └── jvmTest/
│ │ │ ├── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── core/
│ │ │ │ └── networkutil/
│ │ │ │ └── api/
│ │ │ │ └── extensions/
│ │ │ │ ├── ApiResponseExtensionsTest.kt
│ │ │ │ ├── InternetConnectionPluginTest.kt
│ │ │ │ └── TestResourceLoader.jvm.kt
│ │ │ └── resources/
│ │ │ ├── error_response.json
│ │ │ └── success_response.json
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── core/
│ │ │ │ └── networkutil/
│ │ │ │ └── ratelimit/
│ │ │ │ └── AdaptiveApiRateLimiter.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── core/
│ │ │ └── networkutil/
│ │ │ ├── model/
│ │ │ │ └── SyncErrorTest.kt
│ │ │ └── ratelimit/
│ │ │ └── AdaptiveApiRateLimiterTest.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── core/
│ │ └── networkutil/
│ │ └── testing/
│ │ └── FakeApiRateLimiter.kt
│ ├── notifications/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── androidMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── core/
│ │ │ │ └── notifications/
│ │ │ │ └── api/
│ │ │ │ └── NotificationIconProvider.kt
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── core/
│ │ │ └── notifications/
│ │ │ └── api/
│ │ │ ├── EpisodeNotification.kt
│ │ │ ├── NotificationChannel.kt
│ │ │ └── NotificationManager.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── androidMain/
│ │ │ │ ├── AndroidManifest.xml
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── core/
│ │ │ │ └── notifications/
│ │ │ │ └── implementation/
│ │ │ │ ├── AndroidNotificationManager.kt
│ │ │ │ ├── BootCompletedReceiver.kt
│ │ │ │ ├── DebugNotificationManager.kt
│ │ │ │ ├── EpisodeNotificationReceiver.kt
│ │ │ │ ├── PendingNotificationsStore.kt
│ │ │ │ └── model/
│ │ │ │ └── StoredNotification.kt
│ │ │ └── iosMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── core/
│ │ │ └── notifications/
│ │ │ └── implementation/
│ │ │ └── IosNotificationManager.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── core/
│ │ └── notifications/
│ │ └── testing/
│ │ └── FakeNotificationManager.kt
│ ├── paging/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── core/
│ │ └── paging/
│ │ ├── CommonPagingConfig.kt
│ │ ├── KeyedQueryPagingSource.kt
│ │ ├── OffsetQueryPagingSource.kt
│ │ ├── PaginatedRemoteMediator.kt
│ │ └── QueryPagingSource.kt
│ ├── screenshot-tests/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── main/
│ │ ├── AndroidManifest.xml
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── screenshottests/
│ │ └── RoborazziScreenshotUtil.kt
│ ├── tasks/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── core/
│ │ │ └── tasks/
│ │ │ └── api/
│ │ │ ├── BackgroundTaskScheduler.kt
│ │ │ ├── BackgroundWorker.kt
│ │ │ ├── PeriodicTaskRequest.kt
│ │ │ ├── TaskConstraints.kt
│ │ │ ├── WorkerFactory.kt
│ │ │ └── WorkerResult.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── androidMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── core/
│ │ │ │ └── tasks/
│ │ │ │ └── implementation/
│ │ │ │ ├── AndroidTaskScheduler.kt
│ │ │ │ ├── SchedulerDispatchWorker.kt
│ │ │ │ └── di/
│ │ │ │ └── WorkManagerBindingContainer.kt
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── core/
│ │ │ │ └── tasks/
│ │ │ │ └── implementation/
│ │ │ │ └── DefaultWorkerFactory.kt
│ │ │ └── iosMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── core/
│ │ │ └── tasks/
│ │ │ └── implementation/
│ │ │ └── IosTaskScheduler.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── core/
│ │ └── tasks/
│ │ └── testing/
│ │ └── FakeBackgroundTaskScheduler.kt
│ ├── test-tags/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── testtags/
│ │ ├── calendar/
│ │ │ └── CalendarTestTags.kt
│ │ ├── component/
│ │ │ └── DesignComponentTestTags.kt
│ │ ├── discover/
│ │ │ └── DiscoverTestTags.kt
│ │ ├── episodesheet/
│ │ │ └── EpisodeSheetTestTags.kt
│ │ ├── home/
│ │ │ └── HomeTestTags.kt
│ │ ├── library/
│ │ │ └── LibraryTestTags.kt
│ │ ├── moreshows/
│ │ │ └── MoreShowsTestTags.kt
│ │ ├── notifications/
│ │ │ └── NotificationRationaleTestTags.kt
│ │ ├── profile/
│ │ │ └── ProfileTestTags.kt
│ │ ├── progress/
│ │ │ └── ProgressTestTags.kt
│ │ ├── search/
│ │ │ └── SearchTestTags.kt
│ │ ├── seasondetails/
│ │ │ └── SeasonDetailsTestTags.kt
│ │ ├── settings/
│ │ │ └── SettingsTestTags.kt
│ │ ├── showdetails/
│ │ │ └── ShowDetailsTestTags.kt
│ │ └── upnext/
│ │ └── UpNextTestTags.kt
│ ├── util/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── util/
│ │ │ └── api/
│ │ │ ├── AppUtils.kt
│ │ │ ├── DateTimeProvider.kt
│ │ │ ├── FormatterUtil.kt
│ │ │ └── ItemSyncer.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── androidMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── util/
│ │ │ │ ├── AndroidAppUtils.kt
│ │ │ │ └── AndroidFormatterUtil.kt
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── util/
│ │ │ │ ├── DateTimeBindingContainer.kt
│ │ │ │ └── DefaultDateTimeProvider.kt
│ │ │ ├── commonTest/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── util/
│ │ │ │ └── DefaultDateTimeProviderTest.kt
│ │ │ ├── iosMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── util/
│ │ │ │ ├── IosAppUtils.kt
│ │ │ │ └── IosFormatterUtil.kt
│ │ │ ├── iosTest/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── util/
│ │ │ │ └── IosFormatterUtilTest.kt
│ │ │ └── test/
│ │ │ └── java/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── util/
│ │ │ └── AndroidFormatterUtilTest.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── util/
│ │ │ └── testing/
│ │ │ ├── FakeApplicationInfo.kt
│ │ │ ├── FakeDateTimeProvider.kt
│ │ │ ├── FakeFormatterUtil.kt
│ │ │ └── FlakyTests.kt
│ │ └── jvmAndroidMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── util/
│ │ └── testing/
│ │ └── FlakyTestRule.kt
│ └── view/
│ ├── build.gradle.kts
│ └── src/
│ └── commonMain/
│ └── kotlin/
│ └── com/
│ └── thomaskioko/
│ └── tvmaniac/
│ └── core/
│ └── view/
│ ├── ErrorToStringMapper.kt
│ ├── InvokeStatus.kt
│ ├── ObservableLoadingCounter.kt
│ └── UiMessage.kt
├── data/
│ ├── calendar/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── calendar/
│ │ │ ├── CalendarDao.kt
│ │ │ ├── CalendarEntry.kt
│ │ │ └── CalendarRepository.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── calendar/
│ │ │ └── implementation/
│ │ │ ├── CalendarStore.kt
│ │ │ ├── DefaultCalendarDao.kt
│ │ │ └── DefaultCalendarRepository.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── data/
│ │ └── calendar/
│ │ └── testing/
│ │ └── FakeCalendarRepository.kt
│ ├── cast/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── cast/
│ │ │ └── api/
│ │ │ ├── CastDao.kt
│ │ │ └── CastRepository.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── cast/
│ │ │ └── implementation/
│ │ │ ├── DefaultCastDao.kt
│ │ │ ├── DefaultCastRepository.kt
│ │ │ ├── ShowCastResult.kt
│ │ │ └── ShowCastStore.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── data/
│ │ └── cast/
│ │ └── testing/
│ │ └── FakeCastRepository.kt
│ ├── database/
│ │ ├── sqldelight/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── androidMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── db/
│ │ │ │ └── DatabasePlatformBindingContainer.kt
│ │ │ ├── commonMain/
│ │ │ │ └── sqldelight/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ ├── db/
│ │ │ │ │ ├── Calendar.sq
│ │ │ │ │ ├── Cast.sq
│ │ │ │ │ ├── EpisodeImage.sq
│ │ │ │ │ ├── Episodes.sq
│ │ │ │ │ ├── FeaturedShows.sq
│ │ │ │ │ ├── FollowedShows.sq
│ │ │ │ │ ├── GenreShows.sq
│ │ │ │ │ ├── Genres.sq
│ │ │ │ │ ├── LastRequests.sq
│ │ │ │ │ ├── Library.sq
│ │ │ │ │ ├── NextEpisodes.sq
│ │ │ │ │ ├── PopularShows.sq
│ │ │ │ │ ├── RecommendedShows.sq
│ │ │ │ │ ├── SeasonImages.sq
│ │ │ │ │ ├── SeasonVideos.sq
│ │ │ │ │ ├── Seasons.sq
│ │ │ │ │ ├── ShowGenres.sq
│ │ │ │ │ ├── ShowMetadata.sq
│ │ │ │ │ ├── ShowsLastWatched.sq
│ │ │ │ │ ├── ShowsNextToWatch.sq
│ │ │ │ │ ├── SimilarShows.sq
│ │ │ │ │ ├── Stats.sq
│ │ │ │ │ ├── TopratedShows.sq
│ │ │ │ │ ├── Trailers.sq
│ │ │ │ │ ├── TraktGenres.sq
│ │ │ │ │ ├── TraktLastActivity.sq
│ │ │ │ │ ├── TraktListShows.sq
│ │ │ │ │ ├── TraktLists.sq
│ │ │ │ │ ├── TrendingShows.sq
│ │ │ │ │ ├── TvShow.sq
│ │ │ │ │ ├── UpcomingShows.sq
│ │ │ │ │ ├── User.sq
│ │ │ │ │ ├── WatchProviders.sq
│ │ │ │ │ └── WatchedEpisodes.sq
│ │ │ │ └── migrations/
│ │ │ │ ├── 1.sqm
│ │ │ │ ├── 10.sqm
│ │ │ │ ├── 11.sqm
│ │ │ │ ├── 12.sqm
│ │ │ │ ├── 13.sqm
│ │ │ │ ├── 14.sqm
│ │ │ │ ├── 15.sqm
│ │ │ │ ├── 16.sqm
│ │ │ │ ├── 17.sqm
│ │ │ │ ├── 18.sqm
│ │ │ │ ├── 19.sqm
│ │ │ │ ├── 2.sqm
│ │ │ │ ├── 20.sqm
│ │ │ │ ├── 21.sqm
│ │ │ │ ├── 22.sqm
│ │ │ │ ├── 23.sqm
│ │ │ │ ├── 3.sqm
│ │ │ │ ├── 4.sqm
│ │ │ │ ├── 5.sqm
│ │ │ │ ├── 6.sqm
│ │ │ │ ├── 7.sqm
│ │ │ │ ├── 8.sqm
│ │ │ │ └── 9.sqm
│ │ │ ├── iosMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── db/
│ │ │ │ └── DatabasePlatformBindingContainer.kt
│ │ │ └── jvmTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── db/
│ │ │ ├── Migration22TraktListShowsTest.kt
│ │ │ ├── Migration23DropParentFkTest.kt
│ │ │ ├── SchemaCreateTest.kt
│ │ │ └── util/
│ │ │ └── MigrationTestUtil.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── androidMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── database/
│ │ │ └── test/
│ │ │ └── BaseDatabaseTest.android.kt
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── database/
│ │ │ └── test/
│ │ │ └── BaseDatabaseTest.kt
│ │ ├── iosMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── database/
│ │ │ └── test/
│ │ │ └── BaseDatabaseTest.ios.kt
│ │ └── jvmMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── database/
│ │ └── test/
│ │ └── BaseDatabaseTest.jvm.kt
│ ├── datastore/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── datastore/
│ │ │ └── api/
│ │ │ ├── AppTheme.kt
│ │ │ ├── DatastoreRepository.kt
│ │ │ ├── ImageQuality.kt
│ │ │ └── ListStyle.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ ├── src/
│ │ │ │ ├── androidMain/
│ │ │ │ │ └── kotlin/
│ │ │ │ │ └── com/
│ │ │ │ │ └── thomaskioko/
│ │ │ │ │ └── tvmaniac/
│ │ │ │ │ └── datastore/
│ │ │ │ │ └── implementation/
│ │ │ │ │ └── DataStorePlatformBindingContainer.kt
│ │ │ │ ├── commonMain/
│ │ │ │ │ └── kotlin/
│ │ │ │ │ └── com/
│ │ │ │ │ └── thomaskioko/
│ │ │ │ │ └── tvmaniac/
│ │ │ │ │ └── datastore/
│ │ │ │ │ └── implementation/
│ │ │ │ │ ├── DataStoreHelper.kt
│ │ │ │ │ └── DefaultDatastoreRepository.kt
│ │ │ │ ├── commonTest/
│ │ │ │ │ └── kotlin/
│ │ │ │ │ └── com/
│ │ │ │ │ └── thomaskioko/
│ │ │ │ │ └── tvmaniac/
│ │ │ │ │ └── datastore/
│ │ │ │ │ └── implemetation/
│ │ │ │ │ └── DatastoreRepositoryTest.kt
│ │ │ │ └── iosMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── datastore/
│ │ │ │ └── implementation/
│ │ │ │ └── DataStorePlatformBindingContainer.kt
│ │ │ └── test.preferences_pb
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── datastore/
│ │ └── testing/
│ │ └── FakeDatastoreRepository.kt
│ ├── episode/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── episodes/
│ │ │ └── api/
│ │ │ ├── EpisodeRepository.kt
│ │ │ ├── EpisodeWatchesDataSource.kt
│ │ │ ├── EpisodesDao.kt
│ │ │ ├── NextEpisodeDao.kt
│ │ │ ├── WatchedEpisodeDao.kt
│ │ │ ├── WatchedEpisodeEntry.kt
│ │ │ ├── WatchedEpisodeSyncRepository.kt
│ │ │ └── model/
│ │ │ ├── EpisodeExtensions.kt
│ │ │ ├── EpisodeWatchParams.kt
│ │ │ ├── LastWatchedEpisode.kt
│ │ │ ├── SeasonWatchProgress.kt
│ │ │ ├── ShowWatchProgress.kt
│ │ │ ├── UnwatchedEpisode.kt
│ │ │ ├── UpcomingEpisode.kt
│ │ │ ├── WatchProgress.kt
│ │ │ └── WatchedEpisode.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── episodes/
│ │ │ │ └── implementation/
│ │ │ │ ├── DefaultEpisodeRepository.kt
│ │ │ │ ├── DefaultWatchedEpisodeSyncRepository.kt
│ │ │ │ ├── EpisodeWatchesLastRequestStore.kt
│ │ │ │ ├── TraktEpisodeWatchesDataSource.kt
│ │ │ │ ├── UpcomingEpisodesStore.kt
│ │ │ │ ├── dao/
│ │ │ │ │ ├── DefaultEpisodesDao.kt
│ │ │ │ │ ├── DefaultNextEpisodeDao.kt
│ │ │ │ │ └── DefaultWatchedEpisodeDao.kt
│ │ │ │ └── model/
│ │ │ │ └── NextEpisodeKey.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── episodes/
│ │ │ └── implementation/
│ │ │ ├── DefaultEpisodeRepositoryTest.kt
│ │ │ ├── DefaultEpisodesDaoTest.kt
│ │ │ ├── DefaultNextEpisodeDaoTest.kt
│ │ │ ├── DefaultWatchedEpisodeDaoTest.kt
│ │ │ ├── EpisodesCacheTest.kt
│ │ │ └── MockData.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── episodes/
│ │ └── testing/
│ │ ├── FakeEpisodeRepository.kt
│ │ ├── FakeEpisodeWatchesDataSource.kt
│ │ └── FakeWatchedEpisodeSyncRepository.kt
│ ├── featuredshows/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── featuredshows/
│ │ │ └── api/
│ │ │ ├── FeaturedShowsDao.kt
│ │ │ ├── FeaturedShowsRepository.kt
│ │ │ └── interactor/
│ │ │ └── FeaturedShowsInteractor.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── data/
│ │ │ │ └── featuredshows/
│ │ │ │ └── implementation/
│ │ │ │ ├── DefaultFeaturedShowsDao.kt
│ │ │ │ ├── DefaultFeaturedShowsRepository.kt
│ │ │ │ └── FeaturedShowsStore.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── featuredshows/
│ │ │ └── implementation/
│ │ │ └── DefaultFeaturedShowsDaoTest.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── data/
│ │ └── featuredshows/
│ │ └── testing/
│ │ └── FakeFeaturedShowsRepository.kt
│ ├── followedshows/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── followedshows/
│ │ │ └── api/
│ │ │ ├── FollowedShowEntry.kt
│ │ │ ├── FollowedShowsDao.kt
│ │ │ ├── FollowedShowsRepository.kt
│ │ │ └── PendingAction.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── followedshows/
│ │ │ │ └── implementation/
│ │ │ │ ├── DefaultFollowedShowsDao.kt
│ │ │ │ └── DefaultFollowedShowsRepository.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── followedshows/
│ │ │ └── implementation/
│ │ │ ├── DefaultFollowedShowsDaoTest.kt
│ │ │ └── DefaultFollowedShowsRepositoryTest.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── followedshows/
│ │ └── testing/
│ │ ├── FakeFollowedShowsDao.kt
│ │ └── FakeFollowedShowsRepository.kt
│ ├── genre/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── genre/
│ │ │ ├── GenreDao.kt
│ │ │ ├── GenreRepository.kt
│ │ │ ├── ShowGenresEntity.kt
│ │ │ ├── TraktGenreDao.kt
│ │ │ └── model/
│ │ │ ├── GenreShowCategory.kt
│ │ │ ├── GenreShowsStoreKey.kt
│ │ │ ├── GenreWithShowsEntity.kt
│ │ │ └── TraktGenreEntity.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── genre/
│ │ │ ├── DefaultGenreDao.kt
│ │ │ ├── DefaultGenreRepository.kt
│ │ │ ├── DefaultTraktGenreDao.kt
│ │ │ ├── GenrePosterStore.kt
│ │ │ ├── GenreShowsStore.kt
│ │ │ ├── GenreStore.kt
│ │ │ ├── ShowsByGenreIdStore.kt
│ │ │ └── TraktGenresStore.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── genre/
│ │ └── FakeGenreRepository.kt
│ ├── library/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── library/
│ │ │ ├── LibraryDao.kt
│ │ │ ├── LibraryRepository.kt
│ │ │ └── model/
│ │ │ ├── LibraryItem.kt
│ │ │ ├── LibrarySortOption.kt
│ │ │ └── WatchProvider.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── library/
│ │ │ └── implementation/
│ │ │ ├── DefaultLibraryDao.kt
│ │ │ ├── DefaultLibraryRepository.kt
│ │ │ └── LibraryStore.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── data/
│ │ └── library/
│ │ └── testing/
│ │ └── FakeLibraryRepository.kt
│ ├── popularshows/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── popularshows/
│ │ │ └── api/
│ │ │ ├── PopularShowsDao.kt
│ │ │ ├── PopularShowsInteractor.kt
│ │ │ └── PopularShowsRepository.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── data/
│ │ │ │ └── popularshows/
│ │ │ │ └── implementation/
│ │ │ │ ├── DefaultPopularShowsDao.kt
│ │ │ │ ├── DefaultPopularShowsRepository.kt
│ │ │ │ └── PopularShowsStore.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── popularshows/
│ │ │ └── implementation/
│ │ │ └── DefaultPopularShowsDaoTest.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── data/
│ │ └── popularshows/
│ │ └── testing/
│ │ └── FakePopularShowsRepository.kt
│ ├── recommendedshows/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── recommendedshows/
│ │ │ └── api/
│ │ │ ├── RecommendedShowsDao.kt
│ │ │ ├── RecommendedShowsParams.kt
│ │ │ └── RecommendedShowsRepository.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── recommendedshows/
│ │ │ └── implementation/
│ │ │ ├── DefaultRecommendedShowsDao.kt
│ │ │ ├── DefaultRecommendedShowsRepository.kt
│ │ │ ├── RecommendedShowResult.kt
│ │ │ └── RecommendedShowsStore.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── data/
│ │ └── recommendedshows/
│ │ └── testing/
│ │ └── FakeRecommendedShowsRepository.kt
│ ├── request-manager/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── resourcemanager/
│ │ │ └── api/
│ │ │ ├── RequestManagerRepository.kt
│ │ │ └── RequestTypeConfig.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── resourcemanager/
│ │ │ │ └── implementation/
│ │ │ │ └── DefaultRequestManagerRepository.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── resourcemanager/
│ │ │ └── implementation/
│ │ │ ├── CacheValidationTest.kt
│ │ │ └── DefaultRequestManagerRepositoryTest.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── requestmanager/
│ │ └── testing/
│ │ └── FakeRequestManagerRepository.kt
│ ├── search/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── search/
│ │ │ └── api/
│ │ │ └── SearchRepository.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── search/
│ │ │ └── implementation/
│ │ │ ├── DefaultSearchRepository.kt
│ │ │ ├── SearchShowResult.kt
│ │ │ └── SearchShowStore.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── search/
│ │ └── testing/
│ │ └── FakeSearchRepository.kt
│ ├── seasondetails/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── seasondetails/
│ │ │ └── api/
│ │ │ ├── SeasonDetailsDao.kt
│ │ │ ├── SeasonDetailsParam.kt
│ │ │ ├── SeasonDetailsRepository.kt
│ │ │ └── model/
│ │ │ ├── ContinueTrackingResult.kt
│ │ │ ├── EpisodeDetails.kt
│ │ │ └── SeasonDetailsWithEpisodes.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── seasondetails/
│ │ │ └── implementation/
│ │ │ ├── DefaultSeasonDetailsDao.kt
│ │ │ ├── DefaultSeasonDetailsRepository.kt
│ │ │ ├── SeasonDetailsResponse.kt
│ │ │ └── SeasonDetailsStore.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── seasondetails/
│ │ └── testing/
│ │ └── FakeSeasonDetailsRepository.kt
│ ├── seasons/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── seasons/
│ │ │ └── api/
│ │ │ ├── FollowedShowSeason.kt
│ │ │ ├── SeasonsDao.kt
│ │ │ ├── SeasonsEpisodesSyncRepository.kt
│ │ │ └── SeasonsRepository.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── seasons/
│ │ │ └── implementation/
│ │ │ ├── DefaultSeasonsDao.kt
│ │ │ ├── DefaultSeasonsEpisodesSyncRepository.kt
│ │ │ ├── DefaultSeasonsRepository.kt
│ │ │ └── SeasonsWithEpisodesStore.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── seasons/
│ │ └── testing/
│ │ └── FakeSeasonsRepository.kt
│ ├── showdetails/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── showdetails/
│ │ │ └── api/
│ │ │ ├── ShowDetailsDao.kt
│ │ │ └── ShowDetailsRepository.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── showdetails/
│ │ │ └── implementation/
│ │ │ ├── DefaultShowDetailsDao.kt
│ │ │ ├── DefaultShowDetailsRepository.kt
│ │ │ ├── ShowDetailsResponse.kt
│ │ │ └── ShowDetailsStore.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── data/
│ │ └── showdetails/
│ │ └── testing/
│ │ └── FakeShowDetailsRepository.kt
│ ├── shows/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── shows/
│ │ │ │ └── api/
│ │ │ │ ├── MergeShowUtil.kt
│ │ │ │ ├── TvShowsDao.kt
│ │ │ │ └── model/
│ │ │ │ ├── Category.kt
│ │ │ │ ├── ShowDefaults.kt
│ │ │ │ └── ShowEntity.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── shows/
│ │ │ └── api/
│ │ │ ├── MockData.kt
│ │ │ └── TvShowCacheTest.kt
│ │ └── implementation/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── shows/
│ │ └── implementation/
│ │ └── DefaultTvShowsDao.kt
│ ├── similar/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── similar/
│ │ │ └── api/
│ │ │ ├── SimilarShowsDao.kt
│ │ │ └── SimilarShowsRepository.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── similar/
│ │ │ └── implementation/
│ │ │ ├── DefaultSimilarShowsDao.kt
│ │ │ ├── DefaultSimilarShowsRepository.kt
│ │ │ ├── SimilarParams.kt
│ │ │ ├── SimilarShowResult.kt
│ │ │ └── SimilarShowStore.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── similar/
│ │ └── testing/
│ │ └── FakeSimilarShowsRepository.kt
│ ├── sync-activity/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── syncactivity/
│ │ │ └── api/
│ │ │ ├── TraktActivityDao.kt
│ │ │ ├── TraktActivityRepository.kt
│ │ │ └── model/
│ │ │ ├── ActivityType.kt
│ │ │ └── TraktLastActivity.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── syncactivity/
│ │ │ │ └── implementation/
│ │ │ │ ├── DefaultTraktActivityDao.kt
│ │ │ │ ├── DefaultTraktActivityRepository.kt
│ │ │ │ └── TraktActivityStore.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── syncactivity/
│ │ │ └── implementation/
│ │ │ └── DefaultTraktActivityDaoTest.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── syncactivity/
│ │ └── testing/
│ │ ├── FakeTraktActivityDao.kt
│ │ └── FakeTraktActivityRepository.kt
│ ├── topratedshows/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── topratedshows/
│ │ │ └── data/
│ │ │ └── api/
│ │ │ ├── TopRatedShowsDao.kt
│ │ │ ├── TopRatedShowsInteractor.kt
│ │ │ └── TopRatedShowsRepository.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── toprated/
│ │ │ │ └── data/
│ │ │ │ └── implementation/
│ │ │ │ ├── DefaultTopRatedShowsDao.kt
│ │ │ │ ├── DefaultTopRatedShowsRepository.kt
│ │ │ │ ├── TopRatedShowWithImages.kt
│ │ │ │ └── TopRatedShowsStore.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── toprated/
│ │ │ └── data/
│ │ │ └── implementation/
│ │ │ └── DefaultTopRatedShowsDaoTest.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── data/
│ │ └── topratedshows/
│ │ └── testing/
│ │ └── FakeTopRatedShowsRepository.kt
│ ├── trailers/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com.thomaskioko.tvmaniac.data.trailers.implementation/
│ │ │ ├── TrailerDao.kt
│ │ │ └── TrailerRepository.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── trailers/
│ │ │ └── implementation/
│ │ │ ├── DefaultTrailerDao.kt
│ │ │ ├── DefaultTrailerRepository.kt
│ │ │ └── TrailerStore.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── trailers/
│ │ └── testing/
│ │ ├── FakeTrailerRepository.kt
│ │ └── MockData.kt
│ ├── traktauth/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── traktauth/
│ │ │ └── api/
│ │ │ ├── AuthError.kt
│ │ │ ├── AuthState.kt
│ │ │ ├── AuthStore.kt
│ │ │ ├── RefreshTokenResult.kt
│ │ │ ├── TokenRefreshResult.kt
│ │ │ ├── TraktAuthManager.kt
│ │ │ ├── TraktAuthRepository.kt
│ │ │ ├── TraktAuthState.kt
│ │ │ └── TraktRefreshTokenAction.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── androidMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── traktauth/
│ │ │ │ └── implementation/
│ │ │ │ ├── AndroidAuthStore.kt
│ │ │ │ ├── AndroidTraktAuthManager.kt
│ │ │ │ ├── TraktActivityResultContract.kt
│ │ │ │ └── di/
│ │ │ │ └── TraktAuthAndroidBindingContainer.kt
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── traktauth/
│ │ │ │ └── implementation/
│ │ │ │ ├── DefaultTraktAuthRepository.kt
│ │ │ │ ├── DefaultTraktRefreshTokenAction.kt
│ │ │ │ ├── TokenRefreshInitializer.kt
│ │ │ │ ├── TokenRefreshWorker.kt
│ │ │ │ └── di/
│ │ │ │ └── TokenRefreshInitializerBindingContainer.kt
│ │ │ └── iosMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── traktauth/
│ │ │ └── implementation/
│ │ │ ├── DefaultIOSTraktAuthManager.kt
│ │ │ └── IosAuthStore.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── traktauth/
│ │ └── testing/
│ │ ├── FakeAuthStore.kt
│ │ ├── FakeTraktAuthManager.kt
│ │ ├── FakeTraktAuthRepository.kt
│ │ └── FakeTraktRefreshTokenAction.kt
│ ├── traktlists/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── traktlists/
│ │ │ └── api/
│ │ │ ├── TraktList.kt
│ │ │ ├── TraktListDao.kt
│ │ │ ├── TraktListEntity.kt
│ │ │ ├── TraktListRepository.kt
│ │ │ ├── TraktListShowDao.kt
│ │ │ └── TraktListShowEntry.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── traktlists/
│ │ │ └── implementation/
│ │ │ ├── CreateTraktListStore.kt
│ │ │ ├── DefaultTraktListDao.kt
│ │ │ ├── DefaultTraktListRepository.kt
│ │ │ ├── DefaultTraktListShowDao.kt
│ │ │ └── TraktListsStore.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── traktlists/
│ │ └── testing/
│ │ └── FakeTraktListRepository.kt
│ ├── trendingshows/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── discover/
│ │ │ └── api/
│ │ │ ├── TrendingShowsDao.kt
│ │ │ ├── TrendingShowsInteractor.kt
│ │ │ ├── TrendingShowsParams.kt
│ │ │ └── TrendingShowsRepository.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── discover/
│ │ │ │ └── implementation/
│ │ │ │ ├── DefaultTrendingShowsDao.kt
│ │ │ │ ├── DefaultTrendingShowsRepository.kt
│ │ │ │ ├── TrendingShowWithImages.kt
│ │ │ │ └── TrendingShowsStore.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── discover/
│ │ │ └── implementation/
│ │ │ └── DefaultTrendingShowsDaoTest.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── data/
│ │ └── trendingshows/
│ │ └── testing/
│ │ └── FakeTrendingShowsRepository.kt
│ ├── upcomingshows/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── upcomingshows/
│ │ │ └── api/
│ │ │ ├── UpcomingShowsDao.kt
│ │ │ ├── UpcomingShowsInteractor.kt
│ │ │ └── UpcomingShowsRepository.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── data/
│ │ │ │ └── upcomingshows/
│ │ │ │ └── implementation/
│ │ │ │ ├── DefaultUpcomingShowsDao.kt
│ │ │ │ ├── DefaultUpcomingShowsRepository.kt
│ │ │ │ ├── UpcomingShowsStore.kt
│ │ │ │ └── model/
│ │ │ │ ├── UpcomingParams.kt
│ │ │ │ └── UpcomingShowResult.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── upcomingshows/
│ │ │ └── implementation/
│ │ │ └── DefaultUpcomingShowsDaoTest.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── data/
│ │ └── upcomingshows/
│ │ └── testing/
│ │ └── FakeUpcomingShowsRepository.kt
│ ├── upnext/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── upnext/
│ │ │ └── api/
│ │ │ ├── UpNextDao.kt
│ │ │ ├── UpNextRepository.kt
│ │ │ └── model/
│ │ │ └── NextEpisodeWithShow.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── upnext/
│ │ │ │ └── implementation/
│ │ │ │ ├── DefaultUpNextDao.kt
│ │ │ │ ├── DefaultUpNextRepository.kt
│ │ │ │ └── ShowUpNextStore.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── upnext/
│ │ │ └── implementation/
│ │ │ ├── DefaultUpNextDaoTest.kt
│ │ │ └── DefaultUpNextRepositoryTest.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── upnext/
│ │ └── testing/
│ │ ├── FakeUpNextDao.kt
│ │ └── FakeUpNextRepository.kt
│ ├── user/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── user/
│ │ │ └── api/
│ │ │ ├── UserDao.kt
│ │ │ ├── UserRepository.kt
│ │ │ ├── UserStatsDao.kt
│ │ │ └── model/
│ │ │ ├── UserProfile.kt
│ │ │ ├── UserProfileStats.kt
│ │ │ └── UserWatchTime.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── data/
│ │ │ │ └── user/
│ │ │ │ └── implementation/
│ │ │ │ ├── DefaultUserDao.kt
│ │ │ │ ├── DefaultUserRepository.kt
│ │ │ │ ├── DefaultUserStatsDao.kt
│ │ │ │ ├── UserStatsStore.kt
│ │ │ │ └── UserStore.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── data/
│ │ │ └── user/
│ │ │ └── implementation/
│ │ │ ├── DefaultUserDaoTest.kt
│ │ │ └── DefaultUserStatsDaoTest.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── data/
│ │ └── user/
│ │ └── testing/
│ │ └── FakeUserRepository.kt
│ ├── watchlist/
│ │ ├── api/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── shows/
│ │ │ └── api/
│ │ │ ├── WatchlistDao.kt
│ │ │ └── WatchlistRepository.kt
│ │ ├── implementation/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── watchlist/
│ │ │ └── implementation/
│ │ │ ├── DefaultWatchlistDao.kt
│ │ │ └── DefaultWatchlistRepository.kt
│ │ └── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── watchlist/
│ │ └── testing/
│ │ └── FakeWatchlistRepository.kt
│ └── watchproviders/
│ ├── api/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── data/
│ │ └── watchproviders/
│ │ └── api/
│ │ ├── WatchProviderDao.kt
│ │ └── WatchProviderRepository.kt
│ ├── implementation/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── data/
│ │ └── watchproviders/
│ │ └── implementation/
│ │ ├── DefaultWatchProviderDao.kt
│ │ ├── DefaultWatchProviderRepository.kt
│ │ └── WatchProvidersStore.kt
│ └── testing/
│ ├── build.gradle.kts
│ └── src/
│ └── commonMain/
│ └── kotlin/
│ └── com/
│ └── thomaskioko/
│ └── tvmaniac/
│ └── data/
│ └── watchproviders/
│ └── testing/
│ └── FakeWatchProviderRepository.kt
├── docs/
│ ├── architecture/
│ │ ├── README.md
│ │ ├── data-layer.md
│ │ ├── dependency-injection.md
│ │ ├── integration-testing.md
│ │ ├── journey-tests.md
│ │ ├── modularization.md
│ │ ├── navigation-codegen.md
│ │ ├── navigation.md
│ │ ├── presentation-layer.md
│ │ └── scopes.md
│ ├── privacy_policy.md
│ ├── setup.md
│ └── terms_conditions.md
├── domain/
│ ├── calendar/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── domain/
│ │ │ └── calendar/
│ │ │ ├── CalendarEpisodeFormatter.kt
│ │ │ ├── CalendarWeekCalculator.kt
│ │ │ ├── FetchCalendarInteractor.kt
│ │ │ ├── ObserveCalendarInteractor.kt
│ │ │ └── model/
│ │ │ ├── DateLabel.kt
│ │ │ ├── GroupedCalendarEntry.kt
│ │ │ └── GroupedEpisodeEntry.kt
│ │ └── commonTest/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── calendar/
│ │ ├── CalendarWeekCalculatorTest.kt
│ │ └── ObserveCalendarInteractorTest.kt
│ ├── discover/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── domain/
│ │ │ └── discover/
│ │ │ └── DiscoverShowsInteractor.kt
│ │ └── commonTest/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── discover/
│ │ └── DiscoverShowsInteractorTest.kt
│ ├── episode/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── domain/
│ │ │ └── episode/
│ │ │ ├── MarkEpisodeUnwatchedInteractor.kt
│ │ │ ├── MarkEpisodeWatchedInteractor.kt
│ │ │ ├── ObserveEpisodeByIdInteractor.kt
│ │ │ └── ObserveShowWatchProgressInteractor.kt
│ │ └── commonTest/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── episode/
│ │ └── MarkEpisodeWatchedInteractorTest.kt
│ ├── followedshows/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── followedshows/
│ │ └── UnfollowShowInteractor.kt
│ ├── genre/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── genre/
│ │ ├── FetchGenreContentInteractor.kt
│ │ └── GenreShowsInteractor.kt
│ ├── library/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── library/
│ │ ├── LibrarySyncWorker.kt
│ │ ├── ObserveLibraryInteractor.kt
│ │ ├── SyncLibraryInteractor.kt
│ │ ├── SyncTasksInitializer.kt
│ │ └── di/
│ │ └── SyncTasksInitializerBindingContainer.kt
│ ├── logout/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── logout/
│ │ └── LogoutInteractor.kt
│ ├── notifications/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── domain/
│ │ │ └── notifications/
│ │ │ ├── EpisodeNotificationWorker.kt
│ │ │ ├── NotificationTasksInitializer.kt
│ │ │ ├── di/
│ │ │ │ └── NotificationTasksInitializerBindingContainer.kt
│ │ │ └── interactor/
│ │ │ ├── RefreshUpcomingSeasonDetailsInteractor.kt
│ │ │ ├── ScheduleDebugEpisodeNotificationInteractor.kt
│ │ │ ├── ScheduleEpisodeNotificationsInteractor.kt
│ │ │ ├── SyncTraktCalendarInteractor.kt
│ │ │ └── ToggleEpisodeNotificationsInteractor.kt
│ │ └── commonTest/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── notifications/
│ │ └── interactor/
│ │ └── ScheduleEpisodeNotificationsInteractorTest.kt
│ ├── recommendedshows/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── recommendedshows/
│ │ └── RecommendedShowsInteractor.kt
│ ├── seasondetails/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── seasondetails/
│ │ ├── FetchPreviousSeasonsInteractor.kt
│ │ ├── MarkSeasonUnwatchedInteractor.kt
│ │ ├── MarkSeasonWatchedInteractor.kt
│ │ ├── ObservableSeasonDetailsInteractor.kt
│ │ ├── ObserveSeasonWatchProgressInteractor.kt
│ │ ├── ObserveUnwatchedInPreviousSeasonsInteractor.kt
│ │ ├── SeasonDetailsInteractor.kt
│ │ └── model/
│ │ ├── SeasonCast.kt
│ │ ├── SeasonDetailsResult.kt
│ │ └── SeasonImages.kt
│ ├── settings/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── settings/
│ │ └── ObserveSettingsPreferencesInteractor.kt
│ ├── showdetails/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── showdetails/
│ │ ├── FollowShowInteractor.kt
│ │ ├── Mapper.kt
│ │ ├── ObservableShowDetailsInteractor.kt
│ │ ├── ShowContentSyncInteractor.kt
│ │ ├── ShowDetailsInteractor.kt
│ │ └── model/
│ │ └── ShowDetails.kt
│ ├── similarshows/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── similarshows/
│ │ └── SimilarShowsInteractor.kt
│ ├── theme/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── theme/
│ │ ├── ImageQuality.kt
│ │ └── Theme.kt
│ ├── traktlists/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── traktlists/
│ │ ├── CreateTraktListInteractor.kt
│ │ ├── ObserveTraktListsInteractor.kt
│ │ ├── SyncTraktListsInteractor.kt
│ │ └── ToggleShowInListInteractor.kt
│ ├── upnext/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── domain/
│ │ │ └── upnext/
│ │ │ ├── ObserveUpNextInteractor.kt
│ │ │ ├── RefreshUpNextInteractor.kt
│ │ │ ├── UpNextSyncWorker.kt
│ │ │ ├── UpNextTasksInitializer.kt
│ │ │ ├── di/
│ │ │ │ └── UpNextTasksInitializerBindingContainer.kt
│ │ │ └── model/
│ │ │ ├── UpNextResult.kt
│ │ │ └── UpNextSortOption.kt
│ │ └── commonTest/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── upnext/
│ │ └── ObserveUpNextInteractorTest.kt
│ ├── user/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── user/
│ │ ├── ObserveUserProfileInteractor.kt
│ │ ├── UpdateUserProfileData.kt
│ │ └── model/
│ │ ├── UserProfile.kt
│ │ └── UserStats.kt
│ ├── watchlist/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── domain/
│ │ │ └── watchlist/
│ │ │ ├── ObservableWatchlistInteractor.kt
│ │ │ ├── ObserveUpNextSectionsInteractor.kt
│ │ │ ├── ObserveWatchlistSectionsInteractor.kt
│ │ │ ├── UpNextSectionsMapper.kt
│ │ │ ├── WatchlistSyncInteractor.kt
│ │ │ └── model/
│ │ │ ├── EpisodeBadge.kt
│ │ │ ├── UpNextSections.kt
│ │ │ └── WatchlistSections.kt
│ │ └── commonTest/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── domain/
│ │ └── watchlist/
│ │ ├── ObservableWatchlistInteractorTest.kt
│ │ ├── ObserveUpNextSectionsInteractorTest.kt
│ │ ├── ObserveWatchlistSectionsInteractorTest.kt
│ │ └── UpNextSectionsMapperTest.kt
│ └── watchproviders/
│ ├── build.gradle.kts
│ └── src/
│ └── commonMain/
│ └── kotlin/
│ └── com/
│ └── thomaskioko/
│ └── tvmaniac/
│ └── domain/
│ └── watchproviders/
│ └── WatchProvidersInteractor.kt
├── fastlane/
│ ├── Appfile
│ ├── Fastfile
│ ├── Matchfile
│ ├── Pluginfile
│ └── README.md
├── features/
│ ├── calendar/
│ │ ├── nav/
│ │ │ └── build.gradle.kts
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── presentation/
│ │ │ │ └── calendar/
│ │ │ │ ├── CalendarAction.kt
│ │ │ │ ├── CalendarPresenter.kt
│ │ │ │ ├── CalendarState.kt
│ │ │ │ ├── CalendarStateMapper.kt
│ │ │ │ └── model/
│ │ │ │ ├── CalendarDateGroup.kt
│ │ │ │ └── CalendarEpisodeItem.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── presentation/
│ │ │ └── calendar/
│ │ │ └── CalendarPresenterTest.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── main/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── ui/
│ │ │ └── calendar/
│ │ │ └── CalendarScreen.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── ui/
│ │ └── calendar/
│ │ └── roborrazi/
│ │ └── CalendarScreenshotTest.kt
│ ├── debug/
│ │ ├── nav/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── debug/
│ │ │ └── nav/
│ │ │ └── DebugRoute.kt
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── debug/
│ │ │ │ └── presenter/
│ │ │ │ ├── DebugActions.kt
│ │ │ │ ├── DebugPresenter.kt
│ │ │ │ └── DebugState.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── debug/
│ │ │ └── presenter/
│ │ │ └── DebugPresenterTest.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── main/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── debug/
│ │ └── ui/
│ │ └── DebugMenuScreen.kt
│ ├── discover/
│ │ ├── nav/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── discover/
│ │ │ └── nav/
│ │ │ └── DiscoverNavigator.kt
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── discover/
│ │ │ │ └── presenter/
│ │ │ │ ├── DiscoverShowsAction.kt
│ │ │ │ ├── DiscoverShowsMapper.kt
│ │ │ │ ├── DiscoverShowsPresenter.kt
│ │ │ │ ├── DiscoverViewState.kt
│ │ │ │ ├── di/
│ │ │ │ │ └── DefaultDiscoverNavigator.kt
│ │ │ │ └── model/
│ │ │ │ ├── DiscoverShow.kt
│ │ │ │ └── NextEpisodeUiModel.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── discover/
│ │ │ └── presenter/
│ │ │ └── DiscoverShowsPresenterTest.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ ├── lint-baseline.xml
│ │ └── src/
│ │ ├── main/
│ │ │ └── java/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── discover/
│ │ │ └── ui/
│ │ │ ├── DiscoverPreviewParameterProvider.kt
│ │ │ ├── DiscoverScreen.kt
│ │ │ └── component/
│ │ │ ├── CircularIndicator.kt
│ │ │ ├── DiscoverHeaderContent.kt
│ │ │ ├── HorizontalRowContent.kt
│ │ │ ├── NextEpisodeCard.kt
│ │ │ └── NextEpisodesSection.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── discover/
│ │ └── roborrazi/
│ │ └── DiscoverScreenshotTest.kt
│ ├── episode-sheet/
│ │ ├── nav/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── espisodedetails/
│ │ │ └── nav/
│ │ │ └── model/
│ │ │ ├── EpisodeSheetConfig.kt
│ │ │ ├── ScreenSource.kt
│ │ │ └── SheetNavigatorExt.kt
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── presentation/
│ │ │ │ └── episodedetail/
│ │ │ │ ├── EpisodeSheetAction.kt
│ │ │ │ ├── EpisodeSheetMapper.kt
│ │ │ │ ├── EpisodeSheetPresenter.kt
│ │ │ │ └── EpisodeSheetState.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── presentation/
│ │ │ └── episodedetail/
│ │ │ └── EpisodeSheetPresenterTest.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── main/
│ │ │ └── java/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── episodedetail/
│ │ │ └── ui/
│ │ │ ├── EpisodeDetailBottomSheet.kt
│ │ │ └── EpisodeSheet.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── episodedetail/
│ │ └── roborrazi/
│ │ └── EpisodeSheetScreenshotTest.kt
│ ├── genre-shows/
│ │ ├── nav/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── genreshows/
│ │ │ └── nav/
│ │ │ ├── GenreShowsDestination.kt
│ │ │ └── GenreShowsRoute.kt
│ │ └── presenter/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── genreshows/
│ │ └── presenter/
│ │ └── di/
│ │ └── GenreShowsNavDestinationBinding.kt
│ ├── home/
│ │ ├── nav/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── home/
│ │ │ └── nav/
│ │ │ ├── HomeRoute.kt
│ │ │ ├── HomeTabNavigator.kt
│ │ │ ├── TabChild.kt
│ │ │ ├── TabDestination.kt
│ │ │ └── di/
│ │ │ ├── TabDestinationMultibindings.kt
│ │ │ └── model/
│ │ │ └── HomeConfig.kt
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── presenter/
│ │ │ │ └── home/
│ │ │ │ ├── HomePresenter.kt
│ │ │ │ └── di/
│ │ │ │ └── DefaultHomeTabNavigator.kt
│ │ │ ├── commonTest/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── presenter/
│ │ │ │ └── home/
│ │ │ │ └── HomePresenterTest.kt
│ │ │ ├── iosTest/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── presenter/
│ │ │ │ └── home/
│ │ │ │ └── HomePresenterIosTest.kt
│ │ │ └── jvmTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── presenter/
│ │ │ └── home/
│ │ │ └── HomePresenterJvmTest.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── main/
│ │ └── java/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── home/
│ │ └── ui/
│ │ └── HomeScreen.kt
│ ├── library/
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── presentation/
│ │ │ └── library/
│ │ │ ├── LibraryAction.kt
│ │ │ ├── LibraryPresenter.kt
│ │ │ ├── LibraryState.kt
│ │ │ └── model/
│ │ │ ├── LibraryShowItem.kt
│ │ │ ├── LibrarySortOption.kt
│ │ │ └── ShowStatus.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── main/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── ui/
│ │ └── library/
│ │ ├── LibraryListItem.kt
│ │ ├── LibraryScreen.kt
│ │ ├── LibrarySearchbar.kt
│ │ ├── SortOptionsContent.kt
│ │ └── preview/
│ │ ├── LibraryListItemPreviewParameterProvider.kt
│ │ └── LibraryStatePreviewParameterProvider.kt
│ ├── more-shows/
│ │ ├── nav/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── moreshows/
│ │ │ └── nav/
│ │ │ └── MoreShowsRoute.kt
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── moreshows/
│ │ │ └── presentation/
│ │ │ ├── MoreShowsAction.kt
│ │ │ ├── MoreShowsPresenter.kt
│ │ │ ├── MoreShowsState.kt
│ │ │ └── TvShow.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── main/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── moreshows/
│ │ │ └── ui/
│ │ │ ├── MoreShowsPreviewParameterProvider.kt
│ │ │ └── MoreShowsScreen.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── moreshows/
│ │ └── roborrazi/
│ │ └── MoreShowsScreenTest.kt
│ ├── profile/
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── profile/
│ │ │ │ └── presenter/
│ │ │ │ ├── ProfileAction.kt
│ │ │ │ ├── ProfilePresenter.kt
│ │ │ │ └── model/
│ │ │ │ ├── ProfileInfo.kt
│ │ │ │ ├── ProfileState.kt
│ │ │ │ └── ProfileStats.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── presenter/
│ │ │ └── profile/
│ │ │ └── ProfilePresenterTest.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── main/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── profile/
│ │ │ └── ui/
│ │ │ ├── ProfilePreviewParameterProvider.kt
│ │ │ ├── ProfileScreen.kt
│ │ │ ├── StatsCardItem.kt
│ │ │ └── UnauthenticatedContent.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── profile/
│ │ └── roborazzi/
│ │ └── ProfileScreenTest.kt
│ ├── progress/
│ │ ├── nav/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── progress/
│ │ │ └── nav/
│ │ │ └── scope/
│ │ │ └── ProgressChildScope.kt
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── presentation/
│ │ │ └── progress/
│ │ │ ├── ProgressAction.kt
│ │ │ ├── ProgressChildGraph.kt
│ │ │ ├── ProgressPresenter.kt
│ │ │ └── ProgressState.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── main/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── ui/
│ │ │ └── progress/
│ │ │ └── ProgressScreen.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── ui/
│ │ └── progress/
│ │ └── roborrazi/
│ │ └── ProgressScreenshotTest.kt
│ ├── root/
│ │ ├── nav/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── root/
│ │ │ ├── model/
│ │ │ │ ├── DeepLinkDestination.kt
│ │ │ │ ├── NotificationPermissionState.kt
│ │ │ │ └── ThemeState.kt
│ │ │ └── nav/
│ │ │ └── NotificationRationale.kt
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── presenter/
│ │ │ │ └── root/
│ │ │ │ ├── DefaultRootPresenter.kt
│ │ │ │ ├── RootPresenter.kt
│ │ │ │ └── di/
│ │ │ │ ├── DefaultNotificationRationale.kt
│ │ │ │ ├── DefaultSheetNavigator.kt
│ │ │ │ └── RootPresenterBindingContainer.kt
│ │ │ ├── commonTest/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── presenter/
│ │ │ │ └── root/
│ │ │ │ └── DefaultRootPresenterTest.kt
│ │ │ ├── iosTest/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── presenter/
│ │ │ │ └── root/
│ │ │ │ └── DefaultRootPresenterIosTest.kt
│ │ │ └── jvmTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── presenter/
│ │ │ └── root/
│ │ │ └── DefaultRootPresenterJvmTest.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── main/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── app/
│ │ └── ui/
│ │ └── RootScreen.kt
│ ├── search/
│ │ ├── nav/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── search/
│ │ │ └── nav/
│ │ │ └── SearchRoute.kt
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── search/
│ │ │ │ └── presenter/
│ │ │ │ ├── Mapper.kt
│ │ │ │ ├── SearchShowAction.kt
│ │ │ │ ├── SearchShowState.kt
│ │ │ │ ├── SearchShowsPresenter.kt
│ │ │ │ └── model/
│ │ │ │ ├── CategoryItem.kt
│ │ │ │ ├── GenreRowModel.kt
│ │ │ │ ├── ShowGenre.kt
│ │ │ │ └── ShowItem.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── presenter/
│ │ │ └── search/
│ │ │ └── SearchShowsPresenterTest.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── main/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── search/
│ │ │ └── ui/
│ │ │ ├── SearchPreviewParameterProvider.kt
│ │ │ ├── SearchScreen.kt
│ │ │ └── components/
│ │ │ ├── HorizontalShowContentRow.kt
│ │ │ └── SearchResultItem.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── search/
│ │ └── roborrazi/
│ │ └── SearchScreenTest.kt
│ ├── season-details/
│ │ ├── nav/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── seasondetails/
│ │ │ └── nav/
│ │ │ ├── SeasonDetailsRoute.kt
│ │ │ └── SeasonDetailsUiParam.kt
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── seasondetails/
│ │ │ │ └── presenter/
│ │ │ │ ├── Mapper.kt
│ │ │ │ ├── SeasonDetailsAction.kt
│ │ │ │ ├── SeasonDetailsModel.kt
│ │ │ │ ├── SeasonDetailsPresenter.kt
│ │ │ │ └── model/
│ │ │ │ ├── Cast.kt
│ │ │ │ ├── EpisodeDetailsModel.kt
│ │ │ │ └── SeasonImagesModel.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── seasondetails/
│ │ │ └── presenter/
│ │ │ ├── SeasonPresenterTest.kt
│ │ │ └── data/
│ │ │ └── MockData.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── main/
│ │ │ └── java/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── seasondetails/
│ │ │ └── ui/
│ │ │ ├── SeasonDetailsScreen.kt
│ │ │ ├── SeasonPreviewParameterProvider.kt
│ │ │ └── components/
│ │ │ ├── CollapsableContent.kt
│ │ │ └── EpisodeItem.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── seasondetails/
│ │ └── roborrazi/
│ │ └── SeasonScreenshotTest.kt
│ ├── settings/
│ │ ├── nav/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── settings/
│ │ │ └── nav/
│ │ │ └── SettingsRoute.kt
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── settings/
│ │ │ │ └── presenter/
│ │ │ │ ├── SettingsActions.kt
│ │ │ │ ├── SettingsPresenter.kt
│ │ │ │ ├── SettingsState.kt
│ │ │ │ └── ThemeModel.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── presenter/
│ │ │ └── settings/
│ │ │ └── SettingsPresenterTest.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── main/
│ │ │ ├── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── settings/
│ │ │ │ └── ui/
│ │ │ │ ├── AboutSheetContent.kt
│ │ │ │ ├── SettingsPreviewParameterProvider.kt
│ │ │ │ ├── SettingsScreen.kt
│ │ │ │ ├── ThemePreviewSwatch.kt
│ │ │ │ └── ThemeSelectorSection.kt
│ │ │ └── res/
│ │ │ └── drawable/
│ │ │ └── ic_app_launcher.xml
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── seasondetails/
│ │ └── roborrazi/
│ │ └── SettingsScreenshotTest.kt
│ ├── show-details/
│ │ ├── nav/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── showdetails/
│ │ │ └── nav/
│ │ │ ├── ShowDetailsRoute.kt
│ │ │ └── model/
│ │ │ ├── ShowDetailsParam.kt
│ │ │ └── ShowSeasonDetailsParam.kt
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── presenter/
│ │ │ │ └── showdetails/
│ │ │ │ ├── ShowDetailsAction.kt
│ │ │ │ ├── ShowDetailsContent.kt
│ │ │ │ ├── ShowDetailsMapper.kt
│ │ │ │ ├── ShowDetailsPresenter.kt
│ │ │ │ └── model/
│ │ │ │ ├── CastModel.kt
│ │ │ │ ├── ContinueTrackingEpisodeModel.kt
│ │ │ │ ├── ProviderModel.kt
│ │ │ │ ├── SeasonModel.kt
│ │ │ │ ├── ShowDetailsModel.kt
│ │ │ │ ├── ShowModel.kt
│ │ │ │ ├── TrailerModel.kt
│ │ │ │ └── TraktListModel.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── presenter/
│ │ │ └── showdetails/
│ │ │ ├── MockData.kt
│ │ │ └── ShowDetailsPresenterTest.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── main/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── showdetails/
│ │ │ └── ui/
│ │ │ ├── DetailPreviewParameterProvider.kt
│ │ │ ├── ShowDetailScreen.kt
│ │ │ └── components/
│ │ │ ├── ContinueTrackingCard.kt
│ │ │ ├── ContinueTrackingSection.kt
│ │ │ ├── SeasonChipItem.kt
│ │ │ ├── ShowListSheetContent.kt
│ │ │ └── WatchProgressSection.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── showdetails/
│ │ └── roborrazi/
│ │ ├── ShowDetailsScreenScreenshotTest.kt
│ │ └── ShowListSheetScreenshotTest.kt
│ ├── trailers/
│ │ ├── nav/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ └── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── trailers/
│ │ │ └── nav/
│ │ │ └── TrailersRoute.kt
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── presenter/
│ │ │ │ └── trailers/
│ │ │ │ ├── Mapper.kt
│ │ │ │ ├── TrailersAction.kt
│ │ │ │ ├── TrailersPresenter.kt
│ │ │ │ ├── TrailersState.kt
│ │ │ │ └── model/
│ │ │ │ └── Trailer.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── presenter/
│ │ │ └── trailers/
│ │ │ └── TrailersPresenterTest.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── main/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── trailers/
│ │ └── ui/
│ │ ├── TrailerPreviewParameterProvider.kt
│ │ └── TrailersScreen.kt
│ ├── upnext/
│ │ ├── presenter/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/
│ │ │ ├── commonMain/
│ │ │ │ └── kotlin/
│ │ │ │ └── com/
│ │ │ │ └── thomaskioko/
│ │ │ │ └── tvmaniac/
│ │ │ │ └── presentation/
│ │ │ │ └── upnext/
│ │ │ │ ├── UpNextAction.kt
│ │ │ │ ├── UpNextPresenter.kt
│ │ │ │ ├── UpNextState.kt
│ │ │ │ └── model/
│ │ │ │ └── UpNextEpisodeUiModel.kt
│ │ │ └── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── presentation/
│ │ │ └── upnext/
│ │ │ └── UpNextPresenterTest.kt
│ │ └── ui/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── main/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── ui/
│ │ │ └── upnext/
│ │ │ ├── UpNextListItem.kt
│ │ │ ├── UpNextScreen.kt
│ │ │ └── preview/
│ │ │ └── UpNextPreviewParameterProvider.kt
│ │ └── test/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── ui/
│ │ └── upnext/
│ │ └── roborrazi/
│ │ └── UpNextScreenshotTest.kt
│ └── watchlist/
│ ├── presenter/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── watchlist/
│ │ │ └── presenter/
│ │ │ ├── Mapper.kt
│ │ │ ├── WatchlistAction.kt
│ │ │ ├── WatchlistPresenter.kt
│ │ │ ├── WatchlistState.kt
│ │ │ └── model/
│ │ │ ├── EpisodeBadge.kt
│ │ │ ├── NextEpisodeItem.kt
│ │ │ ├── SectionedEpisodes.kt
│ │ │ ├── SectionedItems.kt
│ │ │ ├── UpNextEpisodeItem.kt
│ │ │ └── WatchlistItem.kt
│ │ └── commonTest/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ ├── domain/
│ │ │ └── watchlist/
│ │ │ ├── MockData.kt
│ │ │ └── WatchlistPresenterTest.kt
│ │ └── watchlist/
│ │ └── presenter/
│ │ └── FakeWatchlistPresenterBuilder.kt
│ └── ui/
│ ├── build.gradle.kts
│ └── src/
│ ├── main/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── ui/
│ │ └── library/
│ │ ├── WatchListUpNextListItem.kt
│ │ ├── WatchlistListItem.kt
│ │ ├── WatchlistPreviewParameterProvider.kt
│ │ ├── WatchlistScreen.kt
│ │ └── component/
│ │ └── Searchbar.kt
│ └── test/
│ └── kotlin/
│ └── com/
│ └── thomaskioko/
│ └── tvmaniac/
│ └── watchlist/
│ └── roborrazi/
│ └── WatchlistScreenTest.kt
├── gradle/
│ ├── gradle-daemon-jvm.properties
│ ├── libs.versions.toml
│ ├── lint.xml
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── i18n/
│ ├── api/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── i18n/
│ │ └── api/
│ │ └── Localizer.kt
│ ├── generator/
│ │ ├── build.gradle.kts
│ │ ├── lint-baseline.xml
│ │ └── src/
│ │ ├── androidMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── i18n/
│ │ │ └── Resources.kt
│ │ ├── commonMain/
│ │ │ └── moko-resources/
│ │ │ ├── base/
│ │ │ │ ├── plurals.xml
│ │ │ │ └── strings.xml
│ │ │ ├── de/
│ │ │ │ ├── plurals.xml
│ │ │ │ └── strings.xml
│ │ │ └── fr/
│ │ │ ├── plurals.xml
│ │ │ └── strings.xml
│ │ ├── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── i18n/
│ │ │ └── generator/
│ │ │ └── ResourceTest.kt
│ │ └── iosMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── i18n/
│ │ └── Resources.kt
│ ├── implementation/
│ │ ├── build.gradle.kts
│ │ ├── lint-baseline.xml
│ │ └── src/
│ │ ├── androidHostTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── i18n/
│ │ │ └── util/
│ │ │ └── BaseResourceTests.kt
│ │ ├── androidMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── i18n/
│ │ │ └── PlatformLocalizer.android.kt
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── i18n/
│ │ │ ├── LocalizedErrorToStringMapper.kt
│ │ │ ├── MokoLocaleInitializer.kt
│ │ │ ├── MokoResourcesLocalizer.kt
│ │ │ ├── PlatformLocalizer.kt
│ │ │ └── di/
│ │ │ └── MokoLocaleInitializerBindingContainer.kt
│ │ ├── commonTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── i18n/
│ │ │ ├── LocalizedStringTest.kt
│ │ │ ├── MokoLocalizerTest.kt
│ │ │ └── util/
│ │ │ └── BaseResourceTests.kt
│ │ ├── iosMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── i18n/
│ │ │ └── PlatformLocalizer.ios.kt
│ │ ├── iosTest/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── i18n/
│ │ │ └── util/
│ │ │ └── BaseResourceTests.kt
│ │ ├── jvmMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── i18n/
│ │ │ └── PlatformLocalizer.jvm.kt
│ │ └── jvmTest/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── i18n/
│ │ └── util/
│ │ └── BaseResourceTests.kt
│ └── testing/
│ ├── build.gradle.kts
│ └── src/
│ ├── androidMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── i18n/
│ │ └── testing/
│ │ └── util/
│ │ ├── BaseLocalizerTest.android.kt
│ │ └── StringDescExt.kt
│ ├── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── i18n/
│ │ └── testing/
│ │ ├── FakeLocalizer.kt
│ │ └── util/
│ │ ├── BaseLocalizerTest.kt
│ │ └── StringDescExt.kt
│ ├── iosMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── i18n/
│ │ └── testing/
│ │ └── util/
│ │ ├── BaseLocalizerTest.ios.kt
│ │ └── StringDescExt.ios.kt
│ └── jvmMain/
│ └── kotlin/
│ └── com/
│ └── thomaskioko/
│ └── tvmaniac/
│ └── i18n/
│ └── testing/
│ └── util/
│ ├── BaseLocalizerTest.jvm.kt
│ └── StringDescExt.jvm.kt
├── ios/
│ ├── .gitignore
│ ├── .swiftformat
│ ├── Config/
│ │ ├── Debug.xcconfig
│ │ └── Release.xcconfig
│ ├── Modules/
│ │ ├── CoreKit/
│ │ │ ├── Package.swift
│ │ │ └── Sources/
│ │ │ └── CoreKit/
│ │ │ ├── CoreLogger.swift
│ │ │ ├── DefaultDiagnosticLogger.swift
│ │ │ ├── DiagnosticLogger.swift
│ │ │ ├── FirebaseCrashlyticsBridge.swift
│ │ │ ├── ImageCacheManager.swift
│ │ │ ├── MemoryMonitor.swift
│ │ │ └── SystemMemory.swift
│ │ ├── SnapshotTestingLib/
│ │ │ ├── .gitignore
│ │ │ ├── .swiftpm/
│ │ │ │ └── xcode/
│ │ │ │ └── package.xcworkspace/
│ │ │ │ └── xcshareddata/
│ │ │ │ └── IDEWorkspaceChecks.plist
│ │ │ ├── Package.swift
│ │ │ └── Sources/
│ │ │ └── SnapshotTestingLib/
│ │ │ └── SnapshotTesting+Extensions.swift
│ │ ├── SwiftUIComponents/
│ │ │ ├── .gitignore
│ │ │ ├── .swiftpm/
│ │ │ │ └── xcode/
│ │ │ │ └── package.xcworkspace/
│ │ │ │ └── xcshareddata/
│ │ │ │ └── IDEWorkspaceChecks.plist
│ │ │ ├── Package.swift
│ │ │ ├── Sources/
│ │ │ │ └── SwiftUIComponents/
│ │ │ │ ├── Components/
│ │ │ │ │ ├── BorderTextView.swift
│ │ │ │ │ ├── BottomSheet/
│ │ │ │ │ │ └── EpisodeDetailSheetContent.swift
│ │ │ │ │ ├── Buttons/
│ │ │ │ │ │ ├── CircularButton.swift
│ │ │ │ │ │ ├── FilledImageButton.swift
│ │ │ │ │ │ ├── OutlinedButton.swift
│ │ │ │ │ │ ├── RoundedButton.swift
│ │ │ │ │ │ └── TvManiacButton.swift
│ │ │ │ │ ├── CarouselView.swift
│ │ │ │ │ ├── CastListView.swift
│ │ │ │ │ ├── ChevronTitle.swift
│ │ │ │ │ ├── ChipView.swift
│ │ │ │ │ ├── CircularIndicator.swift
│ │ │ │ │ ├── ContinueTracking/
│ │ │ │ │ │ ├── ContinueTrackingCard.swift
│ │ │ │ │ │ ├── ContinueTrackingSection.swift
│ │ │ │ │ │ └── SwiftContinueTrackingEpisode.swift
│ │ │ │ │ ├── EmptyUIView.swift
│ │ │ │ │ ├── Episode/
│ │ │ │ │ │ ├── EpisodeCollapsible.swift
│ │ │ │ │ │ ├── EpisodeItemView.swift
│ │ │ │ │ │ └── EpisodeListView.swift
│ │ │ │ │ ├── FilterChip.swift
│ │ │ │ │ ├── FilterChipSection.swift
│ │ │ │ │ ├── FlowLayout.swift
│ │ │ │ │ ├── FullScreenView.swift
│ │ │ │ │ ├── GlassButton.swift
│ │ │ │ │ ├── GlassToolbar.swift
│ │ │ │ │ ├── GridView.swift
│ │ │ │ │ ├── HorizontalItemListView.swift
│ │ │ │ │ ├── ImageGalleryContentView.swift
│ │ │ │ │ ├── Images/
│ │ │ │ │ │ ├── AvatarView.swift
│ │ │ │ │ │ ├── BackdropPosterCard.swift
│ │ │ │ │ │ ├── CastCardView.swift
│ │ │ │ │ │ ├── FeaturedContentPosterView.swift
│ │ │ │ │ │ ├── HeaderCoverArtWorkView.swift
│ │ │ │ │ │ ├── LazyResizableImage.swift
│ │ │ │ │ │ ├── PosterCardView.swift
│ │ │ │ │ │ ├── PosterItemView.swift
│ │ │ │ │ │ ├── PosterPlaceholder.swift
│ │ │ │ │ │ ├── ProviderItemView.swift
│ │ │ │ │ │ └── TransparentImageBackground.swift
│ │ │ │ │ ├── LibraryListItemView.swift
│ │ │ │ │ ├── LoadingIndicatorView.swift
│ │ │ │ │ ├── Models/
│ │ │ │ │ │ ├── DebugMenuItem.swift
│ │ │ │ │ │ ├── SettingsModels.swift
│ │ │ │ │ │ ├── ShowPosterImage.swift
│ │ │ │ │ │ ├── SwiftCalendarDateGroup.swift
│ │ │ │ │ │ ├── SwiftCast.swift
│ │ │ │ │ │ ├── SwiftGenreRow.swift
│ │ │ │ │ │ ├── SwiftGenres.swift
│ │ │ │ │ │ ├── SwiftLibraryItem.swift
│ │ │ │ │ │ ├── SwiftProfile.swift
│ │ │ │ │ │ ├── SwiftProviders.swift
│ │ │ │ │ │ ├── SwiftSearchShow.swift
│ │ │ │ │ │ ├── SwiftSeason.swift
│ │ │ │ │ │ ├── SwiftShow.swift
│ │ │ │ │ │ ├── SwiftShowGenre.swift
│ │ │ │ │ │ ├── SwiftTrailer.swift
│ │ │ │ │ │ └── SwiftTraktListItem.swift
│ │ │ │ │ ├── NavigationTopBar.swift
│ │ │ │ │ ├── NextEpisode/
│ │ │ │ │ │ ├── NextEpisodeCard.swift
│ │ │ │ │ │ ├── NextEpisodesSection.swift
│ │ │ │ │ │ ├── SwiftNextEpisode.swift
│ │ │ │ │ │ └── UpNextListItemView.swift
│ │ │ │ │ ├── NotificationRationaleSheet.swift
│ │ │ │ │ ├── OverviewBoxView.swift
│ │ │ │ │ ├── ParallaxView.swift
│ │ │ │ │ ├── ProviderListView.swift
│ │ │ │ │ ├── ScanlineOverlay.swift
│ │ │ │ │ ├── Search/
│ │ │ │ │ │ ├── SearchItemView.swift
│ │ │ │ │ │ ├── SearchResultListView.swift
│ │ │ │ │ │ └── ShowContent/
│ │ │ │ │ │ ├── HorizontalShowContentView.swift
│ │ │ │ │ │ └── ShowContentItemView.swift
│ │ │ │ │ ├── SeasonChipViewList.swift
│ │ │ │ │ ├── SeasonProgress/
│ │ │ │ │ │ ├── SeasonProgressCard.swift
│ │ │ │ │ │ ├── SeasonProgressSection.swift
│ │ │ │ │ │ └── SegmentedProgressBar.swift
│ │ │ │ │ ├── SectionHeaderView.swift
│ │ │ │ │ ├── SelectionChip.swift
│ │ │ │ │ ├── ShowDetails/
│ │ │ │ │ │ ├── HeaderView.swift
│ │ │ │ │ │ ├── ShowHeaderInfoView.swift
│ │ │ │ │ │ └── ShowInfoView.swift
│ │ │ │ │ ├── SnapCarousel.swift
│ │ │ │ │ ├── StatsCardItem.swift
│ │ │ │ │ ├── TextTitlePill.swift
│ │ │ │ │ ├── ThemeSelector/
│ │ │ │ │ │ ├── ThemePreviewSwatch.swift
│ │ │ │ │ │ └── ThemeSelectorView.swift
│ │ │ │ │ ├── ThemedProgressView.swift
│ │ │ │ │ ├── Toast/
│ │ │ │ │ │ ├── Toast.swift
│ │ │ │ │ │ ├── ToastManager.swift
│ │ │ │ │ │ ├── ToastModifier.swift
│ │ │ │ │ │ └── ToastView.swift
│ │ │ │ │ ├── TopBar.swift
│ │ │ │ │ ├── TrailerListView.swift
│ │ │ │ │ ├── TransparentBlurView.swift
│ │ │ │ │ ├── Watchlist/
│ │ │ │ │ │ ├── TraktListSelectorContent.swift
│ │ │ │ │ │ └── WatchListItemView.swift
│ │ │ │ │ └── YoutubeItemView.swift
│ │ │ │ ├── Core/
│ │ │ │ │ ├── CenteredFullScreenView.swift
│ │ │ │ │ ├── ImageConfiguration.swift
│ │ │ │ │ ├── KeyboardHeightManager.swift
│ │ │ │ │ └── ViewConstants.swift
│ │ │ │ ├── Effects/
│ │ │ │ │ └── ButtonElevationEffect.swift
│ │ │ │ ├── Extensions/
│ │ │ │ │ ├── Geometry+Extensions.swift
│ │ │ │ │ ├── HorizontalAlignment.swift
│ │ │ │ │ ├── PrintExtension.swift
│ │ │ │ │ └── View+onSizeChange.swift
│ │ │ │ ├── Screens/
│ │ │ │ │ ├── Calendar/
│ │ │ │ │ │ └── CalendarScreen.swift
│ │ │ │ │ ├── Discover/
│ │ │ │ │ │ ├── DiscoverListContent.swift
│ │ │ │ │ │ └── DiscoverScreen.swift
│ │ │ │ │ ├── Library/
│ │ │ │ │ │ ├── LibraryScreen.swift
│ │ │ │ │ │ └── MoreShowsScreen.swift
│ │ │ │ │ ├── Profile/
│ │ │ │ │ │ ├── DebugScreen.swift
│ │ │ │ │ │ ├── ProfileScreen.swift
│ │ │ │ │ │ └── SettingsScreen.swift
│ │ │ │ │ ├── Progress/
│ │ │ │ │ │ └── ProgressScreen.swift
│ │ │ │ │ ├── Search/
│ │ │ │ │ │ ├── SearchScreen.swift
│ │ │ │ │ │ └── SearchScreenPreviews.swift
│ │ │ │ │ ├── ShowDetails/
│ │ │ │ │ │ ├── SeasonDetailsScreen.swift
│ │ │ │ │ │ └── ShowDetailsScreen.swift
│ │ │ │ │ └── Watchlist/
│ │ │ │ │ ├── WatchlistGridItem.swift
│ │ │ │ │ └── WatchlistScreen.swift
│ │ │ │ ├── Styles/
│ │ │ │ │ ├── RoundedProgressIndicatorStyle.swift
│ │ │ │ │ └── TransparentGroupBox.swift
│ │ │ │ ├── Theme/
│ │ │ │ │ ├── Colors/
│ │ │ │ │ │ ├── AmberColorScheme.swift
│ │ │ │ │ │ ├── AquaColorScheme.swift
│ │ │ │ │ │ ├── AutumnColorScheme.swift
│ │ │ │ │ │ ├── ColorScheme.swift
│ │ │ │ │ │ ├── ColorTokens.swift
│ │ │ │ │ │ ├── CrimsonColorScheme.swift
│ │ │ │ │ │ ├── DarkColorScheme.swift
│ │ │ │ │ │ ├── LightColorScheme.swift
│ │ │ │ │ │ ├── SnowColorScheme.swift
│ │ │ │ │ │ └── TerminalColorScheme.swift
│ │ │ │ │ ├── Environment/
│ │ │ │ │ │ ├── ThemeEnvironment.swift
│ │ │ │ │ │ └── TvManiacTheme.swift
│ │ │ │ │ ├── Preview/
│ │ │ │ │ │ └── ThemedPreview.swift
│ │ │ │ │ ├── Shape/
│ │ │ │ │ │ └── ShapeTokens.swift
│ │ │ │ │ ├── Spacing/
│ │ │ │ │ │ └── SpacingTokens.swift
│ │ │ │ │ └── Typography/
│ │ │ │ │ └── TypographyScheme.swift
│ │ │ │ ├── Utilities/
│ │ │ │ │ └── BindingFactories.swift
│ │ │ │ └── ViewModifiers/
│ │ │ │ ├── SwipeBackGesture.swift
│ │ │ │ └── TestTagModifier.swift
│ │ │ └── Tests/
│ │ │ └── SwiftUIComponentsTests/
│ │ │ ├── BorderTextViewTest.swift
│ │ │ ├── CalendarScreenTest.swift
│ │ │ ├── CastCardViewTest.swift
│ │ │ ├── CastListViewTest.swift
│ │ │ ├── ChevronTitleTest.swift
│ │ │ ├── ChipViewTest.swift
│ │ │ ├── CircularButtonTest.swift
│ │ │ ├── DebugScreenTest.swift
│ │ │ ├── DiscoverScreenTest.swift
│ │ │ ├── EpisodeCollapsibleTest.swift
│ │ │ ├── EpisodeDetailSheetContentTest.swift
│ │ │ ├── EpisodeItemViewTest.swift
│ │ │ ├── EpisodeListViewTest.swift
│ │ │ ├── FilledImageButtonTest.swift
│ │ │ ├── FullScreenViewTest.swift
│ │ │ ├── GridViewTest.swift
│ │ │ ├── HeaderViewTest.swift
│ │ │ ├── HorizontalItemListViewTest.swift
│ │ │ ├── HorizontalShowContentViewTest.swift
│ │ │ ├── LibraryScreenTest.swift
│ │ │ ├── MoreShowsScreenTest.swift
│ │ │ ├── NavigationTopBarTest.swift
│ │ │ ├── NextEpisodesSectionTest.swift
│ │ │ ├── NotificationRationaleSheetTest.swift
│ │ │ ├── OutlinedButtonTest.swift
│ │ │ ├── OverviewBoxViewTest.swift
│ │ │ ├── PosterItemViewTest.swift
│ │ │ ├── ProfileScreenTest.swift
│ │ │ ├── ProgressScreenTest.swift
│ │ │ ├── ProviderItemViewTest.swift
│ │ │ ├── ProviderListViewTest.swift
│ │ │ ├── SearchItemViewTest.swift
│ │ │ ├── SearchScreenTest.swift
│ │ │ ├── SeasonChipViewListTest.swift
│ │ │ ├── SeasonDetailsScreenTest.swift
│ │ │ ├── SettingsScreenTest.swift
│ │ │ ├── ShowContentItemViewTest.swift
│ │ │ ├── ShowDetailsScreenTest.swift
│ │ │ ├── ShowInfoViewTest.swift
│ │ │ ├── SnapshotTestCase.swift
│ │ │ ├── ThemeSelectorViewTest.swift
│ │ │ ├── ToastViewTest.swift
│ │ │ ├── TopBarTest.swift
│ │ │ ├── TrailerListViewTest.swift
│ │ │ ├── TraktListSelectorContentTest.swift
│ │ │ ├── WatchlistScreenTest.swift
│ │ │ └── YoutubeItemViewTest.swift
│ │ ├── TraktAuthKit/
│ │ │ ├── .gitignore
│ │ │ ├── Package.swift
│ │ │ └── Sources/
│ │ │ └── TraktAuthKit/
│ │ │ ├── AppAuthCoordinator.swift
│ │ │ ├── TraktAuthConfiguration.swift
│ │ │ ├── TraktAuthError.swift
│ │ │ ├── TraktCredential.swift
│ │ │ └── TraktOAuthClient.swift
│ │ └── TvManiacKit/
│ │ ├── .gitignore
│ │ ├── Package.swift
│ │ └── Sources/
│ │ └── TvManiacKit/
│ │ ├── AppDelegate.swift
│ │ ├── Architecture/
│ │ │ ├── ComponentHolder.swift
│ │ │ ├── ObservableValue.swift
│ │ │ └── StateValue.swift
│ │ ├── AuthCoordinatorFactory.swift
│ │ ├── Decompose/
│ │ │ └── DecomposeNavigationStack.swift
│ │ ├── Extensions/
│ │ │ ├── Mapper+Extensions.swift
│ │ │ ├── Moko+Extensions.swift
│ │ │ └── View+Extensions.swift
│ │ ├── KmpLoggerBridge.swift
│ │ ├── Models/
│ │ │ └── SwiftImageQuality.swift
│ │ ├── Modifiers/
│ │ │ ├── AppThemeModifier.swift
│ │ │ └── DebugTapGesture.swift
│ │ ├── Navigation/
│ │ │ ├── ScreenRegistry+Typed.swift
│ │ │ └── ScreenRegistry.swift
│ │ ├── NotificationDelegate.swift
│ │ ├── SFSafariViewWrapper.swift
│ │ ├── SettingsAppStorage.swift
│ │ ├── Theme/
│ │ │ └── TvManiacTypographyScheme+Moko.swift
│ │ ├── ThemeUtilities.swift
│ │ ├── TraktAuthCoordinator.swift
│ │ └── TvManiacKit.swift
│ ├── ios/
│ │ ├── App/
│ │ │ ├── AppIcon.swift
│ │ │ └── iOSApp.swift
│ │ ├── Assets.xcassets/
│ │ │ ├── AppIcon.appiconset/
│ │ │ │ └── Contents.json
│ │ │ ├── AppIconDebug.appiconset/
│ │ │ │ └── Contents.json
│ │ │ ├── Colors/
│ │ │ │ ├── Background.colorset/
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── Contents.json
│ │ │ │ ├── TabBackgroundColor.colorset/
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── TextColor.colorset/
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── accent.colorset/
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── accentBlue.colorset/
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── content_background.colorset/
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── gradient_background.colorset/
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── grey_200.colorset/
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── grey_500.colorset/
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── grey_900.colorset/
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── text_color_bg.colorset/
│ │ │ │ │ └── Contents.json
│ │ │ │ ├── yellow_300.colorset/
│ │ │ │ │ └── Contents.json
│ │ │ │ └── yellow_500.colorset/
│ │ │ │ └── Contents.json
│ │ │ ├── Contents.json
│ │ │ ├── TvManiacIcon.imageset/
│ │ │ │ └── Contents.json
│ │ │ ├── TvManiacIconDebug.imageset/
│ │ │ │ └── Contents.json
│ │ │ └── trakt_logo.imageset/
│ │ │ └── Contents.json
│ │ ├── Components/
│ │ │ └── ColorScheme.swift
│ │ ├── Feature/
│ │ │ └── Grid/
│ │ │ └── ShowGridView.swift
│ │ ├── Info.plist
│ │ ├── PrivacyInfo.xcprivacy
│ │ └── UI/
│ │ ├── Debug/
│ │ │ └── DebugMenuView.swift
│ │ ├── EpisodeDetail/
│ │ │ └── EpisodeDetailSheetView.swift
│ │ ├── MoreShows/
│ │ │ └── MoreShowsView.swift
│ │ ├── Root/
│ │ │ ├── RootNavigationView.swift
│ │ │ └── ScreenRegistryBootstrap.swift
│ │ ├── SeasonDetails/
│ │ │ └── SeasonDetailsView.swift
│ │ ├── Settings/
│ │ │ └── SettingsView.swift
│ │ ├── ShowDetails/
│ │ │ └── ShowDetailsView.swift
│ │ ├── SplashScreen/
│ │ │ └── SplashView.swift
│ │ ├── Tabs/
│ │ │ ├── Components/
│ │ │ │ ├── SortOptionsSheet.swift
│ │ │ │ ├── TabContentView.swift
│ │ │ │ ├── UpNextPageContent.swift
│ │ │ │ └── WatchlistListItem.swift
│ │ │ ├── DiscoverTab.swift
│ │ │ ├── LibraryTab.swift
│ │ │ ├── ProfileTab.swift
│ │ │ ├── ProgressTab.swift
│ │ │ ├── SearchTab.swift
│ │ │ ├── TabBarView.swift
│ │ │ └── WatchlistTab.swift
│ │ └── Watchlist/
│ │ └── WatchlistSelector.swift
│ └── tv-maniac.xcodeproj/
│ ├── project.pbxproj
│ ├── project.xcworkspace/
│ │ └── xcshareddata/
│ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata/
│ └── xcschemes/
│ ├── SwiftUIComponents.xcscheme
│ ├── TvManiacKit.xcscheme
│ └── tv-maniac.xcscheme
├── ios-framework/
│ ├── build.gradle.kts
│ └── src/
│ └── iosMain/
│ └── kotlin/
│ └── com.thomaskioko.tvmaniac.iosframework/
│ ├── IosApplicationGraph.kt
│ └── IosViewPresenterGraph.kt
├── navigation/
│ ├── api/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ └── commonMain/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── navigation/
│ │ ├── NavDestination.kt
│ │ ├── NavRoute.kt
│ │ ├── NavRouteBinding.kt
│ │ ├── NavRouteSerializer.kt
│ │ ├── NavigationResultRegistry.kt
│ │ ├── NavigationResultRequest.kt
│ │ ├── NavigationResults.kt
│ │ ├── Navigator.kt
│ │ ├── RootChild.kt
│ │ ├── ScreenDestination.kt
│ │ ├── SheetChild.kt
│ │ ├── SheetChildFactory.kt
│ │ ├── SheetConfig.kt
│ │ ├── SheetConfigBinding.kt
│ │ ├── SheetConfigSerializer.kt
│ │ ├── SheetDestination.kt
│ │ └── SheetNavigator.kt
│ ├── implementation/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── navigation/
│ │ │ ├── DefaultNavRouteSerializer.kt
│ │ │ ├── DefaultNavigationResultRegistry.kt
│ │ │ ├── DefaultNavigator.kt
│ │ │ ├── DefaultSheetConfigSerializer.kt
│ │ │ └── di/
│ │ │ └── NavigationMultibindings.kt
│ │ └── commonTest/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── navigation/
│ │ ├── DefaultNavRouteSerializerTest.kt
│ │ ├── DefaultNavigationResultRegistryTest.kt
│ │ ├── DefaultNavigatorTest.kt
│ │ ├── DefaultSheetConfigSerializerTest.kt
│ │ └── FakeNavigator.kt
│ ├── testing/
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── commonMain/
│ │ │ └── kotlin/
│ │ │ └── com/
│ │ │ └── thomaskioko/
│ │ │ └── tvmaniac/
│ │ │ └── navigation/
│ │ │ └── testing/
│ │ │ ├── FakeSheetNavigator.kt
│ │ │ ├── NavEvent.kt
│ │ │ ├── NavigatorTurbine.kt
│ │ │ └── TestNavigator.kt
│ │ └── commonTest/
│ │ └── kotlin/
│ │ └── com/
│ │ └── thomaskioko/
│ │ └── tvmaniac/
│ │ └── navigation/
│ │ └── testing/
│ │ └── NavigatorTest.kt
│ └── ui/
│ ├── build.gradle.kts
│ └── src/
│ └── main/
│ └── kotlin/
│ └── com/
│ └── thomaskioko/
│ └── tvmaniac/
│ └── navigation/
│ └── ui/
│ ├── ScreenContent.kt
│ ├── SheetContent.kt
│ └── di/
│ └── NavigationUiMultibindings.kt
├── release/
│ ├── RELEASE.md
│ ├── app-release.aes
│ ├── firebase-sa.aes
│ └── play-service-account.aes
├── scripts/
│ ├── git-hooks/
│ │ └── pre-commit
│ └── install-git-hooks.sh
├── settings.gradle.kts
└── version.txt
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
root = true
[*]
charset = utf-8
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
# noinspection EditorConfigKeyCorrectness
[*.{kt,kts}]
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
ktlint_function_naming_ignore_when_annotated_with = Composable, Test
ktlint_standard_backing-property-naming = disabled
ktlint_standard_binary-expression-wrapping = disabled
ktlint_standard_chain-method-continuation = disabled
ktlint_standard_class-signature = disabled
ktlint_standard_condition-wrapping = disabled
ktlint_standard_function-expression-body = disabled
ktlint_standard_function-literal = disabled
ktlint_standard_function-type-modifier-spacing = disabled
ktlint_standard_multiline-loop = disabled
ktlint_standard_function-signature = disabled
================================================
FILE: .geminiignore
================================================
.gradle/
build/
.kotlin/
.idea/
.build/
ios/build/
ios/SourcePackages/
derived_data/
*.log
*.class
*.apk
*.ap_
*.dex
local.properties
.DS_Store
fastlane/test_output
fastlane/builds
node_modules/
dist/
target/
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.yml
================================================
name: Bug Report
description: Report a reproducible bug in TvManiac.
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a bug report. Please fill out as much detail as you can. Issues without reproduction steps are hard to act on.
- type: textarea
id: description
attributes:
label: Description
description: What happened, and what did you expect to happen instead?
placeholder: A clear description of the bug.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: Minimal, numbered steps that reliably reproduce the issue.
placeholder: |
1. Open the app.
2. Navigate to...
3. Tap...
4. Observe the bug.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
placeholder: What you expected to happen.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
placeholder: What actually happened. Include error messages if any.
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
options:
- Android
- iOS
- Both
validations:
required: true
- type: input
id: device
attributes:
label: Device and OS Version
description: e.g. Pixel 8 on Android 15, iPhone 15 Pro on iOS 18.2.
placeholder: Device model and OS version.
validations:
required: true
- type: input
id: version
attributes:
label: App Version or Commit SHA
description: Version from the About screen, or the commit SHA if running from source.
placeholder: e.g. 1.4.0, or 3298cb27f.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs, Screenshots, or Recordings
description: Logcat output, Xcode console logs, screenshots, or screen recordings. Redact any API keys or personal data.
render: shell
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I searched existing issues and did not find a duplicate.
required: true
- label: I redacted any secrets, tokens, or personal data from logs and screenshots.
required: true
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.yml
================================================
name: Feature Request
description: Suggest a new feature or improvement for TvManiac.
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting an improvement. Please describe the problem first, then the proposed solution. Ideas grounded in a concrete use case are easier to evaluate.
- type: textarea
id: problem
attributes:
label: Problem Statement
description: What problem are you trying to solve? What is missing or inconvenient today?
placeholder: As a user, I want... so that...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the behavior you would like to see. Include mockups or references if helpful.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Other approaches you thought about and why they are less suitable.
- type: dropdown
id: platform
attributes:
label: Platform Scope
options:
- Android
- iOS
- Both
- Not sure
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional Context
description: Any other context, links, or references that help motivate the feature.
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I searched existing issues and discussions and did not find a duplicate.
required: true
================================================
FILE: .github/actions/setup-android-release/action.yml
================================================
name: 'Setup Android Release'
description: 'Common setup for Android release builds: Gradle, Ruby, google-services.json, and signing key decryption'
inputs:
google-services-json:
description: 'Base64-encoded google-services.json for release'
required: false
signing-encrypt-key:
description: 'Encryption key for decrypting signing keystores'
required: true
runs:
using: 'composite'
steps:
- name: Setup Gradle Environment
uses: ./.github/actions/setup-gradle
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Install google-services.json
shell: bash
env:
GOOGLE_SERVICES_JSON_RELEASE: ${{ inputs.google-services-json }}
run: |
if [ -n "$GOOGLE_SERVICES_JSON_RELEASE" ]; then
mkdir -p app/src/release
echo "$GOOGLE_SERVICES_JSON_RELEASE" | base64 --decode > app/src/release/google-services.json
else
echo "warning: google-services.json not provided - Firebase will be disabled"
fi
- name: Decrypt signing keys
shell: bash
env:
SIGNING_ENCRYPT_KEY: ${{ inputs.signing-encrypt-key }}
run: |
openssl aes-256-cbc -d -pbkdf2 -in release/app-release.aes -out release/app-release.jks -k "$SIGNING_ENCRYPT_KEY"
openssl aes-256-cbc -d -pbkdf2 -in release/play-service-account.aes -out release/play-service-account.json -k "$SIGNING_ENCRYPT_KEY"
if [ -f "release/firebase-sa.aes" ]; then
openssl aes-256-cbc -d -pbkdf2 -in release/firebase-sa.aes -out release/firebase-sa.json -k "$SIGNING_ENCRYPT_KEY"
fi
================================================
FILE: .github/actions/setup-gradle/action.yml
================================================
name: 'Setup Gradle Environment'
description: 'Common setup for Gradle builds with JDK 21 and environment variables'
inputs:
gradle-cache-disabled:
description: 'Disable Gradle cache (default: false = cache enabled). Only set to true for baseline-profile workflow.'
required: false
default: 'false'
gradle-cache-read-only:
description: 'Make Gradle cache read-only (default: auto-detected from event type). PRs read cache but do not write.'
required: false
default: ${{ github.event_name == 'pull_request' }}
runs:
using: 'composite'
steps:
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: 'temurin'
java-version: '21'
- name: Install git-cliff
uses: kenji-miyake/setup-git-cliff@2778609c643a39a2576c4bae2e493b855eb4aee8 # v2.0.1
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
cache-provider: basic
cache-disabled: ${{ inputs.gradle-cache-disabled }}
cache-read-only: ${{ inputs.gradle-cache-read-only }}
================================================
FILE: .github/actions/setup-ios/action.yml
================================================
name: 'Setup iOS Environment'
description: 'Common setup for iOS builds with Xcode, Ruby, SPM cache, and Gradle'
inputs:
xcode-version:
description: 'Xcode version to use'
required: true
spm-cache-path:
description: 'Path to SPM dependencies for caching'
required: false
default: 'ios/SourcePackages'
runs:
using: 'composite'
steps:
- name: 📀 Setup Xcode version
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: ${{ inputs.xcode-version }}
- name: 📱 Verify Simulator Availability
shell: bash
run: |
echo "Xcode: $(xcodebuild -version | head -1)"
echo "Available iOS runtimes:"
xcrun simctl list runtimes iOS
echo "Available iPhone simulators:"
xcrun simctl list devices available iPhone
- name: 💎 Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Cache SPM dependencies
uses: actions/cache@v5
with:
path: ${{ inputs.spm-cache-path }}
key: ${{ runner.os }}-spm-${{ github.sha }}
restore-keys: |
${{ runner.os }}-spm-
- name: 🐘 Setup Gradle
uses: ./.github/actions/setup-gradle
================================================
FILE: .github/actions/setup-ios-release/action.yml
================================================
name: 'Setup iOS Release'
description: 'Common setup for iOS release builds: Xcode, Ruby, SPM cache, Gradle, and GoogleService-Info.plist'
inputs:
xcode-version:
description: 'Xcode version to use'
required: true
google-service-info-plist:
description: 'Base64-encoded GoogleService-Info.plist for release'
required: false
runs:
using: 'composite'
steps:
- name: Setup iOS Environment
uses: ./.github/actions/setup-ios
with:
xcode-version: ${{ inputs.xcode-version }}
- name: Install GoogleService-Info.plist
shell: bash
env:
GOOGLE_SERVICE_INFO: ${{ inputs.google-service-info-plist }}
run: |
if [ -n "$GOOGLE_SERVICE_INFO" ]; then
mkdir -p ios/ios/Firebase/Release
echo "$GOOGLE_SERVICE_INFO" | base64 --decode > ios/ios/Firebase/Release/GoogleService-Info.plist
else
echo "warning: GoogleService-Info.plist not provided - Firebase will be disabled"
fi
================================================
FILE: .github/release.yml
================================================
changelog:
exclude:
labels:
- skip-changelog
authors:
- renovate[bot]
- dependabot[bot]
categories:
- title: "Features"
labels:
- feature
- enhancement
- title: "Bug Fixes"
labels:
- bug
- fix
- title: "Dependencies"
labels:
- dependencies
- title: "Other Changes"
exclude:
labels:
- feature
- enhancement
- bug
- fix
- dependencies
- skip-changelog
================================================
FILE: .github/renovate.json
================================================
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":disableRateLimiting",
":semanticCommitsDisabled"
],
"labels": [
"renovate"
],
"ignoreDeps": [
"renovate/renovate"
],
"ignorePaths": [
"**/.ruby-version"
],
"packageRules": [
{
"matchDatasources": [
"maven"
],
"registryUrls": [
"https://repo.maven.apache.org/maven2",
"https://dl.google.com/android/maven2",
"https://plugins.gradle.org/m2"
]
},
{
"groupName": "Compose",
"matchPackageNames": [
"/androidx.compose.runtime/",
"/androidx.compose.ui/",
"/androidx.compose.foundation/",
"/androidx.compose.animation/",
"/androidx.compose.material/",
"/androidx.compose.material3/",
"/org.jetbrains.compose$/",
"/org.jetbrains.compose.runtime/",
"/org.jetbrains.compose.ui/",
"/org.jetbrains.compose.foundation/",
"/org.jetbrains.compose.animation/",
"/org.jetbrains.compose.material/",
"/org.jetbrains.compose.material3/"
]
},
{
"groupName": "Roborazzi",
"matchPackageNames": [
"/io.github.takahirom.roborazzi/"
]
}
],
"dependencyDashboard": true,
"rebaseWhen": "conflicted",
"configMigration": true,
"commitMessagePrefix": "chore(deps):"
}
================================================
FILE: .github/workflows/baseline-profile.yml
================================================
name: Weekly Baseline Profile Generation
on:
schedule:
- cron: '30 0 * * 0,3,5' #Every Sunday, Wednesday, and Friday at 12:30AM
env:
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
TRAKT_CLIENT_SECRET: ${{ secrets.TRAKT_CLIENT_SECRET }}
TRAKT_REDIRECT_URI: ${{ secrets.TRAKT_REDIRECT_URI }}
jobs:
baseline_profiles:
name: "Generate Baseline Profiles"
runs-on: ubuntu-latest
permissions:
contents: write
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Setup Gradle Environment
uses: ./.github/actions/setup-gradle
with:
gradle-cache-disabled: 'true'
- name: Setup Android SDK
uses: android-actions/setup-android@v4
- name: Accept licenses
run: yes | sdkmanager --licenses || trueMovr
- name: Build app and benchmark
run: ./gradlew assembleNonMinifiedRelease -Papp.debugOnly=false
- name: Clear Gradle Managed Devices
run: ./gradlew cleanManagedDevices
- name: Generate Baseline Profile
run: ./gradlew :app:generateBaselineProfile
-Papp.debugOnly=false
-Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=baselineprofile
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
-Pandroid.experimental.androidTest.numManagedDeviceShards=1
--no-configuration-cache
## ToDo: Commit baseline profile changes to main.
================================================
FILE: .github/workflows/beta-release.yml
================================================
name: Beta Release
on:
workflow_dispatch:
inputs:
skip_android:
description: 'Skip Android build'
type: boolean
default: false
skip_ios:
description: 'Skip iOS build'
type: boolean
default: false
permissions:
contents: write
env:
XCODE_VERSION: 16.4
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
TRAKT_CLIENT_SECRET: ${{ secrets.TRAKT_CLIENT_SECRET }}
TRAKT_REDIRECT_URI: ${{ secrets.TRAKT_REDIRECT_URI }}
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.version }}
build: ${{ steps.bump.outputs.build }}
sha: ${{ steps.push.outputs.sha }}
steps:
- name: Checkout main
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}
- name: Setup Gradle Environment
uses: ./.github/actions/setup-gradle
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Bump beta version
id: bump
run: |
./gradlew bumpVersion -Ptype=beta
version=$(grep 'VERSION_NUMBER' version.txt | sed 's/.*= *//')
build=$(grep 'BUILD_NUMBER' version.txt | sed 's/.*= *//')
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "build=$build" >> "$GITHUB_OUTPUT"
echo "Version: $version, BUILD_NUMBER: $build"
- name: Commit and push
id: push
run: |
git add version.txt
git commit -m "chore: bump beta build number to ${{ steps.bump.outputs.build }}"
git push origin main
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
build-android:
needs: prepare
if: ${{ !inputs.skip_android }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.sha }}
- name: Setup Android Release
uses: ./.github/actions/setup-android-release
with:
google-services-json: ${{ secrets.GOOGLE_SERVICES_JSON_RELEASE }}
signing-encrypt-key: ${{ secrets.SIGNING_ENCRYPT_KEY }}
- name: Build release AAB and APK
env:
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
run: |
./gradlew :app:bundleRelease :app:assembleRelease \
-Pandroidx.baselineprofile.skipgeneration=true \
-Papp.debugOnly=false \
-Papp.versionSuffix=-beta \
-PreleaseStoreFile=release/app-release.jks \
-PreleaseStorePassword="$SIGNING_STORE_PASSWORD" \
-PreleaseKeyAlias=tvmaniac \
-PreleaseKeyPassword="$SIGNING_KEY_PASSWORD" \
--no-configuration-cache
- name: Print build info
run: |
echo "Version: $(grep 'VERSION_NUMBER' version.txt | sed 's/.*= *//')-beta"
echo "Build: $(grep 'BUILD_NUMBER' version.txt | sed 's/.*= *//')"
- name: Deploy to Play Store open testing track
run: |
export PLAY_STORE_SERVICE_ACCOUNT_JSON=$(cat release/play-service-account.json)
bundle exec fastlane android deploy_play_store track:beta
- name: Distribute to Firebase App Distribution
env:
FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
run: |
if [ -n "$FIREBASE_APP_ID" ] && [ -f "release/firebase-sa.json" ]; then
export FIREBASE_APP_DISTRIBUTION_SA=release/firebase-sa.json
bundle exec fastlane android distribute_firebase
else
echo "warning: Firebase not configured — skipping distribution"
fi
build-ios:
needs: prepare
if: ${{ !inputs.skip_ios }}
runs-on: macos-15
timeout-minutes: 60
env:
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 60
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.sha }}
- name: Setup iOS Release
uses: ./.github/actions/setup-ios-release
with:
xcode-version: ${{ env.XCODE_VERSION }}
google-service-info-plist: ${{ secrets.GOOGLE_SERVICE_INFO_PLIST_RELEASE }}
- name: Build and upload to TestFlight
env:
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
run: bundle exec fastlane ios build_beta
- name: Upload iOS artifacts
uses: actions/upload-artifact@v7
if: always()
with:
name: ios-internal
path: |
build/
fastlane/logs
================================================
FILE: .github/workflows/ci.yml
================================================
name: build
on:
push:
branches: [ main ]
pull_request:
types: [ opened, synchronize ]
workflow_call:
concurrency:
group: ci-${{ github.ref }}-${{ github.head_ref }}-${{ github.workflow }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
XCODE_VERSION: 16.4
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
TRAKT_CLIENT_SECRET: ${{ secrets.TRAKT_CLIENT_SECRET }}
TRAKT_REDIRECT_URI: ${{ secrets.TRAKT_REDIRECT_URI }}
jobs:
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: 🐘 Setup Gradle Environment
uses: ./.github/actions/setup-gradle
- name: Run Spotless
run: ./gradlew spotlessCheck -Papp.debugOnly=false
- name: Lint Project
run: ./gradlew :app:lintRelease -Papp.debugOnly=false
- name: Upload Lint Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: android-lint-report
path: build/reports/lint/
if-no-files-found: ignore
- name: Dependency Health
run: ./gradlew buildHealth -Papp.debugOnly=false
- name: Upload Dependency Health Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: dependency-health-report
path: '**/build/reports/dependency-analysis/build-health-report.txt'
swift-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: 🔍 Run SwiftLint
uses: norio-nomura/action-swiftlint@3.2.1
with:
args: --config .swiftlint.yml
build-android:
needs: [static-analysis]
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: 🚚 Checkout Code
uses: actions/checkout@v6
- name: 🐘 Setup Gradle Environment
uses: ./.github/actions/setup-gradle
- name: 🔥 Install google-services.json
env:
GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
run: |
if [ -n "$GOOGLE_SERVICES_JSON" ]; then
echo "$GOOGLE_SERVICES_JSON" | base64 --decode > app/google-services.json
else
echo "warning: GOOGLE_SERVICES_JSON secret not set — Firebase will be disabled"
fi
- name: 🤖 Build Android App
run: |
./gradlew :app:assembleRelease \
:app:bundleRelease \
-Pandroidx.baselineprofile.skipgeneration=true \
-Pcompose.enableCompilerMetrics=true \
-Pcompose.enableCompilerReports=true \
-Papp.debugOnly=false
- name: Upload Compose Compiler Metrics
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: compose-compiler-metrics
path: |
**/build/compose_metrics/
**/build/compose_reports/
linux-test:
needs: [static-analysis]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: 🐘 Setup Gradle Environment
uses: ./.github/actions/setup-gradle
- name: Run Linux Tests
run: ./gradlew linuxTest -Papp.debugOnly=false
- name: Upload Test Reports
uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: linux-test-report
path: '**/build/reports/*'
# ios-test:
# needs: [static-analysis]
# runs-on: macos-15
# environment:
# name: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) && 'Protected' || '' }}
# steps:
# - uses: actions/checkout@v6
#
# - name: 🐘 Setup Gradle Environment
# uses: ./.github/actions/setup-gradle
#
# - name: Run iOS Tests
# run: ./gradlew iosTest -Papp.enableIos=true -Papp.debugOnly=false
#
# - name: Upload Test Reports
# uses: actions/upload-artifact@v7
# if: ${{ !cancelled() }}
# with:
# name: ios-test-report
# path: '**/build/reports/*'
build-ios:
needs: [swift-lint]
runs-on: macos-15
timeout-minutes: 60
env:
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 60
steps:
- name: 🚚 Checkout project
uses: actions/checkout@v6
- name: 📱 Setup iOS Environment
uses: ./.github/actions/setup-ios
with:
xcode-version: ${{ env.XCODE_VERSION }}
- name: 🔥 Install GoogleService-Info.plist
env:
GOOGLE_SERVICE_INFO: ${{ secrets.GOOGLE_SERVICE_INFO_PLIST }}
run: |
if [ -n "$GOOGLE_SERVICE_INFO" ]; then
mkdir -p ios/ios/Firebase/Debug
echo "$GOOGLE_SERVICE_INFO" | base64 --decode > ios/ios/Firebase/Debug/GoogleService-Info.plist
else
echo "warning: GOOGLE_SERVICE_INFO_PLIST secret not set — Firebase will be disabled"
fi
- name: 🧱 Build iOS App
run: bundle exec fastlane build_tvmaniac
- name: Upload build artifacts
uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: ios-build
path: |
derived_data/Build/Products/
fastlane/logs
ios-snapshot-test:
needs: [swift-lint]
runs-on: macos-15
timeout-minutes: 60
env:
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 60
steps:
- name: 🚚 Checkout project
uses: actions/checkout@v6
- name: 📱 Setup iOS Environment
uses: ./.github/actions/setup-ios
with:
xcode-version: ${{ env.XCODE_VERSION }}
- name: 📸 Run Snapshot Tests
run: bundle exec fastlane snapshot_tests
- name: Upload test results
uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: snapshot-test-results
path: |
fastlane/test_output
fastlane/logs
derived_data/Logs/Test
================================================
FILE: .github/workflows/compare-screenshot.yml
================================================
name: Compare Screenshot
on:
pull_request:
permissions: {}
env:
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
TRAKT_CLIENT_SECRET: ${{ secrets.TRAKT_CLIENT_SECRET }}
TRAKT_REDIRECT_URI: ${{ secrets.TRAKT_REDIRECT_URI }}
jobs:
compare-screenshot-test:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: write # for pushing screenshot-diff to companion branch
actions: write # for upload-artifact
pull-requests: write # for creating a comment on pull requests
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Gradle Environment
uses: ./.github/actions/setup-gradle
- name: Get base commit SHA
id: get-base-commit
run: |
git fetch origin ${{ github.event.pull_request.base.ref }}
BASE_SHA=$(git merge-base origin/${{ github.event.pull_request.base.ref }} HEAD)
echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT"
- uses: dawidd6/action-download-artifact@v21
continue-on-error: true
with:
name: screenshot
workflow: store-screenshot.yml
commit: ${{ steps.get-base-commit.outputs.base_sha }}
- name: Compare screenshot test
run: ./gradlew compareRoborazziDebug --stacktrace
- name: Check for screenshot diffs
id: check-diffs
run: |
if find . -type f -name "*_compare.png" | grep -q .; then
echo "has_diffs=true" >> "$GITHUB_OUTPUT"
else
echo "has_diffs=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload screenshot diff
if: ${{ !cancelled() && steps.check-diffs.outputs.has_diffs == 'true' }}
uses: actions/upload-artifact@v7
with:
name: screenshot-diff
path: '**/build/outputs/roborazzi'
retention-days: 30
- name: Upload diff reports
if: ${{ !cancelled() && steps.check-diffs.outputs.has_diffs == 'true' }}
uses: actions/upload-artifact@v7
with:
name: screenshot-diff-reports
path: '**/build/reports'
retention-days: 30
- name: Upload diff test results
if: ${{ !cancelled() && steps.check-diffs.outputs.has_diffs == 'true' }}
uses: actions/upload-artifact@v7
with:
name: screenshot-diff-test-results
path: '**/build/test-results'
retention-days: 30
- name: Push diffs to companion branch
id: push-screenshot-diff
if: steps.check-diffs.outputs.has_diffs == 'true'
env:
BRANCH_NAME: companion_${{ github.head_ref }}
run: |
git branch -D "$BRANCH_NAME" || true
git checkout --orphan "$BRANCH_NAME"
git rm -rf .
files_to_add=$(find . -type f -name "*_compare.png")
for file in $files_to_add; do
if [[ "$file" =~ ^[a-zA-Z0-9_./-]+$ ]]; then
git add "$file"
fi
done
git config --global user.name ScreenshotBot
git config --global user.email 41898282+github-actions[bot]@users.noreply.github.com
git commit -m "Add screenshot diff"
git push origin HEAD:"$BRANCH_NAME" -f
- name: Generate diff report
id: generate-diff-reports
if: steps.check-diffs.outputs.has_diffs == 'true'
env:
BRANCH_NAME: companion_${{ github.head_ref }}
shell: bash
run: |
files=$(find . -type f -name "*_compare.png" | grep "roborazzi/" | grep -E "^[a-zA-Z0-9_./-]+$")
delimiter="$(openssl rand -hex 8)"
{
echo "reports<<${delimiter}"
echo "## Roborazzi Screenshot Diff Report"
echo "Comparing against base branch: ${{ github.event.pull_request.base.ref }}"
echo ""
echo "| File name | Image |"
echo "|-------|-------|"
} >> "$GITHUB_OUTPUT"
for file in $files; do
fileName=$(basename "$file" | sed -r 's/(.{20})/\1 /g')
echo "| [$fileName](https://github.com/${{ github.repository }}/blob/$BRANCH_NAME/$file) |  |" >> "$GITHUB_OUTPUT"
done
echo "${delimiter}" >> "$GITHUB_OUTPUT"
- name: Find existing comment
uses: peter-evans/find-comment@v4
id: fc
if: steps.generate-diff-reports.outputs.reports != ''
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: Roborazzi Screenshot Diff Report
- name: Add or update comment on PR
uses: peter-evans/create-or-update-comment@v5
if: steps.generate-diff-reports.outputs.reports != ''
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: ${{ steps.generate-diff-reports.outputs.reports }}
edit-mode: replace
- name: Cleanup outdated companion branches
if: steps.check-diffs.outputs.has_diffs == 'true'
run: |
git fetch origin
git branch -r --format="%(refname:lstrip=3)" | grep companion_ | while read -r branch; do
last_commit_date_timestamp=$(git log -1 --format=%ct "origin/$branch")
now_timestamp=$(date +%s)
if [ $((now_timestamp - last_commit_date_timestamp)) -gt 2592000 ]; then
echo "Deleting outdated branch: $branch"
git push origin --delete "$branch"
fi
done
================================================
FILE: .github/workflows/daily-build.yml
================================================
name: Daily Build
on:
workflow_dispatch:
# Uncomment to enable scheduled daily builds
# schedule:
# - cron: '0 6 * * 1-5' # Weekdays at 6:00 AM UTC
permissions:
contents: write
env:
XCODE_VERSION: 16.4
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
TRAKT_CLIENT_SECRET: ${{ secrets.TRAKT_CLIENT_SECRET }}
TRAKT_REDIRECT_URI: ${{ secrets.TRAKT_REDIRECT_URI }}
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.version }}
build: ${{ steps.bump.outputs.build }}
sha: ${{ steps.push.outputs.sha }}
steps:
- name: Checkout main
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}
- name: Setup Gradle Environment
uses: ./.github/actions/setup-gradle
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Bump build number
id: bump
run: |
./gradlew bumpVersion -Ptype=beta
version=$(grep 'VERSION_NUMBER' version.txt | sed 's/.*= *//')
build=$(grep 'BUILD_NUMBER' version.txt | sed 's/.*= *//')
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "build=$build" >> "$GITHUB_OUTPUT"
echo "Version: $version, BUILD_NUMBER: $build"
- name: Commit and push
id: push
run: |
git add version.txt
git commit -m "chore: bump daily build number to ${{ steps.bump.outputs.build }}"
git push origin main
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
build-android:
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.sha }}
- name: Setup Android Release
uses: ./.github/actions/setup-android-release
with:
google-services-json: ${{ secrets.GOOGLE_SERVICES_JSON_RELEASE }}
signing-encrypt-key: ${{ secrets.SIGNING_ENCRYPT_KEY }}
- name: Build release AAB and APK
env:
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
run: |
./gradlew :app:bundleRelease :app:assembleRelease \
-Pandroidx.baselineprofile.skipgeneration=true \
-Papp.debugOnly=false \
-Papp.versionSuffix=-dev \
-PreleaseStoreFile=release/app-release.jks \
-PreleaseStorePassword="$SIGNING_STORE_PASSWORD" \
-PreleaseKeyAlias=tvmaniac \
-PreleaseKeyPassword="$SIGNING_KEY_PASSWORD" \
--no-configuration-cache
- name: Print build info
run: |
echo "Version: $(grep 'VERSION_NUMBER' version.txt | sed 's/.*= *//')-dev"
echo "Build: $(grep 'BUILD_NUMBER' version.txt | sed 's/.*= *//')"
- name: Deploy to Play Store internal track
run: |
export PLAY_STORE_SERVICE_ACCOUNT_JSON=$(cat release/play-service-account.json)
bundle exec fastlane android deploy_play_store
- name: Distribute to Firebase App Distribution
env:
FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
run: |
if [ -n "$FIREBASE_APP_ID" ] && [ -f "release/firebase-sa.json" ]; then
export FIREBASE_APP_DISTRIBUTION_SA=release/firebase-sa.json
bundle exec fastlane android distribute_firebase
else
echo "warning: Firebase not configured — skipping distribution"
fi
build-ios:
needs: prepare
runs-on: macos-15
timeout-minutes: 60
env:
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 60
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.sha }}
- name: Setup iOS Release
uses: ./.github/actions/setup-ios-release
with:
xcode-version: ${{ env.XCODE_VERSION }}
google-service-info-plist: ${{ secrets.GOOGLE_SERVICE_INFO_PLIST_RELEASE }}
- name: Build and upload to TestFlight
env:
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
run: bundle exec fastlane ios build_beta
- name: Upload iOS artifacts
uses: actions/upload-artifact@v7
if: always()
with:
name: ios-daily
path: |
build/
fastlane/logs
================================================
FILE: .github/workflows/nightly-integration-tests.yml
================================================
name: Nightly Integration Tests
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch:
workflow_call:
concurrency:
group: nightly-integration-tests-${{ github.ref }}
cancel-in-progress: false
env:
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
TRAKT_CLIENT_SECRET: ${{ secrets.TRAKT_CLIENT_SECRET }}
TRAKT_REDIRECT_URI: ${{ secrets.TRAKT_REDIRECT_URI }}
jobs:
integration-tests:
name: "Pixel 6 API 34 Integration Tests"
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: 🚚 Checkout
uses: actions/checkout@v6
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: 🐘 Setup Gradle Environment
uses: ./.github/actions/setup-gradle
- name: Setup Android SDK
uses: android-actions/setup-android@v4
- name: Accept licenses
run: yes | sdkmanager --licenses || true
- name: 📦 Cache AVD
uses: actions/cache@v5
with:
path: |
~/.android/avd/*
~/.android/adb*
~/.android/debug.keystore
key: avd-pixel6Api34-${{ hashFiles('gradle/libs.versions.toml') }}
restore-keys: |
avd-pixel6Api34-
- name: 🔥 Install google-services.json
env:
GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
run: |
if [ -n "$GOOGLE_SERVICES_JSON" ]; then
echo "$GOOGLE_SERVICES_JSON" | base64 --decode > app/google-services.json
else
echo "warning: GOOGLE_SERVICES_JSON secret not set — Firebase will be disabled"
fi
- name: 🤖 Run Integration Tests on Emulator
run: |
./gradlew :app:pixel6Api34DebugAndroidTest \
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" \
--no-configuration-cache
- name: 📊 Upload Test Reports
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: integration-test-reports
path: |
app/build/reports/androidTests/
app/build/outputs/androidTest-results/
app/build/outputs/managed_device_android_test_additional_output/
if-no-files-found: ignore
================================================
FILE: .github/workflows/promote-release.yml
================================================
name: Promote Release
on:
# schedule:
# - cron: '0 5 * * *'
workflow_dispatch:
inputs:
android_rollout:
description: 'Android rollout percentage override (e.g., 0.5 for 50%)'
type: string
required: false
skip_ios:
description: 'Skip iOS App Store submission'
type: boolean
default: false
crash_free_threshold:
description: 'Minimum crash-free rate (%) to allow promotion'
type: string
default: '99.0'
permissions:
contents: read
env:
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
TRAKT_CLIENT_SECRET: ${{ secrets.TRAKT_CLIENT_SECRET }}
TRAKT_REDIRECT_URI: ${{ secrets.TRAKT_REDIRECT_URI }}
jobs:
check-rollout:
runs-on: ubuntu-latest
outputs:
should_promote: ${{ steps.check.outputs.should_promote }}
next_rollout: ${{ steps.check.outputs.next_rollout }}
current_rollout: ${{ steps.check.outputs.current_rollout }}
release_tag: ${{ steps.check.outputs.release_tag }}
days_since_release: ${{ steps.check.outputs.days_since_release }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Determine rollout state
id: check
run: |
if [ -n "${{ inputs.android_rollout }}" ]; then
echo "should_promote=true" >> "$GITHUB_OUTPUT"
echo "next_rollout=${{ inputs.android_rollout }}" >> "$GITHUB_OUTPUT"
echo "current_rollout=manual" >> "$GITHUB_OUTPUT"
echo "days_since_release=manual" >> "$GITHUB_OUTPUT"
latest_tag=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | head -1)
echo "release_tag=$latest_tag" >> "$GITHUB_OUTPUT"
exit 0
fi
latest_tag=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | head -1)
if [ -z "$latest_tag" ]; then
echo "No release tags found"
echo "should_promote=false" >> "$GITHUB_OUTPUT"
exit 0
fi
tag_epoch=$(git log -1 --format=%ct "$latest_tag")
now_epoch=$(date +%s)
days_since=$(( (now_epoch - tag_epoch) / 86400 ))
# Rollout schedule: Day 0=0.1%, Day 1=1%, Day 3=10%, Day 5=50%, Day 7=100%
if [ "$days_since" -ge 7 ]; then
next_rollout="1.0"
elif [ "$days_since" -ge 5 ]; then
next_rollout="0.5"
elif [ "$days_since" -ge 3 ]; then
next_rollout="0.1"
elif [ "$days_since" -ge 1 ]; then
next_rollout="0.01"
else
echo "Day 0: release just deployed at 0.1%, no promotion needed yet"
echo "should_promote=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "should_promote=true" >> "$GITHUB_OUTPUT"
echo "next_rollout=$next_rollout" >> "$GITHUB_OUTPUT"
echo "days_since_release=$days_since" >> "$GITHUB_OUTPUT"
echo "release_tag=$latest_tag" >> "$GITHUB_OUTPUT"
echo "Release: $latest_tag, Days since: $days_since, Next rollout: $next_rollout"
check-vitals:
needs: check-rollout
if: ${{ needs.check-rollout.outputs.should_promote == 'true' }}
runs-on: ubuntu-latest
outputs:
passed: ${{ steps.vitals.outputs.passed }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Decrypt Play Store service account
env:
SIGNING_ENCRYPT_KEY: ${{ secrets.SIGNING_ENCRYPT_KEY }}
run: |
openssl aes-256-cbc -d -pbkdf2 \
-in release/play-service-account.aes \
-out release/play-service-account.json \
-k "$SIGNING_ENCRYPT_KEY"
- name: Check Play Vitals crash-free Rate
id: vitals
run: |
THRESHOLD="${{ inputs.crash_free_threshold }}"
THRESHOLD="${THRESHOLD:-99.0}"
APP_PACKAGE="com.thomaskioko.tvmaniac"
if ! gcloud auth activate-service-account --key-file=release/play-service-account.json 2>/dev/null; then
echo "::warning::Could not authenticate — skipping vitals check"
echo "passed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
ACCESS_TOKEN=$(gcloud auth print-access-token 2>/dev/null || true)
if [ -z "$ACCESS_TOKEN" ]; then
echo "::warning::Could not obtain access token — skipping vitals check"
echo "passed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
END_DATE=$(date -u +%Y-%m-%d)
START_DATE=$(date -u -d "7 days ago" +%Y-%m-%d)
RESPONSE=$(curl -sf \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
"https://playdeveloperreporting.googleapis.com/v1beta1/apps/${APP_PACKAGE}/crashRateMetricSet:query" \
-d "{
\"timelineSpec\": {
\"aggregationPeriod\": \"DAILY\",
\"startTime\": {
\"year\": $(date -u -d "$START_DATE" +%Y),
\"month\": $(date -u -d "$START_DATE" +%-m),
\"day\": $(date -u -d "$START_DATE" +%-d)
},
\"endTime\": {
\"year\": $(date -u -d "$END_DATE" +%Y),
\"month\": $(date -u -d "$END_DATE" +%-m),
\"day\": $(date -u -d "$END_DATE" +%-d)
}
},
\"metrics\": [\"crashRate\"],
\"dimensions\": []
}" 2>/dev/null) || true
if [ -z "$RESPONSE" ]; then
echo "::warning::Play Vitals API unavailable — skipping crash check"
echo "::warning::To enable: activate the Play Developer Reporting API and grant the service account 'View app information' permission"
echo "passed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CRASH_RATE=$(echo "$RESPONSE" | jq -r '[.rows[].metrics[0].decimalValue.value // "0"] | last' 2>/dev/null || echo "")
if [ -z "$CRASH_RATE" ] || [ "$CRASH_RATE" = "null" ]; then
echo "::warning::Could not parse crash rate — skipping check"
echo "passed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CRASH_FREE=$(echo "scale=2; (1 - $CRASH_RATE) * 100" | bc -l)
echo "Crash-free rate: ${CRASH_FREE}% (threshold: ${THRESHOLD}%)"
echo "crash_free_rate=${CRASH_FREE}" >> "$GITHUB_OUTPUT"
if (( $(echo "$CRASH_FREE < $THRESHOLD" | bc -l) )); then
echo "::error::Crash-free rate ${CRASH_FREE}% is below threshold ${THRESHOLD}% — blocking promotion"
echo "passed=false" >> "$GITHUB_OUTPUT"
else
echo "passed=true" >> "$GITHUB_OUTPUT"
fi
promote-android:
needs: [check-rollout, check-vitals]
if: ${{ needs.check-rollout.outputs.should_promote == 'true' && needs.check-vitals.outputs.passed == 'true' }}
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Decrypt Play Store service account
env:
SIGNING_ENCRYPT_KEY: ${{ secrets.SIGNING_ENCRYPT_KEY }}
run: |
openssl aes-256-cbc -d -pbkdf2 -in release/play-service-account.aes -out release/play-service-account.json -k "$SIGNING_ENCRYPT_KEY"
- name: Promote Android release
run: |
export PLAY_STORE_SERVICE_ACCOUNT_JSON=$(cat release/play-service-account.json)
bundle exec fastlane android promote \
from:production \
to:production \
rollout:${{ needs.check-rollout.outputs.next_rollout }}
- name: Summary
run: |
echo "### Android Promotion" >> $GITHUB_STEP_SUMMARY
echo "- **Release**: ${{ needs.check-rollout.outputs.release_tag }}" >> $GITHUB_STEP_SUMMARY
echo "- **Days since release**: ${{ needs.check-rollout.outputs.days_since_release }}" >> $GITHUB_STEP_SUMMARY
echo "- **Rollout**: ${{ needs.check-rollout.outputs.next_rollout }}" >> $GITHUB_STEP_SUMMARY
echo "- **Vitals**: crash-free ${{ needs.check-vitals.outputs.crash_free_rate || 'N/A' }}%" >> $GITHUB_STEP_SUMMARY
promote-ios:
needs: [check-rollout, check-vitals]
if: ${{ needs.check-rollout.outputs.should_promote == 'true' && !inputs.skip_ios }}
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Submit to App Store
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
IOS_SUBMIT_FOR_REVIEW: "true"
run: bundle exec fastlane ios deploy_app_store
- name: Summary
run: |
echo "### iOS Promotion" >> $GITHUB_STEP_SUMMARY
echo "- **Release**: ${{ needs.check-rollout.outputs.release_tag }}" >> $GITHUB_STEP_SUMMARY
echo "- **Action**: Submitted for App Store review" >> $GITHUB_STEP_SUMMARY
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
permissions:
contents: write
env:
XCODE_VERSION: 16.4
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
TRAKT_CLIENT_SECRET: ${{ secrets.TRAKT_CLIENT_SECRET }}
TRAKT_REDIRECT_URI: ${{ secrets.TRAKT_REDIRECT_URI }}
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.info.outputs.version }}
changelog: ${{ steps.changelog.outputs.body }}
steps:
- name: Checkout tag
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Gradle Environment
uses: ./.github/actions/setup-gradle
- name: Read version info
id: info
run: |
version=$(grep 'VERSION_NUMBER' version.txt | sed 's/.*= *//')
build=$(grep 'BUILD_NUMBER' version.txt | sed 's/.*= *//')
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "build=$build" >> "$GITHUB_OUTPUT"
echo "Version: $version, Build: $build, Tag: ${{ github.ref_name }}"
- name: Validate tag matches version.txt
run: |
tag="${{ github.ref_name }}"
version=$(grep 'VERSION_NUMBER' version.txt | sed 's/.*= *//')
expected_tag="v${version}"
if [ "$tag" != "$expected_tag" ]; then
echo "::error::Tag '$tag' does not match version.txt version '$expected_tag'"
exit 1
fi
- name: Generate release notes
id: changelog
run: |
body=$(git cliff --current --strip header 2>/dev/null || echo "Release ${{ github.ref_name }}")
{
echo 'body<> "$GITHUB_OUTPUT"
build-android:
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Checkout tag
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Android Release
uses: ./.github/actions/setup-android-release
with:
google-services-json: ${{ secrets.GOOGLE_SERVICES_JSON_RELEASE }}
signing-encrypt-key: ${{ secrets.SIGNING_ENCRYPT_KEY }}
- name: Build release AAB and APK
env:
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
run: |
./gradlew :app:bundleRelease :app:assembleRelease \
-Pandroidx.baselineprofile.skipgeneration=true \
-Papp.debugOnly=false \
-Papp.versionSuffix= \
-PreleaseStoreFile=release/app-release.jks \
-PreleaseStorePassword="$SIGNING_STORE_PASSWORD" \
-PreleaseKeyAlias=tvmaniac \
-PreleaseKeyPassword="$SIGNING_KEY_PASSWORD" \
--no-configuration-cache
- name: Write release notes
env:
RELEASE_CHANGELOG: ${{ needs.prepare.outputs.changelog }}
run: |
mkdir -p fastlane/metadata/android/en-US/changelogs
echo "$RELEASE_CHANGELOG" > fastlane/metadata/android/en-US/changelogs/default.txt
- name: Deploy to Play Store production (0.1% rollout)
run: |
export PLAY_STORE_SERVICE_ACCOUNT_JSON=$(cat release/play-service-account.json)
bundle exec fastlane android deploy_production rollout:0.001
- name: Distribute to Firebase App Distribution
env:
FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
run: |
if [ -n "$FIREBASE_APP_ID" ] && [ -f "release/firebase-sa.json" ]; then
export FIREBASE_APP_DISTRIBUTION_SA=release/firebase-sa.json
bundle exec fastlane android distribute_firebase
else
echo "warning: Firebase not configured - skipping distribution"
fi
- name: Upload Android artifacts
uses: actions/upload-artifact@v7
with:
name: android-release
path: |
app/build/outputs/bundle/release/*.aab
app/build/outputs/apk/release/*.apk
build-ios:
needs: prepare
runs-on: macos-15
timeout-minutes: 60
env:
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 60
steps:
- name: Checkout tag
uses: actions/checkout@v6
- name: Setup iOS Release
uses: ./.github/actions/setup-ios-release
with:
xcode-version: ${{ env.XCODE_VERSION }}
google-service-info-plist: ${{ secrets.GOOGLE_SERVICE_INFO_PLIST_RELEASE }}
- name: Build and upload to TestFlight
env:
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
run: bundle exec fastlane ios build_release
- name: Upload iOS artifacts
uses: actions/upload-artifact@v7
if: always()
with:
name: ios-release
path: |
build/
fastlane/logs
create-github-release:
needs: [prepare, build-android, build-ios]
if: ${{ always() && (needs.build-android.result == 'success' || needs.build-ios.result == 'success') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download Android artifacts
if: ${{ needs.build-android.result == 'success' }}
uses: actions/download-artifact@v8
with:
name: android-release
path: artifacts/android
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${{ github.ref_name }}"
version="${tag#v}"
release_args=(
"$tag"
--title "Release $version"
--notes "${{ needs.prepare.outputs.changelog }}"
--draft
)
if [ -d "artifacts/android" ]; then
apk=$(find artifacts/android -name "*.apk" -type f | head -1)
if [ -n "$apk" ]; then
release_args+=("$apk")
fi
fi
gh release create "${release_args[@]}"
================================================
FILE: .github/workflows/store-screenshot.yml
================================================
name: Store Screenshot
on:
push:
branches:
- main
permissions: {}
env:
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
TRAKT_CLIENT_SECRET: ${{ secrets.TRAKT_CLIENT_SECRET }}
TRAKT_REDIRECT_URI: ${{ secrets.TRAKT_REDIRECT_URI }}
jobs:
store-screenshot-test:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read # for clone
actions: write # for upload-artifact
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Gradle Environment
uses: ./.github/actions/setup-gradle
- name: Record screenshot
id: record-test
run: |
# Use --rerun-tasks to disable cache for test task
./gradlew recordRoborazziDebug --stacktrace --rerun-tasks
- uses: actions/upload-artifact@v7
if: ${{ always() }}
with:
name: screenshot
path: |
**/build/outputs/roborazzi
retention-days: 30
- uses: actions/upload-artifact@v7
if: ${{ always() }}
with:
name: screenshot-reports
path: |
**/build/reports
retention-days: 30
- uses: actions/upload-artifact@v7
if: ${{ always() }}
with:
name: screenshot-test-results
path: |
**/build/test-results
retention-days: 30
================================================
FILE: .gitignore
================================================
# Built application files
*.apk
*.ap_
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Gradle files
.gradle/
build/
.kotlin/
/build
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# Android Studio
*.iws
*.ipr
*.iml
.gradle
/local.properties
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
/.idea/*
!/.idea/codeStyles
!/.idea/dictionaries
# Eclipse project files
.classpath
.project
## Playgrounds
timeline.xctimeline
playground.xcworkspace
## Shared
/shared/domain/settings/implementation/*.preferences_pb
*.xcworkspacedata
*.xcuserstate
xcschememanagement.plist
*.xcbkptlist
## User settings
xcuserdata/
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
DerivedData/
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
*.xcuserstate
###### FastLane #######
fastlane/report.xml
fastlane/Preview.html
fastlane/test_output
fastlane/builds
/ios/fastlane/report.xml
ios/build
ios/SourcePackages/
derived_data/
*.xcresult
/.junie/guidelines.md
/tasks
CLAUDE.md
.claude/
# Configuration files with secrets
**/dev.yaml
**/prod.yaml
!**/*.yaml.template
**/.build/
**/Package.resolved
# Firebase
google-services.json
GoogleService-Info.plist
# Release signing
release/app-release.jks
release/play-service-account.json
release/firebase-sa.json
release/signing.properties
================================================
FILE: .idea/codeStyles/Project.xml
================================================
.*:id
http://schemas.android.com/apk/res/android
.*:name
http://schemas.android.com/apk/res/android
.*
http://schemas.android.com/apk/res/android
ANDROID_ATTRIBUTE_ORDER
================================================
FILE: .idea/codeStyles/codeStyleConfig.xml
================================================
================================================
FILE: .idea/dictionaries/project.xml
================================================
tmdb
trakt
================================================
FILE: .ruby-version
================================================
3.3.0
================================================
FILE: .swiftformat
================================================
--indent 4
--exclude ios/build,ios/Modules/*/.build,**/.build,**/build,**/ios-framework/build,ios/SourcePackages,ios/derived_data,**/SourcePackages,**/derived_data,**/checkouts
================================================
FILE: .swiftlint.yml
================================================
cyclomatic_complexity:
warning: 15
error: 20
function_body_length:
warning: 75
error: 100
disabled_rules:
- todo
- trailing_whitespace
- trailing_comma
- trailing_newline
- identifier_name
- type_name
- multiple_closures_with_trailing_closure
- type_body_length
- line_length
- opening_brace
- leading_whitespace
- void_function_in_ternary
opt_in_rules:
- sorted_imports
# Files & Folder not to check
excluded:
- .build
- "**/build"
- "**/.build"
- "**/checkouts"
- "**/SourcePackages"
- derived_data
- DerivedData
- ios/Modules/SnapshotTestingLib
- ios/build
- vendor/bundle
- ios-framework/build
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.
## [0.1.2] - 2026-03-25
### Bug Fixes
- **Android**: Fix SnackBar shown in error state.
- **auth**: Update Trakt token endpoint and client authentication method
- update Firebase distribution path to use release APK
- fix linting
### CI/CD
- Ignore iOS tests in watchlist module
### Features
- **i18n**: Add new strings for marking episodes
- **ios**: Configure background processing and fetch modes
- enable experimental R8 optimized resource shrinking
- enable mapping file upload for Firebase Crashlytics in release build
- Add query to get the latest season for followed shows
- Add episode notification scheduling tasks
- Add 'Auto' image quality setting and optimize image loading
- Add `season_numbers` to `TvShow` SQL queries
- Refactor theme implementation in settings
### Miscellaneous
- update release workflow to use release-specific secrets and add signing files to .gitignore
- add encrypted signing keys
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to TvManiac
TvManiac is a personal learning playground for Kotlin Multiplatform development. Contributions that fix bugs,
improve documentation, or demonstrate KMP patterns are welcome.
## Prerequisites
| Tool | Version |
|---|---|
| JDK | 21 (Zulu recommended: https://www.azul.com/downloads/?package=jdk#zulu) |
| Android Studio | Latest stable or canary |
| Xcode | 16.4 (iOS contributions) |
| KMM Plugin | Latest compatible with the Kotlin version in `gradle/libs.versions.toml` |
CocoaPods is not used. The iOS app consumes the shared KMP framework as an XCFramework built by Gradle.
## Cloning and Initial Setup
```bash
git clone https://github.com/thomaskioko/tv-maniac.git
cd tv-maniac
./scripts/install-git-hooks.sh
```
The git hooks run Spotless formatting checks before each commit. A commit is blocked if formatting fails.
Fix formatting with `./gradlew spotlessApply` and then re-commit.
### API Keys
The app requires TMDB and Trakt credentials at build time. See [docs/setup.md](docs/setup.md) for how to
obtain them. Create `local.properties` in the project root:
```properties
TMDB_API_KEY=your_tmdb_api_key
TRAKT_CLIENT_ID=your_trakt_client_id
TRAKT_CLIENT_SECRET=your_trakt_client_secret
TRAKT_REDIRECT_URI=tvmaniac://callback
```
This file is gitignored. Do not commit it.
## Building
**Android (debug):**
```bash
./gradlew :app:assembleDebug
```
For a faster local build that skips the iOS XCFramework:
```bash
./gradlew assembleDebug -Ptvmaniac.debugOnly=true
```
**iOS:**
Open `ios/tv-maniac.xcodeproj` in Xcode 16.4, select a simulator or device, and run.
## Running Tests
```bash
# JVM unit tests (fast, no device needed)
./gradlew jvmTest
# Android connected tests (requires a running emulator or device)
./gradlew connectedAndroidTest
# iOS simulator tests
./gradlew iosSimulatorArm64Test -Papp.enableIos=true
```
## Code Style
**Kotlin:**
- 2-space indentation, 140-character line length.
- Use `ImmutableList` and `toImmutableList()` from `kotlinx.collections.immutable` for state classes.
- No try-catch blocks that silently swallow errors. Propagate exceptions up to the presentation layer.
- Fakes, not mocks. Each `data/*/testing` module provides fake implementations for tests.
- Test names follow the pattern: `should X given Y`. Do not include function names in test names.
- Spotless enforces formatting. Run `./gradlew spotlessApply` before pushing.
**Swift:**
- Follow the Swift API Design Guidelines.
- Format with SwiftFormat. Check with `fastlane ios check_swift_format` before pushing.
- Do not add business logic to SwiftUI views. Views consume shared KMP presenters only.
**General:**
- No comments in code unless the intent cannot be expressed through naming.
- Always specify access modifiers on all Kotlin declarations.
- Business logic belongs in shared KMP code (`domain/*`, `data/*`), never in platform UI layers.
## Branching and Pull Requests
1. Fork the repository and create your branch from `main`.
2. Keep branches focused on a single concern. Large refactors should be discussed in an issue first.
3. Ensure CI passes: Spotless, unit tests, and lint checks must all be green.
4. Include a clear description in your PR: what changed, why, and how to test it.
5. Reference any related issue with `Closes #123` or `Relates to #123` in the PR body.
PRs that add new architectural patterns should also update the relevant file under `docs/architecture/`.
## Architecture Overview
Before contributing, read the architecture docs so your change fits the existing patterns:
- [Modularization](docs/architecture/modularization.md)
- [Presentation Layer](docs/architecture/presentation-layer.md)
- [Data Layer](docs/architecture/data-layer.md)
- [Navigation](docs/architecture/navigation.md)
- [Dependency Injection](docs/architecture/dependency-injection.md)
## Filing Bugs and Questions
Open an issue on GitHub. Use the bug report template for reproducible defects and the feature request
template for new ideas. For open-ended questions, open a GitHub issue with the `question` label.
================================================
FILE: GEMINI.md
================================================
# TV Maniac Agent Rules
## Project Overview
TV Maniac is a Kotlin Multiplatform (KMP) project for tracking TV shows. It follows a highly modularized Clean Architecture with a strict separation of concerns.
## Architecture & Tech Stack
- **KMP**: Shared business logic, state management, and data layer.
- **Clean Architecture**: Organized into `data`, `domain`, `presenter`, and `ui` layers.
- **Metro**: A custom compile-time Dependency Injection (DI) system.
- **Decompose**: Used for shared navigation and lifecycle management.
- **Store 5**: Used for data fetching (Fetcher) and caching (SourceOfTruth).
- **SQLDelight**: Local persistence.
- **UI**: Jetpack Compose for Android, SwiftUI for iOS.
## Core Mandates & Conventions
### 1. Modularization & Dependencies
- **Strict API/Implementation Split**: Most modules are split into `:api` and `:implementation` (or implicit).
- **Rule**: Modules MUST depend on `api` modules of other features, never `implementation` (except for entry points like `:app` and `:ios-framework`).
- **Feature Structure**: Features follow a 3-module split:
- `nav`: Contains routes, parameters, and navigation-related DI.
- `presenter`: Contains business logic, state management (MVI), and domain interactors.
- `ui`: Contains platform-specific UI (Compose).
### 2. Dependency Injection (Metro)
- Use `@DependencyGraph`, `@GraphExtension`, and `@BindingContainer` for DI.
- **Naming**:
- DI interfaces MUST use the `*Graph` suffix.
- Binding providers MUST use the `*BindingContainer` suffix.
- **Scopes**:
- `AppScope`: Singleton/Application lifetime.
- `ActivityScope`: Activity lifetime.
- `ScreenScope`: Decompose component lifetime.
### 3. Navigation (Decompose)
- Use `NavRoute` for standard navigation and `SheetConfig` for bottom sheets.
- **Annotations**:
- `@NavScreen`: Annotate presenters for standard screens.
- `@NavSheet`: Annotate presenters for bottom sheets.
- `@TabScreen`: Annotate presenters for tab screens.
- **Codegen**: Navigation is largely handled via codegen based on these annotations.
### 4. Presentation Layer (MVI)
- **Presenters**:
- MUST use `@AssistedInject` and have an `@AssistedFactory`.
- MUST extend `ComponentContext` (via delegation).
- MUST expose state as `Value` (for Decompose) or `StateFlow`.
- MUST use a `dispatch(Action)` function for UI events.
- **Naming**: `*Presenter`, `*Action`, `*Content` (for state).
- **Standardized Helpers**:
- Use `ObservableLoadingCounter` for tracking loading states.
- Use `UiMessageManager` for managing transient UI messages (errors, snackbars).
- Use `.collectStatus()` extension to handle flow statuses and pipe errors/loading to the above managers.
### 5. Data Layer (Store)
- Use `Store 5` for fetching and caching.
- `Fetcher`: For network requests (Ktor).
- `SourceOfTruth`: For local caching (SQLDelight).
- `Validator`: To determine if cached data is still valid.
### 6. Localization
- **Rule**: Never use hardcoded strings or platform-specific string resources in shared code.
- Use `Localizer` interface for shared string resolution.
- Use `MR` (MOKO Resources) for string and plural definitions.
- In Compose UI, use `resource.resolve(LocalContext.current)` or similar helpers.
### 7. Coding Style
- **Naming**: Use descriptive names. Suffix classes with their role (e.g., `Interactor`, `Repository`, `Presenter`).
- **Types**: Prefer explicit types for public APIs.
- **Immutability**: Use `kotlinx.collections.immutable` for state collections.
### 8. Testing
- **Prefer Fakes over Mocks**: Use hand-written fakes for repositories and interactors.
- **Testing Modules**: Fakes should reside in a `:testing` module corresponding to the feature or layer.
- **Navigator**: Use `TestNavigator` for asserting navigation events in presenter tests.
- **Turbine**: Use Turbine for testing flows.
### 9. Integration Testing
- **Location**: Integration tests reside in the `:core:integration` module.
- **TestGraph**: Use `TestGraph` to resolve dependencies. It provides a real dependency graph with specific components replaced by fakes (e.g., Network, DataStore).
- **Execution**:
- Use `runTestWithGraph { graph -> ... }` to run tests. This helper handles setting up the `TestGraph` and the `TestDispatcher`.
- Ensure `Lifecycle.destroy()` is called and `advanceUntilIdle()` is used when testing presenters to prevent coroutine leaks.
- **Platform Specifics**: Metro's graph factories are materialized per target. JVM and iOS variants of helpers must live in their respective source sets.
- **Android**: Use `androidHostTest` for running integration tests on the JVM while having access to Android resources if needed.
## Development Workflow
1. **Feature Addition**:
- Define `Route` in `nav`.
- Implement `Interactor` in `domain` (if needed).
- Implement `Presenter` in `presenter` using `@AssistedInject` and `@NavScreen`.
- Implement `Screen` in `ui` using `@ScreenUi`.
2. **Data Changes**:
- Update SQLDelight `.sq` files.
- Update `Store` configuration.
- Update `Mapper` to convert database/network entities to domain/UI models.
## Verification
- Run `./gradlew lint` for Android linting.
- Run `./gradlew test` for unit tests.
- Ensure all DI graphs are valid by running a build.
================================================
FILE: Gemfile
================================================
source "https://rubygems.org"
gem "fastlane"
gem "xcode-install"
gem "xcpretty"
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
================================================
FILE: LICENSE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: README.md
================================================
# TvManiac




[](https://github.com/thomaskioko/tv-maniac/releases/latest)
**TvManiac** is a personalized entertainment tracking and recommendation Multiplatform app (Android & iOS) for tracking TV Shows. By utilizing [Trakt](https://trakt.tv) and [TMDB](https://developer.themoviedb.org/docs), you can discover shows, manage your watchlist, track watch progress, and get personalized recommendations.
| Android | iOS |
|---|---|
| | |
> **Under Heavy Development**
>
> This is my playground for learning Kotlin Multiplatform. With that said, I'm sure it's filled with bugs crawling everywhere, and I'm probably doing a couple of things wrong. So a lot is changing, but that shouldn't stop you from checking it out.
## Install
Download the latest APK from [GitHub Releases](https://github.com/thomaskioko/tv-maniac/releases).
Join the open beta on [Google Play](https://play.google.com/store/apps/details?id=com.thomaskioko.tvmaniac&hl=en_US) or stay up to date with daily builds via Firebase:
[ ](https://appdistribution.firebase.dev/i/564c934cc970634b)
---
## Getting Started
### Requirements
- [Zulu Java 21](https://www.azul.com/downloads/?package=jdk#zulu)
- Latest [Android Studio](https://developer.android.com/studio/preview)
- [KMM Plugin](https://kotlinlang.org/docs/multiplatform-mobile-setup.html)
### API Keys
The app requires TMDB and Trakt API credentials. See [docs/setup.md](docs/setup.md) for detailed instructions.
Create `local.properties` in the project root:
```properties
TMDB_API_KEY=your_tmdb_api_key
TRAKT_CLIENT_ID=your_trakt_client_id
TRAKT_CLIENT_SECRET=your_trakt_client_secret
TRAKT_REDIRECT_URI=tvmaniac://callback
```
### Setup & Build
```bash
./scripts/install-git-hooks.sh
```
**Android:**
```bash
./gradlew :app:assembleDebug
```
**iOS:**
Open `ios/tv-maniac.xcodeproj` in Xcode and run.
---
## Architecture
The project follows Clean Architecture with a modular design organized by feature and layer. Business logic and state management live in shared KMP code, while Android (Compose) and iOS (SwiftUI) contain only UI rendering.
For detailed documentation:
- [Modularization](docs/architecture/modularization.md)
- [Presentation Layer](docs/architecture/presentation-layer.md)
- [Data Layer](docs/architecture/data-layer.md)
- [Navigation](docs/architecture/navigation.md)
- [Dependency Injection](docs/architecture/dependency-injection.md)
- [Integration Testing](docs/architecture/integration-testing.md)
---
## Key Concepts
A few foundational libraries and patterns drive the architecture.
- **[Decompose](https://arkivanov.github.io/Decompose/)**. Shared navigation and lifecycle for KMP. The navigation stack, child components, and back handling all live in shared Kotlin code. Android (Compose) and iOS (SwiftUI) only render the active child. See [Navigation](docs/architecture/navigation.md).
- **[Metro](https://zacsweers.github.io/metro/latest/)**. Compile time dependency injection. There is no KSP processor and no runtime reflection. Modules expose interfaces from `api/` packages, implementations are bound with `@ContributesBinding`, and the full graph is assembled at the app entry point. See [Dependency Injection](docs/architecture/dependency-injection.md).
- **[Store pattern](https://store.mobilenativefoundation.org/)**. One fetch and cache pipeline per data type. A `Store` combines a `Fetcher` (network), a `SourceOfTruth` (SQLDelight DAO), and a `Validator` (cache freshness via `RequestManager`). Presenters never call the network or DAO directly. See [Data Layer](docs/architecture/data-layer.md).
- **Interactor and SubjectInteractor**. Thin orchestration in the domain layer. An `Interactor` runs a one shot action (mark watched, sign in). A `SubjectInteractor` exposes a continuous `Flow` of data (observe show details). Presenters compose these into screen state. See [Presentation Layer](docs/architecture/presentation-layer.md).
---
## Tech Stack
Architectural choices (Decompose, Metro, Store) are described in [Key Concepts](#key-concepts) above. The libraries below cover the rest of the shared and platform stack.
**Shared (KMP)**
- [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) - Concurrency
- [Ktor](https://ktor.io/) - Networking
- [SQLDelight](https://github.com/cashapp/sqldelight) - Local database
- [kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization) - JSON serialization
- [Multiplatform Paging](https://github.com/cashapp/multiplatform-paging) - Pagination
**Android**
- [Jetpack Compose](https://developer.android.com/jetpack/compose) - UI toolkit
- [Coil](https://coil-kt.github.io/coil/) - Image loading
- [AppAuth](https://openid.github.io/AppAuth-Android/) - OAuth authentication
**iOS**
- [SwiftUI](https://developer.apple.com/xcode/swiftui/) - UI framework
- [Nuke](https://github.com/kean/Nuke) - Image loading
- [OAuthSwift](https://github.com/OAuthSwift/OAuthSwift) - OAuth authentication
---
## Gradle Convention Plugins
Build configurations are managed by [app-gradle-plugins](https://github.com/thomaskioko/app-gradle-plugins), a set of custom Gradle convention plugins published to Maven Central. They handle Android/KMP module setup, versioning, release automation, and R8 optimization. For a deep dive into how they work, see [Publishing Gradle Convention Plugins](https://thomaskioko.me/posts/publishing_gradle_plugins/).
---
## References & Inspiration
- [Design Inspiration](https://dribbble.com/shots/7591814-HBO-Max-Companion-App-Animation)
- [Tivi](https://github.com/chrisbanes/tivi)
- [Compose Samples](https://github.com/android/compose-samples)
## License
```
Copyright 2021 Thomas Kioko
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
================================================
FILE: android-designsystem/build.gradle.kts
================================================
plugins { alias(libs.plugins.app.android) }
scaffold {
android {
enableAndroidResources()
useCompose()
useRoborazzi()
}
optIn("androidx.compose.material3.ExperimentalMaterial3Api")
}
dependencies {
api(libs.androidx.compose.ui.tooling)
api(libs.androidx.compose.ui.tooling.preview)
api(libs.androidx.compose.material3)
api(libs.androidx.compose.ui.ui)
api(libs.androidx.compose.material.icons)
api(libs.androidx.compose.runtime)
api(projects.domain.theme)
implementation(projects.core.testTags)
implementation(projects.i18n.generator)
api(libs.coil.base)
implementation(libs.androidx.annotation)
implementation(libs.androidx.collections)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.constraintlayout)
implementation(libs.coil.coil)
implementation(libs.coil.compose)
implementation(libs.kenburns)
implementation(libs.androidx.palette)
implementation(libs.coroutines.jvm)
implementation(libs.kotlinx.collections)
testImplementation(libs.robolectric.annotations)
testImplementation(projects.core.screenshotTests)
}
================================================
FILE: android-designsystem/src/debug/res/values/strings.xml
================================================
TvManiac
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/Background.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.LocalAbsoluteTonalElevation
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.theme.LocalBackgroundTheme
/**
* The main background for the app. Uses [LocalBackgroundTheme] to set the color and tonal elevation
* of a [Surface].
*
* @param modifier Modifier to be applied to the background.
* @param content The background content.
*/
@Composable
public fun TvManiacBackground(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val color = LocalBackgroundTheme.current.color
val tonalElevation = LocalBackgroundTheme.current.tonalElevation
Surface(
color = if (color == Color.Unspecified) Color.Transparent else color,
tonalElevation = if (tonalElevation == Dp.Unspecified) 0.dp else tonalElevation,
modifier = modifier.fillMaxSize(),
) {
CompositionLocalProvider(LocalAbsoluteTonalElevation provides 0.dp) { content() }
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light Theme", showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Theme", showBackground = true)
public annotation class ThemePreviews
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun BackgroundDefault() {
Spacer(Modifier.size(100.dp))
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/BadgeChip.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
@Composable
public fun PremiereBadge(
modifier: Modifier = Modifier,
text: String,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.onSurface,
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.background,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
@Composable
public fun NewBadge(
modifier: Modifier = Modifier,
text: String,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.secondary,
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondary,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun PremiereBadgePreview() {
PremiereBadge(text = "Premiere")
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun NewBadgePreview() {
NewBadge(text = "New")
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/Buttons.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LibraryAddCheck
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.extensions.iconButtonBackgroundScrim
import com.thomaskioko.tvmaniac.compose.theme.TvManiacTheme
import com.thomaskioko.tvmaniac.domain.theme.Theme
@Composable
public fun FilledTextButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = RectangleShape,
buttonColors: ButtonColors = ButtonDefaults.textButtonColors(),
content: @Composable RowScope.() -> Unit,
) {
TextButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
colors = buttonColors,
content = content,
shape = shape,
)
}
@Composable
public fun FilledVerticalIconButton(
text: String,
onClick: () -> Unit,
imageVector: ImageVector,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = RectangleShape,
style: TextStyle = MaterialTheme.typography.bodyMedium,
containerColor: Color = MaterialTheme.colorScheme.secondary,
contentColor: Color = MaterialTheme.colorScheme.onSecondary,
) {
TextButtonContent(
onClick = onClick,
modifier = modifier,
enabled = enabled,
containerColor = containerColor,
shape = shape,
content = {
Column(
modifier = Modifier
.sizeIn(minWidth = 120.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = imageVector,
contentDescription = null,
tint = when {
enabled -> contentColor
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
},
)
Text(
modifier = Modifier.padding(top = 2.dp),
text = text,
style = style,
color = when {
enabled -> contentColor
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
},
)
}
},
)
}
@Composable
public fun FilledHorizontalIconButton(
text: String,
onClick: () -> Unit,
imageVector: ImageVector,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = RectangleShape,
style: TextStyle = MaterialTheme.typography.bodyMedium,
containerColor: Color = MaterialTheme.colorScheme.secondary,
) {
TextButtonContent(
onClick = onClick,
modifier = modifier,
enabled = enabled,
containerColor = containerColor,
shape = shape,
content = {
Row(
modifier = Modifier
.sizeIn(minHeight = 32.dp, minWidth = 140.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = imageVector,
contentDescription = null,
tint = when {
enabled -> MaterialTheme.colorScheme.onSecondary
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
},
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = text,
style = style,
color = when {
enabled -> MaterialTheme.colorScheme.onSecondary
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
},
)
}
},
)
}
@Composable
private fun TextButtonContent(
onClick: () -> Unit,
enabled: Boolean,
containerColor: Color,
shape: Shape,
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
TextButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
colors = ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colorScheme.onBackground,
containerColor = containerColor,
),
shape = shape,
) {
content()
}
}
@Composable
public fun HorizontalOutlinedButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
textPadding: Dp = 0.dp,
shape: Shape = MaterialTheme.shapes.small,
borderColor: Color = MaterialTheme.colorScheme.secondary,
leadingIcon: @Composable (() -> Unit)? = null,
) {
OutlinedButton(
onClick = onClick,
modifier = modifier.padding(2.dp),
enabled = enabled,
shape = shape,
content = {
if (leadingIcon != null) {
Box(Modifier.sizeIn(maxHeight = ButtonDefaults.IconSize)) { leadingIcon() }
}
Box(
Modifier.padding(
start = when {
leadingIcon != null -> ButtonDefaults.IconSpacing
else -> 0.dp
},
),
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = if (enabled) {
MaterialTheme.colorScheme.secondary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
},
modifier = Modifier.padding(textPadding),
)
}
},
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onSecondary,
),
border = BorderStroke(
width = 1.dp,
color = when {
enabled -> borderColor
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
},
),
)
}
@Composable
public fun OutlinedVerticalIconButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = MaterialTheme.shapes.small,
borderColor: Color = MaterialTheme.colorScheme.secondary,
leadingIcon: @Composable (() -> Unit) = {},
) {
OutlinedButton(
onClick = onClick,
modifier = modifier.widthIn(min = 140.dp),
enabled = enabled,
shape = shape,
content = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
leadingIcon()
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = when {
enabled -> MaterialTheme.colorScheme.secondary
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
},
modifier = Modifier.padding(top = 2.dp),
)
}
},
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onSecondary,
),
border = BorderStroke(
width = 1.dp,
color = when {
enabled -> borderColor
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
},
),
)
}
@Composable
public fun OutlinedVerticalIconButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
shape: Shape = MaterialTheme.shapes.small,
borderColor: Color = MaterialTheme.colorScheme.secondary,
text: @Composable (() -> Unit) = {},
leadingIcon: @Composable (() -> Unit) = {},
) {
OutlinedButton(
onClick = onClick,
modifier = modifier.widthIn(min = 140.dp),
enabled = enabled,
shape = shape,
content = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
leadingIcon()
text()
}
},
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onSecondary,
),
border = BorderStroke(
width = 1.dp,
color = when {
enabled -> borderColor
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
},
),
)
}
@Composable
public fun ScrimButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
show: Boolean = false,
color: Color = MaterialTheme.colorScheme.surface,
alpha: Float = 0.4f,
content: @Composable () -> Unit,
) {
val isLight = color.luminance() > 0.5
val scrimEnabled = !show
if (scrimEnabled) {
val appTheme = if (isLight) Theme.LIGHT_THEME else Theme.DARK_THEME
TvManiacTheme(appTheme = appTheme) {
IconButton(
onClick = onClick,
modifier = modifier.iconButtonBackgroundScrim(enabled = true, alpha = alpha),
) {
content()
}
}
} else {
IconButton(
onClick = onClick,
modifier = modifier.iconButtonBackgroundScrim(enabled = false, alpha = alpha),
) {
content()
}
}
}
@Composable
public fun RefreshButton(
isRefreshing: Boolean,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Crossfade(isRefreshing, label = "ActionButtonCrossfade") { targetRefreshing ->
if (targetRefreshing) {
AutoSizedCircularProgressIndicator(
modifier = modifier,
)
} else {
content()
}
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun FilledTextButtonPreview() {
FilledTextButton(
onClick = {},
enabled = false,
buttonColors = ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colorScheme.onBackground,
containerColor = MaterialTheme.colorScheme.secondary,
),
modifier = Modifier
.fillMaxWidth()
.padding(2.dp)
.background(color = MaterialTheme.colorScheme.secondary),
) {
Text(
text = "Horror",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondary,
)
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun FilledIconButtonPreview(@PreviewParameter(ButtonPreviewParamProvider::class) isEnable: Boolean) {
FilledVerticalIconButton(
onClick = {},
enabled = isEnable,
text = "Track",
imageVector = Icons.Default.LibraryAddCheck,
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun FilledHorizontalIconButtonPreview(@PreviewParameter(ButtonPreviewParamProvider::class) isEnable: Boolean) {
FilledHorizontalIconButton(
onClick = {},
enabled = isEnable,
text = "Add To Library",
imageVector = Icons.Default.LibraryAddCheck,
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun TvManiacAlphaTextButtonPreview() {
FilledTextButton(
onClick = {},
enabled = false,
buttonColors = ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colorScheme.onSecondary,
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.08f),
),
) {
Text(
text = "Horror",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
)
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun TvManiacOutlinedButtonPreview() {
OutlinedVerticalIconButton(
onClick = {},
enabled = true,
leadingIcon = {
Image(
imageVector = Icons.Filled.LibraryAddCheck,
contentDescription = null,
colorFilter = ColorFilter.tint(
MaterialTheme.colorScheme.secondary.copy(
alpha = 0.8F,
),
),
)
},
text = "Following",
)
}
private class ButtonPreviewParamProvider : PreviewParameterProvider {
override val values: Sequence = sequenceOf(
true,
false,
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/Card.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmarks
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.i18n.MR.strings.cd_show_poster
@Composable
public fun PosterCard(
imageUrl: String?,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
title: String? = null,
imageWidth: Dp = 120.dp,
aspectRatio: Float = 2 / 3f,
contentScale: ContentScale = ContentScale.Crop,
shape: Shape = RectangleShape,
isInLibrary: Boolean = false,
libraryImageOverlay: ImageVector = Icons.Filled.Bookmarks,
) {
PosterCard(
onClick = onClick,
modifier = modifier,
shape = shape,
imageWidth = imageWidth,
content = {
Box {
PosterPlaceholder(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(aspectRatio)
.align(Alignment.Center),
title = title,
)
AsyncImageComposable(
model = imageUrl,
contentScale = contentScale,
contentDescription = title?.let {
stringResource(
cd_show_poster.resourceId,
title,
)
},
modifier = Modifier
.fillMaxWidth()
.aspectRatio(aspectRatio),
)
if (isInLibrary) {
LibraryOverlay(libraryImageOverlay = libraryImageOverlay)
}
}
},
)
}
@Composable
private fun LibraryOverlay(
libraryImageOverlay: ImageVector,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.TopEnd,
) {
Icon(
imageVector = libraryImageOverlay,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.padding(8.dp)
.size(20.dp),
)
}
}
@Composable
public fun PosterBackdropCard(
title: String,
imageUrl: String?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
textAlign: TextAlign = TextAlign.Start,
contentScale: ContentScale = ContentScale.Crop,
imageWidth: Dp = 120.dp,
aspectRatio: Float = 2 / 3f,
shape: Shape = RectangleShape,
) {
val surface = MaterialTheme.colorScheme.surface
val brush = remember(surface) {
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
surface.copy(alpha = 0.4f),
surface.copy(alpha = 0.7f),
surface.copy(alpha = 0.9f),
surface,
),
)
}
PosterCard(
onClick = onClick,
modifier = modifier,
shape = shape,
imageWidth = imageWidth,
content = {
Box {
PosterPlaceholder(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(aspectRatio)
.align(Alignment.Center),
imageSize = 84.dp,
)
AsyncImageComposable(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(aspectRatio),
model = imageUrl,
contentScale = contentScale,
contentDescription = stringResource(cd_show_poster.resourceId, title),
alignment = Alignment.Center,
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.align(Alignment.BottomCenter)
.background(brush),
)
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = textAlign,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.align(Alignment.BottomStart),
)
}
},
)
}
@Composable
internal fun PosterCard(
onClick: () -> Unit,
modifier: Modifier = Modifier,
imageWidth: Dp = 120.dp,
shape: Shape = RectangleShape,
content: @Composable () -> Unit,
) {
Card(
onClick = onClick,
modifier = modifier
.width(imageWidth),
shape = shape,
elevation = CardDefaults.cardElevation(
defaultElevation = 4.dp,
),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
content()
}
}
@Composable
public fun CastCard(
profileUrl: String?,
name: String,
characterName: String,
modifier: Modifier = Modifier,
height: Dp = 160.dp,
) {
Card(
modifier = modifier,
shape = MaterialTheme.shapes.small,
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
) {
Box(
modifier = Modifier
.fillMaxSize()
.size(width = 120.dp, height = height),
contentAlignment = Alignment.BottomStart,
) {
CastPlaceholder(
modifier = Modifier.fillMaxSize(),
imageUrl = profileUrl,
name = name,
)
AsyncImageComposable(
model = profileUrl,
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
Box(
modifier = Modifier
.matchParentSize()
.background(contentBackgroundGradient()),
)
CastNameOverlay(
name = name,
characterName = characterName,
)
}
}
}
@Composable
private fun CastPlaceholder(
imageUrl: String?,
modifier: Modifier = Modifier,
name: String? = null,
) {
if (imageUrl.isNullOrEmpty()) {
Box(
modifier = modifier
.background(
Brush.verticalGradient(
colors = listOf(
Color.Gray.copy(alpha = 0.8f),
Color.Gray,
),
),
),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(52.dp),
imageVector = Icons.Outlined.Person,
contentDescription = name,
tint = Color.White.copy(alpha = 0.8f),
)
}
}
}
@Composable
private fun CastNameOverlay(
name: String,
characterName: String,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.padding(8.dp)) {
Text(
text = name,
modifier = Modifier
.padding(vertical = 2.dp)
.fillMaxWidth(),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
),
)
Text(
text = characterName,
modifier = Modifier.fillMaxWidth(),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface,
),
)
}
}
@Composable
private fun contentBackgroundGradient(): Brush {
val surface = MaterialTheme.colorScheme.surface
return remember(surface) {
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
surface.copy(alpha = 0.3f),
surface.copy(alpha = 0.6f),
surface.copy(alpha = 0.9f),
surface,
),
)
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun CastCardPreview() {
CastCard(
profileUrl = null,
name = "Tom Hiddleston",
characterName = "Loki",
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun PosterCardPreview() {
PosterCard(
imageUrl = "",
title = "Loki",
modifier = Modifier
.width(100.dp)
.aspectRatio(0.8f),
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun PosterCardWithLibraryOverlayPreview() {
PosterCard(
imageUrl = "",
title = "Loki",
isInLibrary = true,
modifier = Modifier
.width(100.dp)
.aspectRatio(0.8f),
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun PosterBackdropPreview() {
PosterBackdropCard(
imageUrl = "",
title = "Game of Thrones",
onClick = {},
modifier = Modifier
.fillMaxWidth()
.height(240.dp),
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun AvatarComponentPreview() {
AvatarComponent(
imageUrl = "",
size = 38.dp,
contentDescription = "Profile",
onClick = {},
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/Chip.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
@Composable
public fun TvManiacChip(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
selected: Boolean = true,
enabled: Boolean = true,
) {
FilterChip(
modifier = modifier,
selected = selected,
onClick = onClick,
label = {
ProvideTextStyle(value = MaterialTheme.typography.labelSmall) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(vertical = 8.dp),
)
}
},
enabled = enabled,
leadingIcon = null,
border = null,
shape = RoundedCornerShape(4.dp),
colors = FilterChipDefaults.filterChipColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.08f),
labelColor = MaterialTheme.colorScheme.secondary,
selectedContainerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.24f),
selectedLabelColor = MaterialTheme.colorScheme.secondary,
),
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun ChipItemSelectedPreview() {
TvManiacChip(
selected = true,
text = "Season 1",
onClick = {},
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/Dialogs.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
@Composable
public fun TvManiacAlertDialog(
title: String,
message: String,
confirmButtonText: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
shape: Shape = MaterialTheme.shapes.small,
icon: ImageVector? = null,
dismissButtonText: String? = null,
confirmButtonTestTag: String? = null,
dismissButtonTestTag: String? = null,
) {
val density = LocalDensity.current
val containerWidth = with(density) {
LocalWindowInfo.current.containerSize.width.toDp()
}
AlertDialog(
properties = DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.widthIn(max = (containerWidth - 80.dp).coerceAtLeast(0.dp)),
shape = shape,
onDismissRequest = onDismiss,
icon = icon?.let {
{
Icon(
imageVector = it,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
)
}
},
title = {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
},
text = {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
confirmButton = {
TextButton(
modifier = confirmButtonTestTag?.let { Modifier.testTag(it) } ?: Modifier,
onClick = onConfirm,
) {
Text(
text = confirmButtonText,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.secondary,
)
}
},
dismissButton = dismissButtonText?.let {
{
TextButton(
modifier = dismissButtonTestTag?.let { tag -> Modifier.testTag(tag) } ?: Modifier,
onClick = onDismiss,
) {
Text(
text = it,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun TvManiacAlertDialogPreview() {
TvManiacAlertDialog(
title = "Enable Notifications",
message = "Get notified when new episodes of your favorite shows are released.",
confirmButtonText = "Enable",
dismissButtonText = "Not Now",
icon = Icons.Default.Info,
onConfirm = {},
onDismiss = {},
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun TvManiacAlertDialogNoIconPreview() {
TvManiacAlertDialog(
title = "Confirm Action",
message = "Are you sure you want to proceed with this action?",
confirmButtonText = "Confirm",
dismissButtonText = "Cancel",
onConfirm = {},
onDismiss = {},
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/EmptyLayout.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Inbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
@Composable
public fun EmptyStateView(
title: String,
modifier: Modifier = Modifier,
imageVector: ImageVector = Icons.Outlined.Inbox,
message: String? = null,
buttonText: String? = null,
buttonTestTag: String? = null,
onClick: () -> Unit = {},
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
modifier = Modifier.size(64.dp),
imageVector = imageVector,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
contentDescription = null,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
message?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
buttonText?.let {
Spacer(modifier = Modifier.height(24.dp))
HorizontalOutlinedButton(
modifier = buttonTestTag?.let { Modifier.testTag(it) } ?: Modifier,
text = it,
onClick = onClick,
shape = MaterialTheme.shapes.small,
)
}
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun EmptyStateViewPreview() {
EmptyStateView(
title = "Nothing here yet",
message = "Shows you follow will appear here.",
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun EmptyStateViewWithButtonPreview() {
EmptyStateView(
title = "Something went wrong",
message = "We couldn't load the data.",
buttonText = "Retry",
onClick = {},
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/ErrorLayout.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SignalWifi4Bar
import androidx.compose.material.icons.outlined.SignalWifiOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
import com.thomaskioko.tvmaniac.compose.theme.green
import com.thomaskioko.tvmaniac.i18n.MR.strings.cd_connectivity_icon
import com.thomaskioko.tvmaniac.i18n.MR.strings.status_connected
import com.thomaskioko.tvmaniac.i18n.MR.strings.status_no_connection
import com.thomaskioko.tvmaniac.i18n.MR.strings.unexpected_error_retry
import com.thomaskioko.tvmaniac.i18n.resolve
@Composable
public fun ConnectionStatus(
isConnected: Boolean,
modifier: Modifier = Modifier,
) {
val backgroundColor by
animateColorAsState(
if (isConnected) green else MaterialTheme.colorScheme.error,
label = "",
)
val message = if (isConnected) {
status_connected.resolve(LocalContext.current)
} else {
status_no_connection.resolve(LocalContext.current)
}
val icon = if (isConnected) Icons.Outlined.SignalWifi4Bar else Icons.Outlined.SignalWifiOff
Box(
modifier = modifier
.background(backgroundColor)
.fillMaxWidth()
.padding(8.dp),
contentAlignment = Alignment.TopCenter,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
imageVector = icon,
contentDescription = cd_connectivity_icon.resolve(LocalContext.current),
tint = Color.White,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
message,
color = Color.White,
style = MaterialTheme.typography.labelMedium,
)
}
}
}
@Composable
public fun RowError(
onRetry: () -> Unit,
modifier: Modifier = Modifier,
errorMessage: String = unexpected_error_retry.resolve(LocalContext.current),
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = errorMessage,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
HorizontalOutlinedButton(
text = "Retry",
onClick = onRetry,
)
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun RowErrorPreview() {
RowError(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
onRetry = {},
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/FilterChipSection.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
import com.thomaskioko.tvmaniac.i18n.MR.strings.label_library_filter_show_less
import com.thomaskioko.tvmaniac.i18n.MR.strings.label_library_filter_show_more
import com.thomaskioko.tvmaniac.i18n.resolve
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@OptIn(ExperimentalLayoutApi::class)
@Composable
public fun FilterChipSection(
title: String,
items: ImmutableList,
selectedItems: ImmutableSet,
onItemToggle: (T) -> Unit,
labelProvider: (T) -> String,
modifier: Modifier = Modifier,
collapsedItemCount: Int = 5,
singleSelect: Boolean = false,
) {
val context = LocalContext.current
var isExpanded by remember { mutableStateOf(false) }
val visibleItems = if (isExpanded) items else items.take(collapsedItemCount)
val hasMoreItems = items.size > collapsedItemCount
Column(
modifier = modifier
.fillMaxWidth()
.animateContentSize(),
) {
SectionHeader(title = title)
Spacer(modifier = Modifier.height(12.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
visibleItems.forEach { item ->
val isSelected = item in selectedItems
SelectableFilterChip(
label = labelProvider(item),
isSelected = isSelected,
onClick = { onItemToggle(item) },
)
}
}
if (hasMoreItems) {
Spacer(modifier = Modifier.height(8.dp))
ShowMoreToggle(
isExpanded = isExpanded,
showMoreText = label_library_filter_show_more.resolve(context),
showLessText = label_library_filter_show_less.resolve(context),
onToggle = { isExpanded = !isExpanded },
)
}
}
}
@Composable
public fun SectionHeader(
title: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
HorizontalDivider(
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.outlineVariant,
)
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
HorizontalDivider(
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
}
@Composable
public fun SelectableFilterChip(
label: String,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
FilterChip(
modifier = modifier,
selected = isSelected,
onClick = onClick,
label = {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
)
},
shape = RoundedCornerShape(8.dp),
colors = FilterChipDefaults.filterChipColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
selectedContainerColor = MaterialTheme.colorScheme.secondary,
labelColor = MaterialTheme.colorScheme.onSurface,
selectedLabelColor = MaterialTheme.colorScheme.onSecondary,
),
border = FilterChipDefaults.filterChipBorder(
borderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
selectedBorderColor = Color.Transparent,
enabled = true,
selected = isSelected,
),
)
}
@Composable
internal fun ShowMoreToggle(
isExpanded: Boolean,
showMoreText: String,
showLessText: String,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.clickable { onToggle() }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = if (isExpanded) showLessText else showMoreText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
imageVector = if (isExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun FilterChipSectionPreview() {
FilterChipSection(
title = "GENRES",
items = persistentListOf(
"Action & Adventure",
"Animation",
"Comedy",
"Crime",
"Drama",
"Fantasy",
"Sci-Fi",
),
selectedItems = persistentSetOf("Drama", "Comedy"),
onItemToggle = {},
labelProvider = { it },
modifier = Modifier.padding(16.dp),
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun FilterChipSectionCollapsedPreview() {
FilterChipSection(
title = "STATUS",
items = persistentListOf(
"Returning Series",
"Planned",
"In Production",
"Ended",
"Canceled",
),
selectedItems = persistentSetOf("Returning Series"),
onItemToggle = {},
labelProvider = { it },
collapsedItemCount = 3,
modifier = Modifier.padding(16.dp),
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun SectionHeaderPreview() {
SectionHeader(
title = "SORT BY",
modifier = Modifier.padding(16.dp),
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun SelectableFilterChipPreview() {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(16.dp),
) {
SelectableFilterChip(
label = "Last watched ↓",
isSelected = true,
onClick = {},
)
SelectableFilterChip(
label = "Alphabetical",
isSelected = false,
onClick = {},
)
}
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/GradientScrim.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import android.annotation.SuppressLint
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import kotlin.math.pow
/**
* Draws a vertical gradient scrim in the foreground.
*
* @param color The color of the gradient scrim.
* @param decay The exponential decay to apply to the gradient. Defaults to `3.0f` which is
* a cubic decay.
* @param numStops The number of color stops to draw in the gradient. Higher numbers result in
* the higher visual quality at the cost of draw performance. Defaults to `16`.
*/
@SuppressLint("ComposeModifierComposed")
public fun Modifier.drawForegroundGradientScrim(
color: Color,
decay: Float = 3.0f,
numStops: Int = 16,
startY: Float = 0f,
endY: Float = 1f,
): Modifier = composed {
val colors = remember(color, numStops) {
val baseAlpha = color.alpha
List(numStops) { i ->
val x = i * 1f / (numStops - 1)
val opacity = x.pow(decay)
color.copy(alpha = baseAlpha * opacity)
}
}
drawWithContent {
drawContent()
drawRect(
topLeft = Offset(x = 0f, y = startY * size.height),
size = size.copy(height = (endY - startY) * size.height),
brush = Brush.verticalGradient(colors = colors),
)
}
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/Image.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import androidx.compose.ui.util.lerp
import androidx.compose.ui.viewinterop.AndroidView
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.load
import coil.request.ImageRequest
import com.flaviofaria.kenburnsview.KenBurnsView
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
import kotlin.math.absoluteValue
@Composable
public fun AsyncImageComposable(
model: Any?,
contentDescription: String?,
modifier: Modifier = Modifier,
shape: Shape = RectangleShape,
border: BorderStroke? = null,
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
onState: ((AsyncImagePainter.State) -> Unit)? = null,
requestBuilder: (ImageRequest.Builder.() -> ImageRequest.Builder)? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
) {
AsyncImage(
model = requestBuilder?.let { builder ->
when (model) {
is ImageRequest -> model.newBuilder()
else -> ImageRequest.Builder(LocalContext.current).data(model)
}
.builder()
.build()
} ?: model,
contentDescription = contentDescription,
modifier = modifier
.clip(shape)
.then(if (border != null) Modifier.border(border, shape) else Modifier),
transform = transform,
onState = onState,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
)
}
@Composable
public fun AvatarComponent(
imageUrl: String?,
size: Dp,
modifier: Modifier = Modifier,
contentDescription: String? = null,
border: BorderStroke? = null,
placeholderIcon: ImageVector = Icons.Outlined.Person,
onClick: (() -> Unit)? = null,
) {
val commonModifier = modifier
.size(size)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
if (imageUrl.isNullOrEmpty()) {
Box(
modifier = commonModifier
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = CircleShape,
)
.then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = placeholderIcon,
contentDescription = contentDescription,
modifier = Modifier.size(size * 0.6f),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
AsyncImageComposable(
model = imageUrl,
contentDescription = contentDescription,
modifier = commonModifier,
shape = CircleShape,
border = border,
contentScale = ContentScale.Crop,
)
}
}
@Composable
public fun KenBurnsViewImage(
imageUrl: String?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val kenBuns = remember { KenBurnsView(context) }
Box(modifier = modifier) {
PosterPlaceholder(modifier = Modifier.fillMaxSize())
AndroidView(
factory = { kenBuns },
modifier = Modifier.fillMaxSize(),
) { it.load(imageUrl) }
}
}
@Composable
public fun ParallaxCarouselImage(
state: PagerState,
currentPage: Int,
imageUrl: String?,
modifier: Modifier = Modifier,
shape: Shape = RectangleShape,
overlayContent: @Composable () -> Unit = {},
) {
val currentPageOffset = calculatePageOffset(state, currentPage)
val cardTranslationX = lerp(100f, 0f, 1f - currentPageOffset)
val cardScaleX = lerp(0.8f, 1f, 1f - currentPageOffset.absoluteValue.coerceIn(0f, 1f))
val density = LocalDensity.current
val screenWidth = with(density) {
LocalWindowInfo.current.containerSize.width.toDp()
}
val parallaxOffset = currentPageOffset * screenWidth * 2f
Box(
modifier = modifier
.fillMaxWidth()
.graphicsLayer {
scaleX = cardScaleX
translationX = cardTranslationX
},
) {
PosterPlaceholder(
modifier = Modifier
.fillMaxSize()
.clip(shape),
)
AsyncImageComposable(
modifier = Modifier
.fillMaxSize()
.clip(shape)
.graphicsLayer {
translationX = lerp(10f, 0f, 1f - currentPageOffset) + parallaxOffset.value
},
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
)
overlayContent()
}
}
private fun calculatePageOffset(state: PagerState, currentPage: Int): Float {
return (state.currentPage + state.currentPageOffsetFraction - currentPage).coerceIn(-1f, 1f)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun ParallaxCarouselImagePreview() {
val pagerState = rememberPagerState(pageCount = { 3 })
ParallaxCarouselImage(
state = pagerState,
currentPage = 0,
imageUrl = null,
modifier = Modifier
.fillMaxWidth()
.height(360.dp),
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun KenBurnsViewImagePreview() {
KenBurnsViewImage(
imageUrl = null,
modifier = Modifier
.fillMaxWidth()
.height(240.dp),
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun AvatarComponentPreview() {
AvatarComponent(
imageUrl = "https://image.png",
size = 64.dp,
modifier = Modifier.padding(16.dp),
border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary),
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/NavigationBar.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.VideoLibrary
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
import com.thomaskioko.tvmaniac.i18n.MR.strings.menu_item_discover
import com.thomaskioko.tvmaniac.i18n.MR.strings.menu_item_library
import com.thomaskioko.tvmaniac.i18n.MR.strings.menu_item_search
import com.thomaskioko.tvmaniac.i18n.MR.strings.menu_item_settings
import com.thomaskioko.tvmaniac.i18n.resolve
@Composable
public fun TvManiacNavigationBar(
modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit,
) {
NavigationBar(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
contentColor = NavigationDefaultColors.navigationContentColor(),
tonalElevation = 8.dp,
content = content,
)
}
@Composable
public fun RowScope.TvManiacBottomNavigationItem(
imageVector: ImageVector,
title: String,
selected: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
NavigationBarItem(
modifier = modifier,
icon = {
Icon(
imageVector = imageVector,
contentDescription = title,
)
},
label = { Text(title) },
selected = selected,
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
selectedIconColor = NavigationDefaultColors.navigationSelectedItemColor(),
unselectedIconColor = NavigationDefaultColors.navigationContentColor(),
selectedTextColor = NavigationDefaultColors.navigationSelectedItemColor(),
unselectedTextColor = NavigationDefaultColors.navigationContentColor(),
indicatorColor = Color.Transparent,
),
onClick = onClick,
)
}
public object NavigationDefaultColors {
@Composable
public fun navigationContentColor(): Color = MaterialTheme.colorScheme.onSurfaceVariant
@Composable
public fun navigationSelectedItemColor(): Color = MaterialTheme.colorScheme.secondary
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun TvManiacTvManiacNavigationBarPreviewPreview() {
TvManiacNavigationBar {
TvManiacBottomNavigationItem(
imageVector = Icons.Outlined.Movie,
title = menu_item_discover.resolve(LocalContext.current),
selected = true,
onClick = { },
)
TvManiacBottomNavigationItem(
imageVector = Icons.Outlined.Search,
title = menu_item_search.resolve(LocalContext.current),
selected = false,
onClick = { },
)
TvManiacBottomNavigationItem(
imageVector = Icons.Outlined.VideoLibrary,
title = menu_item_library.resolve(LocalContext.current),
selected = false,
onClick = { },
)
TvManiacBottomNavigationItem(
imageVector = Icons.Outlined.Settings,
title = menu_item_settings.resolve(LocalContext.current),
selected = false,
onClick = { },
)
}
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/NotificationRationaleContent.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
import com.thomaskioko.tvmaniac.i18n.MR.strings.notification_rationale_enable
import com.thomaskioko.tvmaniac.i18n.MR.strings.notification_rationale_message
import com.thomaskioko.tvmaniac.i18n.MR.strings.notification_rationale_not_now
import com.thomaskioko.tvmaniac.i18n.MR.strings.notification_rationale_title
import com.thomaskioko.tvmaniac.testtags.notifications.NotificationRationaleTestTags
import dev.icerock.moko.resources.compose.stringResource
@Composable
public fun NotificationRationaleContent(
onEnable: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.testTag(NotificationRationaleTestTags.BOTTOM_SHEET)
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.secondary,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(notification_rationale_title),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(notification_rationale_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
EpisodeDateSection()
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onEnable,
modifier = Modifier
.fillMaxWidth()
.testTag(NotificationRationaleTestTags.ENABLE_BUTTON),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary,
),
shape = MaterialTheme.shapes.small,
) {
Text(text = stringResource(notification_rationale_enable))
}
TextButton(
onClick = onDismiss,
modifier = Modifier
.fillMaxWidth()
.testTag(NotificationRationaleTestTags.DISMISS_BUTTON),
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.secondary,
),
) {
Text(text = stringResource(notification_rationale_not_now))
}
Spacer(modifier = Modifier.height(16.dp))
}
}
@Composable
private fun EpisodeDateSection() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth(),
) {
GradientDivider()
Spacer(modifier = Modifier.height(12.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
listOf(12, 13, 14).forEach { day ->
Text(
text = "$day",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.size(56.dp)
.border(
width = 2.dp,
color = MaterialTheme.colorScheme.secondary,
shape = RoundedCornerShape(8.dp),
),
) {
Text(
text = "15",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "FEB",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
listOf(16, 17, 18).forEach { day ->
Text(
text = "$day",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(12.dp))
GradientDivider()
}
}
@Composable
private fun GradientDivider() {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.height(1.dp)
.background(
Brush.horizontalGradient(
colors = listOf(
Color.Transparent,
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
Color.Transparent,
),
),
),
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun NotificationRationaleContentPreview() {
NotificationRationaleContent(
onEnable = {},
onDismiss = {},
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/PosterPlaceholder.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
@Composable
public fun PosterPlaceholder(
modifier: Modifier = Modifier,
imageSize: Dp = 52.dp,
title: String? = null,
) {
val brush = remember {
Brush.verticalGradient(
colors = listOf(
Color.Gray.copy(alpha = 0.8f),
Color.Gray,
),
)
}
ConstraintLayout(
modifier = modifier
.fillMaxSize()
.background(brush),
) {
val (icon, text) = createRefs()
Icon(
modifier = Modifier
.size(imageSize)
.constrainAs(icon) {
centerTo(parent)
},
imageVector = Icons.Outlined.Movie,
contentDescription = title,
tint = Color.White.copy(alpha = 0.8f),
)
title?.let {
Text(
text = it,
modifier = Modifier
.padding(horizontal = 4.dp)
.constrainAs(text) {
top.linkTo(icon.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
},
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.8f),
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun PosterPlaceholderPreview() {
PosterPlaceholder(
title = "Loki",
modifier = Modifier
.width(120.dp)
.aspectRatio(2 / 3f),
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun PosterPlaceholderNoTitlePreview() {
PosterPlaceholder(
modifier = Modifier
.width(120.dp)
.aspectRatio(2 / 3f),
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/ProgressIndicator.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
public fun LoadingIndicator(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.secondary,
strokeWidth: Dp = 4.dp,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = color,
strokeWidth = strokeWidth,
)
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun CircularProgressIndicatorPreview() {
CircularProgressIndicator()
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/ScanlineOverlay.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.domain.theme.Theme
public data class ScanlineConfiguration(
val enabled: Boolean,
val color: Color,
val lineHeight: Dp = 2.dp,
val opacity: Float = 0.15f,
) {
internal companion object {
internal val Disabled: ScanlineConfiguration =
ScanlineConfiguration(enabled = false, color = Color.Transparent)
internal fun terminal(): ScanlineConfiguration = ScanlineConfiguration(
enabled = true,
color = Color(0xFF20C020),
opacity = 0.12f,
)
internal fun amber(): ScanlineConfiguration = ScanlineConfiguration(
enabled = true,
color = Color(0xFFFF8C00),
opacity = 0.12f,
)
internal fun snow(): ScanlineConfiguration = ScanlineConfiguration(
enabled = true,
color = Color(0xFFFFFFFF),
opacity = 0.08f,
)
internal fun crimson(): ScanlineConfiguration = ScanlineConfiguration(
enabled = true,
color = Color(0xFFFF4D6A),
opacity = 0.12f,
)
}
}
internal fun Theme.toScanlineConfiguration(): ScanlineConfiguration = when (this) {
Theme.TERMINAL_THEME -> ScanlineConfiguration.terminal()
Theme.AMBER_THEME -> ScanlineConfiguration.amber()
Theme.SNOW_THEME -> ScanlineConfiguration.snow()
Theme.CRIMSON_THEME -> ScanlineConfiguration.crimson()
else -> ScanlineConfiguration.Disabled
}
@Composable
public fun ScanlineOverlay(
configuration: ScanlineConfiguration,
modifier: Modifier = Modifier,
) {
if (!configuration.enabled) return
val lineColor = configuration.color.copy(alpha = configuration.opacity)
Canvas(modifier = modifier.fillMaxSize()) {
val lineHeightPx = configuration.lineHeight.toPx()
val lineSpacing = lineHeightPx * 2
var y = 0f
while (y < size.height) {
drawRect(
color = lineColor,
topLeft = Offset(0f, y),
size = Size(size.width, lineHeightPx),
)
y += lineSpacing
}
}
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/SearchTextField.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
import com.thomaskioko.tvmaniac.i18n.MR.strings.cd_clear_text
import com.thomaskioko.tvmaniac.i18n.resolve
import kotlinx.coroutines.launch
@Composable
public fun SearchTextContainer(
query: String,
hint: String,
lazyListState: LazyListState,
onQueryChanged: (String) -> Unit,
onClearQuery: () -> Unit,
modifier: Modifier = Modifier,
textFieldModifier: Modifier = Modifier,
keyboardType: KeyboardType = KeyboardType.Text,
content: @Composable () -> Unit,
) {
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val coroutineScope = rememberCoroutineScope()
val textState = remember(query) {
mutableStateOf(TextFieldValue(query, TextRange(query.length)))
}
val hasFocus = remember { mutableStateOf(false) }
LaunchedEffect(lazyListState) {
snapshotFlow { lazyListState.isScrollInProgress }
.collect { isScrolling ->
if (isScrolling) {
keyboardController?.hide()
focusManager.clearFocus()
}
}
}
SearchTextFieldContent(
modifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
keyboardController?.hide()
focusManager.clearFocus()
},
)
},
textFieldModifier = textFieldModifier,
textFieldValue = textState.value,
hint = hint,
keyboardType = keyboardType,
onTextChanged = { newValue ->
textState.value = newValue
onQueryChanged(newValue.text)
},
onFocusChanged = { hasFocus.value = it },
onClearClick = {
textState.value = TextFieldValue()
onClearQuery()
keyboardController?.hide()
focusManager.clearFocus()
},
onSubmit = {
coroutineScope.launch {
onQueryChanged(textState.value.text)
keyboardController?.hide()
focusManager.clearFocus()
}
},
content = content,
)
}
@Composable
private fun SearchTextFieldContent(
textFieldValue: TextFieldValue,
hint: String,
keyboardType: KeyboardType,
onTextChanged: (TextFieldValue) -> Unit,
onFocusChanged: (Boolean) -> Unit,
onClearClick: () -> Unit,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
textFieldModifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Column(modifier = modifier) {
SearchTextField(
modifier = textFieldModifier
.padding(horizontal = 16.dp),
onFocusChanged = onFocusChanged,
textFieldValue = textFieldValue,
onTextChanged = onTextChanged,
hint = hint,
keyboardType = keyboardType,
onSubmit = onSubmit,
onClearClick = onClearClick,
)
content()
}
}
@Composable
private fun SearchTextField(
onFocusChanged: (Boolean) -> Unit,
textFieldValue: TextFieldValue,
onTextChanged: (TextFieldValue) -> Unit,
hint: String,
keyboardType: KeyboardType,
onSubmit: () -> Unit,
onClearClick: () -> Unit,
modifier: Modifier = Modifier,
shape: Shape = MaterialTheme.shapes.medium,
) {
OutlinedTextField(
modifier = modifier
.fillMaxWidth()
.onFocusChanged { onFocusChanged(it.isFocused) },
value = textFieldValue,
onValueChange = onTextChanged,
placeholder = {
Text(
text = hint,
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
),
)
},
singleLine = true,
maxLines = 1,
textStyle = MaterialTheme.typography.bodyMedium,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(
onSearch = { onSubmit() },
),
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
},
trailingIcon = {
IconButton(onClick = onClearClick) {
Icon(
imageVector = Icons.Filled.Clear,
contentDescription = cd_clear_text.resolve(LocalContext.current),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
}
},
shape = shape,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
unfocusedIndicatorColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
cursorColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
),
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun SearchTextFieldPreview() {
SearchTextContainer(
hint = "Enter Show Title",
query = "",
lazyListState = remember { LazyListState() },
onClearQuery = {},
onQueryChanged = {},
content = {},
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/SegmentedProgressBar.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
public fun SegmentedProgressBar(
segmentProgress: ImmutableList,
modifier: Modifier = Modifier,
height: Dp = 6.dp,
segmentGap: Dp = 4.dp,
trackColor: Color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f),
) {
if (segmentProgress.isEmpty()) return
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(segmentGap),
) {
segmentProgress.forEach { progress ->
ProgressSegment(
progress = progress,
modifier = Modifier.weight(1f),
height = height,
trackColor = trackColor,
)
}
}
}
@Composable
private fun ProgressSegment(
progress: Float,
modifier: Modifier = Modifier,
height: Dp = 6.dp,
trackColor: Color,
) {
val shape = RoundedCornerShape(height / 2)
val progressColor = MaterialTheme.colorScheme.secondary
Box(
modifier = modifier
.height(height)
.clip(shape)
.background(trackColor),
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction = progress.coerceIn(0f, 1f))
.height(height)
.clip(shape)
.background(progressColor),
)
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun SegmentedProgressBarPreview() {
SegmentedProgressBar(
segmentProgress = persistentListOf(1f, 0.5f, 0f),
modifier = Modifier.fillMaxWidth(),
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun SegmentedProgressBarSinglePreview() {
SegmentedProgressBar(
segmentProgress = persistentListOf(0.75f),
modifier = Modifier.fillMaxWidth(),
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/SheetDragHandle.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.KeyboardArrowDown
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
import com.thomaskioko.tvmaniac.i18n.MR.strings.cd_expand_collapse
import com.thomaskioko.tvmaniac.i18n.resolve
@Composable
public fun SheetDragHandle(
onClick: () -> Unit,
imageVector: ImageVector,
modifier: Modifier = Modifier,
title: String? = null,
textAlign: TextAlign? = null,
tint: Color = LocalContentColor.current,
) {
val context = LocalContext.current
Box(
modifier = modifier
.fillMaxWidth()
.statusBarsPadding()
.height(56.dp)
.background(Color.Transparent),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterStart)
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = imageVector,
tint = tint,
contentDescription = cd_expand_collapse.resolve(context),
modifier = Modifier
.size(24.dp)
.clickable { onClick() },
)
Spacer(modifier = Modifier.width(8.dp))
title?.let {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = textAlign,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun CustomSheetDragHandlePreview() {
SheetDragHandle(
title = "Drag Handle",
onClick = {},
imageVector = Icons.Outlined.KeyboardArrowDown,
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/ShowLinearProgressIndicator.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.theme.green
@Composable
public fun ShowLinearProgressIndicator(
progress: Float,
modifier: Modifier = Modifier,
) {
LinearProgressIndicator(
progress = { progress },
color = MaterialTheme.colorScheme.secondary,
trackColor = if (progress == 1f) {
green.copy(alpha = 0.5F)
} else {
MaterialTheme.colorScheme.secondary.copy(
alpha = 0.5F,
)
},
strokeCap = StrokeCap.Butt,
drawStopIndicator = {},
gapSize = 0.dp,
modifier = modifier,
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun ShowLinearProgressIndicatorPreview() {
ShowLinearProgressIndicator(progress = 0.6f)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/Snackbar.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
@Stable
public enum class SnackBarStyle(
internal val backgroundColor: Color,
internal val icon: ImageVector,
) {
Error(
backgroundColor = Color(0xFFE53935),
icon = Icons.Default.Cancel,
),
Warning(
backgroundColor = Color(0xFFFB8C00),
icon = Icons.Default.Warning,
),
Success(
backgroundColor = Color(0xFF43A047),
icon = Icons.Default.CheckCircle,
),
Info(
backgroundColor = Color(0xFF1E88E5),
icon = Icons.Default.Info,
),
}
@Composable
public fun TvManiacSnackBarHost(
message: String?,
modifier: Modifier = Modifier,
style: SnackBarStyle = SnackBarStyle.Error,
durationMillis: Long = 10000L,
alignment: Alignment = Alignment.TopCenter,
onDismiss: () -> Unit = {},
) {
var visible by remember { mutableStateOf(false) }
val currentOnDismiss by rememberUpdatedState(onDismiss)
val offsetX = remember { Animatable(0f) }
val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current
val dismissThreshold = remember(density) { with(density) { 56.dp.toPx() } }
val flingVelocityThreshold = remember(density) { with(density) { 500.dp.toPx() } }
fun dismiss() {
visible = false
currentOnDismiss()
}
LaunchedEffect(message) {
offsetX.snapTo(0f)
if (message != null) {
visible = true
delay(durationMillis)
dismiss()
} else {
visible = false
}
}
Box(
modifier = modifier
.fillMaxSize()
.statusBarsPadding(),
) {
AnimatedVisibility(
visible = visible,
modifier = Modifier.align(alignment),
enter = slideInVertically(
animationSpec = tween(durationMillis = 300),
initialOffsetY = { -it },
),
exit = slideOutVertically(
animationSpec = tween(durationMillis = 300),
targetOffsetY = { -it },
),
) {
TvManiacSnackBar(
modifier = Modifier
.padding(vertical = 16.dp)
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.alpha(
(1f - abs(offsetX.value) / (dismissThreshold * 3)).coerceIn(0f, 1f),
)
.pointerInput(Unit) {
val velocityTracker = VelocityTracker()
detectDragGestures(
onDragStart = { velocityTracker.resetTracking() },
onDragEnd = {
val velocity = velocityTracker.calculateVelocity()
val isFlingHorizontal = abs(velocity.x) > flingVelocityThreshold
if (abs(offsetX.value) > dismissThreshold || isFlingHorizontal) {
val targetX = if (offsetX.value > 0 || velocity.x > flingVelocityThreshold) {
2000f // Off-screen right
} else {
-2000f // Off-screen left
}
coroutineScope.launch {
offsetX.animateTo(targetX, tween(200))
dismiss()
}
} else {
coroutineScope.launch { offsetX.animateTo(0f) }
}
},
onDragCancel = {
velocityTracker.resetTracking()
coroutineScope.launch { offsetX.animateTo(0f) }
},
onDrag = { change, dragAmount ->
change.consume()
velocityTracker.addPosition(
change.uptimeMillis,
change.position,
)
coroutineScope.launch { offsetX.snapTo(offsetX.value + dragAmount.x) }
},
)
},
message = message.orEmpty(),
style = style,
)
}
}
}
@Composable
internal fun TvManiacSnackBar(
message: String,
modifier: Modifier = Modifier,
style: SnackBarStyle = SnackBarStyle.Error,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(MaterialTheme.shapes.large)
.background(style.backgroundColor)
.padding(16.dp)
.testTag("tvmaniac_snackbar"),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = style.icon,
contentDescription = null,
tint = Color.White,
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = Color.White,
maxLines = 3,
)
}
}
@Composable
public fun StandardSnackBar(
snackBarHostState: SnackbarHostState,
errorMessage: String?,
actionLabel: String?,
showError: Boolean = !errorMessage.isNullOrBlank(),
onErrorAction: () -> Unit = {},
) {
AnimatedVisibility(
visible = showError,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
) {
errorMessage?.let {
LaunchedEffect(errorMessage) {
val actionResult = snackBarHostState.showSnackbar(
message = errorMessage,
actionLabel = actionLabel,
duration = SnackbarDuration.Long,
)
when (actionResult) {
SnackbarResult.ActionPerformed -> onErrorAction()
SnackbarResult.Dismissed -> onErrorAction()
}
}
}
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun TvManiacSnackBarPreview(
@PreviewParameter(SnackBarPreviewParameterProvider::class) param: SnackBarPreviewParam,
) {
TvManiacSnackBar(
message = param.message,
style = param.style,
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun StandardSnackBarPreview() {
StandardSnackBar(
snackBarHostState = SnackbarHostState(),
errorMessage = "Somethig went wrong",
actionLabel = "Retry",
)
}
internal data class SnackBarPreviewParam(
val message: String,
val style: SnackBarStyle,
)
private class SnackBarPreviewParameterProvider : PreviewParameterProvider {
override val values: Sequence = sequenceOf(
SnackBarPreviewParam(
message = "Something went wrong while syncing your data. Check your internet connection. If the problem persists, contact us.",
style = SnackBarStyle.Error,
),
SnackBarPreviewParam(
message = "Your session is about to expire.",
style = SnackBarStyle.Warning,
),
SnackBarPreviewParam(
message = "Changes saved successfully.",
style = SnackBarStyle.Success,
),
SnackBarPreviewParam(
message = "Your data has been synced successfully.",
style = SnackBarStyle.Info,
),
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/Text.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontWeight.Companion.Bold
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
@Composable
public fun BoxTextItems(
title: String,
modifier: Modifier = Modifier,
subtitle: String? = null,
label: String? = null,
onMoreClicked: () -> Unit = {},
moreModifier: Modifier = Modifier,
) {
Box(
modifier = modifier,
) {
Column(
modifier = Modifier.align(Alignment.CenterStart),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
fontWeight = Bold,
),
)
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
}
label?.let {
Text(
text = label,
modifier = moreModifier
.align(Alignment.CenterEnd)
.clickable { onMoreClicked() }
.padding(16.dp),
style = MaterialTheme.typography.labelMedium.copy(
color = MaterialTheme.colorScheme.secondary,
),
)
}
}
}
@Composable
public fun TextLoadingItem(
title: String,
modifier: Modifier = Modifier,
subTitle: String? = null,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp),
) {
Column(
modifier = Modifier.align(Alignment.CenterStart),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
fontWeight = Bold,
),
)
subTitle?.let {
Text(
text = subTitle,
modifier = Modifier.padding(vertical = 2.dp),
style = MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.onSurface,
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
),
)
}
}
}
content()
}
@Composable
public fun ExpandingText(
text: String,
modifier: Modifier = Modifier,
fontWeight: FontWeight = FontWeight.Normal,
textStyle: TextStyle = MaterialTheme.typography.bodyMedium,
expandable: Boolean = true,
collapsedMaxLines: Int = 4,
expandedMaxLines: Int = Int.MAX_VALUE,
color: Color = MaterialTheme.colorScheme.onSurface,
) {
var canTextExpand by remember(text) { mutableStateOf(true) }
var expanded by remember { mutableStateOf(false) }
Text(
text = text,
style = textStyle,
fontWeight = fontWeight,
overflow = TextOverflow.Ellipsis,
color = color,
maxLines = if (expanded) expandedMaxLines else collapsedMaxLines,
modifier = modifier.clickable(
enabled = expandable && canTextExpand,
onClick = { expanded = !expanded },
),
onTextLayout = {
if (!expanded) {
canTextExpand = it.hasVisualOverflow
}
},
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun ExpandingTextPreview() {
ExpandingText(
text = "After stealing the Tesseract during the events of “Avengers: Endgame,” " +
"an alternate version of Loki is brought to the mysterious Time Variance " +
"Authority, a bureaucratic organization that exists outside of time and " +
"space and monitors the timeline. They give Loki a choice: face being " +
"erased from existence due to being a “time variant”or help fix " +
"the timeline and stop a greater threat.",
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun BoxTextItemsPreview() {
BoxTextItems(
modifier = Modifier.fillMaxWidth(),
title = "Being Watched",
label = "More",
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun TextLoadingItemPreview() {
TextLoadingItem(
title = "Seasons",
content = {},
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/TextTitlePill.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
@Composable
public fun TextTitlePill(
showName: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
titleStyle: TextStyle = MaterialTheme.typography.titleSmall,
) {
Surface(
modifier = modifier.clickable { onClick() },
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface),
) {
Row(
modifier = Modifier.padding(start = 8.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = showName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = titleStyle,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f, fill = false),
)
Icon(
modifier = Modifier.size(16.dp),
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun TextTitlePillPreview() {
TextTitlePill(
showName = "The Walking Dead: Daryl Dixon",
onClick = {},
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/TopBar.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import android.annotation.SuppressLint
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import com.thomaskioko.tvmaniac.compose.components.ThemePreviews
import com.thomaskioko.tvmaniac.compose.components.TvManiacPreviewWrapperProvider
import com.thomaskioko.tvmaniac.compose.extensions.iconButtonBackgroundScrim
import kotlin.math.roundToInt
@Composable
public fun TvManiacTopBar(
modifier: Modifier = Modifier,
elevation: Dp = 0.dp,
colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(),
scrollBehavior: TopAppBarScrollBehavior? = null,
title: @Composable () -> Unit = {},
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
) {
TopAppBar(
modifier = modifier.shadow(elevation = elevation),
scrollBehavior = scrollBehavior,
title = title,
navigationIcon = navigationIcon,
colors = colors,
actions = actions,
)
}
@Composable
public fun RefreshCollapsableTopAppBar(
listState: LazyListState,
modifier: Modifier = Modifier,
isRefreshing: Boolean = false,
scrollBehavior: TopAppBarScrollBehavior? = null,
title: @Composable () -> Unit = {},
navigationIcon: @Composable (() -> Unit)? = null,
actionIcon: @Composable (() -> Unit)? = null,
onNavIconClicked: () -> Unit = {},
onActionIconClicked: () -> Unit = {},
navIconModifier: Modifier = Modifier,
) {
var appBarHeight by remember { mutableIntStateOf(0) }
val showAppBarBackground by remember {
derivedStateOf {
val visibleItemsInfo = listState.layoutInfo.visibleItemsInfo
when {
visibleItemsInfo.isEmpty() -> false
appBarHeight <= 0 -> false
else -> {
val firstVisibleItem = visibleItemsInfo[0]
when {
firstVisibleItem.index > 0 -> true
else -> firstVisibleItem.size + firstVisibleItem.offset - 5 <= appBarHeight
}
}
}
}
}
RefreshCollapsableTopAppBar(
modifier = modifier
.fillMaxWidth()
.onSizeChanged { appBarHeight = it.height },
title = title,
navigationIcon = navigationIcon,
actionIcon = actionIcon,
showAppBarBackground = showAppBarBackground,
scrollBehavior = scrollBehavior,
onActionClicked = onActionIconClicked,
onNavIconPressed = onNavIconClicked,
isRefreshing = isRefreshing,
navIconModifier = navIconModifier,
)
}
@Composable
public fun RefreshCollapsableTopAppBar(
listState: LazyListState,
modifier: Modifier = Modifier,
scrollBehavior: TopAppBarScrollBehavior? = null,
title: @Composable () -> Unit = {},
navigationIcon: @Composable (() -> Unit)? = null,
actions: @Composable RowScope.(Boolean) -> Unit = {},
) {
var appBarHeight by remember { mutableIntStateOf(0) }
val showAppBarBackground by remember {
derivedStateOf {
val visibleItemsInfo = listState.layoutInfo.visibleItemsInfo
when {
visibleItemsInfo.isEmpty() -> false
appBarHeight <= 0 -> false
else -> {
val firstVisibleItem = visibleItemsInfo[0]
when {
firstVisibleItem.index > 0 -> true
else -> firstVisibleItem.size + firstVisibleItem.offset - 5 <= appBarHeight
}
}
}
}
}
RefreshCollapsableTopAppBar(
modifier = modifier
.fillMaxWidth()
.onSizeChanged { appBarHeight = it.height },
scrollBehavior = scrollBehavior,
title = title,
navigationIcon = navigationIcon,
showAppBarBackground = showAppBarBackground,
actions = { actions(showAppBarBackground) },
)
}
@Composable
internal fun RefreshCollapsableTopAppBar(
showAppBarBackground: Boolean,
scrollBehavior: TopAppBarScrollBehavior?,
title: @Composable () -> Unit,
navigationIcon: @Composable (() -> Unit)?,
actions: @Composable RowScope.(Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val backgroundColor by animateColorAsState(
targetValue = when {
showAppBarBackground -> MaterialTheme.colorScheme.surface
else -> Color.Transparent
},
animationSpec = spring(),
label = "backgroundColorAnimation",
)
val elevation by animateDpAsState(
targetValue = when {
showAppBarBackground -> 4.dp
else -> 0.dp
},
animationSpec = spring(),
label = "elevationAnimation",
)
TopAppBar(
title = {
Crossfade(
targetState = showAppBarBackground,
label = "titleAnimation",
) { show ->
if (show) title()
}
},
navigationIcon = navigationIcon ?: {},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = backgroundColor,
),
actions = { actions(showAppBarBackground) },
modifier = modifier.shadow(elevation = elevation),
scrollBehavior = scrollBehavior,
)
}
@Composable
internal fun RefreshCollapsableTopAppBar(
showAppBarBackground: Boolean,
isRefreshing: Boolean,
scrollBehavior: TopAppBarScrollBehavior?,
onActionClicked: () -> Unit,
title: @Composable () -> Unit,
navigationIcon: @Composable (() -> Unit)?,
actionIcon: @Composable (() -> Unit)?,
onNavIconPressed: () -> Unit,
modifier: Modifier = Modifier,
navIconModifier: Modifier = Modifier,
) {
val backgroundColor by animateColorAsState(
targetValue = when {
showAppBarBackground -> MaterialTheme.colorScheme.surface
else -> Color.Transparent
},
animationSpec = spring(),
label = "backgroundColorAnimation",
)
val elevation by animateDpAsState(
targetValue = when {
showAppBarBackground -> 4.dp
else -> 0.dp
},
animationSpec = spring(),
label = "elevationAnimation",
)
TopAppBar(
modifier = modifier.shadow(elevation = elevation),
title = {
Crossfade(
targetState = showAppBarBackground,
label = "titleAnimation",
) { show ->
if (show) title()
}
},
navigationIcon = {
if (navigationIcon != null) {
ScrimButton(
show = showAppBarBackground,
onClick = onNavIconPressed,
modifier = navIconModifier,
) {
navigationIcon()
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = backgroundColor,
),
actions = {
if (isRefreshing || actionIcon != null) {
ScrimButton(
show = showAppBarBackground,
onClick = onActionClicked,
) {
RefreshButton(
modifier = Modifier
.size(20.dp)
.padding(2.dp),
isRefreshing = isRefreshing,
content = actionIcon ?: {},
)
}
}
},
scrollBehavior = scrollBehavior,
)
}
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
internal fun AutoSizedCircularProgressIndicator(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.onBackground,
) {
BoxWithConstraints(modifier) {
val diameter = with(LocalDensity.current) {
// We need to minus the padding added within CircularProgressIndicator
(min(constraints.maxWidth.toDp(), constraints.maxHeight.toDp()) - 4.dp)
.coerceAtLeast(0.dp)
}
CircularProgressIndicator(
strokeWidth = (diameter.value * (4.dp / 40.dp)).roundToInt().dp.coerceAtLeast(2.dp),
color = color,
)
}
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun TopBarPreview() {
TvManiacTopBar(
title = {
Text(
text = "Tv Maniac",
style = MaterialTheme.typography.titleSmall.copy(
color = MaterialTheme.colorScheme.onSurface,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
},
)
}
public inline fun actionIconWhen(
visible: Boolean,
crossinline content: @Composable () -> Unit,
): (@Composable () -> Unit)? = if (visible) {
{ content() }
} else {
null
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun TopBarActionPreview() {
TvManiacTopBar(
title = {
Text(
text = "Tv Maniac",
style = MaterialTheme.typography.titleSmall.copy(
color = MaterialTheme.colorScheme.onSurface,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
},
actions = {
IconButton(
onClick = {},
) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
}
},
)
}
@ThemePreviews
@PreviewWrapper(TvManiacPreviewWrapperProvider::class)
@Composable
private fun TopBarScrimPreview() {
TvManiacTopBar(
title = {
Text(
text = "Tv Maniac",
style = MaterialTheme.typography.titleSmall.copy(
color = MaterialTheme.colorScheme.onSurface,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
},
navigationIcon = {
Image(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.onSurface),
modifier = Modifier
.clickable(onClick = {})
.padding(16.dp),
)
},
modifier = Modifier.iconButtonBackgroundScrim(enabled = true, alpha = 0.4f),
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/TvManiacBottomSheet.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetValue
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
public fun TvManiacBottomSheetScaffold(
sheetContent: @Composable ColumnScope.() -> Unit,
content: @Composable (PaddingValues) -> Unit,
onDismissBottomSheet: () -> Unit,
modifier: Modifier = Modifier,
topBar: @Composable (() -> Unit)? = null,
sheetPeekHeight: Dp = 0.dp,
showBottomSheet: Boolean = false,
skipHiddenState: Boolean = false,
sheetShadowElevation: Dp = 0.dp,
initialSheetState: SheetValue = SheetValue.Hidden,
sheetShape: Shape = RoundedCornerShape(5.dp),
containerColor: Color = MaterialTheme.colorScheme.background,
sheetContainerColor: Color = MaterialTheme.colorScheme.background,
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
) {
val bottomSheetState = rememberStandardBottomSheetState(
initialValue = initialSheetState,
skipHiddenState = skipHiddenState,
)
val bottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = bottomSheetState,
)
var isSheetContentComposing by remember { mutableStateOf(initialSheetState != SheetValue.Hidden) }
LaunchedEffect(key1 = showBottomSheet) {
if (showBottomSheet) {
isSheetContentComposing = true
bottomSheetState.expand()
} else {
bottomSheetState.hide()
isSheetContentComposing = false
}
}
LaunchedEffect(bottomSheetScaffoldState.bottomSheetState.currentValue) {
if (bottomSheetScaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded) {
onDismissBottomSheet()
}
}
BottomSheetScaffold(
modifier = modifier,
topBar = topBar,
sheetPeekHeight = sheetPeekHeight,
scaffoldState = bottomSheetScaffoldState,
sheetShape = sheetShape,
sheetShadowElevation = sheetShadowElevation,
sheetContent = {
if (isSheetContentComposing) {
sheetContent()
}
},
sheetContainerColor = sheetContainerColor,
snackbarHost = snackbarHost,
containerColor = containerColor,
sheetDragHandle = {
if (isSheetContentComposing) {
sheetDragHandle?.invoke()
}
},
content = content,
)
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/components/TvManiacPreviewWrapperProvider.kt
================================================
package com.thomaskioko.tvmaniac.compose.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewWrapperProvider
import com.thomaskioko.tvmaniac.compose.theme.TvManiacTheme
/**
* A [PreviewWrapperProvider] that provides the [TvManiacTheme] for previews.
*/
public class TvManiacPreviewWrapperProvider : PreviewWrapperProvider {
@Composable
override fun Wrap(content: @Composable () -> Unit) {
TvManiacTheme {
TvManiacBackground {
content()
}
}
}
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/extensions/GradientExtensions.kt
================================================
package com.thomaskioko.tvmaniac.compose.extensions
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@Composable
public fun contentBackgroundGradient(): Brush {
val background = MaterialTheme.colorScheme.background
return remember(background) {
Brush.verticalGradient(
listOf(
Color.Transparent,
Color.Transparent,
Color.Transparent,
background.copy(alpha = 0.6F),
background.copy(alpha = 0.8F),
background,
),
)
}
}
@Composable
public fun backgroundGradient(): List {
val background = MaterialTheme.colorScheme.background
return remember(background) {
listOf(
background,
background.copy(alpha = 0.9F),
background.copy(alpha = 0.8F),
background.copy(alpha = 0.7F),
Color.Transparent,
)
}
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/extensions/LazyListExtensions.kt
================================================
package com.thomaskioko.tvmaniac.compose.extensions
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Calculates the scroll offset needed to make the next item in a lazy list partially visible.
*
* This function is useful for creating a "peek" effect where the next or previous item is
* slightly visible to the user, indicating that there is more content to scroll to.
*
* @param itemWidth The width of a single item in the lazy list.
* @param itemSpacing The spacing between items in the lazy list. Defaults to 0.dp.
* @param visibleFraction The fraction of the next item that should be visible.
* A value of 0.1f means 10% of the next item will be visible. Defaults to 0.1f.
* @return The calculated scroll offset in pixels. This value can be used with `LazyListState.scrollToItem()`
* to position the list correctly.
*/
@Composable
public fun calculateScrollOffset(
itemWidth: Dp,
itemSpacing: Dp = 0.dp,
visibleFraction: Float = 0.1f,
): Int {
val density = LocalDensity.current
val totalItemWidth = with(density) { (itemWidth + itemSpacing).roundToPx() }
return (totalItemWidth * (1f - visibleFraction)).toInt()
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/extensions/PaddingValuesExtentions.kt
================================================
package com.thomaskioko.tvmaniac.compose.extensions
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@Composable
public fun PaddingValues.copy(
copyStart: Boolean = true,
copyTop: Boolean = true,
copyEnd: Boolean = true,
copyBottom: Boolean = true,
): PaddingValues {
return remember(this) {
derivedStateOf {
PaddingValues(
start = if (copyStart) calculateStartPadding(LayoutDirection.Ltr) else 0.dp,
top = if (copyTop) calculateTopPadding() else 0.dp,
end = if (copyEnd) calculateEndPadding(LayoutDirection.Ltr) else 0.dp,
bottom = if (copyBottom) calculateBottomPadding() else 0.dp,
)
}
}
.value
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/extensions/ScrimExtentions.kt
================================================
package com.thomaskioko.tvmaniac.compose.extensions
import android.annotation.SuppressLint
import androidx.annotation.FloatRange
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
import kotlin.math.pow
/**
* Draws a vertical gradient scrim in the foreground.
*
* @param color The color of the gradient scrim.
* @param startYPercentage The start y value, in percentage of the layout's height (0f to 1f)
* @param endYPercentage The end y value, in percentage of the layout's height (0f to 1f)
* @param decay The exponential decay to apply to the gradient. Defaults to `1.0f` which is a linear
* gradient.
* @param numStops The number of color stops to draw in the gradient. Higher numbers result in the
* higher visual quality at the cost of draw performance. Defaults to `16`.
*/
@SuppressLint("ComposeModifierComposed")
public fun Modifier.verticalGradientScrim(
color: Color,
@FloatRange(from = 0.0, to = 1.0) startYPercentage: Float = 0f,
@FloatRange(from = 0.0, to = 1.0) endYPercentage: Float = 1f,
decay: Float = 1.0f,
numStops: Int = 16,
): Modifier = composed {
val colors = remember(color, numStops) {
if (decay != 1f) {
// If we have a non-linear decay, we need to create the color gradient steps
// manually
val baseAlpha = color.alpha
List(numStops) { i ->
val x = i * 1f / (numStops - 1)
val opacity = x.pow(decay)
color.copy(alpha = baseAlpha * opacity)
}
} else {
// If we have a linear decay, we just create a simple list of start + end colors
listOf(color.copy(alpha = 0f), color)
}
}
var height by remember { mutableFloatStateOf(0f) }
val brush = remember(color, numStops, startYPercentage, endYPercentage, height) {
Brush.verticalGradient(
colors = colors,
startY = height * startYPercentage,
endY = height * endYPercentage,
)
}
drawBehind {
height = size.height
drawRect(brush = brush)
}
}
@SuppressLint("ComposeModifierComposed")
internal fun Modifier.iconButtonBackgroundScrim(
enabled: Boolean,
shape: Shape = CircleShape,
@FloatRange(from = 0.0, to = 1.0) alpha: Float,
): Modifier = composed {
if (enabled) {
Modifier
.padding(horizontal = 8.dp)
.background(
color = MaterialTheme.colorScheme.background.copy(alpha = alpha),
shape = shape,
)
} else {
this.padding(horizontal = 4.dp)
}
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/theme/Background.kt
================================================
package com.thomaskioko.tvmaniac.compose.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
/** A class to model background color and tonal elevation values for Now in Android. */
@Immutable
internal data class BackgroundTheme(
val color: Color = Color.Unspecified,
val tonalElevation: Dp = Dp.Unspecified,
)
/** A composition local for [BackgroundTheme]. */
internal val LocalBackgroundTheme = staticCompositionLocalOf { BackgroundTheme() }
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/theme/Colors.kt
================================================
package com.thomaskioko.tvmaniac.compose.theme
import androidx.compose.ui.graphics.Color
public val green: Color = Color(0xFF00b300)
public val grey: Color = Color(0xFF808080)
public val md_theme_light_primary: Color = Color(0xFF0049c7)
public val md_theme_light_primaryContainer: Color = Color(0xFFdbe8f8)
public val md_theme_light_onPrimary: Color = Color(0xFFFFFFFF)
public val md_theme_light_secondary: Color = Color(0xFF3947EA)
public val md_theme_light_onSecondary: Color = Color(0xFFFFFFFF)
public val md_theme_light_error: Color = Color(0xFFBA1A1A)
public val md_theme_light_background: Color = Color(0xFFF8FDFF)
public val md_theme_light_onBackground: Color = Color(0xFF001F25)
public val md_theme_light_surface: Color = Color(0xFFe6f1fa)
public val md_theme_light_onSurface: Color = Color(0xFF1F2123)
public val md_theme_light_outline: Color = Color(0xFF1646F7)
public val md_theme_dark_primary: Color = Color(0xFF1F2123)
public val md_theme_dark_primaryContainer: Color = Color(0xFF1F2123)
public val md_theme_dark_onPrimary: Color = Color(0xFFE0E0FF)
public val md_theme_dark_secondary: Color = Color(0xFFF7d633)
public val md_theme_dark_onSecondary: Color = Color(0xFFFFFFFF)
public val md_theme_dark_error: Color = Color(0xFFBA1A1A)
public val md_theme_dark_background: Color = Color(0xFF373737)
public val md_theme_dark_onBackground: Color = Color(0xFFE0E0FF)
public val md_theme_dark_surface: Color = Color(0xFF43474c)
public val md_theme_dark_onSurface: Color = Color(0xFFF8FDFF)
public val md_theme_dark_outline: Color = Color(0xFF1F2123)
public val md_theme_terminal_primary: Color = Color(0xFF0A0A0A)
public val md_theme_terminal_primaryContainer: Color = Color(0xFF0A0A0A)
public val md_theme_terminal_onPrimary: Color = Color(0xFFE0E0FF)
public val md_theme_terminal_secondary: Color = Color(0xFF20C020)
public val md_theme_terminal_onSecondary: Color = Color(0xFFFFFFFF)
public val md_theme_terminal_error: Color = Color(0xFFCF6679)
public val md_theme_terminal_background: Color = Color(0xFF000000)
public val md_theme_terminal_onBackground: Color = Color(0xFFE0E0FF)
public val md_theme_terminal_surface: Color = Color(0xFF121212)
public val md_theme_terminal_onSurface: Color = Color(0xFFF8FDFF)
public val md_theme_terminal_surfaceVariant: Color = Color(0xFF1E1E1E)
public val md_theme_terminal_onSurfaceVariant: Color = Color(0xFFB0B0B0)
public val md_theme_terminal_outline: Color = Color(0xFF2C2C2C)
public val md_theme_autumn_primary: Color = Color(0xFF8B4513)
public val md_theme_autumn_primaryContainer: Color = Color(0xFFF5E6D3)
public val md_theme_autumn_onPrimary: Color = Color(0xFFFFFAF0)
public val md_theme_autumn_secondary: Color = Color(0xFFCD853F)
public val md_theme_autumn_onSecondary: Color = Color(0xFFFFFFFF)
public val md_theme_autumn_error: Color = Color(0xFF8B0000)
public val md_theme_autumn_background: Color = Color(0xFFFAF0E6)
public val md_theme_autumn_onBackground: Color = Color(0xFF3E2723)
public val md_theme_autumn_surface: Color = Color(0xFFFFF8DC)
public val md_theme_autumn_onSurface: Color = Color(0xFF3E2723)
public val md_theme_autumn_surfaceVariant: Color = Color(0xFFEFEBE9)
public val md_theme_autumn_onSurfaceVariant: Color = Color(0xFF5D4037)
public val md_theme_autumn_outline: Color = Color(0xFFA1887F)
public val md_theme_aqua_primary: Color = Color(0xFF19232B)
public val md_theme_aqua_primaryContainer: Color = Color(0xFF1B2933)
public val md_theme_aqua_onPrimary: Color = Color(0xFFFFFFFF)
public val md_theme_aqua_secondary: Color = Color(0xFF3FD2E6)
public val md_theme_aqua_onSecondary: Color = Color(0xFFFFFFFF)
public val md_theme_aqua_error: Color = Color(0xFFF84F44)
public val md_theme_aqua_background: Color = Color(0xFF161A20)
public val md_theme_aqua_onBackground: Color = Color(0xFFFFFFFF)
public val md_theme_aqua_surface: Color = Color(0xFF19232B)
public val md_theme_aqua_onSurface: Color = Color(0xFFFFFFFF)
public val md_theme_aqua_surfaceVariant: Color = Color(0xFF212835)
public val md_theme_aqua_onSurfaceVariant: Color = Color(0xFF8A9BAA)
public val md_theme_aqua_outline: Color = Color(0xFF1E3C4A)
public val md_theme_amber_primary: Color = Color(0xFF261A0A)
public val md_theme_amber_primaryContainer: Color = Color(0xFF261A0A)
public val md_theme_amber_onPrimary: Color = Color(0xFFFF8C00)
public val md_theme_amber_secondary: Color = Color(0xFFFF9500)
public val md_theme_amber_onSecondary: Color = Color(0xFF1A1005)
public val md_theme_amber_error: Color = Color(0xFFFF6B35)
public val md_theme_amber_background: Color = Color(0xFF1A1005)
public val md_theme_amber_onBackground: Color = Color(0xFFFF8C00)
public val md_theme_amber_surface: Color = Color(0xFF261A0A)
public val md_theme_amber_onSurface: Color = Color(0xFFFF8C00)
public val md_theme_amber_surfaceVariant: Color = Color(0xFF33220D)
public val md_theme_amber_onSurfaceVariant: Color = Color(0xFFCC7000)
public val md_theme_amber_outline: Color = Color(0xFF995500)
public val md_theme_snow_primary: Color = Color(0xFF1A1A1A)
public val md_theme_snow_primaryContainer: Color = Color(0xFF1A1A1A)
public val md_theme_snow_onPrimary: Color = Color(0xFFF0F0F0)
public val md_theme_snow_secondary: Color = Color(0xFFC8C8CC)
public val md_theme_snow_onSecondary: Color = Color(0xFF0A0A0A)
public val md_theme_snow_error: Color = Color(0xFFBA1A1A)
public val md_theme_snow_background: Color = Color(0xFF0A0A0A)
public val md_theme_snow_onBackground: Color = Color(0xFFF0F0F0)
public val md_theme_snow_surface: Color = Color(0xFF1A1A1A)
public val md_theme_snow_onSurface: Color = Color(0xFFF0F0F0)
public val md_theme_snow_surfaceVariant: Color = Color(0xFF2A2A2A)
public val md_theme_snow_onSurfaceVariant: Color = Color(0xFFA0A0A0)
public val md_theme_snow_outline: Color = Color(0xFF606060)
public val md_theme_crimson_primary: Color = Color(0xFF2A1519)
public val md_theme_crimson_primaryContainer: Color = Color(0xFF2A1519)
public val md_theme_crimson_onPrimary: Color = Color(0xFFFF4D6A)
public val md_theme_crimson_secondary: Color = Color(0xFFFF6B8A)
public val md_theme_crimson_onSecondary: Color = Color(0xFF150A0D)
public val md_theme_crimson_error: Color = Color(0xFFFF3333)
public val md_theme_crimson_background: Color = Color(0xFF150A0D)
public val md_theme_crimson_onBackground: Color = Color(0xFFFF4D6A)
public val md_theme_crimson_surface: Color = Color(0xFF2A1519)
public val md_theme_crimson_onSurface: Color = Color(0xFFFF4D6A)
public val md_theme_crimson_surfaceVariant: Color = Color(0xFF3D1F25)
public val md_theme_crimson_onSurfaceVariant: Color = Color(0xFFB03050)
public val md_theme_crimson_outline: Color = Color(0xFF802040)
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/theme/Shape.kt
================================================
package com.thomaskioko.tvmaniac.compose.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
internal val tvManiacShapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(8.dp),
large = RoundedCornerShape(16.dp),
)
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/theme/Theme.kt
================================================
package com.thomaskioko.tvmaniac.compose.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.unit.dp
import com.thomaskioko.tvmaniac.compose.components.ScanlineOverlay
import com.thomaskioko.tvmaniac.compose.components.toScanlineConfiguration
import com.thomaskioko.tvmaniac.domain.theme.Theme
public val LightColorScheme: ColorScheme = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
error = md_theme_light_error,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
outline = md_theme_light_outline,
)
public val DarkColorScheme: ColorScheme = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
error = md_theme_dark_error,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
outline = md_theme_dark_outline,
)
public val TerminalColorScheme: ColorScheme = darkColorScheme(
primary = md_theme_terminal_primary,
onPrimary = md_theme_terminal_onPrimary,
primaryContainer = md_theme_terminal_primaryContainer,
secondary = md_theme_terminal_secondary,
onSecondary = md_theme_terminal_onSecondary,
error = md_theme_terminal_error,
background = md_theme_terminal_background,
onBackground = md_theme_terminal_onBackground,
surface = md_theme_terminal_surface,
onSurface = md_theme_terminal_onSurface,
surfaceVariant = md_theme_terminal_surfaceVariant,
outline = md_theme_terminal_outline,
)
public val AutumnColorScheme: ColorScheme = lightColorScheme(
primary = md_theme_autumn_primary,
onPrimary = md_theme_autumn_onPrimary,
primaryContainer = md_theme_autumn_primaryContainer,
secondary = md_theme_autumn_secondary,
onSecondary = md_theme_autumn_onSecondary,
error = md_theme_autumn_error,
background = md_theme_autumn_background,
onBackground = md_theme_autumn_onBackground,
surface = md_theme_autumn_surface,
onSurface = md_theme_autumn_onSurface,
outline = md_theme_autumn_outline,
)
public val AquaColorScheme: ColorScheme = darkColorScheme(
primary = md_theme_aqua_primary,
onPrimary = md_theme_aqua_onPrimary,
primaryContainer = md_theme_aqua_primaryContainer,
secondary = md_theme_aqua_secondary,
onSecondary = md_theme_aqua_onSecondary,
error = md_theme_aqua_error,
background = md_theme_aqua_background,
onBackground = md_theme_aqua_onBackground,
surface = md_theme_aqua_surface,
onSurface = md_theme_aqua_onSurface,
surfaceVariant = md_theme_aqua_surfaceVariant,
onSurfaceVariant = md_theme_aqua_onSurfaceVariant,
outline = md_theme_aqua_outline,
)
public val AmberColorScheme: ColorScheme = darkColorScheme(
primary = md_theme_amber_primary,
onPrimary = md_theme_amber_onPrimary,
primaryContainer = md_theme_amber_primaryContainer,
secondary = md_theme_amber_secondary,
onSecondary = md_theme_amber_onSecondary,
error = md_theme_amber_error,
background = md_theme_amber_background,
onBackground = md_theme_amber_onBackground,
surface = md_theme_amber_surface,
onSurface = md_theme_amber_onSurface,
surfaceVariant = md_theme_amber_surfaceVariant,
onSurfaceVariant = md_theme_amber_onSurfaceVariant,
outline = md_theme_amber_outline,
)
public val SnowColorScheme: ColorScheme = darkColorScheme(
primary = md_theme_snow_primary,
onPrimary = md_theme_snow_onPrimary,
primaryContainer = md_theme_snow_primaryContainer,
secondary = md_theme_snow_secondary,
onSecondary = md_theme_snow_onSecondary,
error = md_theme_snow_error,
background = md_theme_snow_background,
onBackground = md_theme_snow_onBackground,
surface = md_theme_snow_surface,
onSurface = md_theme_snow_onSurface,
surfaceVariant = md_theme_snow_surfaceVariant,
onSurfaceVariant = md_theme_snow_onSurfaceVariant,
outline = md_theme_snow_outline,
)
public val CrimsonColorScheme: ColorScheme = darkColorScheme(
primary = md_theme_crimson_primary,
onPrimary = md_theme_crimson_onPrimary,
primaryContainer = md_theme_crimson_primaryContainer,
secondary = md_theme_crimson_secondary,
onSecondary = md_theme_crimson_onSecondary,
error = md_theme_crimson_error,
background = md_theme_crimson_background,
onBackground = md_theme_crimson_onBackground,
surface = md_theme_crimson_surface,
onSurface = md_theme_crimson_onSurface,
surfaceVariant = md_theme_crimson_surfaceVariant,
onSurfaceVariant = md_theme_crimson_onSurfaceVariant,
outline = md_theme_crimson_outline,
)
internal fun Theme.toColorScheme(isSystemInDarkTheme: Boolean): ColorScheme = when (this) {
Theme.LIGHT_THEME -> LightColorScheme
Theme.DARK_THEME -> DarkColorScheme
Theme.TERMINAL_THEME -> TerminalColorScheme
Theme.AUTUMN_THEME -> AutumnColorScheme
Theme.AQUA_THEME -> AquaColorScheme
Theme.AMBER_THEME -> AmberColorScheme
Theme.SNOW_THEME -> SnowColorScheme
Theme.CRIMSON_THEME -> CrimsonColorScheme
Theme.SYSTEM_THEME -> if (isSystemInDarkTheme) DarkColorScheme else LightColorScheme
}
@Composable
public fun TvManiacTheme(
appTheme: Theme = Theme.SYSTEM_THEME,
content: @Composable () -> Unit,
) {
val isSystemDark = isSystemInDarkTheme()
val colorScheme = appTheme.toColorScheme(isSystemDark)
val backgroundTheme = BackgroundTheme(
color = colorScheme.surface,
tonalElevation = 2.dp,
)
val scanlineConfig = appTheme.toScanlineConfiguration()
CompositionLocalProvider(
LocalBackgroundTheme provides backgroundTheme,
) {
MaterialTheme(
colorScheme = colorScheme,
typography = tvManiacTypography(),
shapes = tvManiacShapes,
) {
Box {
content()
ScanlineOverlay(configuration = scanlineConfig)
}
}
}
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/theme/Type.kt
================================================
package com.thomaskioko.tvmaniac.compose.theme
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.thomaskioko.tvmaniac.i18n.MR
import dev.icerock.moko.resources.compose.asFont
@Composable
private fun workSansFontFamily() = FontFamily(
MR.fonts.work_sans_thin.asFont(
weight = FontWeight.W200,
style = FontStyle.Normal,
)!!,
MR.fonts.work_sans_medium.asFont(
weight = FontWeight.W400,
style = FontStyle.Normal,
)!!,
MR.fonts.work_sans_semibold.asFont(
weight = FontWeight.W500,
style = FontStyle.Normal,
)!!,
MR.fonts.work_sans_bold.asFont(
weight = FontWeight.W600,
style = FontStyle.Normal,
)!!,
MR.fonts.work_sans_extrabold.asFont(
weight = FontWeight.W700,
style = FontStyle.Normal,
)!!,
)
@Composable
internal fun tvManiacTypography() = Typography(
displayLarge = TextStyle(
fontFamily = workSansFontFamily(),
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp,
),
displayMedium = TextStyle(
fontFamily = workSansFontFamily(),
fontWeight = FontWeight.Normal,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp,
),
displaySmall = TextStyle(
fontFamily = workSansFontFamily(),
fontWeight = FontWeight.Normal,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp,
),
headlineLarge = TextStyle(
fontFamily = workSansFontFamily(),
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp,
),
headlineMedium = TextStyle(
fontFamily = workSansFontFamily(),
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp,
),
headlineSmall = TextStyle(
fontFamily = workSansFontFamily(),
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp,
),
titleLarge = TextStyle(
fontFamily = workSansFontFamily(),
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontFamily = workSansFontFamily(),
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp,
),
titleSmall = TextStyle(
fontFamily = workSansFontFamily(),
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
bodyLarge = TextStyle(
fontFamily = workSansFontFamily(),
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp,
),
bodyMedium = TextStyle(
fontFamily = workSansFontFamily(),
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp,
),
bodySmall = TextStyle(
fontFamily = workSansFontFamily(),
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp,
),
labelLarge = TextStyle(
fontFamily = workSansFontFamily(),
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontFamily = workSansFontFamily(),
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
labelSmall = TextStyle(
fontFamily = workSansFontFamily(),
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
)
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/util/AutoAdvanceLocal.kt
================================================
package com.thomaskioko.tvmaniac.compose.util
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.staticCompositionLocalOf
/**
* Controls the auto-advance LaunchedEffect on the Discover featured pager (`PosterCardsPager`).
*
* Defaults to `true` in production: every 4.5 seconds the pager animates to the next page so the
* featured carousel cycles on its own. Tests can override this to `false` via
* `CompositionLocalProvider(LocalAutoAdvanceEnabled provides false)` so that the visible page
* stays where the test left it. This makes pager assertions deterministic regardless of how much
* wall-time has elapsed since activity launch.
*/
public val LocalAutoAdvanceEnabled: ProvidableCompositionLocal = staticCompositionLocalOf {
true
}
================================================
FILE: android-designsystem/src/main/kotlin/com/thomaskioko/tvmaniac/compose/util/DynamicTheming.kt
================================================
package com.thomaskioko.tvmaniac.compose.util
import android.content.Context
import androidx.collection.LruCache
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.drawable.toBitmap
import androidx.palette.graphics.Palette
import coil.imageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import coil.size.Scale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val COVER_IMAGE_SIZE = 240
private const val MAC_COLOR_COUNT = 8
@Composable
public fun rememberDominantColorState(
context: Context = LocalContext.current,
defaultColor: Color = MaterialTheme.colorScheme.primary,
defaultOnColor: Color = MaterialTheme.colorScheme.onPrimary,
cacheSize: Int = 12,
isColorValid: (Color) -> Boolean = { true },
): DominantColorState = remember {
DominantColorState(context, defaultColor, defaultOnColor, cacheSize, isColorValid)
}
/**
* A composable which allows dynamic theming of the [androidx.compose.material.Colors.primary] color
* from an image.
*/
@Composable
public fun DynamicThemePrimaryColorsFromImage(
dominantColorState: DominantColorState = rememberDominantColorState(),
content: @Composable () -> Unit,
) {
val colors = MaterialTheme.colorScheme.copy(
primary = animateColorAsState(
dominantColorState.color,
spring(stiffness = Spring.StiffnessLow),
label = "primaryColorAnimation",
)
.value,
onPrimary = animateColorAsState(
dominantColorState.onColor,
spring(stiffness = Spring.StiffnessLow),
label = "onPrimaryColorAnimation",
)
.value,
)
MaterialTheme(colorScheme = colors, content = content)
}
/**
* A class which stores and caches the result of any calculated dominant colors from images.
*
* @param context Android context
* @param defaultColor The default color, which will be used if [calculateDominantColor] fails to
* calculate a dominant color
* @param defaultOnColor The default foreground 'on color' for [defaultColor].
* @param cacheSize The size of the [LruCache] used to store recent results. Pass `0` to disable the
* cache.
* @param isColorValid A lambda which allows filtering of the calculated image colors.
*/
@Stable
public class DominantColorState(
private val context: Context,
private val defaultColor: Color,
private val defaultOnColor: Color,
cacheSize: Int = 12,
private val isColorValid: (Color) -> Boolean = { true },
) {
public var color: Color by mutableStateOf(defaultColor)
private set
public var onColor: Color by mutableStateOf(defaultOnColor)
private set
private val cache = when {
cacheSize > 0 -> LruCache(cacheSize)
else -> null
}
public suspend fun updateColorsFromImageUrl(url: String) {
val result = calculateDominantColor(url)
color = result?.color ?: defaultColor
onColor = result?.onColor ?: defaultOnColor
}
private suspend fun calculateDominantColor(url: String): DominantColors? {
val cached = cache?.get(url)
if (cached != null) {
return cached
}
// Otherwise we calculate the swatches in the image, and return the first valid color
return calculateSwatchesInImage(context, url)
.sortedByDescending { swatch -> swatch.population }
.firstOrNull { swatch -> isColorValid(Color(swatch.rgb)) }
?.let { swatch ->
DominantColors(
color = Color(swatch.rgb),
onColor = Color(swatch.bodyTextColor).copy(alpha = 1f),
)
}
?.also { result -> cache?.put(url, result) }
}
/** Reset the color values to [defaultColor]. */
public fun reset() {
color = defaultColor
onColor = defaultColor
}
}
@Immutable
private data class DominantColors(val color: Color, val onColor: Color)
/** Fetches the given [imageUrl] with Coil, then uses [Palette] to calculate the dominant color. */
private suspend fun calculateSwatchesInImage(
context: Context,
imageUrl: String,
): List {
val request = ImageRequest.Builder(context)
.data(imageUrl)
.size(COVER_IMAGE_SIZE)
.scale(Scale.FILL)
.allowHardware(false)
.memoryCacheKey("$imageUrl.palette")
.build()
val bitmap = when (val result = context.imageLoader.execute(request)) {
is SuccessResult -> result.drawable.toBitmap()
else -> null
}
return bitmap?.let {
withContext(Dispatchers.Default) {
val palette = Palette.Builder(bitmap)
.resizeBitmapArea(0)
.clearFilters()
.maximumColorCount(MAC_COLOR_COUNT)
.generate()
palette.swatches
}
}
?: emptyList()
}
================================================
FILE: android-designsystem/src/main/res/values/strings.xml
================================================
TvManiac
================================================
FILE: android-designsystem/src/test/kotlin/com/thomaskioko/tvmaniac/compose/roborazzi/NotificationRationaleContentScreenshotTest.kt
================================================
package com.thomaskioko.tvmaniac.compose.roborazzi
import androidx.activity.ComponentActivity
import androidx.compose.material3.Surface
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import com.thomaskioko.tvmaniac.compose.components.NotificationRationaleContent
import com.thomaskioko.tvmaniac.screenshottests.captureMultiDevice
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@LooperMode(LooperMode.Mode.PAUSED)
internal class NotificationRationaleContentScreenshotTest {
@get:Rule
val composeTestRule = createAndroidComposeRule()
@Test
fun notificationRationaleContent() {
composeTestRule.captureMultiDevice("NotificationRationaleContent") {
Surface {
NotificationRationaleContent(
onEnable = {},
onDismiss = {},
)
}
}
}
}
================================================
FILE: android-designsystem/src/test/kotlin/com/thomaskioko/tvmaniac/compose/roborazzi/TvManiacSnackBarScreenshotTest.kt
================================================
package com.thomaskioko.tvmaniac.compose.roborazzi
import androidx.activity.ComponentActivity
import androidx.compose.material3.Surface
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import com.thomaskioko.tvmaniac.compose.components.SnackBarStyle
import com.thomaskioko.tvmaniac.compose.components.TvManiacSnackBar
import com.thomaskioko.tvmaniac.screenshottests.captureMultiDevice
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@LooperMode(LooperMode.Mode.PAUSED)
internal class TvManiacSnackBarScreenshotTest {
@get:Rule
val composeTestRule = createAndroidComposeRule()
@Test
fun snackBarError() {
composeTestRule.captureMultiDevice("TvManiacSnackBar_Error") {
Surface {
TvManiacSnackBar(
message = "Something went wrong while syncing your data. Check your internet connection.",
style = SnackBarStyle.Error,
)
}
}
}
@Test
fun snackBarWarning() {
composeTestRule.captureMultiDevice("TvManiacSnackBar_Warning") {
Surface {
TvManiacSnackBar(
message = "Your session is about to expire.",
style = SnackBarStyle.Warning,
)
}
}
}
@Test
fun snackBarSuccess() {
composeTestRule.captureMultiDevice("TvManiacSnackBar_Success") {
Surface {
TvManiacSnackBar(
message = "Changes saved successfully.",
style = SnackBarStyle.Success,
)
}
}
}
@Test
fun snackBarInfo() {
composeTestRule.captureMultiDevice("TvManiacSnackBar_Info") {
Surface {
TvManiacSnackBar(
message = "Your data has been synced successfully.",
style = SnackBarStyle.Info,
)
}
}
}
}
================================================
FILE: api/tmdb/api/build.gradle.kts
================================================
plugins {
alias(libs.plugins.app.kmp)
}
scaffold {
useSerialization()
}
kotlin {
sourceSets {
commonMain {
dependencies {
api(projects.data.database.sqldelight)
api(projects.core.networkUtil.api)
}
}
}
}
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/TmdbConfig.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api
public interface TmdbConfig {
public val apiKey: String
}
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/TmdbSeasonDetailsNetworkDataSource.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.tmdb.api.model.TmdbSeasonDetailsResponse
public interface TmdbSeasonDetailsNetworkDataSource {
/**
* Query the details of a TV season.
*
* @param id TV show id
* @param seasonNumber Season number
*/
public suspend fun getSeasonDetails(id: Long, seasonNumber: Long): ApiResponse
}
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/TmdbShowDetailsNetworkDataSource.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.tmdb.api.model.TmdbShowDetailsResponse
import com.thomaskioko.tvmaniac.tmdb.api.model.TmdbShowResult
import com.thomaskioko.tvmaniac.tmdb.api.model.WatchProvidersResult
public interface TmdbShowDetailsNetworkDataSource {
/**
* Get the primary TV show details by id.
*
* @param id TV show id
*/
public suspend fun getShowDetails(id: Long): ApiResponse
/**
* Get the similar TV shows.
*
* @param id TV show id
* @param page Page number
*/
public suspend fun getSimilarShows(id: Long, page: Long): ApiResponse
/**
* Get TV shows recommendations
*
* @param id TV show id
* @param page Page number
*/
public suspend fun getRecommendedShows(id: Long, page: Long): ApiResponse
/**
* Returns a list of the watch provider (OTT/streaming) data we have available for TV series.
*
* @param id TV show id
*/
public suspend fun getShowWatchProviders(id: Long): ApiResponse
}
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/TmdbShowsNetworkDataSource.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.tmdb.api.model.CreditsResponse
import com.thomaskioko.tvmaniac.tmdb.api.model.TmdbGenreResult
import com.thomaskioko.tvmaniac.tmdb.api.model.TmdbShowResult
public const val DEFAULT_API_PAGE: Long = 1
public const val DEFAULT_SORT_ORDER: String = "popularity.desc"
public interface TmdbShowsNetworkDataSource {
/**
* Get a list of TV shows airing today.
*
* @param page Page number
*/
public suspend fun getAiringToday(page: Long): ApiResponse
/**
* Find TV shows using over 30 filters and sort options. Available filter options asc & desc
* popularity.asc, primary_release_date.desc, vote_average.desc, vote_count.desc
*
* @param page Page number
* @param sortBy Default: popularity.desc.
* @param genres Comma separated list of genre ids.
* @param voteAverageGte Minimum vote average (e.g., 7.0)
* @param voteCountGte Minimum vote count (e.g., 100)
* @param firstAirDateGte Shows aired after this date (YYYY-MM-DD)
* @param firstAirDateLte Shows aired before this date (YYYY-MM-DD)
*/
public suspend fun discoverShows(
page: Long = DEFAULT_API_PAGE,
sortBy: String = DEFAULT_SORT_ORDER,
genres: String? = null,
watchProviders: String? = null,
screenedTheatrically: Boolean = true,
voteAverageGte: Double? = null,
voteCountGte: Int? = null,
firstAirDateGte: String? = null,
firstAirDateLte: String? = null,
): ApiResponse
/**
* Get a list of TV shows ordered by popularity.
*
* @param page Page number
*/
public suspend fun getPopularShows(page: Long): ApiResponse
/**
* Get a list of TV shows ordered by rating.
*
* @param page Page number
*/
public suspend fun getTopRatedShows(page: Long): ApiResponse
/**
* Get the trending TV shows on TMDB for the day or week.
*
* @param timeWindow Default: Day
*/
public suspend fun getTrendingShows(timeWindow: String): ApiResponse
/**
* Get the trending TV shows on TMDB for the day or week.
*
* @param year:
*/
public suspend fun getUpComingShows(
year: Int,
page: Long,
sortBy: String = DEFAULT_SORT_ORDER,
): ApiResponse
/**
* Get upcoming shows in a given date range. Eg. 4 weeks, 6 months.
*
* @param page Page number
* @param firstAirDate Start range date range 2023-11-01
* @param lastAirDate End range date range 2026-04-01
* @param sortBy Default: popularity.desc.
*/
public suspend fun getUpComingShows(
page: Long,
firstAirDate: String,
lastAirDate: String,
sortBy: String = DEFAULT_SORT_ORDER,
): ApiResponse
/**
* Search for TV shows by their original, translated and also known as names.
*
* @param query Search query
*/
public suspend fun searchShows(query: String): ApiResponse
/**
* Get the list of official genres for TV shows.
*/
public suspend fun getShowGenres(): ApiResponse
/**
* Get the cast and crew for a TV show.
*
* @param tmdbId TMDB show ID
*/
public suspend fun getShowCredits(tmdbId: Long): ApiResponse
}
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/model/CreditsResponse.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class CreditsResponse(
@SerialName("cast") var cast: ArrayList = arrayListOf(),
)
@Serializable
public data class CastResponse(
@SerialName("id") var id: Int,
@SerialName("name") var name: String,
@SerialName("profile_path") var profilePath: String? = null,
@SerialName("character") var character: String,
@SerialName("popularity") var popularity: Double,
)
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/model/EpisodesResponse.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class EpisodesResponse(
@SerialName("air_date") var airDate: String? = null,
@SerialName("episode_number") var episodeNumber: Int,
@SerialName("id") var id: Int,
@SerialName("name") var name: String,
@SerialName("overview") var overview: String,
@SerialName("runtime") var runtime: Int? = null,
@SerialName("season_number") var seasonNumber: Int,
@SerialName("show_id") var showId: Int,
@SerialName("still_path") var stillPath: String? = null,
@SerialName("vote_average") var voteAverage: Double,
@SerialName("vote_count") var voteCount: Int,
)
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/model/GenreResponse.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class GenreResponse(
@SerialName("id") var id: Int,
@SerialName("name") var name: String,
)
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/model/ImagesResponse.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class ImagesResponse(
@SerialName("posters") var posters: ArrayList,
)
@Serializable
public data class Posters(
@SerialName("aspect_ratio") var aspectRatio: Double,
@SerialName("height") var height: Int,
@SerialName("file_path") var filePath: String,
@SerialName("vote_average") var voteAverage: Double,
@SerialName("vote_count") var voteCount: Int,
@SerialName("width") var width: Int,
)
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/model/LastEpisodeToAirResponse.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class LastEpisodeToAirResponse(
@SerialName("id") var id: Int,
@SerialName("name") var name: String,
@SerialName("overview") var overview: String,
@SerialName("vote_average") var voteAverage: Double,
@SerialName("vote_count") var voteCount: Int,
@SerialName("air_date") var airDate: String,
@SerialName("episode_number") var episodeNumber: Int,
@SerialName("episode_type") var episodeType: String,
@SerialName("runtime") var runtime: Int? = null,
@SerialName("season_number") var seasonNumber: Int,
@SerialName("show_id") var showId: Int,
@SerialName("still_path") var stillPath: String? = null,
)
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/model/NetworksResponse.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class NetworksResponse(
@SerialName("id") var id: Int,
@SerialName("logo_path") var logoPath: String? = null,
@SerialName("name") var name: String,
)
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/model/NextEpisodeToAirResponse.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class NextEpisodeToAirResponse(
@SerialName("id") var id: Int,
@SerialName("name") var name: String,
@SerialName("overview") var overview: String,
@SerialName("vote_average") var voteAverage: Double,
@SerialName("vote_count") var voteCount: Int,
@SerialName("air_date") var airDate: String,
@SerialName("episode_number") var episodeNumber: Int,
@SerialName("episode_type") var episodeType: String,
@SerialName("runtime") var runtime: Int? = null,
@SerialName("season_number") var seasonNumber: Int,
@SerialName("show_id") var showId: Int,
@SerialName("still_path") var stillPath: String? = null,
)
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/model/SeasonsResponse.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class SeasonsResponse(
@SerialName("air_date") var airDate: String? = null,
@SerialName("episode_count") var episodeCount: Int,
@SerialName("id") var id: Int,
@SerialName("name") var name: String,
@SerialName("overview") var overview: String? = null,
@SerialName("poster_path") var posterPath: String? = null,
@SerialName("season_number") var seasonNumber: Int,
@SerialName("vote_average") var voteAverage: Double,
)
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/model/TmdbGenreResult.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TmdbGenreResult(
@SerialName("genres") var genres: ArrayList,
)
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/model/TmdbSeasonDetailsResponse.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TmdbSeasonDetailsResponse(
@SerialName("air_date") var airDate: String? = null,
@SerialName("episodes") var episodes: ArrayList,
@SerialName("name") var name: String,
@SerialName("overview") var overview: String,
@SerialName("id") var id: Int,
@SerialName("poster_path") var posterPath: String? = null,
@SerialName("season_number") var seasonNumber: Int,
@SerialName("vote_average") var voteAverage: Double,
@SerialName("videos") var videos: VideosResponse,
@SerialName("images") var images: ImagesResponse,
@SerialName("credits") var credits: CreditsResponse,
)
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/model/TmdbShowDetailsResponse.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TmdbShowDetailsResponse(
@SerialName("adult") var adult: Boolean,
@SerialName("backdrop_path") var backdropPath: String? = null,
@SerialName("episode_run_time") var episodeRunTime: ArrayList = arrayListOf(),
@SerialName("first_air_date") var firstAirDate: String? = null,
@SerialName("genres") var genres: ArrayList,
@SerialName("id") var id: Int,
@SerialName("last_air_date") var lastAirDate: String? = null,
@SerialName("last_episode_to_air") var lastEpisodeToAir: LastEpisodeToAirResponse? = null,
@SerialName("name") var name: String,
@SerialName("next_episode_to_air") var nextEpisodeToAir: NextEpisodeToAirResponse? = null,
@SerialName("networks") var networks: ArrayList,
@SerialName("number_of_episodes") var numberOfEpisodes: Int,
@SerialName("number_of_seasons") var numberOfSeasons: Int,
@SerialName("overview") var overview: String,
@SerialName("popularity") var popularity: Double,
@SerialName("poster_path") var posterPath: String? = null,
@SerialName("seasons") var seasons: ArrayList,
@SerialName("status") var status: String,
@SerialName("vote_average") var voteAverage: Double,
@SerialName("vote_count") var voteCount: Int,
@SerialName("videos") var videos: VideosResponse,
@SerialName("credits") var credits: CreditsResponse,
@SerialName("original_language") var originalLanguage: String? = null,
)
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/model/TmdbShowResponse.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TmdbShowResult(
@SerialName("page") var page: Int,
@SerialName("total_pages") var totalPages: Int,
@SerialName("total_results") var totalResults: Int,
@SerialName("results") var results: ArrayList = arrayListOf(),
)
@Serializable
public data class TmdbShowResponse(
@SerialName("id") var id: Int,
@SerialName("name") var name: String,
@SerialName("overview") var overview: String,
@SerialName("popularity") var popularity: Double,
@SerialName("vote_average") var voteAverage: Double,
@SerialName("vote_count") var voteCount: Int,
@SerialName("genre_ids") var genreIds: ArrayList,
@SerialName("origin_country") var originCountry: ArrayList,
@SerialName("backdrop_path") var backdropPath: String? = null,
@SerialName("first_air_date") var firstAirDate: String? = null,
@SerialName("original_language") var originalLanguage: String? = null,
@SerialName("original_name") var originalName: String? = null,
@SerialName("poster_path") var posterPath: String? = null,
)
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/model/VideosResponse.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class VideosResponse(
@SerialName("results") var results: ArrayList,
)
@Serializable
public data class VideoResultResponse(
@SerialName("iso_639_1") var iso6391: String,
@SerialName("iso_3166_1") var iso31661: String,
@SerialName("name") var name: String,
@SerialName("key") var key: String,
@SerialName("site") var site: String,
@SerialName("size") var size: Int,
@SerialName("type") var type: String,
@SerialName("official") var official: Boolean,
@SerialName("id") var id: String,
)
================================================
FILE: api/tmdb/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/api/model/WatchProvidersResult.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class WatchProvidersResult(
@SerialName("id") var id: Int,
@SerialName("results") var results: Results,
)
@Serializable
public data class Results(
@SerialName("US") var US: US? = US(),
)
@Serializable
public data class US(
@SerialName("link") var link: String? = null,
@SerialName("flatrate") var flatrate: ArrayList = arrayListOf(),
@SerialName("ads") var ads: ArrayList = arrayListOf(),
@SerialName("free") var free: ArrayList = arrayListOf(),
)
@Serializable
public data class FlatRate(
@SerialName("logo_path") var logoPath: String? = null,
@SerialName("provider_id") var providerId: Int,
@SerialName("provider_name") var providerName: String,
@SerialName("display_priority") var displayPriority: Int? = null,
)
@Serializable
public data class Ads(
@SerialName("logo_path") var logoPath: String? = null,
@SerialName("provider_id") var providerId: Int,
@SerialName("provider_name") var providerName: String,
@SerialName("display_priority") var displayPriority: Int? = null,
)
@Serializable
public data class Free(
@SerialName("logo_path") var logoPath: String? = null,
@SerialName("provider_id") var providerId: Int,
@SerialName("provider_name") var providerName: String,
@SerialName("display_priority") var displayPriority: Int? = null,
)
================================================
FILE: api/tmdb/implementation/build.gradle.kts
================================================
plugins {
alias(libs.plugins.app.kmp)
}
scaffold {
addAndroidTarget()
useSerialization()
useMetro()
}
kotlin {
sourceSets {
androidMain { dependencies { implementation(libs.ktor.okhttp) } }
commonMain {
dependencies {
implementation(projects.core.appconfig.api)
implementation(projects.core.base)
implementation(projects.core.connectivity.api)
implementation(projects.core.networkUtil.api)
implementation(projects.core.util.api)
implementation(projects.core.logger.api)
implementation(projects.api.tmdb.api)
implementation(libs.ktor.core)
implementation(libs.ktor.logging)
implementation(libs.ktor.negotiation)
implementation(libs.ktor.serialization.json)
implementation(libs.sqldelight.extensions)
implementation(libs.sqldelight.extensions)
}
}
commonTest { dependencies { implementation(libs.ktor.serialization) } }
iosMain { dependencies { implementation(libs.ktor.darwin) } }
}
}
================================================
FILE: api/tmdb/implementation/src/androidMain/kotlin/com/thomaskioko/tvmaniac/tmdb/implementation/TmdbPlatformBindingContainer.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.implementation
import com.thomaskioko.tvmaniac.core.base.TmdbApi
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.okhttp.OkHttp
@BindingContainer
@ContributesTo(AppScope::class)
public object TmdbPlatformBindingContainer {
@Provides
@SingleIn(AppScope::class)
@TmdbApi
public fun provideTmdbHttpClientEngine(): HttpClientEngine = OkHttp.create()
}
================================================
FILE: api/tmdb/implementation/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/implementation/DefaultTmdbSeasonDetailsNetworkDataSource.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.implementation
import com.thomaskioko.tvmaniac.core.base.TmdbApi
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.safeRequest
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.tmdb.api.TmdbSeasonDetailsNetworkDataSource
import com.thomaskioko.tvmaniac.tmdb.api.model.TmdbSeasonDetailsResponse
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
import io.ktor.client.request.parameter
import io.ktor.http.HttpMethod
import io.ktor.http.path
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
public class DefaultTmdbSeasonDetailsNetworkDataSource(
@TmdbApi
private val httpClient: HttpClient,
) : TmdbSeasonDetailsNetworkDataSource {
override suspend fun getSeasonDetails(
id: Long,
seasonNumber: Long,
): ApiResponse = httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/tv/$id/season/$seasonNumber")
parameter("append_to_response", "credits,videos,images")
}
}
}
================================================
FILE: api/tmdb/implementation/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/implementation/DefaultTmdbShowDetailsNetworkDataSource.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.implementation
import com.thomaskioko.tvmaniac.core.base.TmdbApi
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.safeRequest
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.tmdb.api.TmdbShowDetailsNetworkDataSource
import com.thomaskioko.tvmaniac.tmdb.api.model.TmdbShowDetailsResponse
import com.thomaskioko.tvmaniac.tmdb.api.model.TmdbShowResult
import com.thomaskioko.tvmaniac.tmdb.api.model.WatchProvidersResult
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
import io.ktor.client.request.parameter
import io.ktor.http.HttpMethod
import io.ktor.http.path
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
public class DefaultTmdbShowDetailsNetworkDataSource(
@TmdbApi
private val httpClient: HttpClient,
) : TmdbShowDetailsNetworkDataSource {
override suspend fun getShowDetails(id: Long): ApiResponse {
return httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/tv/$id")
parameter("append_to_response", "credits,videos")
}
}
}
override suspend fun getSimilarShows(id: Long, page: Long): ApiResponse {
return httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/tv/$id/similar")
parameter("page", "$page")
}
}
}
override suspend fun getRecommendedShows(id: Long, page: Long): ApiResponse {
return httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/tv/$id/recommendations")
parameter("page", "$page")
}
}
}
override suspend fun getShowWatchProviders(id: Long): ApiResponse {
return httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/tv/$id/watch/providers")
}
}
}
}
================================================
FILE: api/tmdb/implementation/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/implementation/DefaultTmdbShowsNetworkDataSource.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.implementation
import com.thomaskioko.tvmaniac.core.base.TmdbApi
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.safeRequest
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.tmdb.api.TmdbShowsNetworkDataSource
import com.thomaskioko.tvmaniac.tmdb.api.model.CreditsResponse
import com.thomaskioko.tvmaniac.tmdb.api.model.TmdbGenreResult
import com.thomaskioko.tvmaniac.tmdb.api.model.TmdbShowResult
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
import io.ktor.client.request.parameter
import io.ktor.http.HttpMethod
import io.ktor.http.path
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
public class DefaultTmdbShowsNetworkDataSource(
@TmdbApi
private val httpClient: HttpClient,
) : TmdbShowsNetworkDataSource {
override suspend fun getAiringToday(page: Long): ApiResponse {
return httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/tv/airing_today")
parameter("page", "$page")
}
}
}
override suspend fun discoverShows(
page: Long,
sortBy: String,
genres: String?,
watchProviders: String?,
screenedTheatrically: Boolean,
voteAverageGte: Double?,
voteCountGte: Int?,
firstAirDateGte: String?,
firstAirDateLte: String?,
): ApiResponse {
return httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/discover/tv")
parameter("page", "$page")
parameter("sort_by", sortBy)
parameter("include_adult", "false")
parameter("screened_theatrically", screenedTheatrically)
parameter("language", "en-US")
genres?.let { parameter("with_genres", it) }
watchProviders?.let { parameter("with_watch_providers", it) }
voteAverageGte?.let { parameter("vote_average.gte", it) }
voteCountGte?.let { parameter("vote_count.gte", it) }
firstAirDateGte?.let { parameter("first_air_date.gte", it) }
firstAirDateLte?.let { parameter("first_air_date.lte", it) }
}
}
}
override suspend fun getPopularShows(page: Long): ApiResponse {
return httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/tv/popular")
parameter("page", "$page")
}
}
}
override suspend fun getTopRatedShows(page: Long): ApiResponse {
return httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/tv/top_rated")
parameter("page", "$page")
}
}
}
override suspend fun getTrendingShows(timeWindow: String): ApiResponse {
return httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/trending/tv/$timeWindow")
}
}
}
override suspend fun getUpComingShows(
year: Int,
page: Long,
sortBy: String,
): ApiResponse {
return httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/discover/tv")
parameter("page", "$page")
parameter("first_air_date_year", year)
parameter("sort_by", sortBy)
}
}
}
override suspend fun getUpComingShows(
page: Long,
firstAirDate: String,
lastAirDate: String,
sortBy: String,
): ApiResponse {
return httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/discover/tv")
parameter("page", "$page")
parameter("first_air_date.gte", firstAirDate)
parameter("first_air_date.lte", lastAirDate)
parameter("sort_by", sortBy)
}
}
}
override suspend fun searchShows(query: String): ApiResponse {
return httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/search/tv")
parameter("query", query)
}
}
}
override suspend fun getShowGenres(): ApiResponse {
return httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/genre/tv/list")
}
}
}
override suspend fun getShowCredits(tmdbId: Long): ApiResponse {
return httpClient.safeRequest {
url {
method = HttpMethod.Get
path("3/tv/$tmdbId/credits")
}
}
}
}
================================================
FILE: api/tmdb/implementation/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/implementation/TmdbBindingContainer.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.implementation
import com.thomaskioko.tvmaniac.appconfig.ApplicationInfo
import com.thomaskioko.tvmaniac.core.base.TmdbApi
import com.thomaskioko.tvmaniac.core.connectivity.api.InternetConnectionChecker
import com.thomaskioko.tvmaniac.core.logger.Logger
import com.thomaskioko.tvmaniac.tmdb.api.TmdbConfig
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngine
import kotlinx.serialization.json.Json
@BindingContainer
@ContributesTo(AppScope::class)
public object TmdbBindingContainer {
private val json: Json = Json {
isLenient = true
ignoreUnknownKeys = true
useAlternativeNames = false
explicitNulls = false
}
@Provides
@SingleIn(AppScope::class)
@TmdbApi
public fun provideTmdbHttpClient(
@TmdbApi httpClientEngine: HttpClientEngine,
applicationInfo: ApplicationInfo,
tmdbConfig: TmdbConfig,
logger: Logger,
internetConnectionChecker: InternetConnectionChecker,
): HttpClient = tmdbHttpClient(
tmdbApiKey = tmdbConfig.apiKey,
json = json,
httpClientEngine = httpClientEngine,
kermitLogger = logger,
isDebug = applicationInfo.debugBuild,
internetConnectionChecker = internetConnectionChecker,
)
}
================================================
FILE: api/tmdb/implementation/src/commonMain/kotlin/com/thomaskioko/tvmaniac/tmdb/implementation/TmdbClient.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.implementation
import com.thomaskioko.tvmaniac.core.connectivity.api.InternetConnectionChecker
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.InternetConnectionPlugin
import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.EMPTY
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.headers
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.URLProtocol
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import com.thomaskioko.tvmaniac.core.logger.Logger as KermitLogger
internal const val TIMEOUT_DURATION: Long = 60_000
internal fun tmdbHttpClient(
isDebug: Boolean = false,
tmdbApiKey: String,
json: Json,
httpClientEngine: HttpClientEngine,
kermitLogger: KermitLogger,
internetConnectionChecker: InternetConnectionChecker,
) =
HttpClient(httpClientEngine) {
install(ContentNegotiation) { json(json = json) }
install(InternetConnectionPlugin) {
this.internetConnectionChecker = internetConnectionChecker
}
install(DefaultRequest) {
url {
protocol = URLProtocol.HTTPS
host = "api.themoviedb.org"
parameters.append("api_key", tmdbApiKey)
headers {
append(HttpHeaders.Accept, "application/vnd.api+json")
append(HttpHeaders.ContentType, "application/vnd.api+json")
}
}
}
install(HttpTimeout) {
requestTimeoutMillis = TIMEOUT_DURATION
connectTimeoutMillis = TIMEOUT_DURATION
socketTimeoutMillis = TIMEOUT_DURATION
}
install(HttpRequestRetry) {
retryIf(5) { _, httpResponse ->
when {
httpResponse.status.value in 500..599 -> true
httpResponse.status == HttpStatusCode.TooManyRequests -> true
else -> false
}
}
exponentialDelay(
base = 2.0,
maxDelayMs = 60_000L,
randomizationMs = 1000L,
)
}
install(Logging) {
level = if (isDebug) LogLevel.BODY else LogLevel.NONE
logger = if (isDebug) {
object : Logger {
override fun log(message: String) {
kermitLogger.info("TmbdHttp", message)
}
}
} else {
Logger.EMPTY
}
}
}
================================================
FILE: api/tmdb/implementation/src/iosMain/kotlin/com/thomaskioko/tvmaniac/tmdb/implementation/TmdbPlatformBindingContainer.kt
================================================
package com.thomaskioko.tvmaniac.tmdb.implementation
import com.thomaskioko.tvmaniac.core.base.TmdbApi
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.darwin.Darwin
@BindingContainer
@ContributesTo(AppScope::class)
public object TmdbPlatformBindingContainer {
@Provides
@SingleIn(AppScope::class)
@TmdbApi
public fun provideTmdbHttpClientEngine(): HttpClientEngine = Darwin.create()
}
================================================
FILE: api/trakt/api/build.gradle.kts
================================================
plugins {
alias(libs.plugins.app.kmp)
}
scaffold {
useSerialization()
}
kotlin {
sourceSets {
commonMain {
dependencies {
api(projects.core.networkUtil.api)
}
}
}
}
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/TraktCalendarRemoteDataSource.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktCalendarResponse
public interface TraktCalendarRemoteDataSource {
public suspend fun getMyShowsCalendar(
startDate: String,
days: Int,
): ApiResponse>
}
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/TraktConfig.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api
public interface TraktConfig {
public val clientId: String
public val clientSecret: String
public val redirectUri: String
}
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/TraktEpisodeHistoryRemoteDataSource.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktHistoryEntry
import com.thomaskioko.tvmaniac.trakt.api.model.TraktSyncItems
import com.thomaskioko.tvmaniac.trakt.api.model.TraktSyncResponse
public interface TraktEpisodeHistoryRemoteDataSource {
public suspend fun getShowEpisodeWatches(showTraktId: Long): ApiResponse>
public suspend fun addEpisodeWatches(items: TraktSyncItems): ApiResponse
public suspend fun removeEpisodeWatches(items: TraktSyncItems): ApiResponse
}
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/TraktListRemoteDataSource.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktAddRemoveShowFromListResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktAddShowToListResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktCreateListResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktFollowedShowResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktPersonalListsResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktUserResponse
public interface TraktListRemoteDataSource {
public suspend fun getUser(userId: String): ApiResponse
public suspend fun getUserList(userId: String): ApiResponse>
public suspend fun createList(userSlug: String, name: String): ApiResponse
public suspend fun getWatchList(sortBy: String, sortHow: String): ApiResponse>
public suspend fun addShowToWatchListByTmdbId(tmdbId: Long): ApiResponse
public suspend fun removeShowFromWatchListByTmdbId(tmdbId: Long): ApiResponse
public suspend fun addShowToWatchListByTraktId(traktId: Long): ApiResponse
public suspend fun removeShowFromWatchListByTraktId(traktId: Long): ApiResponse
public suspend fun addShowToList(
userSlug: String,
listId: Long,
traktShowId: Long,
): ApiResponse
public suspend fun removeShowFromList(
userSlug: String,
listId: Long,
traktShowId: Long,
): ApiResponse
}
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/TraktShowsRemoteDataSource.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktEpisodesResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktGenreResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktSearchResult
import com.thomaskioko.tvmaniac.trakt.api.model.TraktSeasonEpisodesResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktSeasonsResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktShowPeopleResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktShowResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktShowsResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktVideosResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktWatchedProgressResponse
/**
* Remote data source for fetching TV show data from the Trakt API.
*
* @see [Trakt API Documentation](https://trakt.docs.apiary.io/)
*/
public interface TraktShowsRemoteDataSource {
/**
* Fetches currently trending shows.
*
* Trending shows are calculated based on the most watches, collected, and favorited
* activities over the last 24 hours.
*
* @param page Page number for pagination (1-indexed)
* @param limit Number of results per page (max 100)
* @return List of shows with engagement metrics (watchers count)
* @see [Trakt Trending Shows](https://trakt.docs.apiary.io/#reference/shows/trending)
*/
public suspend fun getTrendingShows(
page: Int = 1,
limit: Int = 20,
genres: String? = null,
): ApiResponse>
/**
* Fetches available show genres from Trakt.
*
* @return List of genre objects with name and slug
* @see [Trakt Genres](https://trakt.docs.apiary.io/#reference/genres)
*/
public suspend fun getGenres(): ApiResponse>
/**
* Fetches popular shows.
*
* Popularity is calculated based on total number of favorites, plays, and watchers.
*
* @param page Page number for pagination (1-indexed)
* @param limit Number of results per page (max 100)
* @param genres Optional genre slug filter (e.g., "action", "comedy")
* @return List of popular shows ordered by popularity score
* @see [Trakt Popular Shows](https://trakt.docs.apiary.io/#reference/shows/popular)
*/
public suspend fun getPopularShows(
page: Int = 1,
limit: Int = 20,
genres: String? = null,
): ApiResponse>
/**
* Fetches most favorited shows within a time period.
*
* Used for "Top Rated" displays. Shows are ranked by favorite count within the period.
*
* @param page Page number for pagination (1-indexed)
* @param limit Number of results per page (max 100)
* @param period Time window for calculating favorites
* @return List of shows with engagement metrics (user count)
* @see [Trakt Favorited Shows](https://trakt.docs.apiary.io/#reference/shows/favorited)
*/
public suspend fun getFavoritedShows(
page: Int = 1,
limit: Int = 20,
period: TimePeriod = TimePeriod.WEEKLY,
genres: String? = null,
): ApiResponse>
/**
* Fetches most watched shows within a time period.
*
* Shows are ranked by total plays (episode watches) within the period.
*
* @param page Page number for pagination (1-indexed)
* @param limit Number of results per page (max 100)
* @param period Time window for calculating watch counts
* @return List of shows with engagement metrics (play count)
* @see [Trakt Watched Shows](https://trakt.docs.apiary.io/#reference/shows/watched)
*/
public suspend fun getMostWatchedShows(
page: Int = 1,
limit: Int = 20,
period: TimePeriod = TimePeriod.WEEKLY,
genres: String? = null,
): ApiResponse>
/**
* Fetches shows related to a specific show.
*
* Related shows are determined by Trakt's recommendation algorithm based on
* user behavior patterns (users who liked X also liked Y).
*
* @param traktId The Trakt ID of the show to find related shows for
* @param page Page number for pagination (1-indexed)
* @param limit Number of results per page (max 100)
* @return List of related shows
* @see [Trakt Related Shows](https://trakt.docs.apiary.io/#reference/shows/related)
*/
public suspend fun getRelatedShows(
traktId: Long,
page: Int = 1,
limit: Int = 20,
): ApiResponse>
/**
* Fetches detailed information for a specific show.
*
* @param traktId The Trakt ID of the show
* @return Full show details including all extended information
* @see [Trakt Show Summary](https://trakt.docs.apiary.io/#reference/shows/summary)
*/
public suspend fun getShowDetails(traktId: Long): ApiResponse
/**
* Fetches all seasons for a specific show with extended information.
*
* @param traktId The Trakt ID of the show
* @return List of seasons with episode counts, ratings, and air dates
* @see [Trakt Show Seasons](https://trakt.docs.apiary.io/#reference/seasons/summary)
*/
public suspend fun getShowSeasons(traktId: Long): ApiResponse>
/**
* Fetches a specific season with all episodes.
*
* @param traktId The Trakt ID of the show
* @param seasonNumber The season number to fetch
* @return Season details with all episodes
* @see [Trakt Season Summary](https://trakt.docs.apiary.io/#reference/seasons/season)
*/
public suspend fun getShowSeasonEpisodes(
traktId: Long,
seasonNumber: Int,
): ApiResponse>
/**
* Fetches all seasons with all episodes for a show in a single API call.
*
* Uses extended=full,episodes to get complete season and episode data,
* reducing API calls from N (one per season) to 1.
*
* @param traktId The Trakt ID of the show
* @return List of seasons, each containing its episodes
* @see [Trakt Seasons](https://trakt.docs.apiary.io/#reference/seasons/summary)
*/
public suspend fun getSeasonsWithEpisodes(traktId: Long): ApiResponse>
/**
* Searches for a show by its TMDB ID.
*
* Used to cross-reference shows between TMDB and Trakt APIs. Returns search results
* that may include movies - filter by `type == "show"` to get the show entry.
*
* @param tmdbId The TMDB ID to search for
* @return List of search results. Extract the show with:
* `results.firstOrNull { it.type == "show" }?.show`
* @see [Trakt ID Lookup](https://trakt.docs.apiary.io/#reference/search/id-lookup)
*/
public suspend fun getShowByTmdbId(tmdbId: Long): ApiResponse>
/**
* Searches for shows by text query.
*
* @param query The search query string
* @param page Page number for pagination (1-indexed)
* @param limit Number of results per page (max 100)
* @return List of search results containing show information
* @see [Trakt Text Search](https://trakt.docs.apiary.io/#reference/search/text-query)
*/
public suspend fun searchShows(
query: String,
page: Int = 1,
limit: Int = 20,
): ApiResponse>
/**
* Fetches cast and crew for a specific show.
*
* Returns all cast members with their character names and episode counts.
* Data is sorted by episode count (most appearances first).
*
* @param traktId The Trakt ID of the show
* @return Show people response containing cast information
* @see [Trakt Show People](https://trakt.docs.apiary.io/#reference/shows/people)
*/
public suspend fun getShowPeople(traktId: Long): ApiResponse
/**
* Fetches all videos for a specific show.
*
* Returns trailers, teasers, and other video content from YouTube and other sites.
*
* @param traktId The Trakt ID of the show
* @return List of video responses containing URLs and metadata
* @see [Trakt Show Videos](https://trakt.docs.apiary.io/#reference/shows/videos)
*/
public suspend fun getShowVideos(traktId: Long): ApiResponse>
/**
* Fetches the user's watch progress for a specific show.
*
* Returns progress data including the next unwatched episode and the last watched episode.
* Requires user authentication. The response includes `aired`, `completed` counts,
* and nested `next_episode` / `last_episode` objects.
*
* @param traktId The Trakt ID of the show
* @return Watch progress with next and last episode information
* @see [Trakt Watched Progress](https://trakt.docs.apiary.io/#reference/shows/watched-progress)
*/
public suspend fun getWatchedProgress(traktId: Long): ApiResponse
}
/**
* Time periods for filtering Trakt statistics endpoints.
*
* @property value The API parameter value sent to Trakt
*/
public enum class TimePeriod(public val value: String) {
DAILY("daily"),
WEEKLY("weekly"),
MONTHLY("monthly"),
YEARLY("yearly"),
ALL("all"),
}
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/TraktSyncRemoteDataSource.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktLastActivitiesResponse
public interface TraktSyncRemoteDataSource {
public suspend fun getLastActivities(): ApiResponse
}
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/TraktTokenRemoteDataSource.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktAccessRefreshTokenResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktAccessTokenResponse
public interface TraktTokenRemoteDataSource {
public suspend fun getAccessToken(authCode: String): ApiResponse
public suspend fun getAccessRefreshToken(refreshToken: String): ApiResponse
public suspend fun revokeAccessToken(authCode: String)
}
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/TraktUserRemoteDataSource.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktPersonalListsResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktUserResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktUserStatsResponse
public interface TraktUserRemoteDataSource {
public suspend fun getUser(userId: String): ApiResponse
public suspend fun getUserStats(userId: String): ApiResponse
public suspend fun getUserList(userId: String): List
}
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/AccessTokenBody.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class AccessTokenBody(
@SerialName("code") val code: String?,
@SerialName("client_id") val clientId: String,
@SerialName("client_secret") val clientSecret: String,
@SerialName("redirect_uri") val redirectUri: String,
@SerialName("grant_type") val grantType: String? = null,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/RefreshAccessTokenBody.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class RefreshAccessTokenBody(
@SerialName("refresh_token") val refreshToken: String?,
@SerialName("client_id") val clientId: String = "",
@SerialName("client_secret") val clientSecret: String = "",
@SerialName("redirect_uri") val redirectUri: String = "",
@SerialName("grant_type") val grantType: String = "refresh_token",
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktAccessRefreshTokenResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktAccessRefreshTokenResponse(
@SerialName("scope") val scope: String?,
@SerialName("access_token") val accessToken: String?,
@SerialName("created_at") val createdAt: Long?,
@SerialName("expires_in") val expiresIn: Long?,
@SerialName("refresh_token") val refreshToken: String?,
@SerialName("token_type") val tokenType: String?,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktAccessTokenResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktAccessTokenResponse(
@SerialName("scope") val scope: String?,
@SerialName("access_token") val accessToken: String?,
@SerialName("created_at") val createdAt: Long?,
@SerialName("expires_in") val expiresIn: Long?,
@SerialName("refresh_token") val refreshToken: String?,
@SerialName("token_type") val tokenType: String?,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktAddShowRequest.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktAddShowRequest(
@SerialName("shows") val shows: List,
)
@Serializable
public data class TraktShow(
@SerialName("ids") val ids: TraktShowIds,
)
@Serializable
public data class TraktShowIds(
@SerialName("trakt") val traktId: Long? = null,
@SerialName("tmdb") val tmdbId: Long? = null,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktAddShowToListResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktAddShowToListResponse(
@SerialName("added") val added: TraktAddedShowsResponse,
@SerialName("existing") val existing: TraktExistingShowsResponse,
@SerialName("not_found") val notFound: TraktNotFoundShowsResponse,
@SerialName("list") val list: TraktListResponse,
)
@Serializable
public data class TraktAddedShowsResponse(
@SerialName("shows") val shows: Int,
)
@Serializable
public data class TraktExistingShowsResponse(
@SerialName("shows") val shows: Int,
)
@Serializable
public data class TraktNotFoundShowsResponse(
@SerialName("shows") val shows: List,
)
@Serializable
public data class TraktListResponse(
@SerialName("item_count") val itemCount: Int,
@SerialName("updated_at") val updateAdd: String,
)
@Serializable
public data class TraktNotFoundShows(
@SerialName("trakt") val trakt: Int,
@SerialName("tmdb") val tmdb: Int,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktCalendarResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktCalendarResponse(
@SerialName("first_aired") val firstAired: String,
@SerialName("episode") val episode: TraktCalendarEpisode,
@SerialName("show") val show: TraktCalendarShow,
)
@Serializable
public data class TraktCalendarEpisode(
@SerialName("season") val seasonNumber: Int,
@SerialName("number") val episodeNumber: Int,
@SerialName("title") val title: String?,
@SerialName("ids") val ids: EpisodeIds,
@SerialName("overview") val overview: String? = null,
@SerialName("runtime") val runtime: Int? = null,
@SerialName("rating") val rating: Double? = null,
@SerialName("votes") val votes: Int? = null,
)
@Serializable
public data class TraktCalendarShow(
@SerialName("title") val title: String,
@SerialName("year") val year: Int? = null,
@SerialName("ids") val ids: ShowIds,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktCreateListRequest.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktCreateListRequest(
@SerialName("name") val name: String = "Following",
@SerialName("privacy") val privacy: String = "private",
@SerialName("sort_by") val sortBy: String = "added",
@SerialName("sort_how") val sortHow: String = "asc",
@SerialName("description") val description: String = "Your list of followed shows on TvManiac.",
@SerialName("display_numbers") val displayNumbers: Boolean = false,
@SerialName("allow_comments") val allowComments: Boolean = false,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktCreateListResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktCreateListResponse(
@SerialName("name") val name: String,
@SerialName("description") val description: String,
@SerialName("privacy") val privacy: String,
@SerialName("ids") val ids: ListIds,
)
@Serializable
public data class ListIds(
@SerialName("trakt") val trakt: Int,
@SerialName("slug") val slug: String,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktFollowedShowResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktFollowedShowResponse(
@SerialName("rank") var rank: Int,
@SerialName("id") var id: Int,
@SerialName("listed_at") var listedAt: String,
@SerialName("notes") var notes: String? = null,
@SerialName("type") var type: String,
@SerialName("show") var show: ShowResponse,
)
@Serializable
public data class ShowResponse(
@SerialName("title") var title: String,
@SerialName("year") var year: Int? = null,
@SerialName("ids") var ids: IdsResponse,
)
@Serializable
public data class IdsResponse(
@SerialName("slug") var slug: String,
@SerialName("trakt") var trakt: Long,
@SerialName("tmdb") var tmdb: Long,
@SerialName("tvdb") var tvdb: Long? = null,
@SerialName("imdb") var imdb: String? = null,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktGenreResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktGenreResponse(
@SerialName("name") val name: String,
@SerialName("slug") val slug: String,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktLastActivitiesResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktLastActivitiesResponse(
@SerialName("all") val all: String,
@SerialName("shows") val shows: TraktShowActivities,
@SerialName("episodes") val episodes: TraktEpisodeActivities,
)
@Serializable
public data class TraktShowActivities(
@SerialName("watched_at") val watchedAt: String? = null,
@SerialName("collected_at") val collectedAt: String? = null,
@SerialName("rated_at") val ratedAt: String? = null,
@SerialName("watchlisted_at") val watchlistedAt: String? = null,
@SerialName("favorited_at") val favoritedAt: String? = null,
@SerialName("recommendations_at") val recommendationsAt: String? = null,
@SerialName("commented_at") val commentedAt: String? = null,
@SerialName("hidden_at") val hiddenAt: String? = null,
)
@Serializable
public data class TraktEpisodeActivities(
@SerialName("watched_at") val watchedAt: String? = null,
@SerialName("collected_at") val collectedAt: String? = null,
@SerialName("rated_at") val ratedAt: String? = null,
@SerialName("watchlisted_at") val watchlistedAt: String? = null,
@SerialName("commented_at") val commentedAt: String? = null,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktNextEpisodeResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktNextEpisodeResponse(
@SerialName("season") val seasonNumber: Int,
@SerialName("number") val episodeNumber: Int,
@SerialName("title") val title: String? = null,
@SerialName("ids") val ids: EpisodeIds,
@SerialName("overview") val overview: String? = null,
@SerialName("rating") val rating: Double? = null,
@SerialName("votes") val votes: Int? = null,
@SerialName("runtime") val runtime: Int? = null,
@SerialName("first_aired") val firstAired: String? = null,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktPeopleResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Response from Trakt's `/shows/:id/people` endpoint.
*
* @see [Trakt Show People](https://trakt.docs.apiary.io/#reference/shows/people)
*/
@Serializable
public data class TraktShowPeopleResponse(
@SerialName("cast") val cast: List = emptyList(),
)
/**
* Represents a cast member in a show.
*
* @property characters List of character names played by this person
* @property episodeCount Number of episodes this person appeared in
* @property person The person details
*/
@Serializable
public data class TraktCastMember(
@SerialName("characters") val characters: List = emptyList(),
@SerialName("episode_count") val episodeCount: Int? = null,
@SerialName("person") val person: TraktPerson,
)
/**
* Represents a person (actor/crew member) in Trakt.
*/
@Serializable
public data class TraktPerson(
@SerialName("name") val name: String,
@SerialName("ids") val ids: TraktPersonIds,
@SerialName("biography") val biography: String? = null,
@SerialName("birthday") val birthday: String? = null,
@SerialName("death") val death: String? = null,
@SerialName("birthplace") val birthplace: String? = null,
@SerialName("homepage") val homepage: String? = null,
)
/**
* IDs for a person across different services.
*/
@Serializable
public data class TraktPersonIds(
@SerialName("trakt") val trakt: Long,
@SerialName("slug") val slug: String = "",
@SerialName("imdb") val imdb: String? = null,
@SerialName("tmdb") val tmdb: Long? = null,
@SerialName("tvrage") val tvrage: Long? = null,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktPersonalListsResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktPersonalListsResponse(
@SerialName("allow_comments") val allowComments: Boolean,
@SerialName("comment_count") val commentCount: Int,
@SerialName("created_at") val createdAt: String,
@SerialName("description") val description: String,
@SerialName("display_numbers") val display_numbers: Boolean,
@SerialName("ids") val ids: ListIds,
@SerialName("item_count") val item_count: Int,
@SerialName("likes") val likes: Int,
@SerialName("name") val name: String,
@SerialName("privacy") val privacy: String,
@SerialName("sort_by") val sort_by: String,
@SerialName("sort_how") val sort_how: String,
@SerialName("updated_at") val updated_at: String,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktRemoveShowFromListResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktAddRemoveShowFromListResponse(
@SerialName("deleted") val deleted: TraktDeletedShowsResponse,
@SerialName("not_found") val notFound: TraktNotFoundShowsResponse,
@SerialName("list") val list: TraktListResponse,
)
@Serializable
public data class TraktDeletedShowsResponse(
@SerialName("shows") val shows: Int,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktSeasonEpisodesResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktSeasonEpisodesResponse(
@SerialName("number") val number: Int,
@SerialName("ids") val ids: SeasonIds,
@SerialName("votes") val votes: Int = 0,
@SerialName("rating") val rating: Double = 0.0,
@SerialName("aired_episodes") val airedEpisodes: Int = 0,
@SerialName("episode_count") val episodeCount: Int = 0,
@SerialName("title") val title: String? = null,
@SerialName("overview") val overview: String? = null,
@SerialName("first_aired") val firstAirDate: String? = null,
@SerialName("episodes") val episodes: List = emptyList(),
)
@Serializable
public data class TraktEpisodesResponse(
@SerialName("season") val seasonNumber: Int,
@SerialName("number") val episodeNumber: Int,
@SerialName("ids") val ids: EpisodeIds,
@SerialName("title") val title: String = "",
@SerialName("overview") val overview: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
@SerialName("rating") val ratings: Double? = null,
@SerialName("votes") val votes: Int? = null,
@SerialName("runtime") val runtime: Int? = null,
@SerialName("first_aired") val firstAired: String? = null,
)
@Serializable
public data class EpisodeIds(
@SerialName("trakt") val trakt: Int,
@SerialName("tmdb") val tmdb: Int?,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktSeasonsResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktSeasonsResponse(
@SerialName("number") val number: Int,
@SerialName("ids") val ids: SeasonIds,
@SerialName("votes") val votes: Int,
@SerialName("rating") val rating: Double,
@SerialName("aired_episodes") val airedEpisodes: Int,
@SerialName("episode_count") val episodeCount: Int,
@SerialName("title") val title: String,
@SerialName("overview") val overview: String?,
@SerialName("first_aired") val firstAirDate: String?,
)
@Serializable
public data class SeasonIds(
@SerialName("trakt") val trakt: Int,
@SerialName("tmdb") val tmdb: Int?,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktShowsResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktShowsResponse(
@SerialName("watchers") val watchers: Long? = null,
@SerialName("watcher_count") val watcherCount: Long? = null,
@SerialName("play_count") val playCount: Long? = null,
@SerialName("collected_count") val collectedCount: Long? = null,
@SerialName("collector_count") val collectorCount: Long? = null,
@SerialName("user_count") val userCount: Long? = null,
@SerialName("show") val show: TraktShowResponse,
)
@Serializable
public data class TraktShowResponse(
@SerialName("title") val title: String,
@SerialName("year") val year: Long? = null,
@SerialName("ids") val ids: ShowIds,
@SerialName("tagline") val tagline: String? = null,
@SerialName("overview") val overview: String? = null,
@SerialName("language") val language: String? = null,
@SerialName("first_aired") val firstAirDate: String? = null,
@SerialName("runtime") val runtime: Long? = null,
@SerialName("country") val country: String? = null,
@SerialName("trailer") val trailer: String? = null,
@SerialName("homepage") val homepage: String? = null,
@SerialName("status") val status: String? = null,
@SerialName("rating") val rating: Double? = null,
@SerialName("votes") val votes: Long? = null,
@SerialName("aired_episodes") val airedEpisodes: Long? = null,
@SerialName("genres") val genres: List? = null,
@SerialName("certification") val certification: String? = null,
@SerialName("network") val network: String? = null,
@SerialName("airs") val airs: Airs? = null,
)
@Serializable
public data class ShowIds(
@SerialName("trakt") val trakt: Long,
@SerialName("tmdb") val tmdb: Long? = null,
@SerialName("slug") val slug: String = "",
@SerialName("imdb") val imdb: String? = null,
@SerialName("tvdb") val tvdb: Long? = null,
)
@Serializable
public data class Airs(
@SerialName("day") val day: String? = null,
@SerialName("time") val time: String? = null,
@SerialName("timezone") val timezone: String? = null,
)
@Serializable
public data class TraktSearchResult(
@SerialName("type") val type: String,
@SerialName("score") val score: Double? = null,
@SerialName("show") val show: TraktShowResponse? = null,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktSyncModels.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktSyncItems(
@SerialName("ids") val ids: List? = null,
@SerialName("episodes") val episodes: List? = null,
@SerialName("shows") val shows: List? = null,
)
@Serializable
public data class TraktSyncShow(
@SerialName("ids") val ids: TraktShowIds,
@SerialName("seasons") val seasons: List,
)
@Serializable
public data class TraktSyncSeason(
@SerialName("number") val number: Long,
@SerialName("episodes") val episodes: List,
)
@Serializable
public data class TraktSyncSeasonEpisode(
@SerialName("number") val number: Long,
@SerialName("watched_at") val watchedAt: String? = null,
)
@Serializable
public data class TraktSyncEpisode(
@SerialName("ids") val ids: TraktEpisodeIds,
@SerialName("watched_at") val watchedAt: String? = null,
)
@Serializable
public data class TraktEpisodeIds(
@SerialName("trakt") val traktId: Long? = null,
@SerialName("tmdb") val tmdbId: Long? = null,
)
@Serializable
public data class TraktSyncResponse(
@SerialName("added") val added: TraktSyncStats? = null,
@SerialName("deleted") val deleted: TraktSyncStats? = null,
@SerialName("not_found") val notFound: TraktSyncNotFound? = null,
)
@Serializable
public data class TraktSyncStats(
@SerialName("episodes") val episodes: Int? = null,
)
@Serializable
public data class TraktSyncNotFound(
@SerialName("episodes") val episodes: List? = null,
)
@Serializable
public data class TraktHistoryEntry(
@SerialName("id") val id: Long,
@SerialName("watched_at") val watchedAt: String,
@SerialName("action") val action: String? = null,
@SerialName("type") val type: String? = null,
@SerialName("episode") val episode: TraktHistoryEpisode,
@SerialName("show") val show: TraktHistoryShow,
)
@Serializable
public data class TraktHistoryEpisode(
@SerialName("season") val season: Int,
@SerialName("number") val number: Int,
@SerialName("title") val title: String? = null,
@SerialName("ids") val ids: TraktEpisodeIds,
)
@Serializable
public data class TraktHistoryShow(
@SerialName("title") val title: String? = null,
@SerialName("year") val year: Int? = null,
@SerialName("ids") val ids: TraktHistoryShowIds,
)
@Serializable
public data class TraktHistoryShowIds(
@SerialName("trakt") val traktId: Long? = null,
@SerialName("slug") val slug: String? = null,
@SerialName("tvdb") val tvdb: Long? = null,
@SerialName("imdb") val imdb: String? = null,
@SerialName("tmdb") val tmdbId: Long? = null,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktUserResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktUserResponse(
@SerialName("username") val userName: String,
@SerialName("name") val name: String,
@SerialName("images") val images: ProfileImages,
@SerialName("ids") val ids: Ids,
)
@Serializable
public data class Ids(
@SerialName("slug") val slug: String,
)
@Serializable
public data class ProfileImages(
@SerialName("avatar") val avatar: Avatar,
) {
@Serializable
public data class Avatar(
@SerialName("full") val full: String,
)
}
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktUserStatsResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktUserStatsResponse(
@SerialName("movies") val movies: Movies,
@SerialName("shows") val shows: Shows,
@SerialName("seasons") val seasons: Seasons,
@SerialName("episodes") val episodes: Episodes,
)
@Serializable
public data class Movies(
@SerialName("plays") val plays: Int,
@SerialName("watched") val watched: Int,
@SerialName("minutes") val minutes: Int,
@SerialName("collected") val collected: Int,
)
@Serializable
public data class Shows(
@SerialName("watched") val watched: Int,
@SerialName("collected") val collected: Int,
)
@Serializable
public data class Seasons(
@SerialName("ratings") val ratings: Int,
)
@Serializable
public data class Episodes(
@SerialName("plays") val plays: Int,
@SerialName("watched") val watched: Int,
@SerialName("minutes") val minutes: Int,
@SerialName("collected") val collected: Int,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktVideosResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktVideosResponse(
@SerialName("title") val title: String,
@SerialName("url") val url: String,
@SerialName("site") val site: String,
@SerialName("type") val type: String,
@SerialName("size") val size: Int? = null,
@SerialName("official") val official: Boolean? = null,
@SerialName("published_at") val publishedAt: String? = null,
@SerialName("country") val country: String? = null,
@SerialName("language") val language: String? = null,
)
================================================
FILE: api/trakt/api/src/commonMain/kotlin/com/thomaskioko/tvmaniac/trakt/api/model/TraktWatchedProgressResponse.kt
================================================
package com.thomaskioko.tvmaniac.trakt.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
public data class TraktWatchedProgressResponse(
@SerialName("aired") val aired: Int,
@SerialName("completed") val completed: Int,
@SerialName("last_watched_at") val lastWatchedAt: String? = null,
@SerialName("reset_at") val resetAt: String? = null,
@SerialName("next_episode") val nextEpisode: TraktNextEpisodeResponse? = null,
@SerialName("last_episode") val lastEpisode: TraktNextEpisodeResponse? = null,
)
================================================
FILE: api/trakt/implementation/build.gradle.kts
================================================
plugins {
alias(libs.plugins.app.kmp)
}
scaffold {
addAndroidTarget()
useMetro()
useSerialization()
}
kotlin {
sourceSets {
androidMain {
dependencies {
implementation(libs.appauth)
implementation(libs.ktor.okhttp)
}
}
commonMain {
dependencies {
implementation(projects.api.trakt.api)
implementation(projects.core.appconfig.api)
implementation(projects.core.base)
implementation(projects.core.connectivity.api)
implementation(projects.core.networkUtil.api)
implementation(projects.core.util.api)
implementation(projects.core.logger.api)
implementation(projects.data.datastore.api)
implementation(projects.data.traktauth.api)
implementation(libs.ktor.auth)
implementation(libs.ktor.core)
implementation(libs.ktor.logging)
implementation(libs.ktor.negotiation)
implementation(libs.ktor.serialization.json)
implementation(libs.sqldelight.extensions)
}
}
commonTest {
dependencies {
implementation(libs.bundles.unittest)
implementation(libs.ktor.mock)
implementation(libs.ktor.negotiation)
implementation(libs.ktor.serialization.json)
implementation(projects.api.trakt.api)
implementation(projects.core.base)
implementation(projects.core.networkUtil.api)
}
}
iosMain {
dependencies {
implementation(projects.api.trakt.api)
implementation(libs.ktor.darwin)
}
}
}
}
================================================
FILE: api/trakt/implementation/src/androidMain/kotlin/com/thomaskioko/trakt/service/implementation/TraktPlatformBindingContainer.kt
================================================
package com.thomaskioko.trakt.service.implementation
import com.thomaskioko.tvmaniac.core.base.TraktApi
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.okhttp.OkHttp
@BindingContainer
@ContributesTo(AppScope::class)
public object TraktPlatformBindingContainer {
@Provides
@SingleIn(AppScope::class)
@TraktApi
public fun provideTraktHttpClientEngine(): HttpClientEngine = OkHttp.create()
}
================================================
FILE: api/trakt/implementation/src/commonMain/kotlin/com/thomaskioko/trakt/service/implementation/TraktAuthPlugin.kt
================================================
package com.thomaskioko.trakt.service.implementation
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.RequiresAuth
import com.thomaskioko.tvmaniac.core.networkutil.api.model.AuthenticationException
import io.ktor.client.plugins.api.createClientPlugin
/**
* Configuration for [TraktAuthGuard].
*
* @property isAuthenticated Lambda that returns `true` when the current user has a valid
* session. Defaults to `{ false }` (unauthenticated).
*/
internal class TraktAuthConfig {
var isAuthenticated: () -> Boolean = { false }
}
/**
* Ktor client plugin that acts as a defense-in-depth guard for authenticated endpoints.
*
* On every outgoing request, the plugin checks whether the [RequiresAuth] attribute is set
* to `true`. If it is and the user is not authenticated (per [TraktAuthConfig.isAuthenticated]),
* the request is blocked immediately by throwing [AuthenticationException].
*
* In normal operation, [authSafeRequest][com.thomaskioko.tvmaniac.core.networkutil.api.extensions.authSafeRequest]
* performs its own pre-check and returns [ApiResponse.Unauthenticated] before the request
* reaches this plugin. The guard therefore catches only the narrow race where the user logs
* out between the `authSafeRequest` pre-check and the actual request execution, or cases
* where a caller sets [RequiresAuth] without going through `authSafeRequest`.
*/
internal val TraktAuthGuard = createClientPlugin("TraktAuthGuard", ::TraktAuthConfig) {
val isAuthenticated = pluginConfig.isAuthenticated
onRequest { request, _ ->
val requiresAuth = request.attributes.getOrNull(RequiresAuth) == true
if (requiresAuth && !isAuthenticated()) {
throw AuthenticationException(
message = "Authentication required for ${request.method.value} ${request.url.buildString()}",
)
}
}
}
================================================
FILE: api/trakt/implementation/src/commonMain/kotlin/com/thomaskioko/trakt/service/implementation/TraktBindingContainer.kt
================================================
package com.thomaskioko.trakt.service.implementation
import com.thomaskioko.tvmaniac.appconfig.ApplicationInfo
import com.thomaskioko.tvmaniac.core.base.TraktApi
import com.thomaskioko.tvmaniac.core.connectivity.api.InternetConnectionChecker
import com.thomaskioko.tvmaniac.core.logger.Logger
import com.thomaskioko.tvmaniac.trakt.api.TraktConfig
import com.thomaskioko.tvmaniac.traktauth.api.TraktAuthRepository
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngine
import kotlinx.serialization.json.Json
@BindingContainer
@ContributesTo(AppScope::class)
public object TraktBindingContainer {
private val json: Json = Json {
ignoreUnknownKeys = true
prettyPrint = true
encodeDefaults = true
}
@Provides
@SingleIn(AppScope::class)
@TraktApi
public fun provideHttpClient(
@TraktApi httpClientEngine: HttpClientEngine,
applicationInfo: ApplicationInfo,
traktConfig: TraktConfig,
logger: Logger,
traktAuthRepository: TraktAuthRepository,
internetConnectionChecker: InternetConnectionChecker,
): HttpClient = traktHttpClient(
isDebug = applicationInfo.debugBuild,
traktClientId = traktConfig.clientId,
json = json,
httpClientEngine = httpClientEngine,
kermitLogger = logger,
traktAuthRepository = traktAuthRepository,
internetConnectionChecker = internetConnectionChecker,
)
}
================================================
FILE: api/trakt/implementation/src/commonMain/kotlin/com/thomaskioko/trakt/service/implementation/TraktHttpClient.kt
================================================
package com.thomaskioko.trakt.service.implementation
import com.thomaskioko.tvmaniac.core.connectivity.api.InternetConnectionChecker
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.InternetConnectionPlugin
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.IsAuthenticated
import com.thomaskioko.tvmaniac.core.networkutil.api.model.HttpExceptions
import com.thomaskioko.tvmaniac.traktauth.api.TokenRefreshResult
import com.thomaskioko.tvmaniac.traktauth.api.TraktAuthRepository
import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpResponseValidator
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.EMPTY
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.URLProtocol
import io.ktor.http.encodedPath
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import com.thomaskioko.tvmaniac.core.logger.Logger as KermitLogger
internal const val TIMEOUT_DURATION: Long = 60_000
private const val OAUTH_PATH = "oauth/"
internal fun traktHttpClient(
isDebug: Boolean = false,
traktClientId: String,
json: Json,
httpClientEngine: HttpClientEngine,
kermitLogger: KermitLogger,
traktAuthRepository: TraktAuthRepository,
internetConnectionChecker: InternetConnectionChecker,
): HttpClient {
val client = HttpClient(httpClientEngine) {
install(ContentNegotiation) { json(json = json) }
install(InternetConnectionPlugin) {
this.internetConnectionChecker = internetConnectionChecker
}
install(HttpRequestRetry) {
retryIf(5) { _, httpResponse ->
when {
httpResponse.status.value in 500..599 -> true
httpResponse.status == HttpStatusCode.TooManyRequests -> true
else -> false
}
}
exponentialDelay(
base = 2.0,
maxDelayMs = 60_000L,
randomizationMs = 1000L,
)
}
install(DefaultRequest) {
url {
protocol = URLProtocol.HTTPS
host = "api.trakt.tv"
}
headers {
append(HttpHeaders.ContentType, "application/json")
append("trakt-api-version", "2")
append("trakt-api-key", traktClientId)
}
}
install(Auth) {
bearer {
loadTokens {
val state = traktAuthRepository.getAuthState()
?.takeIf { it.isAuthorized && it.accessToken.isNotBlank() }
?: return@loadTokens null
BearerTokens(state.accessToken, state.refreshToken)
}
refreshTokens {
val currentState = traktAuthRepository.getAuthState()
?: return@refreshTokens null
if (oldTokens?.refreshToken != null && oldTokens?.refreshToken != currentState.refreshToken) {
return@refreshTokens BearerTokens(currentState.accessToken, currentState.refreshToken)
}
val result = traktAuthRepository.refreshTokens()
if (result is TokenRefreshResult.Success) {
BearerTokens(result.authState.accessToken, result.authState.refreshToken)
} else {
null
}
}
sendWithoutRequest { request ->
!request.url.encodedPath.startsWith("/$OAUTH_PATH")
}
}
}
HttpResponseValidator {
validateResponse { response ->
if (!response.status.isSuccess() && response.status != HttpStatusCode.Unauthorized) {
val failureReason =
when (response.status) {
HttpStatusCode.Forbidden -> "${response.status.value} Missing API key."
HttpStatusCode.NotFound -> "Invalid Request"
HttpStatusCode.TooManyRequests -> "Rate limited. Please try again in a moment."
HttpStatusCode.UpgradeRequired -> "Upgrade to VIP"
HttpStatusCode.RequestTimeout -> "Network Timeout"
in HttpStatusCode.InternalServerError..HttpStatusCode.GatewayTimeout ->
"${response.status.value} Server Error"
else -> "Network error!"
}
throw HttpExceptions(
response = response,
failureReason = failureReason,
cachedResponseText = response.bodyAsText(),
)
}
}
}
install(TraktAuthGuard) {
isAuthenticated = { traktAuthRepository.isLoggedIn() }
}
install(HttpTimeout) {
requestTimeoutMillis = TIMEOUT_DURATION
connectTimeoutMillis = TIMEOUT_DURATION
socketTimeoutMillis = TIMEOUT_DURATION
}
install(Logging) {
level = if (isDebug) LogLevel.BODY else LogLevel.NONE
logger = if (isDebug) {
object : Logger {
override fun log(message: String) {
kermitLogger.info("TraktHttp", message)
}
}
} else {
Logger.EMPTY
}
}
}
client.attributes.put(IsAuthenticated) { traktAuthRepository.isLoggedIn() }
return client
}
================================================
FILE: api/trakt/implementation/src/commonMain/kotlin/com/thomaskioko/trakt/service/implementation/api/DefaultTraktCalendarRemoteDataSource.kt
================================================
package com.thomaskioko.trakt.service.implementation.api
import com.thomaskioko.tvmaniac.core.base.TraktApi
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.authSafeRequest
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.TraktCalendarRemoteDataSource
import com.thomaskioko.tvmaniac.trakt.api.model.TraktCalendarResponse
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
import io.ktor.client.request.parameter
import io.ktor.http.HttpMethod
import io.ktor.http.path
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
public class DefaultTraktCalendarRemoteDataSource(
@TraktApi
private val httpClient: HttpClient,
) : TraktCalendarRemoteDataSource {
override suspend fun getMyShowsCalendar(
startDate: String,
days: Int,
): ApiResponse> =
httpClient.authSafeRequest {
url {
method = HttpMethod.Get
path("calendars/my/shows/$startDate/$days")
}
parameter("extended", "full")
}
}
================================================
FILE: api/trakt/implementation/src/commonMain/kotlin/com/thomaskioko/trakt/service/implementation/api/DefaultTraktEpisodeRemoteDataSource.kt
================================================
package com.thomaskioko.trakt.service.implementation.api
import com.thomaskioko.tvmaniac.core.base.TraktApi
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.authSafeRequest
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.TraktEpisodeHistoryRemoteDataSource
import com.thomaskioko.tvmaniac.trakt.api.model.TraktHistoryEntry
import com.thomaskioko.tvmaniac.trakt.api.model.TraktSyncItems
import com.thomaskioko.tvmaniac.trakt.api.model.TraktSyncResponse
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.contentType
import io.ktor.http.path
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
public class DefaultTraktEpisodeRemoteDataSource(
@TraktApi
private val httpClient: HttpClient,
) : TraktEpisodeHistoryRemoteDataSource {
override suspend fun getShowEpisodeWatches(showTraktId: Long): ApiResponse> =
httpClient.authSafeRequest {
url {
method = HttpMethod.Get
path("users/me/history/shows/$showTraktId")
parameters.append("extended", "noseasons")
parameters.append("limit", "10000")
}
}
override suspend fun addEpisodeWatches(items: TraktSyncItems): ApiResponse =
httpClient.authSafeRequest {
url {
method = HttpMethod.Post
path("sync/history")
}
contentType(ContentType.Application.Json)
setBody(items)
}
override suspend fun removeEpisodeWatches(items: TraktSyncItems): ApiResponse =
httpClient.authSafeRequest {
url {
method = HttpMethod.Post
path("sync/history/remove")
}
contentType(ContentType.Application.Json)
setBody(items)
}
}
================================================
FILE: api/trakt/implementation/src/commonMain/kotlin/com/thomaskioko/trakt/service/implementation/api/DefaultTraktListRemoteDataSource.kt
================================================
package com.thomaskioko.trakt.service.implementation.api
import com.thomaskioko.tvmaniac.core.base.TraktApi
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.authSafeRequest
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.TraktListRemoteDataSource
import com.thomaskioko.tvmaniac.trakt.api.model.TraktAddRemoveShowFromListResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktAddShowRequest
import com.thomaskioko.tvmaniac.trakt.api.model.TraktAddShowToListResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktCreateListRequest
import com.thomaskioko.tvmaniac.trakt.api.model.TraktCreateListResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktFollowedShowResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktPersonalListsResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktShow
import com.thomaskioko.tvmaniac.trakt.api.model.TraktShowIds
import com.thomaskioko.tvmaniac.trakt.api.model.TraktUserResponse
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
import io.ktor.client.request.parameter
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.contentType
import io.ktor.http.path
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
public class DefaultTraktListRemoteDataSource(
@TraktApi
private val httpClient: HttpClient,
) : TraktListRemoteDataSource {
override suspend fun getUser(userId: String): ApiResponse =
httpClient.authSafeRequest {
url {
method = HttpMethod.Get
path("users/$userId")
parameter("extended", "full")
}
}
override suspend fun getUserList(userId: String): ApiResponse> =
httpClient.authSafeRequest {
url {
method = HttpMethod.Get
path("users/$userId/lists")
}
}
override suspend fun createList(userSlug: String, name: String): ApiResponse =
httpClient.authSafeRequest {
url {
method = HttpMethod.Post
path("users/$userSlug/lists")
}
contentType(ContentType.Application.Json)
setBody(TraktCreateListRequest(name = name))
}
override suspend fun getWatchList(sortBy: String, sortHow: String): ApiResponse> =
httpClient.authSafeRequest {
url {
method = HttpMethod.Get
path("users/me/watchlist/shows")
parameter("limit", "10000")
}
headers.append("X-Sort-By", sortBy)
headers.append("X-Sort-How", sortHow)
}
override suspend fun addShowToWatchListByTmdbId(
tmdbId: Long,
): ApiResponse =
httpClient.authSafeRequest {
url {
method = HttpMethod.Post
path("sync/watchlist")
}
contentType(ContentType.Application.Json)
setBody(
TraktAddShowRequest(
shows = listOf(TraktShow(ids = TraktShowIds(tmdbId = tmdbId))),
),
)
}
override suspend fun removeShowFromWatchListByTmdbId(
tmdbId: Long,
): ApiResponse =
httpClient.authSafeRequest {
url {
method = HttpMethod.Post
path("sync/watchlist/remove")
}
contentType(ContentType.Application.Json)
setBody(
TraktAddShowRequest(
shows = listOf(TraktShow(ids = TraktShowIds(tmdbId = tmdbId))),
),
)
}
override suspend fun addShowToWatchListByTraktId(
traktId: Long,
): ApiResponse =
httpClient.authSafeRequest {
url {
method = HttpMethod.Post
path("sync/watchlist")
}
contentType(ContentType.Application.Json)
setBody(
TraktAddShowRequest(
shows = listOf(TraktShow(ids = TraktShowIds(traktId = traktId))),
),
)
}
override suspend fun removeShowFromWatchListByTraktId(
traktId: Long,
): ApiResponse =
httpClient.authSafeRequest {
url {
method = HttpMethod.Post
path("sync/watchlist/remove")
}
contentType(ContentType.Application.Json)
setBody(
TraktAddShowRequest(
shows = listOf(TraktShow(ids = TraktShowIds(traktId = traktId))),
),
)
}
override suspend fun addShowToList(
userSlug: String,
listId: Long,
traktShowId: Long,
): ApiResponse =
httpClient.authSafeRequest {
url {
method = HttpMethod.Post
path("users/$userSlug/lists/$listId/items")
}
contentType(ContentType.Application.Json)
setBody(
TraktAddShowRequest(
shows = listOf(
TraktShow(
ids = TraktShowIds(
traktId = traktShowId,
),
),
),
),
)
}
override suspend fun removeShowFromList(
userSlug: String,
listId: Long,
traktShowId: Long,
): ApiResponse =
httpClient.authSafeRequest {
url {
method = HttpMethod.Post
path("users/$userSlug/lists/$listId/items/remove")
}
contentType(ContentType.Application.Json)
setBody(
TraktAddShowRequest(
shows = listOf(
TraktShow(
ids = TraktShowIds(
traktId = traktShowId,
),
),
),
),
)
}
}
================================================
FILE: api/trakt/implementation/src/commonMain/kotlin/com/thomaskioko/trakt/service/implementation/api/DefaultTraktShowsRemoteDataSource.kt
================================================
package com.thomaskioko.trakt.service.implementation.api
import com.thomaskioko.tvmaniac.core.base.TraktApi
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.authSafeRequest
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.safeRequest
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.TimePeriod
import com.thomaskioko.tvmaniac.trakt.api.TraktShowsRemoteDataSource
import com.thomaskioko.tvmaniac.trakt.api.model.TraktEpisodesResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktGenreResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktSearchResult
import com.thomaskioko.tvmaniac.trakt.api.model.TraktSeasonEpisodesResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktSeasonsResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktShowPeopleResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktShowResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktShowsResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktVideosResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktWatchedProgressResponse
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
import io.ktor.client.request.parameter
import io.ktor.http.HttpMethod
import io.ktor.http.path
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
public class DefaultTraktShowsRemoteDataSource(
@TraktApi
private val httpClient: HttpClient,
) : TraktShowsRemoteDataSource {
override suspend fun getTrendingShows(
page: Int,
limit: Int,
genres: String?,
): ApiResponse> =
httpClient.safeRequest {
url {
method = HttpMethod.Get
path("shows/trending")
}
parameter("page", page)
parameter("limit", limit)
parameter("extended", "full")
if (genres != null) {
parameter("genres", genres)
}
}
override suspend fun getGenres(): ApiResponse> =
httpClient.safeRequest {
url {
method = HttpMethod.Get
path("genres/shows")
}
}
override suspend fun getPopularShows(
page: Int,
limit: Int,
genres: String?,
): ApiResponse> =
httpClient.safeRequest {
url {
method = HttpMethod.Get
path("shows/popular")
}
parameter("page", page)
parameter("limit", limit)
parameter("extended", "full")
if (genres != null) {
parameter("genres", genres)
}
}
override suspend fun getFavoritedShows(
page: Int,
limit: Int,
period: TimePeriod,
genres: String?,
): ApiResponse> =
httpClient.safeRequest {
url {
method = HttpMethod.Get
path("shows/favorited/${period.value}")
}
parameter("page", page)
parameter("limit", limit)
parameter("extended", "full")
if (genres != null) {
parameter("genres", genres)
}
}
override suspend fun getMostWatchedShows(
page: Int,
limit: Int,
period: TimePeriod,
genres: String?,
): ApiResponse> =
httpClient.safeRequest {
url {
method = HttpMethod.Get
path("shows/watched/${period.value}")
}
parameter("page", page)
parameter("limit", limit)
parameter("extended", "full")
if (genres != null) {
parameter("genres", genres)
}
}
override suspend fun getRelatedShows(
traktId: Long,
page: Int,
limit: Int,
): ApiResponse> =
httpClient.safeRequest {
url {
method = HttpMethod.Get
path("shows/$traktId/related")
}
parameter("page", page)
parameter("limit", limit)
parameter("extended", "full")
}
override suspend fun getShowDetails(traktId: Long): ApiResponse =
httpClient.safeRequest {
url {
method = HttpMethod.Get
path("shows/$traktId")
}
parameter("extended", "full")
}
override suspend fun getShowSeasons(traktId: Long): ApiResponse> =
httpClient.safeRequest {
url {
method = HttpMethod.Get
path("shows/$traktId/seasons")
}
parameter("extended", "full")
}
override suspend fun getShowSeasonEpisodes(
traktId: Long,
seasonNumber: Int,
): ApiResponse> =
httpClient.safeRequest {
url {
method = HttpMethod.Get
path("shows/$traktId/seasons/$seasonNumber")
}
parameter("extended", "full")
}
override suspend fun getSeasonsWithEpisodes(traktId: Long): ApiResponse> =
httpClient.safeRequest {
url {
method = HttpMethod.Get
path("shows/$traktId/seasons")
}
parameter("extended", "full,episodes")
}
override suspend fun getShowByTmdbId(tmdbId: Long): ApiResponse> =
httpClient.safeRequest {
url {
method = HttpMethod.Get
path("search/tmdb/$tmdbId")
}
parameter("type", "show")
parameter("extended", "full")
}
override suspend fun searchShows(
query: String,
page: Int,
limit: Int,
): ApiResponse> =
httpClient.safeRequest {
url {
method = HttpMethod.Get
path("search")
}
parameter("type", "show")
parameter("query", query)
parameter("extended", "full")
}
override suspend fun getShowPeople(traktId: Long): ApiResponse =
httpClient.safeRequest {
url {
method = HttpMethod.Get
path("shows/$traktId/people")
}
parameter("extended", "full")
}
override suspend fun getShowVideos(traktId: Long): ApiResponse> =
httpClient.safeRequest {
url {
method = HttpMethod.Get
path("shows/$traktId/videos")
}
}
override suspend fun getWatchedProgress(traktId: Long): ApiResponse =
httpClient.authSafeRequest {
url {
method = HttpMethod.Get
path("shows/$traktId/progress/watched")
}
parameter("extended", "full")
}
}
================================================
FILE: api/trakt/implementation/src/commonMain/kotlin/com/thomaskioko/trakt/service/implementation/api/DefaultTraktSyncRemoteDataSource.kt
================================================
package com.thomaskioko.trakt.service.implementation.api
import com.thomaskioko.tvmaniac.core.base.TraktApi
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.authSafeRequest
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.TraktSyncRemoteDataSource
import com.thomaskioko.tvmaniac.trakt.api.model.TraktLastActivitiesResponse
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
import io.ktor.http.HttpMethod
import io.ktor.http.path
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
public class DefaultTraktSyncRemoteDataSource(
@TraktApi
private val httpClient: HttpClient,
) : TraktSyncRemoteDataSource {
override suspend fun getLastActivities(): ApiResponse =
httpClient.authSafeRequest {
url {
method = HttpMethod.Get
path("sync/last_activities")
}
}
}
================================================
FILE: api/trakt/implementation/src/commonMain/kotlin/com/thomaskioko/trakt/service/implementation/api/DefaultTraktTokenRemoteDataSource.kt
================================================
package com.thomaskioko.trakt.service.implementation.api
import com.thomaskioko.tvmaniac.core.base.TraktApi
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.safeRequest
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.TraktConfig
import com.thomaskioko.tvmaniac.trakt.api.TraktTokenRemoteDataSource
import com.thomaskioko.tvmaniac.trakt.api.model.AccessTokenBody
import com.thomaskioko.tvmaniac.trakt.api.model.RefreshAccessTokenBody
import com.thomaskioko.tvmaniac.trakt.api.model.TraktAccessRefreshTokenResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktAccessTokenResponse
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
import io.ktor.client.plugins.auth.AuthCircuitBreaker
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.contentType
import io.ktor.http.path
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
@Inject
public class DefaultTraktTokenRemoteDataSource(
@TraktApi
private val httpClient: HttpClient,
private val traktConfig: TraktConfig,
) : TraktTokenRemoteDataSource {
override suspend fun getAccessToken(authCode: String): ApiResponse =
httpClient.safeRequest {
url {
method = HttpMethod.Post
path("oauth/token")
}
contentType(ContentType.Application.Json)
setBody(
AccessTokenBody(
code = authCode,
clientId = traktConfig.clientId,
clientSecret = traktConfig.clientSecret,
redirectUri = traktConfig.redirectUri,
grantType = "authorization_code",
),
)
}
override suspend fun getAccessRefreshToken(
refreshToken: String,
): ApiResponse =
httpClient.safeRequest {
attributes.put(AuthCircuitBreaker, Unit)
url {
method = HttpMethod.Post
path("oauth/token")
}
contentType(ContentType.Application.Json)
setBody(
RefreshAccessTokenBody(
refreshToken = refreshToken,
clientId = traktConfig.clientId,
clientSecret = traktConfig.clientSecret,
redirectUri = traktConfig.redirectUri,
),
)
}
override suspend fun revokeAccessToken(authCode: String) {
httpClient.post("oauth/revoke") {
setBody(
AccessTokenBody(
code = authCode,
clientId = traktConfig.clientId,
clientSecret = traktConfig.clientSecret,
redirectUri = traktConfig.redirectUri,
),
)
}
}
}
================================================
FILE: api/trakt/implementation/src/commonMain/kotlin/com/thomaskioko/trakt/service/implementation/api/DefaultTraktUserRemoteDataSource.kt
================================================
package com.thomaskioko.trakt.service.implementation.api
import com.thomaskioko.tvmaniac.core.base.TraktApi
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.authSafeRequest
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.TraktUserRemoteDataSource
import com.thomaskioko.tvmaniac.trakt.api.model.TraktPersonalListsResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktUserResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktUserStatsResponse
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.http.HttpMethod
import io.ktor.http.path
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
public class DefaultTraktUserRemoteDataSource(
@TraktApi
private val httpClient: HttpClient,
) : TraktUserRemoteDataSource {
override suspend fun getUser(userId: String): ApiResponse =
httpClient.authSafeRequest {
url {
method = HttpMethod.Get
path("users/$userId")
parameter("extended", "full")
}
}
override suspend fun getUserStats(userId: String): ApiResponse =
httpClient.authSafeRequest {
url {
method = HttpMethod.Get
path("users/$userId/stats")
}
}
override suspend fun getUserList(userId: String): List =
httpClient.get("users/$userId/lists").body()
}
================================================
FILE: api/trakt/implementation/src/commonTest/kotlin/com/thomaskioko/trakt/service/implementation/TraktAuthGuardPluginTest.kt
================================================
package com.thomaskioko.trakt.service.implementation
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.RequiresAuth
import com.thomaskioko.tvmaniac.core.networkutil.api.model.AuthenticationException
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
class TraktAuthGuardPluginTest {
private fun createClient(isAuthenticated: () -> Boolean): HttpClient {
val mockEngine = MockEngine { _ ->
respond(
content = """{}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
return HttpClient(mockEngine) {
install(ContentNegotiation) { json() }
install(TraktAuthGuard) {
this.isAuthenticated = isAuthenticated
}
}
}
@Test
fun `should throw AuthenticationException given auth required and user not authenticated`() = runTest {
val client = createClient(isAuthenticated = { false })
shouldThrow {
client.get("/test") {
attributes.put(RequiresAuth, true)
}
}
}
@Test
fun `should allow request given auth required and user is authenticated`() = runTest {
val client = createClient(isAuthenticated = { true })
val response = client.get("/test") {
attributes.put(RequiresAuth, true)
}
response.status shouldBe HttpStatusCode.OK
}
@Test
fun `should allow request given auth not required and user not authenticated`() = runTest {
val client = createClient(isAuthenticated = { false })
val response = client.get("/test")
response.status shouldBe HttpStatusCode.OK
}
@Test
fun `should allow request given RequiresAuth is false`() = runTest {
val client = createClient(isAuthenticated = { false })
val response = client.get("/test") {
attributes.put(RequiresAuth, false)
}
response.status shouldBe HttpStatusCode.OK
}
}
================================================
FILE: api/trakt/implementation/src/iosMain/kotlin/com/thomaskioko/trakt/service/implementation/TraktPlatformBindingContainer.kt
================================================
package com.thomaskioko.trakt.service.implementation
import com.thomaskioko.tvmaniac.core.base.TraktApi
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.darwin.Darwin
@BindingContainer
@ContributesTo(AppScope::class)
public object TraktPlatformBindingContainer {
@Provides
@SingleIn(AppScope::class)
@TraktApi
public fun provideTraktHttpClientEngine(): HttpClientEngine = Darwin.create()
}
================================================
FILE: api/trakt/implementation/src/jvmTest/kotlin/com/thomaskioko/trakt/service/implementation/TestResourceLoader.jvm.kt
================================================
package com.thomaskioko.trakt.service.implementation
internal fun loadJson(fileName: String): String =
Thread.currentThread().contextClassLoader!!.getResource(fileName)!!.readText()
================================================
FILE: api/trakt/implementation/src/jvmTest/kotlin/com/thomaskioko/trakt/service/implementation/api/DefaultTraktListRemoteDataSourceTest.kt
================================================
package com.thomaskioko.trakt.service.implementation.api
import com.thomaskioko.trakt.service.implementation.TraktAuthGuard
import com.thomaskioko.trakt.service.implementation.loadJson
import com.thomaskioko.tvmaniac.core.networkutil.api.extensions.IsAuthenticated
import com.thomaskioko.tvmaniac.core.networkutil.api.model.ApiResponse
import com.thomaskioko.tvmaniac.trakt.api.model.TraktUserResponse
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.types.shouldBeInstanceOf
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.engine.mock.respondError
import io.ktor.client.engine.mock.toByteArray
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
class DefaultTraktListRemoteDataSourceTest {
private val json = Json {
ignoreUnknownKeys = true
prettyPrint = true
encodeDefaults = true
}
private fun createDataSource(engine: MockEngine): DefaultTraktListRemoteDataSource {
val client = HttpClient(engine) {
install(ContentNegotiation) { json(json = json) }
}
client.attributes.put(IsAuthenticated) { true }
return DefaultTraktListRemoteDataSource(httpClient = client)
}
@Test
fun `should use GET method and correct path given getUser is called`() = runTest {
var capturedMethod: HttpMethod? = null
var capturedPath: String? = null
var capturedExtended: String? = null
val engine = MockEngine { request ->
capturedMethod = request.method
capturedPath = request.url.encodedPath
capturedExtended = request.url.parameters["extended"]
respond(
content = loadJson("trakt_user_response.json"),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val dataSource = createDataSource(engine)
dataSource.getUser("me")
capturedMethod shouldBe HttpMethod.Get
capturedPath shouldBe "/users/me"
capturedExtended shouldBe "full"
}
@Test
fun `should return Success given getUser succeeds`() = runTest {
val engine = MockEngine { _ ->
respond(
content = loadJson("trakt_user_response.json"),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val dataSource = createDataSource(engine)
val result = dataSource.getUser("me")
val success = result.shouldBeInstanceOf>()
success.body.userName shouldBe "sean"
success.body.ids.slug shouldBe "sean"
}
@Test
fun `should use GET and correct path given getUserList is called`() = runTest {
var capturedMethod: HttpMethod? = null
var capturedPath: String? = null
val engine = MockEngine { request ->
capturedMethod = request.method
capturedPath = request.url.encodedPath
respond(
content = """[]""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val dataSource = createDataSource(engine)
dataSource.getUserList("sean")
capturedMethod shouldBe HttpMethod.Get
capturedPath shouldBe "/users/sean/lists"
}
@Test
fun `should use POST with body given addShowToWatchListByTmdbId is called`() = runTest {
var capturedMethod: HttpMethod? = null
var capturedPath: String? = null
var capturedBody: String? = null
val engine = MockEngine { request ->
capturedMethod = request.method
capturedPath = request.url.encodedPath
capturedBody = request.body.toByteArray().decodeToString()
respond(
content = loadJson("trakt_add_show_response.json"),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val dataSource = createDataSource(engine)
dataSource.addShowToWatchListByTmdbId(tmdbId = 12345)
capturedMethod shouldBe HttpMethod.Post
capturedPath shouldBe "/sync/watchlist"
capturedBody shouldContain "12345"
}
@Test
fun `should return HttpError given server returns unauthorized`() = runTest {
val engine = MockEngine { _ ->
respondError(
status = HttpStatusCode.Unauthorized,
content = loadJson("trakt_error_response.json"),
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(engine) {
install(ContentNegotiation) { json(json = json) }
expectSuccess = true
}
client.attributes.put(IsAuthenticated) { true }
val dataSource = DefaultTraktListRemoteDataSource(httpClient = client)
val result = dataSource.getUser("me")
result.shouldBeInstanceOf>()
result.code shouldBe 401
}
@Test
fun `should return Success given auth guard allows authenticated request`() = runTest {
val engine = MockEngine { _ ->
respond(
content = """[]""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(engine) {
install(ContentNegotiation) { json(json = json) }
install(TraktAuthGuard) {
isAuthenticated = { true }
}
}
client.attributes.put(IsAuthenticated) { true }
val dataSource = DefaultTraktListRemoteDataSource(httpClient = client)
val result = dataSource.getUserList("sean")
result.shouldBeInstanceOf>()
}
@Test
fun `should return Unauthenticated given user is not authenticated`() = runTest {
val engine = MockEngine { _ ->
respond(
content = """{}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(engine) {
install(ContentNegotiation) { json(json = json) }
install(TraktAuthGuard) {
isAuthenticated = { false }
}
}
client.attributes.put(IsAuthenticated) { false }
val dataSource = DefaultTraktListRemoteDataSource(httpClient = client)
val result = dataSource.getUserList("sean")
result.shouldBeInstanceOf()
}
}
================================================
FILE: api/trakt/implementation/src/jvmTest/resources/trakt_add_show_response.json
================================================
{
"added": {
"shows": 1
},
"existing": {
"shows": 0
},
"not_found": {
"shows": []
},
"list": {
"item_count": 1,
"updated_at": "2024-01-01T00:00:00.000Z"
}
}
================================================
FILE: api/trakt/implementation/src/jvmTest/resources/trakt_error_response.json
================================================
{
"error": "unauthorized",
"error_description": "invalid access token"
}
================================================
FILE: api/trakt/implementation/src/jvmTest/resources/trakt_user_response.json
================================================
{
"username": "sean",
"name": "Sean Rudford",
"images": {
"avatar": {
"full": "https://example.com/avatar.jpg"
}
},
"ids": {
"slug": "sean"
}
}
================================================
FILE: app/benchmark-rules.pro
================================================
-dontobfuscate
================================================
FILE: app/build.gradle.kts
================================================
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
plugins {
alias(libs.plugins.app.application)
}
scaffold {
app {
applicationId("com.thomaskioko.tvmaniac")
minify(
rootProject.file("app/proguard-rules.pro"),
)
}
useMetro()
android {
useCompose()
useBaselineProfile(projects.benchmark)
useManagedDevices()
enableAndroidTests(
testInstrumentationRunner = "com.thomaskioko.tvmaniac.app.test.runner.TvManiacInstrumentationRunner",
clearPackageData = true,
)
}
optIn("androidx.compose.ui.test.ExperimentalTestApi")
}
android {
sourceSets {
getByName("test") {
kotlin.directories.add("src/sharedTest/kotlin")
}
getByName("androidTest") {
kotlin.directories.add("src/sharedTest/kotlin")
}
}
}
dependencies {
implementation(projects.androidDesignsystem)
implementation(projects.features.root.ui)
implementation(projects.features.root.nav)
implementation(projects.features.root.presenter)
implementation(projects.api.tmdb.implementation)
implementation(projects.api.trakt.implementation)
implementation(projects.core.appconfig.api)
implementation(projects.core.appconfig.implementation)
implementation(projects.core.base)
implementation(projects.core.util.implementation)
implementation(projects.core.imageloading.implementation)
implementation(projects.core.locale.api)
implementation(projects.core.locale.implementation)
implementation(projects.core.tasks.implementation)
implementation(projects.navigation.api)
implementation(projects.navigation.implementation)
implementation(projects.navigation.ui)
implementation(projects.features.debug.ui)
implementation(projects.features.episodeSheet.ui)
implementation(projects.features.home.ui)
implementation(projects.features.moreShows.ui)
implementation(projects.features.search.ui)
implementation(projects.features.seasonDetails.ui)
implementation(projects.features.settings.ui)
implementation(projects.features.showDetails.ui)
implementation(projects.features.trailers.ui)
implementation(projects.features.root.presenter)
implementation(projects.core.logger.api)
implementation(projects.core.logger.implementation)
implementation(projects.data.cast.implementation)
implementation(projects.data.episode.implementation)
implementation(projects.data.featuredshows.api)
implementation(projects.data.featuredshows.implementation)
implementation(projects.data.genre.implementation)
implementation(projects.data.traktauth.api)
implementation(projects.data.traktauth.implementation)
implementation(projects.data.popularshows.api)
implementation(projects.data.popularshows.implementation)
implementation(projects.data.recommendedshows.implementation)
implementation(projects.data.requestManager.implementation)
implementation(projects.data.search.implementation)
implementation(projects.data.seasondetails.implementation)
implementation(projects.data.seasons.implementation)
implementation(projects.data.showdetails.implementation)
implementation(projects.data.shows.implementation)
implementation(projects.data.similar.implementation)
implementation(projects.data.topratedshows.api)
implementation(projects.data.topratedshows.implementation)
implementation(projects.data.trailers.implementation)
implementation(projects.data.trendingshows.api)
implementation(projects.data.trendingshows.implementation)
implementation(projects.data.upcomingshows.api)
implementation(projects.data.upcomingshows.implementation)
implementation(projects.data.watchproviders.implementation)
implementation(projects.data.user.implementation)
implementation(projects.data.database.sqldelight)
implementation(projects.data.datastore.api)
implementation(projects.data.datastore.implementation)
implementation(projects.domain.theme)
implementation(projects.domain.calendar)
implementation(projects.domain.discover)
implementation(projects.domain.episode)
implementation(projects.domain.followedshows)
implementation(projects.domain.genre)
implementation(projects.domain.notifications)
implementation(projects.domain.seasondetails)
implementation(projects.domain.showdetails)
implementation(projects.domain.similarshows)
implementation(projects.domain.watchproviders)
implementation(projects.domain.library)
implementation(projects.domain.settings)
implementation(projects.domain.upnext)
implementation(projects.domain.user)
implementation(projects.domain.logout)
implementation(projects.features.debug.presenter)
implementation(projects.features.calendar.presenter)
implementation(projects.features.discover.presenter)
implementation(projects.features.episodeSheet.presenter)
implementation(projects.features.genreShows.presenter)
implementation(projects.features.library.presenter)
implementation(projects.features.home.presenter)
implementation(projects.features.moreShows.presenter)
implementation(projects.features.search.presenter)
implementation(projects.features.seasonDetails.presenter)
implementation(projects.features.settings.presenter)
implementation(projects.features.profile.presenter)
implementation(projects.features.progress.presenter)
implementation(projects.features.showDetails.presenter)
implementation(projects.features.trailers.presenter)
implementation(projects.features.upnext.presenter)
implementation(projects.features.showDetails.nav)
implementation(projects.features.episodeSheet.nav)
implementation(projects.features.episodeSheet.nav)
implementation(projects.features.home.nav)
implementation(projects.data.traktlists.implementation)
implementation(projects.domain.traktlists)
implementation(projects.core.util.api)
implementation(projects.core.view)
implementation(projects.data.calendar.api)
implementation(projects.data.followedshows.api)
implementation(projects.data.genre.api)
implementation(projects.data.user.api)
implementation(projects.i18n.api)
implementation(projects.core.networkUtil.implementation)
implementation(projects.core.notifications.api)
implementation(projects.core.notifications.implementation)
implementation(projects.core.connectivity.implementation)
implementation(projects.core.tasks.api)
implementation(projects.data.followedshows.implementation)
implementation(projects.data.library.implementation)
implementation(projects.data.syncActivity.implementation)
implementation(projects.data.upnext.implementation)
implementation(projects.data.watchlist.implementation)
implementation(projects.i18n.generator)
implementation(projects.i18n.implementation)
implementation(projects.data.calendar.implementation)
implementation(libs.androidx.compose.activity)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.datastore.preference)
implementation(libs.androidx.work.runtime)
implementation(libs.appauth)
implementation(libs.decompose.decompose)
implementation(libs.androidx.datastore.core)
implementation(libs.androidx.savedstate)
implementation(libs.sqldelight.runtime)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.ui.ui)
implementation(libs.coroutines.core)
implementation(libs.ktor.core)
testRuntimeOnly(projects.core.notifications.implementation)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics)
testImplementation(projects.api.tmdb.api)
testImplementation(projects.api.trakt.api)
testImplementation(projects.core.connectivity.api)
testImplementation(projects.core.integration.infra)
testImplementation(projects.core.integration.ui)
testImplementation(projects.core.testTags)
testImplementation(projects.data.episode.api)
testImplementation(projects.data.syncActivity.api)
testImplementation(projects.data.traktauth.testing)
testImplementation(projects.data.traktlists.api)
testImplementation(projects.data.upnext.api)
testImplementation(projects.features.debug.nav)
testImplementation(projects.features.discover.nav)
testImplementation(projects.features.genreShows.nav)
testImplementation(projects.features.moreShows.nav)
testImplementation(projects.features.search.nav)
testImplementation(projects.features.seasonDetails.nav)
testImplementation(projects.features.settings.nav)
testImplementation(projects.features.trailers.nav)
testImplementation(projects.core.locale.testing)
testImplementation(projects.core.util.testing)
testImplementation(libs.androidx.compose.ui.test)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.work.testing)
testImplementation(libs.coroutines.test)
testImplementation(libs.kotest.assertions)
testImplementation(libs.kotlin.test.junit)
testImplementation(libs.ktor.core)
testImplementation(libs.ktor.http)
testRuntimeOnly(libs.robolectric)
testImplementation(libs.robolectric.annotations)
testRuntimeOnly(libs.androidx.compose.ui.test.manifest)
androidTestImplementation(libs.robolectric.annotations)
androidTestImplementation(projects.api.tmdb.api)
androidTestImplementation(projects.api.trakt.api)
androidTestImplementation(projects.core.connectivity.api)
androidTestImplementation(projects.core.integration.infra)
androidTestImplementation(projects.core.integration.ui)
androidTestImplementation(projects.core.testTags)
androidTestImplementation(projects.data.episode.api)
androidTestImplementation(projects.data.syncActivity.api)
androidTestImplementation(projects.data.traktauth.testing)
androidTestImplementation(projects.data.traktlists.api)
androidTestImplementation(projects.data.upnext.api)
androidTestImplementation(projects.features.debug.nav)
androidTestImplementation(projects.features.discover.nav)
androidTestImplementation(projects.features.moreShows.nav)
androidTestImplementation(projects.features.search.nav)
androidTestImplementation(projects.features.seasonDetails.nav)
androidTestImplementation(projects.features.settings.nav)
androidTestImplementation(projects.features.trailers.nav)
androidTestImplementation(projects.core.locale.testing)
androidTestImplementation(projects.core.util.testing)
androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.work.testing)
androidTestImplementation(libs.coroutines.test)
androidTestImplementation(libs.kotest.assertions)
androidTestImplementation(libs.kotlin.test.junit)
androidTestImplementation(libs.ktor.core)
androidTestImplementation(libs.metro.runtime)
androidTestImplementation(libs.ktor.http)
}
if (file("google-services.json").exists()) {
apply(plugin = libs.plugins.google.services.get().pluginId)
apply(plugin = libs.plugins.firebase.crashlytics.gradle.get().pluginId)
}
afterEvaluate {
if (pluginManager.hasPlugin("com.google.firebase.crashlytics")) {
android.buildTypes.getByName("release") {
configure {
mappingFileUploadEnabled = true
}
}
}
}
================================================
FILE: app/lint-baseline.xml
================================================
================================================
FILE: app/proguard-rules.pro
================================================
-verbose
-allowaccessmodification
-repackageclasses
# AndroidX + support library contains references to newer platform versions.
# Don't warn about those in case this app is linking against an older
# platform version. We know about them, and they are safe.
-dontwarn android.support.**
-dontwarn androidx.**
-dontwarn org.slf4j.impl.StaticLoggerBinder
# For enumeration classes
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
-keepattributes SourceFile,
LineNumberTable,
InnerClasses,
EnclosingMethod,
*Annotation*,
AnnotationDefault,
Signature,
Exceptions
-renamesourcefileattribute SourceFile
# --- Kotlinx Serialization ---
-keepattributes RuntimeVisibleAnnotations
-keep,includedescriptorclasses class com.thomaskioko.tvmaniac.**$$serializer { *; }
-keepclassmembers class com.thomaskioko.tvmaniac.** {
*** Companion;
}
-keepclasseswithmembers class com.thomaskioko.tvmaniac.** {
kotlinx.serialization.KSerializer serializer(...);
}
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
-if @kotlinx.serialization.Serializable class ** {
static **$* *;
}
-keepclassmembers class <2>$<3> {
kotlinx.serialization.KSerializer serializer(...);
}
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
-dontwarn kotlinx.serialization.**
# --- Ktor ---
-keepclassmembers class io.ktor.** { volatile ; }
-keep class io.ktor.client.engine.** { *; }
-keep class io.ktor.serialization.** { *; }
-dontwarn io.ktor.**
# --- OkHttp ---
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
-keep class okhttp3.internal.publicsuffix.PublicSuffixDatabase { *; }
# --- Decompose ---
-keep class com.arkivanov.decompose.** { *; }
-keep class com.arkivanov.essenty.** { *; }
# --- kotlin-inject / Metro ---
-keep class me.tatarka.inject.** { *; }
-keep @me.tatarka.inject.annotations.* class * { *; }
-keep class * extends me.tatarka.inject.annotations.Component { *; }
-keepclassmembers class * {
@me.tatarka.inject.annotations.Inject (...);
@me.tatarka.inject.annotations.Provides ;
}
# --- SQLDelight ---
-keep class app.cash.sqldelight.** { *; }
-keep class com.thomaskioko.tvmaniac.db.** { *; }
# --- Coil ---
-dontwarn coil3.**
-keep class coil3.** { *; }
# --- Firebase Crashlytics ---
-keepattributes SourceFile,LineNumberTable
-keep public class * extends java.lang.Exception
# --- AppAuth ---
-keep class net.openid.appauth.** { *; }
-dontwarn net.openid.appauth.**
# --- Coroutines ---
-dontwarn kotlinx.coroutines.**
-keepclassmembers class kotlinx.coroutines.** {
volatile ;
}
# --- Strip Android Logs from production ---
-assumenosideeffects class android.util.Log {
public static int v(...);
public static int d(...);
public static int i(...);
public static int w(...);
public static int e(...);
}
# --- Strip Jetpack Compose tracing from production ---
-assumenosideeffects class androidx.compose.runtime.ComposerKt {
boolean isTraceInProgress();
void traceEventStart(int, int, int, java.lang.String);
void traceEventEnd();
}
# --- General Kotlin ---
-dontwarn kotlin.**
-keep class kotlin.Metadata { *; }
================================================
FILE: app/src/androidTest/kotlin/com/thomaskioko/tvmaniac/app/test/runner/TvManiacInstrumentationRunner.kt
================================================
package com.thomaskioko.tvmaniac.app.test.runner
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import com.thomaskioko.tvmaniac.app.test.TvManiacTestApplication
/**
* Custom [AndroidJUnitRunner] that swaps the production [com.thomaskioko.tvmaniac.app.TvManicApplication]
* for [TvManiacTestApplication] at runtime.
*
* [TvManiacTestApplication] owns the Metro test graph and the [androidx.work.testing.WorkManagerTestInitHelper]
* bootstrap. Without this override, instrumentation tests would load the production application class
* declared in `AndroidManifest.xml` and miss every DI substitution.
*
* No manifest change is required: AGP routes [Application] instantiation through
* [androidx.test.runner.AndroidJUnitRunner.newApplication], so returning [TvManiacTestApplication]
* here supplants the production `android:name` for instrumentation runs only.
*/
public class TvManiacInstrumentationRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
className: String?,
context: Context?,
): Application = super.newApplication(cl, TvManiacTestApplication::class.java.name, context)
}
================================================
FILE: app/src/debug/AndroidManifest.xml
================================================
================================================
FILE: app/src/debug/kotlin/com/thomaskioko/tvmaniac/app/debug/DebugNotificationIconProvider.kt
================================================
package com.thomaskioko.tvmaniac.app.debug
import com.thomaskioko.tvmaniac.app.R
import com.thomaskioko.tvmaniac.app.util.AppNotificationIconProvider
import com.thomaskioko.tvmaniac.core.notifications.api.NotificationIconProvider
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class, replaces = [AppNotificationIconProvider::class])
public class DebugNotificationIconProvider : NotificationIconProvider {
override val smallIconResId: Int = R.drawable.ic_launcher_monochrome
override val debugIconResId: Int = R.drawable.ic_debug_bug
}
================================================
FILE: app/src/debug/kotlin/com/thomaskioko/tvmaniac/app/debug/DebugNotificationInitializer.kt
================================================
package com.thomaskioko.tvmaniac.app.debug
import com.thomaskioko.tvmaniac.core.base.IoCoroutineScope
import com.thomaskioko.tvmaniac.core.notifications.implementation.DebugNotificationManager
import com.thomaskioko.tvmaniac.domain.settings.ObserveSettingsPreferencesInteractor
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@Inject
public class DebugNotificationInitializer(
@IoCoroutineScope private val coroutineScope: CoroutineScope,
private val observeSettingsPreferencesInteractor: Lazy,
private val debugNotificationManager: Lazy,
) {
public fun init() {
coroutineScope.launch {
observeSettingsPreferencesInteractor.value(Unit)
observeSettingsPreferencesInteractor.value.flow
.map { it.episodeNotificationsEnabled }
.collect { enabled ->
when {
enabled -> debugNotificationManager.value.show()
else -> debugNotificationManager.value.dismiss()
}
}
}
}
}
================================================
FILE: app/src/debug/kotlin/com/thomaskioko/tvmaniac/app/debug/di/DebugNotificationInitializerBindingContainer.kt
================================================
package com.thomaskioko.tvmaniac.app.debug.di
import com.thomaskioko.tvmaniac.app.debug.DebugNotificationInitializer
import com.thomaskioko.tvmaniac.core.base.Initializer
import com.thomaskioko.tvmaniac.core.base.Initializers
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.IntoSet
import dev.zacsweers.metro.Provides
@BindingContainer
@ContributesTo(AppScope::class)
public object DebugNotificationInitializerBindingContainer {
@Provides
@IntoSet
@Initializers
public fun provideDebugNotificationInitializer(bind: DebugNotificationInitializer): Initializer = Initializer { bind.init() }
}
================================================
FILE: app/src/debug/res/drawable/ic_app_launcher.xml
================================================
================================================
FILE: app/src/debug/res/drawable/ic_debug_bug.xml
================================================
================================================
FILE: app/src/debug/res/drawable/ic_launcher_foreground.xml
================================================
>
================================================
FILE: app/src/main/AndroidManifest.xml
================================================
================================================
FILE: app/src/main/generated/baselineProfiles/baseline-prof.txt
================================================
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreBaseDiBaseAndroidComponent;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreBaseDiBaseAndroidComponent$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreBaseDiBaseAndroidComponent$DefaultImpls;->provideCoroutineScope(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreBaseDiBaseAndroidComponent;Lcom/thomaskioko/tvmaniac/core/base/model/AppCoroutineDispatchers;)Lcom/thomaskioko/tvmaniac/core/base/model/AppCoroutineScope;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreBaseDiBaseComponent;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreBaseDiBaseComponent$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreBaseDiBaseComponent$DefaultImpls;->provideCoroutineDispatchers(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreBaseDiBaseComponent;)Lcom/thomaskioko/tvmaniac/core/base/model/AppCoroutineDispatchers;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreLoggerInjectLoggingComponent;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreLoggerInjectLoggingComponent$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreLoggerInjectLoggingComponent$DefaultImpls;->provideKermitLogger(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreLoggerInjectLoggingComponent;Lcom/thomaskioko/tvmaniac/core/base/model/Configs;)Lcom/thomaskioko/tvmaniac/core/logger/KermitLogger;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreLoggerLoggingInitializer;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreLoggerLoggingInitializer$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreLoggerLoggingInitializer$DefaultImpls;->provideLoggingInitializerAppInitializerMultibinding(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreLoggerLoggingInitializer;Lcom/thomaskioko/tvmaniac/core/logger/LoggingInitializer;)Lcom/thomaskioko/tvmaniac/core/base/AppInitializer;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreNetworkutilAndroidNetworkExceptionHandlerUtil;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreNetworkutilAndroidNetworkExceptionHandlerUtil$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreNetworkutilAndroidNetworkExceptionHandlerUtil$DefaultImpls;->provideAndroidNetworkExceptionHandlerUtilNetworkExceptionHandler(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacCoreNetworkutilAndroidNetworkExceptionHandlerUtil;Lcom/thomaskioko/tvmaniac/core/networkutil/AndroidNetworkExceptionHandlerUtil;)Lcom/thomaskioko/tvmaniac/core/networkutil/NetworkExceptionHandler;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataCastImplementationDefaultCastDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataCastImplementationDefaultCastRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataFeaturedshowsImplementationDefaultFeaturedShowsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataFeaturedshowsImplementationDefaultFeaturedShowsDao$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacDataFeaturedshowsImplementationDefaultFeaturedShowsDao$DefaultImpls;->provideDefaultFeaturedShowsDaoFeaturedShowsDao(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataFeaturedshowsImplementationDefaultFeaturedShowsDao;Lcom/thomaskioko/tvmaniac/data/featuredshows/implementation/DefaultFeaturedShowsDao;)Lcom/thomaskioko/tvmaniac/data/featuredshows/api/FeaturedShowsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataFeaturedshowsImplementationDefaultFeaturedShowsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataFeaturedshowsImplementationDefaultFeaturedShowsRepository$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacDataFeaturedshowsImplementationDefaultFeaturedShowsRepository$DefaultImpls;->provideDefaultFeaturedShowsRepositoryFeaturedShowsRepository(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataFeaturedshowsImplementationDefaultFeaturedShowsRepository;Lcom/thomaskioko/tvmaniac/data/featuredshows/implementation/DefaultFeaturedShowsRepository;)Lcom/thomaskioko/tvmaniac/data/featuredshows/api/FeaturedShowsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataPopularshowsImplementationDefaultPopularShowsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataPopularshowsImplementationDefaultPopularShowsDao$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacDataPopularshowsImplementationDefaultPopularShowsDao$DefaultImpls;->provideDefaultPopularShowsDaoPopularShowsDao(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataPopularshowsImplementationDefaultPopularShowsDao;Lcom/thomaskioko/tvmaniac/data/popularshows/implementation/DefaultPopularShowsDao;)Lcom/thomaskioko/tvmaniac/data/popularshows/api/PopularShowsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataPopularshowsImplementationDefaultPopularShowsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataPopularshowsImplementationDefaultPopularShowsRepository$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacDataPopularshowsImplementationDefaultPopularShowsRepository$DefaultImpls;->provideDefaultPopularShowsRepositoryPopularShowsRepository(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataPopularshowsImplementationDefaultPopularShowsRepository;Lcom/thomaskioko/tvmaniac/data/popularshows/implementation/DefaultPopularShowsRepository;)Lcom/thomaskioko/tvmaniac/data/popularshows/api/PopularShowsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataRecommendedshowsImplementationDefaultRecommendedShowsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataRecommendedshowsImplementationDefaultRecommendedShowsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataShowdetailsImplementationDefaultShowDetailsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataShowdetailsImplementationDefaultShowDetailsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataTrailersImplementationDefaultTrailerDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataTrailersImplementationDefaultTrailerRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataUpcomingshowsImplementationDefaultUpcomingShowsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataUpcomingshowsImplementationDefaultUpcomingShowsDao$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacDataUpcomingshowsImplementationDefaultUpcomingShowsDao$DefaultImpls;->provideDefaultUpcomingShowsDaoUpcomingShowsDao(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataUpcomingshowsImplementationDefaultUpcomingShowsDao;Lcom/thomaskioko/tvmaniac/data/upcomingshows/implementation/DefaultUpcomingShowsDao;)Lcom/thomaskioko/tvmaniac/data/upcomingshows/api/UpcomingShowsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataUpcomingshowsImplementationDefaultUpcomingShowsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataUpcomingshowsImplementationDefaultUpcomingShowsRepository$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacDataUpcomingshowsImplementationDefaultUpcomingShowsRepository$DefaultImpls;->provideDefaultUpcomingShowsRepositoryUpcomingShowsRepository(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataUpcomingshowsImplementationDefaultUpcomingShowsRepository;Lcom/thomaskioko/tvmaniac/data/upcomingshows/implementation/DefaultUpcomingShowsRepository;)Lcom/thomaskioko/tvmaniac/data/upcomingshows/api/UpcomingShowsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataWatchprovidersImplementationDefaultWatchProviderDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDataWatchprovidersImplementationDefaultWatchProviderRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDatastoreImplementationDataStorePlatformComponent;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDatastoreImplementationDataStorePlatformComponent$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacDatastoreImplementationDataStorePlatformComponent$DefaultImpls;->provideDataStore(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDatastoreImplementationDataStorePlatformComponent;Landroid/app/Application;Lcom/thomaskioko/tvmaniac/core/base/model/AppCoroutineScope;)Landroidx/datastore/core/DataStore;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDatastoreImplementationDefaultDatastoreRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDatastoreImplementationDefaultDatastoreRepository$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacDatastoreImplementationDefaultDatastoreRepository$DefaultImpls;->provideDefaultDatastoreRepositoryDatastoreRepository(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDatastoreImplementationDefaultDatastoreRepository;Lcom/thomaskioko/tvmaniac/datastore/implementation/DefaultDatastoreRepository;)Lcom/thomaskioko/tvmaniac/datastore/api/DatastoreRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDbDatabaseComponent;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDbDatabaseComponent$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacDbDatabaseComponent$DefaultImpls;->provideTvManiacDatabase(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDbDatabaseComponent;Lcom/thomaskioko/tvmaniac/db/DatabaseFactory;)Lcom/thomaskioko/tvmaniac/core/db/TvManiacDatabase;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDbDatabasePlatformComponent;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDbDatabasePlatformComponent$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacDbDatabasePlatformComponent$DefaultImpls;->provideSqlDriver(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDbDatabasePlatformComponent;Landroid/app/Application;)Lapp/cash/sqldelight/db/SqlDriver;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDbDbTransactionRunner;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDbDbTransactionRunner$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacDbDbTransactionRunner$DefaultImpls;->provideDbTransactionRunnerDatabaseTransactionRunner(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDbDbTransactionRunner;Lcom/thomaskioko/tvmaniac/db/DbTransactionRunner;)Lcom/thomaskioko/tvmaniac/db/DatabaseTransactionRunner;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDiscoverImplementationDefaultTrendingShowsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDiscoverImplementationDefaultTrendingShowsDao$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacDiscoverImplementationDefaultTrendingShowsDao$DefaultImpls;->provideDefaultTrendingShowsDaoTrendingShowsDao(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDiscoverImplementationDefaultTrendingShowsDao;Lcom/thomaskioko/tvmaniac/discover/implementation/DefaultTrendingShowsDao;)Lcom/thomaskioko/tvmaniac/discover/api/TrendingShowsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDiscoverImplementationDefaultTrendingShowsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDiscoverImplementationDefaultTrendingShowsRepository$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacDiscoverImplementationDefaultTrendingShowsRepository$DefaultImpls;->provideDefaultTrendingShowsRepositoryTrendingShowsRepository(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacDiscoverImplementationDefaultTrendingShowsRepository;Lcom/thomaskioko/tvmaniac/discover/implementation/DefaultTrendingShowsRepository;)Lcom/thomaskioko/tvmaniac/discover/api/TrendingShowsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacEpisodesImplementationDefaultEpisodeRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacEpisodesImplementationDefaultEpisodesDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacGenreDefaultGenreDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacGenreDefaultGenreDao$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacGenreDefaultGenreDao$DefaultImpls;->provideDefaultGenreDaoGenreDao(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacGenreDefaultGenreDao;Lcom/thomaskioko/tvmaniac/genre/DefaultGenreDao;)Lcom/thomaskioko/tvmaniac/genre/GenreDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacGenreDefaultGenreRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacGenreDefaultGenreRepository$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacGenreDefaultGenreRepository$DefaultImpls;->provideDefaultGenreRepositoryGenreRepository(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacGenreDefaultGenreRepository;Lcom/thomaskioko/tvmaniac/genre/DefaultGenreRepository;)Lcom/thomaskioko/tvmaniac/genre/GenreRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacGenreGenreInitializer;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacGenreGenreInitializer$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacGenreGenreInitializer$DefaultImpls;->provideGenreInitializerAppInitializerMultibinding(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacGenreGenreInitializer;Lcom/thomaskioko/tvmaniac/genre/GenreInitializer;)Lcom/thomaskioko/tvmaniac/core/base/AppInitializer;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacInjectActivityComponentFactory;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacInjectActivityComponentFactory$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacInjectActivityComponentFactory$DefaultImpls;->provideActivityComponentFactory(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacInjectActivityComponentFactory;)Lcom/thomaskioko/tvmaniac/inject/ActivityComponent$Factory;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacNavigationDefaultRootPresenter;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacNavigationDefaultRootPresenter$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacNavigationDefaultRootPresenter$DefaultImpls;->provideDefaultRootPresenterRootPresenter(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacNavigationDefaultRootPresenter;Lcom/thomaskioko/tvmaniac/navigation/DefaultRootPresenter;)Lcom/thomaskioko/tvmaniac/navigation/RootPresenter;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacPresentationHomeDefaultHomePresenterFactory;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacPresentationHomeDefaultHomePresenterFactory$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacPresentationHomeDefaultHomePresenterFactory$DefaultImpls;->provideDefaultHomePresenterFactoryFactory(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacPresentationHomeDefaultHomePresenterFactory;Lcom/thomaskioko/tvmaniac/presentation/home/DefaultHomePresenter$Factory;)Lcom/thomaskioko/tvmaniac/presentation/home/HomePresenter$Factory;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacResourcemanagerImplementationDefaultRequestManagerRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacResourcemanagerImplementationDefaultRequestManagerRepository$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacResourcemanagerImplementationDefaultRequestManagerRepository$DefaultImpls;->provideDefaultRequestManagerRepositoryRequestManagerRepository(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacResourcemanagerImplementationDefaultRequestManagerRepository;Lcom/thomaskioko/tvmaniac/resourcemanager/implementation/DefaultRequestManagerRepository;)Lcom/thomaskioko/tvmaniac/resourcemanager/api/RequestManagerRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacSearchImplementationDefaultSearchRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacSeasondetailsImplementationDefaultSeasonDetailsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacSeasondetailsImplementationDefaultSeasonDetailsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacSeasonsImplementationDefaultSeasonsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacSeasonsImplementationDefaultSeasonsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacShowsImplementationDefaultTvShowsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacShowsImplementationDefaultTvShowsDao$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacShowsImplementationDefaultTvShowsDao$DefaultImpls;->provideDefaultTvShowsDaoTvShowsDao(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacShowsImplementationDefaultTvShowsDao;Lcom/thomaskioko/tvmaniac/shows/implementation/DefaultTvShowsDao;)Lcom/thomaskioko/tvmaniac/shows/api/TvShowsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacSimilarImplementationDefaultSimilarShowsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacSimilarImplementationDefaultSimilarShowsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationDefaultTmdbSeasonDetailsNetworkDataSource;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationDefaultTmdbShowDetailsNetworkDataSource;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationDefaultTmdbShowDetailsNetworkDataSource$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationDefaultTmdbShowDetailsNetworkDataSource$DefaultImpls;->provideDefaultTmdbShowDetailsNetworkDataSourceTmdbShowDetailsNetworkDataSource(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationDefaultTmdbShowDetailsNetworkDataSource;Lcom/thomaskioko/tvmaniac/tmdb/implementation/DefaultTmdbShowDetailsNetworkDataSource;)Lcom/thomaskioko/tvmaniac/tmdb/api/TmdbShowDetailsNetworkDataSource;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationDefaultTmdbShowsNetworkDataSource;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationDefaultTmdbShowsNetworkDataSource$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationDefaultTmdbShowsNetworkDataSource$DefaultImpls;->provideDefaultTmdbShowsNetworkDataSourceTmdbShowsNetworkDataSource(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationDefaultTmdbShowsNetworkDataSource;Lcom/thomaskioko/tvmaniac/tmdb/implementation/DefaultTmdbShowsNetworkDataSource;)Lcom/thomaskioko/tvmaniac/tmdb/api/TmdbShowsNetworkDataSource;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationTmdbComponent;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationTmdbComponent$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationTmdbComponent$DefaultImpls;->provideTmdbHttpClient(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationTmdbComponent;Lcom/thomaskioko/tvmaniac/core/base/model/Configs;Lkotlinx/serialization/json/Json;Lio/ktor/client/engine/HttpClientEngine;Lcom/thomaskioko/tvmaniac/core/logger/KermitLogger;)Lio/ktor/client/HttpClient;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationTmdbComponent$DefaultImpls;->provideTmdbJson(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationTmdbComponent;)Lkotlinx/serialization/json/Json;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationTmdbPlatformComponent;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationTmdbPlatformComponent$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationTmdbPlatformComponent$DefaultImpls;->provideTmdbHttpClientEngine(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTmdbImplementationTmdbPlatformComponent;)Lio/ktor/client/engine/HttpClientEngine;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTopratedDataImplementationDefaultTopRatedShowsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTopratedDataImplementationDefaultTopRatedShowsDao$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacTopratedDataImplementationDefaultTopRatedShowsDao$DefaultImpls;->provideDefaultTopRatedShowsDaoTopRatedShowsDao(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTopratedDataImplementationDefaultTopRatedShowsDao;Lcom/thomaskioko/tvmaniac/toprated/data/implementation/DefaultTopRatedShowsDao;)Lcom/thomaskioko/tvmaniac/topratedshows/data/api/TopRatedShowsDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTopratedDataImplementationDefaultTopRatedShowsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTopratedDataImplementationDefaultTopRatedShowsRepository$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacTopratedDataImplementationDefaultTopRatedShowsRepository$DefaultImpls;->provideDefaultTopRatedShowsRepositoryTopRatedShowsRepository(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTopratedDataImplementationDefaultTopRatedShowsRepository;Lcom/thomaskioko/tvmaniac/toprated/data/implementation/DefaultTopRatedShowsRepository;)Lcom/thomaskioko/tvmaniac/topratedshows/data/api/TopRatedShowsRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationDefaultTraktAuthManager;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationDefaultTraktAuthManager$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationDefaultTraktAuthManager$DefaultImpls;->provideDefaultTraktAuthManagerTraktAuthManager(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationDefaultTraktAuthManager;Lcom/thomaskioko/tvmaniac/traktauth/implementation/DefaultTraktAuthManager;)Lcom/thomaskioko/tvmaniac/traktauth/api/TraktAuthManager;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationDefaultTraktAuthRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationDefaultTraktAuthRepository$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationDefaultTraktAuthRepository$DefaultImpls;->provideDefaultTraktAuthRepositoryTraktAuthRepository(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationDefaultTraktAuthRepository;Lcom/thomaskioko/tvmaniac/traktauth/implementation/DefaultTraktAuthRepository;)Lcom/thomaskioko/tvmaniac/traktauth/api/TraktAuthRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationTraktAuthAndroidComponent;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationTraktAuthAndroidComponent$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationTraktAuthAndroidComponent$DefaultImpls;->provideAuthConfig(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationTraktAuthAndroidComponent;)Lnet/openid/appauth/AuthorizationServiceConfiguration;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationTraktAuthAndroidComponent$DefaultImpls;->provideAuthRequest(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationTraktAuthAndroidComponent;Lnet/openid/appauth/AuthorizationServiceConfiguration;Lcom/thomaskioko/tvmaniac/core/base/model/Configs;)Lnet/openid/appauth/AuthorizationRequest;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationTraktAuthAndroidComponent$DefaultImpls;->provideAuthorizationService(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacTraktauthImplementationTraktAuthAndroidComponent;Landroid/app/Application;)Lnet/openid/appauth/AuthorizationService;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacUtilAndroidAppUtils;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacUtilAndroidFormatterUtil;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacUtilAndroidFormatterUtil$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacUtilAndroidFormatterUtil$DefaultImpls;->provideAndroidFormatterUtilFormatterUtil(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacUtilAndroidFormatterUtil;Lcom/thomaskioko/tvmaniac/util/AndroidFormatterUtil;)Lcom/thomaskioko/tvmaniac/util/FormatterUtil;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacUtilClasspathResourceReader;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacUtilClasspathResourceReader$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacUtilClasspathResourceReader$DefaultImpls;->provideClasspathResourceReaderResourceReader(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacUtilClasspathResourceReader;Lcom/thomaskioko/tvmaniac/util/ClasspathResourceReader;)Lcom/thomaskioko/tvmaniac/util/ResourceReader;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacUtilInjectUtilPlatformComponent;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacUtilInjectUtilPlatformComponent$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacUtilInjectUtilPlatformComponent$DefaultImpls;->provideConfigs(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacUtilInjectUtilPlatformComponent;Lcom/thomaskioko/tvmaniac/util/YamlResourceReader;)Lcom/thomaskioko/tvmaniac/core/base/model/Configs;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacWatchlistImplementationDefaultWatchlistDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacWatchlistImplementationDefaultWatchlistDao$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacWatchlistImplementationDefaultWatchlistDao$DefaultImpls;->provideDefaultWatchlistDaoWatchlistDao(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacWatchlistImplementationDefaultWatchlistDao;Lcom/thomaskioko/tvmaniac/watchlist/implementation/DefaultWatchlistDao;)Lcom/thomaskioko/tvmaniac/shows/api/WatchlistDao;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacWatchlistImplementationDefaultWatchlistRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacWatchlistImplementationDefaultWatchlistRepository$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacWatchlistImplementationDefaultWatchlistRepository$DefaultImpls;->provideDefaultWatchlistRepositoryWatchlistRepository(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacWatchlistImplementationDefaultWatchlistRepository;Lcom/thomaskioko/tvmaniac/watchlist/implementation/DefaultWatchlistRepository;)Lcom/thomaskioko/tvmaniac/shows/api/WatchlistRepository;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacWatchlistImplementationWatchlistSyncer;
Lamazon/lastmile/inject/ComThomaskiokoTvmaniacWatchlistImplementationWatchlistSyncer$DefaultImpls;
HSPLamazon/lastmile/inject/ComThomaskiokoTvmaniacWatchlistImplementationWatchlistSyncer$DefaultImpls;->provideWatchlistSyncerAppInitializerMultibinding(Lamazon/lastmile/inject/ComThomaskiokoTvmaniacWatchlistImplementationWatchlistSyncer;Lcom/thomaskioko/tvmaniac/watchlist/implementation/WatchlistSyncer;)Lcom/thomaskioko/tvmaniac/core/base/AppInitializer;
Landroidx/activity/Cancellable;
Landroidx/activity/ComponentActivity;
HSPLandroidx/activity/ComponentActivity;->$r8$lambda$4IRRzyoWeWaykEOcgWGjbNoGAkw(Landroidx/activity/OnBackPressedDispatcher;Landroidx/activity/ComponentActivity;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$Event;)V
HSPLandroidx/activity/ComponentActivity;->$r8$lambda$KUbBm7ckfqTc9QC-gukC86fguu4(Landroidx/activity/ComponentActivity;Landroid/content/Context;)V
HSPLandroidx/activity/ComponentActivity;->$r8$lambda$h6vvr6zUWA2U1fE-0KsKpOgpr28(Landroidx/activity/ComponentActivity;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$Event;)V
HSPLandroidx/activity/ComponentActivity;->$r8$lambda$ibk6u1HK7J3AWKL_Wn934v2UVI8(Landroidx/activity/ComponentActivity;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$Event;)V
HSPLandroidx/activity/ComponentActivity;->()V
HSPLandroidx/activity/ComponentActivity;->()V
HSPLandroidx/activity/ComponentActivity;->_init_$lambda$2(Landroidx/activity/ComponentActivity;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$Event;)V
HSPLandroidx/activity/ComponentActivity;->_init_$lambda$3(Landroidx/activity/ComponentActivity;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$Event;)V
HSPLandroidx/activity/ComponentActivity;->_init_$lambda$5(Landroidx/activity/ComponentActivity;Landroid/content/Context;)V
HSPLandroidx/activity/ComponentActivity;->access$addObserverForBackInvoker(Landroidx/activity/ComponentActivity;Landroidx/activity/OnBackPressedDispatcher;)V
HSPLandroidx/activity/ComponentActivity;->access$ensureViewModelStore(Landroidx/activity/ComponentActivity;)V
HSPLandroidx/activity/ComponentActivity;->addObserverForBackInvoker$lambda$7(Landroidx/activity/OnBackPressedDispatcher;Landroidx/activity/ComponentActivity;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$Event;)V
HSPLandroidx/activity/ComponentActivity;->addObserverForBackInvoker(Landroidx/activity/OnBackPressedDispatcher;)V
HSPLandroidx/activity/ComponentActivity;->addOnContextAvailableListener(Landroidx/activity/contextaware/OnContextAvailableListener;)V
HSPLandroidx/activity/ComponentActivity;->createFullyDrawnExecutor()Landroidx/activity/ComponentActivity$ReportFullyDrawnExecutor;
HSPLandroidx/activity/ComponentActivity;->ensureViewModelStore()V
HSPLandroidx/activity/ComponentActivity;->getDefaultViewModelCreationExtras()Landroidx/lifecycle/viewmodel/CreationExtras;
HSPLandroidx/activity/ComponentActivity;->getLifecycle()Landroidx/lifecycle/Lifecycle;
HSPLandroidx/activity/ComponentActivity;->getOnBackPressedDispatcher()Landroidx/activity/OnBackPressedDispatcher;
HSPLandroidx/activity/ComponentActivity;->getSavedStateRegistry()Landroidx/savedstate/SavedStateRegistry;
HSPLandroidx/activity/ComponentActivity;->getViewModelStore()Landroidx/lifecycle/ViewModelStore;
HSPLandroidx/activity/ComponentActivity;->initializeViewTreeOwners()V
HSPLandroidx/activity/ComponentActivity;->onCreate(Landroid/os/Bundle;)V
HSPLandroidx/activity/ComponentActivity;->registerForActivityResult(Landroidx/activity/result/contract/ActivityResultContract;Landroidx/activity/result/ActivityResultCallback;)Landroidx/activity/result/ActivityResultLauncher;
HSPLandroidx/activity/ComponentActivity;->registerForActivityResult(Landroidx/activity/result/contract/ActivityResultContract;Landroidx/activity/result/ActivityResultRegistry;Landroidx/activity/result/ActivityResultCallback;)Landroidx/activity/result/ActivityResultLauncher;
HSPLandroidx/activity/ComponentActivity;->setContentView(Landroid/view/View;Landroid/view/ViewGroup$LayoutParams;)V
Landroidx/activity/ComponentActivity$$ExternalSyntheticLambda0;
HSPLandroidx/activity/ComponentActivity$$ExternalSyntheticLambda0;->(Landroidx/activity/ComponentActivity;)V
Landroidx/activity/ComponentActivity$$ExternalSyntheticLambda1;
HSPLandroidx/activity/ComponentActivity$$ExternalSyntheticLambda1;->(Landroidx/activity/ComponentActivity;)V
HSPLandroidx/activity/ComponentActivity$$ExternalSyntheticLambda1;->onStateChanged(Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$Event;)V
Landroidx/activity/ComponentActivity$$ExternalSyntheticLambda2;
HSPLandroidx/activity/ComponentActivity$$ExternalSyntheticLambda2;->(Landroidx/activity/ComponentActivity;)V
HSPLandroidx/activity/ComponentActivity$$ExternalSyntheticLambda2;->onStateChanged(Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$Event;)V
Landroidx/activity/ComponentActivity$$ExternalSyntheticLambda3;
HSPLandroidx/activity/ComponentActivity$$ExternalSyntheticLambda3;->(Landroidx/activity/ComponentActivity;)V
Landroidx/activity/ComponentActivity$$ExternalSyntheticLambda4;
HSPLandroidx/activity/ComponentActivity$$ExternalSyntheticLambda4;->(Landroidx/activity/ComponentActivity;)V
HSPLandroidx/activity/ComponentActivity$$ExternalSyntheticLambda4;->onContextAvailable(Landroid/content/Context;)V
Landroidx/activity/ComponentActivity$$ExternalSyntheticLambda5;
HSPLandroidx/activity/ComponentActivity$$ExternalSyntheticLambda5;->(Landroidx/activity/OnBackPressedDispatcher;Landroidx/activity/ComponentActivity;)V
HSPLandroidx/activity/ComponentActivity$$ExternalSyntheticLambda5;->onStateChanged(Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$Event;)V
Landroidx/activity/ComponentActivity$4;
HSPLandroidx/activity/ComponentActivity$4;->(Landroidx/activity/ComponentActivity;)V
HSPLandroidx/activity/ComponentActivity$4;->onStateChanged(Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$Event;)V
Landroidx/activity/ComponentActivity$Api33Impl;
HSPLandroidx/activity/ComponentActivity$Api33Impl;->()V
HSPLandroidx/activity/ComponentActivity$Api33Impl;->()V
HSPLandroidx/activity/ComponentActivity$Api33Impl;->getOnBackInvokedDispatcher(Landroid/app/Activity;)Landroid/window/OnBackInvokedDispatcher;
Landroidx/activity/ComponentActivity$Companion;
HSPLandroidx/activity/ComponentActivity$Companion;->()V
HSPLandroidx/activity/ComponentActivity$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V
Landroidx/activity/ComponentActivity$NonConfigurationInstances;
Landroidx/activity/ComponentActivity$ReportFullyDrawnExecutor;
Landroidx/activity/ComponentActivity$ReportFullyDrawnExecutorImpl;
HSPLandroidx/activity/ComponentActivity$ReportFullyDrawnExecutorImpl;->(Landroidx/activity/ComponentActivity;)V
HSPLandroidx/activity/ComponentActivity$ReportFullyDrawnExecutorImpl;->onDraw()V
HSPLandroidx/activity/ComponentActivity$ReportFullyDrawnExecutorImpl;->viewCreated(Landroid/view/View;)V
Landroidx/activity/ComponentActivity$activityResultRegistry$1;
HSPLandroidx/activity/ComponentActivity$activityResultRegistry$1;->(Landroidx/activity/ComponentActivity;)V
Landroidx/activity/ComponentActivity$defaultViewModelProviderFactory$2;
HSPLandroidx/activity/ComponentActivity$defaultViewModelProviderFactory$2;->(Landroidx/activity/ComponentActivity;)V
Landroidx/activity/ComponentActivity$fullyDrawnReporter$2;
HSPLandroidx/activity/ComponentActivity$fullyDrawnReporter$2;->(Landroidx/activity/ComponentActivity;)V
Landroidx/activity/ComponentActivity$onBackPressedDispatcher$2;
HSPLandroidx/activity/ComponentActivity$onBackPressedDispatcher$2;->(Landroidx/activity/ComponentActivity;)V
HSPLandroidx/activity/ComponentActivity$onBackPressedDispatcher$2;->invoke()Landroidx/activity/OnBackPressedDispatcher;
HSPLandroidx/activity/ComponentActivity$onBackPressedDispatcher$2;->invoke()Ljava/lang/Object;
Landroidx/activity/ComponentActivity$onBackPressedDispatcher$2$$ExternalSyntheticLambda0;
HSPLandroidx/activity/ComponentActivity$onBackPressedDispatcher$2$$ExternalSyntheticLambda0;->(Landroidx/activity/ComponentActivity;)V
Landroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;
HSPLandroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;->m$1(Landroid/graphics/Insets;)I
HSPLandroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;->m$1(Landroid/view/Window;Z)V
HSPLandroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;->m$2(Landroid/graphics/Insets;)I
HSPLandroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;->m$3(Landroid/graphics/Insets;)I
HSPLandroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;->m$7()Ljava/lang/Class;
HSPLandroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;->m(Landroid/graphics/Canvas;Landroid/graphics/RenderNode;)V
HSPLandroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;->m(Landroid/graphics/Insets;)I
HSPLandroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;->m(Landroid/graphics/RenderNode;)Landroid/graphics/RecordingCanvas;
HSPLandroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;->m(Landroid/graphics/RenderNode;)V
HSPLandroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;->m(Landroid/graphics/RenderNode;IIII)Z
HSPLandroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;->m(Landroid/view/Window;Z)V
HSPLandroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;->m(Landroid/view/WindowManager$LayoutParams;I)V
HSPLandroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;->m(Ljava/lang/Object;)Landroid/view/autofill/AutofillManager;
HSPLandroidx/activity/ComponentDialog$$ExternalSyntheticApiModelOutline0;->m(Ljava/lang/String;)Landroid/graphics/RenderNode;
Landroidx/activity/EdgeToEdge;
HSPLandroidx/activity/EdgeToEdge;->()V
HSPLandroidx/activity/EdgeToEdge;->enable$default(Landroidx/activity/ComponentActivity;Landroidx/activity/SystemBarStyle;Landroidx/activity/SystemBarStyle;ILjava/lang/Object;)V
HSPLandroidx/activity/EdgeToEdge;->enable(Landroidx/activity/ComponentActivity;Landroidx/activity/SystemBarStyle;Landroidx/activity/SystemBarStyle;)V
Landroidx/activity/EdgeToEdgeApi26;
HSPLandroidx/activity/EdgeToEdgeApi26;->()V
Landroidx/activity/EdgeToEdgeApi28;
HSPLandroidx/activity/EdgeToEdgeApi28;->()V
Landroidx/activity/EdgeToEdgeApi29;
HSPLandroidx/activity/EdgeToEdgeApi29;->()V
HSPLandroidx/activity/EdgeToEdgeApi29;->setUp(Landroidx/activity/SystemBarStyle;Landroidx/activity/SystemBarStyle;Landroid/view/Window;Landroid/view/View;ZZ)V
Landroidx/activity/EdgeToEdgeApi30;
HSPLandroidx/activity/EdgeToEdgeApi30;->()V
HSPLandroidx/activity/EdgeToEdgeApi30;->adjustLayoutInDisplayCutoutMode(Landroid/view/Window;)V
Landroidx/activity/EdgeToEdgeBase;
HSPLandroidx/activity/EdgeToEdgeBase;->()V
Landroidx/activity/EdgeToEdgeImpl;
Landroidx/activity/FullyDrawnReporterOwner;
Landroidx/activity/OnBackPressedCallback;
HSPLandroidx/activity/OnBackPressedCallback;->(Z)V
HSPLandroidx/activity/OnBackPressedCallback;->addCancellable(Landroidx/activity/Cancellable;)V
HSPLandroidx/activity/OnBackPressedCallback;->isEnabled()Z
HSPLandroidx/activity/OnBackPressedCallback;->setEnabledChangedCallback$activity_release(Lkotlin/jvm/functions/Function0;)V
Landroidx/activity/OnBackPressedDispatcher;
HSPLandroidx/activity/OnBackPressedDispatcher;->(Ljava/lang/Runnable;)V
HSPLandroidx/activity/OnBackPressedDispatcher;->(Ljava/lang/Runnable;Landroidx/core/util/Consumer;)V
HSPLandroidx/activity/OnBackPressedDispatcher;->addCallback(Landroidx/activity/OnBackPressedCallback;)V
HSPLandroidx/activity/OnBackPressedDispatcher;->addCancellableCallback$activity_release(Landroidx/activity/OnBackPressedCallback;)Landroidx/activity/Cancellable;
HSPLandroidx/activity/OnBackPressedDispatcher;->setOnBackInvokedDispatcher(Landroid/window/OnBackInvokedDispatcher;)V
HSPLandroidx/activity/OnBackPressedDispatcher;->updateBackInvokedCallbackState(Z)V
HSPLandroidx/activity/OnBackPressedDispatcher;->updateEnabledCallbacks()V
Landroidx/activity/OnBackPressedDispatcher$1;
HSPLandroidx/activity/OnBackPressedDispatcher$1;->(Landroidx/activity/OnBackPressedDispatcher;)V
Landroidx/activity/OnBackPressedDispatcher$2;
HSPLandroidx/activity/OnBackPressedDispatcher$2;->(Landroidx/activity/OnBackPressedDispatcher;)V
Landroidx/activity/OnBackPressedDispatcher$3;
HSPLandroidx/activity/OnBackPressedDispatcher$3;->(Landroidx/activity/OnBackPressedDispatcher;)V
Landroidx/activity/OnBackPressedDispatcher$4;
HSPLandroidx/activity/OnBackPressedDispatcher$4;->(Landroidx/activity/OnBackPressedDispatcher;)V
Landroidx/activity/OnBackPressedDispatcher$Api34Impl;
HSPLandroidx/activity/OnBackPressedDispatcher$Api34Impl;->()V
HSPLandroidx/activity/OnBackPressedDispatcher$Api34Impl;->()V
HSPLandroidx/activity/OnBackPressedDispatcher$Api34Impl;->createOnBackAnimationCallback(Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)Landroid/window/OnBackInvokedCallback;
Landroidx/activity/OnBackPressedDispatcher$Api34Impl$createOnBackAnimationCallback$1;
HSPLandroidx/activity/OnBackPressedDispatcher$Api34Impl$createOnBackAnimationCallback$1;->(Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V
Landroidx/activity/OnBackPressedDispatcher$OnBackPressedCancellable;
HSPLandroidx/activity/OnBackPressedDispatcher$OnBackPressedCancellable;->(Landroidx/activity/OnBackPressedDispatcher;Landroidx/activity/OnBackPressedCallback;)V
Landroidx/activity/OnBackPressedDispatcher$addCancellableCallback$1;
HSPLandroidx/activity/OnBackPressedDispatcher$addCancellableCallback$1;->(Ljava/lang/Object;)V
Landroidx/activity/OnBackPressedDispatcherOwner;
Landroidx/activity/R$id;
Landroidx/activity/SystemBarStyle;
HSPLandroidx/activity/SystemBarStyle;->()V
HSPLandroidx/activity/SystemBarStyle;->(IIILkotlin/jvm/functions/Function1;)V
HSPLandroidx/activity/SystemBarStyle;->(IIILkotlin/jvm/functions/Function1;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
HSPLandroidx/activity/SystemBarStyle;->getDetectDarkMode$activity_release()Lkotlin/jvm/functions/Function1;
HSPLandroidx/activity/SystemBarStyle;->getNightMode$activity_release()I
HSPLandroidx/activity/SystemBarStyle;->getScrimWithEnforcedContrast$activity_release(Z)I
Landroidx/activity/SystemBarStyle$Companion;
HSPLandroidx/activity/SystemBarStyle$Companion;->()V
HSPLandroidx/activity/SystemBarStyle$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V
HSPLandroidx/activity/SystemBarStyle$Companion;->auto$default(Landroidx/activity/SystemBarStyle$Companion;IILkotlin/jvm/functions/Function1;ILjava/lang/Object;)Landroidx/activity/SystemBarStyle;
HSPLandroidx/activity/SystemBarStyle$Companion;->auto(IILkotlin/jvm/functions/Function1;)Landroidx/activity/SystemBarStyle;
Landroidx/activity/SystemBarStyle$Companion$auto$1;
HSPLandroidx/activity/SystemBarStyle$Companion$auto$1;->()V
HSPLandroidx/activity/SystemBarStyle$Companion$auto$1;->()V
HSPLandroidx/activity/SystemBarStyle$Companion$auto$1;->invoke(Landroid/content/res/Resources;)Ljava/lang/Boolean;
HSPLandroidx/activity/SystemBarStyle$Companion$auto$1;->invoke(Ljava/lang/Object;)Ljava/lang/Object;
Landroidx/activity/ViewTreeFullyDrawnReporterOwner;
HSPLandroidx/activity/ViewTreeFullyDrawnReporterOwner;->set(Landroid/view/View;Landroidx/activity/FullyDrawnReporterOwner;)V
Landroidx/activity/ViewTreeOnBackPressedDispatcherOwner;
HSPLandroidx/activity/ViewTreeOnBackPressedDispatcherOwner;->set(Landroid/view/View;Landroidx/activity/OnBackPressedDispatcherOwner;)V
Landroidx/activity/compose/ComponentActivityKt;
HSPLandroidx/activity/compose/ComponentActivityKt;->()V
HSPLandroidx/activity/compose/ComponentActivityKt;->setContent$default(Landroidx/activity/ComponentActivity;Landroidx/compose/runtime/CompositionContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
HSPLandroidx/activity/compose/ComponentActivityKt;->setContent(Landroidx/activity/ComponentActivity;Landroidx/compose/runtime/CompositionContext;Lkotlin/jvm/functions/Function2;)V
HSPLandroidx/activity/compose/ComponentActivityKt;->setOwners(Landroidx/activity/ComponentActivity;)V
Landroidx/activity/contextaware/ContextAware;
Landroidx/activity/contextaware/ContextAwareHelper;
HSPLandroidx/activity/contextaware/ContextAwareHelper;->()V
HSPLandroidx/activity/contextaware/ContextAwareHelper;->addOnContextAvailableListener(Landroidx/activity/contextaware/OnContextAvailableListener;)V
HSPLandroidx/activity/contextaware/ContextAwareHelper;->dispatchOnContextAvailable(Landroid/content/Context;)V
Landroidx/activity/contextaware/OnContextAvailableListener;
Landroidx/activity/result/ActivityResult;
Landroidx/activity/result/ActivityResultCallback;
Landroidx/activity/result/ActivityResultCaller;
Landroidx/activity/result/ActivityResultLauncher;
HSPLandroidx/activity/result/ActivityResultLauncher;->()V
Landroidx/activity/result/ActivityResultRegistry;
HSPLandroidx/activity/result/ActivityResultRegistry;->$r8$lambda$TWvtyPFk-iHdx0R-btWVLevVLT0(Landroidx/activity/result/ActivityResultRegistry;Ljava/lang/String;Landroidx/activity/result/ActivityResultCallback;Landroidx/activity/result/contract/ActivityResultContract;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$Event;)V
HSPLandroidx/activity/result/ActivityResultRegistry;->()V
HSPLandroidx/activity/result/ActivityResultRegistry;->()V
HSPLandroidx/activity/result/ActivityResultRegistry;->bindRcKey(ILjava/lang/String;)V
HSPLandroidx/activity/result/ActivityResultRegistry;->generateRandomNumber()I
HSPLandroidx/activity/result/ActivityResultRegistry;->register$lambda$1(Landroidx/activity/result/ActivityResultRegistry;Ljava/lang/String;Landroidx/activity/result/ActivityResultCallback;Landroidx/activity/result/contract/ActivityResultContract;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$Event;)V
HSPLandroidx/activity/result/ActivityResultRegistry;->register(Ljava/lang/String;Landroidx/lifecycle/LifecycleOwner;Landroidx/activity/result/contract/ActivityResultContract;Landroidx/activity/result/ActivityResultCallback;)Landroidx/activity/result/ActivityResultLauncher;
HSPLandroidx/activity/result/ActivityResultRegistry;->registerKey(Ljava/lang/String;)V
Landroidx/activity/result/ActivityResultRegistry$$ExternalSyntheticLambda0;
HSPLandroidx/activity/result/ActivityResultRegistry$$ExternalSyntheticLambda0;->(Landroidx/activity/result/ActivityResultRegistry;Ljava/lang/String;Landroidx/activity/result/ActivityResultCallback;Landroidx/activity/result/contract/ActivityResultContract;)V
HSPLandroidx/activity/result/ActivityResultRegistry$$ExternalSyntheticLambda0;->onStateChanged(Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$Event;)V
Landroidx/activity/result/ActivityResultRegistry$CallbackAndContract;
HSPLandroidx/activity/result/ActivityResultRegistry$CallbackAndContract;->(Landroidx/activity/result/ActivityResultCallback;Landroidx/activity/result/contract/ActivityResultContract;)V
Landroidx/activity/result/ActivityResultRegistry$Companion;
HSPLandroidx/activity/result/ActivityResultRegistry$Companion;->()V
HSPLandroidx/activity/result/ActivityResultRegistry$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V
Landroidx/activity/result/ActivityResultRegistry$LifecycleContainer;
HSPLandroidx/activity/result/ActivityResultRegistry$LifecycleContainer;->(Landroidx/lifecycle/Lifecycle;)V
HSPLandroidx/activity/result/ActivityResultRegistry$LifecycleContainer;->addObserver(Landroidx/lifecycle/LifecycleEventObserver;)V
Landroidx/activity/result/ActivityResultRegistry$generateRandomNumber$1;
HSPLandroidx/activity/result/ActivityResultRegistry$generateRandomNumber$1;->()V
HSPLandroidx/activity/result/ActivityResultRegistry$generateRandomNumber$1;->()V
HSPLandroidx/activity/result/ActivityResultRegistry$generateRandomNumber$1;->invoke()Ljava/lang/Integer;
HSPLandroidx/activity/result/ActivityResultRegistry$generateRandomNumber$1;->invoke()Ljava/lang/Object;
Landroidx/activity/result/ActivityResultRegistry$register$2;
HSPLandroidx/activity/result/ActivityResultRegistry$register$2;->(Landroidx/activity/result/ActivityResultRegistry;Ljava/lang/String;Landroidx/activity/result/contract/ActivityResultContract;)V
Landroidx/activity/result/ActivityResultRegistryOwner;
Landroidx/activity/result/contract/ActivityResultContract;
HSPLandroidx/activity/result/contract/ActivityResultContract;->()V
Landroidx/arch/core/executor/ArchTaskExecutor;
HSPLandroidx/arch/core/executor/ArchTaskExecutor;->()V
HSPLandroidx/arch/core/executor/ArchTaskExecutor;->()V
HSPLandroidx/arch/core/executor/ArchTaskExecutor;->getInstance()Landroidx/arch/core/executor/ArchTaskExecutor;
HSPLandroidx/arch/core/executor/ArchTaskExecutor;->isMainThread()Z
Landroidx/arch/core/executor/ArchTaskExecutor$$ExternalSyntheticLambda0;
HSPLandroidx/arch/core/executor/ArchTaskExecutor$$ExternalSyntheticLambda0;->()V
Landroidx/arch/core/executor/ArchTaskExecutor$$ExternalSyntheticLambda1;
HSPLandroidx/arch/core/executor/ArchTaskExecutor$$ExternalSyntheticLambda1;->()V
Landroidx/arch/core/executor/DefaultTaskExecutor;
HSPLandroidx/arch/core/executor/DefaultTaskExecutor;->()V
HSPLandroidx/arch/core/executor/DefaultTaskExecutor;->isMainThread()Z
Landroidx/arch/core/executor/DefaultTaskExecutor$1;
HSPLandroidx/arch/core/executor/DefaultTaskExecutor$1;->(Landroidx/arch/core/executor/DefaultTaskExecutor;)V
Landroidx/arch/core/executor/TaskExecutor;
HSPLandroidx/arch/core/executor/TaskExecutor;->()V
Landroidx/arch/core/internal/FastSafeIterableMap;
HSPLandroidx/arch/core/internal/FastSafeIterableMap;->()V
HSPLandroidx/arch/core/internal/FastSafeIterableMap;->ceil(Ljava/lang/Object;)Ljava/util/Map$Entry;
HSPLandroidx/arch/core/internal/FastSafeIterableMap;->contains(Ljava/lang/Object;)Z
HSPLandroidx/arch/core/internal/FastSafeIterableMap;->get(Ljava/lang/Object;)Landroidx/arch/core/internal/SafeIterableMap$Entry;
HSPLandroidx/arch/core/internal/FastSafeIterableMap;->putIfAbsent(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
HSPLandroidx/arch/core/internal/FastSafeIterableMap;->remove(Ljava/lang/Object;)Ljava/lang/Object;
Landroidx/arch/core/internal/SafeIterableMap;
HSPLandroidx/arch/core/internal/SafeIterableMap;->()V
HSPLandroidx/arch/core/internal/SafeIterableMap;->eldest()Ljava/util/Map$Entry;
HSPLandroidx/arch/core/internal/SafeIterableMap;->get(Ljava/lang/Object;)Landroidx/arch/core/internal/SafeIterableMap$Entry;
HSPLandroidx/arch/core/internal/SafeIterableMap;->iterator()Ljava/util/Iterator;
HSPLandroidx/arch/core/internal/SafeIterableMap;->iteratorWithAdditions()Landroidx/arch/core/internal/SafeIterableMap$IteratorWithAdditions;
HSPLandroidx/arch/core/internal/SafeIterableMap;->newest()Ljava/util/Map$Entry;
HSPLandroidx/arch/core/internal/SafeIterableMap;->put(Ljava/lang/Object;Ljava/lang/Object;)Landroidx/arch/core/internal/SafeIterableMap$Entry;
HSPLandroidx/arch/core/internal/SafeIterableMap;->putIfAbsent(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
HSPLandroidx/arch/core/internal/SafeIterableMap;->remove(Ljava/lang/Object;)Ljava/lang/Object;
HSPLandroidx/arch/core/internal/SafeIterableMap;->size()I
Landroidx/arch/core/internal/SafeIterableMap$AscendingIterator;
HSPLandroidx/arch/core/internal/SafeIterableMap$AscendingIterator;->(Landroidx/arch/core/internal/SafeIterableMap$Entry;Landroidx/arch/core/internal/SafeIterableMap$Entry;)V
Landroidx/arch/core/internal/SafeIterableMap$Entry;
HSPLandroidx/arch/core/internal/SafeIterableMap$Entry;->(Ljava/lang/Object;Ljava/lang/Object;)V
HSPLandroidx/arch/core/internal/SafeIterableMap$Entry;->getKey()Ljava/lang/Object;
HSPLandroidx/arch/core/internal/SafeIterableMap$Entry;->getValue()Ljava/lang/Object;
Landroidx/arch/core/internal/SafeIterableMap$IteratorWithAdditions;
HSPLandroidx/arch/core/internal/SafeIterableMap$IteratorWithAdditions;->(Landroidx/arch/core/internal/SafeIterableMap;)V
HSPLandroidx/arch/core/internal/SafeIterableMap$IteratorWithAdditions;->hasNext()Z
HSPLandroidx/arch/core/internal/SafeIterableMap$IteratorWithAdditions;->next()Ljava/lang/Object;
HSPLandroidx/arch/core/internal/SafeIterableMap$IteratorWithAdditions;->next()Ljava/util/Map$Entry;
HSPLandroidx/arch/core/internal/SafeIterableMap$IteratorWithAdditions;->supportRemove(Landroidx/arch/core/internal/SafeIterableMap$Entry;)V
Landroidx/arch/core/internal/SafeIterableMap$ListIterator;
HSPLandroidx/arch/core/internal/SafeIterableMap$ListIterator;->(Landroidx/arch/core/internal/SafeIterableMap$Entry;Landroidx/arch/core/internal/SafeIterableMap$Entry;)V
HSPLandroidx/arch/core/internal/SafeIterableMap$ListIterator;->hasNext()Z
Landroidx/arch/core/internal/SafeIterableMap$SupportRemove;
HSPLandroidx/arch/core/internal/SafeIterableMap$SupportRemove;->()V
Landroidx/collection/ArrayMap;
HSPLandroidx/collection/ArrayMap;->()V
Landroidx/collection/ArraySet;
HSPLandroidx/collection/ArraySet;->()V
HSPLandroidx/collection/ArraySet;->(I)V
HSPLandroidx/collection/ArraySet;->(IILkotlin/jvm/internal/DefaultConstructorMarker;)V
HSPLandroidx/collection/ArraySet;->add(Ljava/lang/Object;)Z
HSPLandroidx/collection/ArraySet;->clear()V
HSPLandroidx/collection/ArraySet;->getArray$collection()[Ljava/lang/Object;
HSPLandroidx/collection/ArraySet;->getHashes$collection()[I
HSPLandroidx/collection/ArraySet;->get_size$collection()I
HSPLandroidx/collection/ArraySet;->setArray$collection([Ljava/lang/Object;)V
HSPLandroidx/collection/ArraySet;->setHashes$collection([I)V
HSPLandroidx/collection/ArraySet;->set_size$collection(I)V
HSPLandroidx/collection/ArraySet;->toArray()[Ljava/lang/Object;
Landroidx/collection/ArraySetKt;
HSPLandroidx/collection/ArraySetKt;->allocArrays(Landroidx/collection/ArraySet;I)V
HSPLandroidx/collection/ArraySetKt;->indexOf(Landroidx/collection/ArraySet;Ljava/lang/Object;I)I
Landroidx/collection/FloatFloatPair;
HSPLandroidx/collection/FloatFloatPair;->constructor-impl(FF)J
HSPLandroidx/collection/FloatFloatPair;->constructor-impl(J)J
Landroidx/collection/IntIntMap;
HSPLandroidx/collection/IntIntMap;->()V
HSPLandroidx/collection/IntIntMap;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V
HSPLandroidx/collection/IntIntMap;->getCapacity()I
Landroidx/collection/IntList;
HSPLandroidx/collection/IntList;->(I)V
HSPLandroidx/collection/IntList;->(ILkotlin/jvm/internal/DefaultConstructorMarker;)V
HSPLandroidx/collection/IntList;->get(I)I
HSPLandroidx/collection/IntList;->getSize()I
Landroidx/collection/IntListKt;
HSPLandroidx/collection/IntListKt;->()V
HSPLandroidx/collection/IntListKt;->intListOf([I)Landroidx/collection/IntList;
Landroidx/collection/IntObjectMap;
HSPLandroidx/collection/IntObjectMap;->()V
HSPLandroidx/collection/IntObjectMap;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V
HSPLandroidx/collection/IntObjectMap;->contains(I)Z
HSPLandroidx/collection/IntObjectMap;->get(I)Ljava/lang/Object;
HSPLandroidx/collection/IntObjectMap;->getCapacity()I
HSPLandroidx/collection/IntObjectMap;->getSize()I
Landroidx/collection/IntObjectMapKt;
HSPLandroidx/collection/IntObjectMapKt;->()V
HSPLandroidx/collection/IntObjectMapKt;->intObjectMapOf()Landroidx/collection/IntObjectMap;
HSPLandroidx/collection/IntObjectMapKt;->mutableIntObjectMapOf()Landroidx/collection/MutableIntObjectMap;
HSPLandroidx/collection/IntObjectMapKt;->mutableIntObjectMapOf(ILjava/lang/Object;ILjava/lang/Object;ILjava/lang/Object;)Landroidx/collection/MutableIntObjectMap;
Landroidx/collection/IntSet;
HSPLandroidx/collection/IntSet;->()V
HSPLandroidx/collection/IntSet;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V
HSPLandroidx/collection/IntSet;->getCapacity()I
Landroidx/collection/IntSetKt;
HSPLandroidx/collection/IntSetKt;->()V
HSPLandroidx/collection/IntSetKt;->getEmptyIntArray()[I
HSPLandroidx/collection/IntSetKt;->mutableIntSetOf()Landroidx/collection/MutableIntSet;
Landroidx/collection/LongObjectMap;
HSPLandroidx/collection/LongObjectMap;->()V
HSPLandroidx/collection/LongObjectMap;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V
HSPLandroidx/collection/LongObjectMap;->getCapacity()I
Landroidx/collection/LongSet;
HSPLandroidx/collection/LongSet;->()V
HSPLandroidx/collection/LongSet;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V
HSPLandroidx/collection/LongSet;->getCapacity()I
Landroidx/collection/LongSetKt;
HSPLandroidx/collection/LongSetKt;->()V
HSPLandroidx/collection/LongSetKt;->getEmptyLongArray()[J
Landroidx/collection/LongSparseArray;
HSPLandroidx/collection/LongSparseArray;->(I)V
HSPLandroidx/collection/LongSparseArray;->(IILkotlin/jvm/internal/DefaultConstructorMarker;)V
Landroidx/collection/LruCache;
HSPLandroidx/collection/LruCache;->(I)V
HSPLandroidx/collection/LruCache;->create(Ljava/lang/Object;)Ljava/lang/Object;
HSPLandroidx/collection/LruCache;->get(Ljava/lang/Object;)Ljava/lang/Object;
HSPLandroidx/collection/LruCache;->maxSize()I
HSPLandroidx/collection/LruCache;->put(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
HSPLandroidx/collection/LruCache;->safeSizeOf(Ljava/lang/Object;Ljava/lang/Object;)I
HSPLandroidx/collection/LruCache;->sizeOf(Ljava/lang/Object;Ljava/lang/Object;)I
HSPLandroidx/collection/LruCache;->trimToSize(I)V
Landroidx/collection/MutableIntIntMap;
HSPLandroidx/collection/MutableIntIntMap;->(I)V
HSPLandroidx/collection/MutableIntIntMap;->