Repository: 1RandomDev/showly-oss Branch: master Commit: 8f8e2754e232 Files: 2285 Total size: 33.3 MB Directory structure: gitextract_7hdornwr/ ├── .editorconfig ├── .github/ │ └── ISSUE_TEMPLATE/ │ └── bug-problem-report.md ├── .gitignore ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ └── release_notes.txt │ │ ├── java/ │ │ │ └── com/ │ │ │ └── michaldrabik/ │ │ │ └── showly_oss/ │ │ │ ├── App.kt │ │ │ ├── di/ │ │ │ │ └── module/ │ │ │ │ ├── PreferencesModule.kt │ │ │ │ ├── ServicesModule.kt │ │ │ │ └── WorkModule.kt │ │ │ ├── fcm/ │ │ │ │ └── FcmExtra.kt │ │ │ ├── ui/ │ │ │ │ ├── BaseActivity.kt │ │ │ │ ├── main/ │ │ │ │ │ ├── MainActivity.kt │ │ │ │ │ ├── MainUiState.kt │ │ │ │ │ ├── MainViewModel.kt │ │ │ │ │ ├── cases/ │ │ │ │ │ │ ├── MainAnnouncementsCase.kt │ │ │ │ │ │ ├── MainClearingCase.kt │ │ │ │ │ │ ├── MainInitialsCase.kt │ │ │ │ │ │ ├── MainModesCase.kt │ │ │ │ │ │ ├── MainRateAppCase.kt │ │ │ │ │ │ ├── MainSettingsCase.kt │ │ │ │ │ │ ├── MainTipsCase.kt │ │ │ │ │ │ ├── MainTraktCase.kt │ │ │ │ │ │ └── deeplink/ │ │ │ │ │ │ ├── ImdbDeepLinkCase.kt │ │ │ │ │ │ ├── MainDeepLinksCase.kt │ │ │ │ │ │ ├── TmdbDeepLinkCase.kt │ │ │ │ │ │ └── TraktDeepLinkCase.kt │ │ │ │ │ └── delegates/ │ │ │ │ │ └── TipsDelegate.kt │ │ │ │ └── views/ │ │ │ │ ├── BottomMenuView.kt │ │ │ │ ├── WelcomeLanguageView.kt │ │ │ │ ├── WelcomeNoteView.kt │ │ │ │ └── WhatsNewView.kt │ │ │ └── utilities/ │ │ │ └── deeplink/ │ │ │ ├── DeepLinkBundle.kt │ │ │ ├── DeepLinkResolver.kt │ │ │ ├── DeepLinkSource.kt │ │ │ └── resolvers/ │ │ │ ├── ImdbSourceResolver.kt │ │ │ ├── SourceResolver.kt │ │ │ ├── TmdbSourceResolver.kt │ │ │ └── TraktSourceResolver.kt │ │ ├── play/ │ │ │ └── release-notes/ │ │ │ └── en-GB/ │ │ │ └── default.txt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── bg_dialog.xml │ │ │ ├── ic_eye_off.xml │ │ │ ├── ic_languages.xml │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── ic_launcher_mono.xml │ │ │ └── selector_bottom_menu.xml │ │ ├── layout/ │ │ │ ├── activity_main.xml │ │ │ ├── view_bottom_menu.xml │ │ │ ├── view_welcome_language.xml │ │ │ ├── view_welcome_note.xml │ │ │ └── view_whats_new.xml │ │ ├── menu/ │ │ │ └── bottom_navigation_menu.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── values/ │ │ │ ├── dimens.xml │ │ │ ├── ic_launcher_background.xml │ │ │ └── strings.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-fi/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-sw600dp/ │ │ │ └── dimens.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ ├── values-zh/ │ │ │ └── strings.xml │ │ └── xml/ │ │ ├── locales_config.xml │ │ └── shortcuts.xml │ └── test/ │ └── java/ │ ├── BaseMockTest.kt │ └── com/ │ └── michaldrabik/ │ └── showly_oss/ │ └── ui/ │ └── main/ │ └── cases/ │ ├── MainRateAppCaseTest.kt │ └── MainTipsCaseTest.kt ├── assets/ │ ├── codestyle.xml │ ├── graphics/ │ │ ├── video_preview_feature_graphic.xcf │ │ └── web_feature_graphic.xcf │ └── screenshots/ │ └── screenshots.xcf ├── build.gradle ├── common/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ ├── debug/ │ │ └── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── common/ │ │ └── ConfigVariant.kt │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── common/ │ │ ├── Config.kt │ │ ├── Mode.kt │ │ ├── di/ │ │ │ └── CommonBindingModule.kt │ │ ├── dispatchers/ │ │ │ ├── CoroutineDispatchers.kt │ │ │ └── DefaultCoroutineDispatchers.kt │ │ ├── errors/ │ │ │ ├── ErrorHelper.kt │ │ │ └── ShowlyError.kt │ │ └── extensions/ │ │ └── DateExtensions.kt │ └── release/ │ └── java/ │ └── com/ │ └── michaldrabik/ │ └── common/ │ └── ConfigVariant.kt ├── common-test/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ └── java/ │ └── com/ │ └── michaldrabik/ │ └── common_test/ │ ├── MainDispatcherRule.kt │ └── UnconfinedCoroutineDispatchers.kt ├── crowdin.yml ├── data-local/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── data_local/ │ │ └── database/ │ │ └── dao/ │ │ ├── BaseDaoTest.kt │ │ ├── DiscoverShowsDaoTest.kt │ │ ├── EpisodesDaoTest.kt │ │ ├── EpisodesSyncLogDaoTest.kt │ │ ├── MyShowsDaoTest.kt │ │ ├── RecentSearchDaoTest.kt │ │ ├── RelatedShowsDaoTest.kt │ │ ├── SeasonsDaoTest.kt │ │ ├── SettingsDaoTest.kt │ │ ├── ShowImagesDaoTest.kt │ │ ├── ShowsDaoTest.kt │ │ ├── UserDaoTest.kt │ │ ├── WatchlistShowsDaoTest.kt │ │ ├── converters/ │ │ │ └── DateConverterTest.kt │ │ └── helpers/ │ │ └── TestData.kt │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── com/ │ └── michaldrabik/ │ └── data_local/ │ ├── LocalDataSource.kt │ ├── database/ │ │ ├── AppDatabase.kt │ │ ├── converters/ │ │ │ └── DateConverter.kt │ │ ├── dao/ │ │ │ ├── ArchiveMoviesDao.kt │ │ │ ├── ArchiveShowsDao.kt │ │ │ ├── BaseDao.kt │ │ │ ├── CustomImagesDao.kt │ │ │ ├── CustomListsDao.kt │ │ │ ├── CustomListsItemsDao.kt │ │ │ ├── DiscoverMoviesDao.kt │ │ │ ├── DiscoverShowsDao.kt │ │ │ ├── EpisodeTranslationsDao.kt │ │ │ ├── EpisodesDao.kt │ │ │ ├── EpisodesSyncLogDao.kt │ │ │ ├── MovieCollectionsDao.kt │ │ │ ├── MovieCollectionsItemsDao.kt │ │ │ ├── MovieImagesDao.kt │ │ │ ├── MovieRatingsDao.kt │ │ │ ├── MovieStreamingsDao.kt │ │ │ ├── MovieTranslationsDao.kt │ │ │ ├── MoviesDao.kt │ │ │ ├── MoviesSyncLogDao.kt │ │ │ ├── MyMoviesDao.kt │ │ │ ├── MyShowsDao.kt │ │ │ ├── NewsDao.kt │ │ │ ├── PeopleCreditsDao.kt │ │ │ ├── PeopleDao.kt │ │ │ ├── PeopleImagesDao.kt │ │ │ ├── PeopleShowsMoviesDao.kt │ │ │ ├── RatingsDao.kt │ │ │ ├── RecentSearchDao.kt │ │ │ ├── RelatedMoviesDao.kt │ │ │ ├── RelatedShowsDao.kt │ │ │ ├── SeasonsDao.kt │ │ │ ├── SettingsDao.kt │ │ │ ├── ShowImagesDao.kt │ │ │ ├── ShowRatingsDao.kt │ │ │ ├── ShowStreamingsDao.kt │ │ │ ├── ShowTranslationsDao.kt │ │ │ ├── ShowsDao.kt │ │ │ ├── TraktSyncLogDao.kt │ │ │ ├── TraktSyncQueueDao.kt │ │ │ ├── TranslationsMoviesSyncLogDao.kt │ │ │ ├── TranslationsSyncLogDao.kt │ │ │ ├── UserDao.kt │ │ │ ├── WatchlistMoviesDao.kt │ │ │ └── WatchlistShowsDao.kt │ │ ├── migrations/ │ │ │ └── Migrations.kt │ │ └── model/ │ │ ├── ArchiveMovie.kt │ │ ├── ArchiveShow.kt │ │ ├── CustomImage.kt │ │ ├── CustomList.kt │ │ ├── CustomListItem.kt │ │ ├── DiscoverMovie.kt │ │ ├── DiscoverShow.kt │ │ ├── Episode.kt │ │ ├── EpisodeTranslation.kt │ │ ├── EpisodesSyncLog.kt │ │ ├── Movie.kt │ │ ├── MovieCollection.kt │ │ ├── MovieCollectionItem.kt │ │ ├── MovieImage.kt │ │ ├── MovieRatings.kt │ │ ├── MovieStreaming.kt │ │ ├── MovieTranslation.kt │ │ ├── MoviesSyncLog.kt │ │ ├── MyMovie.kt │ │ ├── MyShow.kt │ │ ├── News.kt │ │ ├── Person.kt │ │ ├── PersonCredits.kt │ │ ├── PersonImage.kt │ │ ├── PersonShowMovie.kt │ │ ├── Rating.kt │ │ ├── RecentSearch.kt │ │ ├── RelatedMovie.kt │ │ ├── RelatedShow.kt │ │ ├── Season.kt │ │ ├── Settings.kt │ │ ├── Show.kt │ │ ├── ShowImage.kt │ │ ├── ShowRatings.kt │ │ ├── ShowStreaming.kt │ │ ├── ShowTranslation.kt │ │ ├── TraktSyncLog.kt │ │ ├── TraktSyncQueue.kt │ │ ├── TranslationsMoviesSyncLog.kt │ │ ├── TranslationsSyncLog.kt │ │ ├── User.kt │ │ ├── WatchlistMovie.kt │ │ └── WatchlistShow.kt │ ├── di/ │ │ ├── LocalDataModule.kt │ │ ├── SourcesModule.kt │ │ └── StorageModule.kt │ ├── sources/ │ │ ├── ArchiveMoviesLocalDataSource.kt │ │ ├── ArchiveShowsLocalDataSource.kt │ │ ├── CustomImagesLocalDataSource.kt │ │ ├── CustomListsItemsLocalDataSource.kt │ │ ├── CustomListsLocalDataSource.kt │ │ ├── DiscoverMoviesLocalDataSource.kt │ │ ├── DiscoverShowsLocalDataSource.kt │ │ ├── EpisodeTranslationsLocalDataSource.kt │ │ ├── EpisodesLocalDataSource.kt │ │ ├── EpisodesSyncLogLocalDataSource.kt │ │ ├── MovieCollectionsItemsLocalDataSource.kt │ │ ├── MovieCollectionsLocalDataSource.kt │ │ ├── MovieImagesLocalDataSource.kt │ │ ├── MovieRatingsLocalDataSource.kt │ │ ├── MovieStreamingsLocalDataSource.kt │ │ ├── MovieTranslationsLocalDataSource.kt │ │ ├── MoviesLocalDataSource.kt │ │ ├── MoviesSyncLogLocalDataSource.kt │ │ ├── MyMoviesLocalDataSource.kt │ │ ├── MyShowsLocalDataSource.kt │ │ ├── NewsLocalDataSource.kt │ │ ├── PeopleCreditsLocalDataSource.kt │ │ ├── PeopleImagesLocalDataSource.kt │ │ ├── PeopleLocalDataSource.kt │ │ ├── PeopleShowsMoviesLocalDataSource.kt │ │ ├── RatingsLocalDataSource.kt │ │ ├── RecentSearchLocalDataSource.kt │ │ ├── RelatedMoviesLocalDataSource.kt │ │ ├── RelatedShowsLocalDataSource.kt │ │ ├── SeasonsLocalDataSource.kt │ │ ├── SettingsLocalDataSource.kt │ │ ├── ShowImagesLocalDataSource.kt │ │ ├── ShowRatingsLocalDataSource.kt │ │ ├── ShowStreamingsLocalDataSource.kt │ │ ├── ShowTranslationsLocalDataSource.kt │ │ ├── ShowsLocalDataSource.kt │ │ ├── TraktSyncLogLocalDataSource.kt │ │ ├── TraktSyncQueueLocalDataSource.kt │ │ ├── TranslationsMoviesSyncLogLocalDataSource.kt │ │ ├── TranslationsShowsSyncLogLocalDataSource.kt │ │ ├── UserLocalDataSource.kt │ │ ├── WatchlistMoviesLocalDataSource.kt │ │ └── WatchlistShowsLocalDataSource.kt │ └── utilities/ │ └── TransactionsProvider.kt ├── data-remote/ │ ├── .gitignore │ ├── build.gradle │ ├── consumer-rules.pro │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── com/ │ └── michaldrabik/ │ └── data_remote/ │ ├── Config.kt │ ├── RemoteDataSource.kt │ ├── aws/ │ │ ├── AwsRemoteDataSource.kt │ │ ├── api/ │ │ │ ├── AwsApi.kt │ │ │ └── AwsService.kt │ │ └── model/ │ │ ├── AwsImage.kt │ │ └── AwsImagesList.kt │ ├── di/ │ │ └── module/ │ │ ├── AwsModule.kt │ │ ├── OkHttpModule.kt │ │ ├── OmdbModule.kt │ │ ├── PreferencesModule.kt │ │ ├── RedditModule.kt │ │ ├── RemoteDataModule.kt │ │ ├── RetrofitModule.kt │ │ ├── TmdbModule.kt │ │ └── TraktModule.kt │ ├── omdb/ │ │ ├── OmdbInterceptor.kt │ │ ├── OmdbRemoteDataSource.kt │ │ ├── api/ │ │ │ ├── OmdbApi.kt │ │ │ └── OmdbService.kt │ │ └── model/ │ │ └── OmdbResult.kt │ ├── reddit/ │ │ ├── RedditRemoteDataSource.kt │ │ ├── api/ │ │ │ ├── RedditApi.kt │ │ │ ├── RedditAuthApi.kt │ │ │ ├── RedditListingApi.kt │ │ │ └── RedditService.kt │ │ └── model/ │ │ ├── RedditAuthResponse.kt │ │ ├── RedditData.kt │ │ ├── RedditDataItem.kt │ │ ├── RedditItem.kt │ │ └── RedditResponse.kt │ ├── tmdb/ │ │ ├── TmdbInterceptor.kt │ │ ├── TmdbRemoteDataSource.kt │ │ ├── api/ │ │ │ ├── TmdbApi.kt │ │ │ └── TmdbService.kt │ │ └── model/ │ │ ├── TmdbImage.kt │ │ ├── TmdbImages.kt │ │ ├── TmdbPeople.kt │ │ ├── TmdbPerson.kt │ │ ├── TmdbStreamingCountry.kt │ │ ├── TmdbStreamingService.kt │ │ ├── TmdbStreamings.kt │ │ ├── TmdbTranslation.kt │ │ └── TmdbTranslationResponse.kt │ ├── token/ │ │ ├── TokenProvider.kt │ │ └── TraktTokenProvider.kt │ └── trakt/ │ ├── TraktRemoteDataSource.kt │ ├── api/ │ │ ├── TraktApi.kt │ │ └── service/ │ │ ├── TraktAuthService.kt │ │ ├── TraktCommentsService.kt │ │ ├── TraktMoviesService.kt │ │ ├── TraktPeopleService.kt │ │ ├── TraktSearchService.kt │ │ ├── TraktShowsService.kt │ │ ├── TraktSyncService.kt │ │ └── TraktUsersService.kt │ ├── interceptors/ │ │ ├── TraktAuthenticator.kt │ │ ├── TraktAuthorizationInterceptor.kt │ │ ├── TraktHeadersInterceptor.kt │ │ ├── TraktRefreshTokenInterceptor.kt │ │ └── TraktRetryInterceptor.kt │ └── model/ │ ├── AirTime.kt │ ├── Comment.kt │ ├── CustomList.kt │ ├── Episode.kt │ ├── HiddenItem.kt │ ├── Ids.kt │ ├── Movie.kt │ ├── MovieCollection.kt │ ├── MovieCollectionItem.kt │ ├── MovieResult.kt │ ├── OAuthResponse.kt │ ├── Person.kt │ ├── PersonCredit.kt │ ├── PersonCreditsResult.kt │ ├── RatingResultMovie.kt │ ├── RatingResultShow.kt │ ├── RatingResultValue.kt │ ├── SearchResult.kt │ ├── Season.kt │ ├── SeasonTranslation.kt │ ├── Show.kt │ ├── ShowResult.kt │ ├── SyncExportItem.kt │ ├── SyncExportRequest.kt │ ├── SyncExportResult.kt │ ├── SyncItem.kt │ ├── TraktUser.kt │ ├── Translation.kt │ ├── User.kt │ └── request/ │ ├── CommentRequest.kt │ ├── CreateListRequest.kt │ ├── OAuthRefreshRequest.kt │ ├── OAuthRequest.kt │ ├── OAuthRevokeRequest.kt │ └── RatingRequest.kt ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── repository/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── repository/ │ │ ├── CommentsRepository.kt │ │ ├── EpisodesManager.kt │ │ ├── ListsRepository.kt │ │ ├── NewsRepository.kt │ │ ├── OnHoldItemsRepository.kt │ │ ├── PeopleRepository.kt │ │ ├── PinnedItemsRepository.kt │ │ ├── RatingsRepository.kt │ │ ├── StreamingsRepository.kt │ │ ├── TranslationsRepository.kt │ │ ├── UserRedditManager.kt │ │ ├── UserTraktManager.kt │ │ ├── images/ │ │ │ ├── EpisodeImagesProvider.kt │ │ │ ├── MovieImagesProvider.kt │ │ │ ├── PeopleImagesProvider.kt │ │ │ └── ShowImagesProvider.kt │ │ ├── mappers/ │ │ │ ├── CollectionMapper.kt │ │ │ ├── CommentMapper.kt │ │ │ ├── CustomListMapper.kt │ │ │ ├── EpisodeMapper.kt │ │ │ ├── IdsMapper.kt │ │ │ ├── ImageMapper.kt │ │ │ ├── Mappers.kt │ │ │ ├── MovieMapper.kt │ │ │ ├── NewsMapper.kt │ │ │ ├── PersonMapper.kt │ │ │ ├── RatingsMapper.kt │ │ │ ├── SeasonMapper.kt │ │ │ ├── SettingsMapper.kt │ │ │ ├── ShowMapper.kt │ │ │ ├── StreamingsMapper.kt │ │ │ ├── TranslationMapper.kt │ │ │ └── UserRatingsMapper.kt │ │ ├── movies/ │ │ │ ├── DiscoverMoviesRepository.kt │ │ │ ├── HiddenMoviesRepository.kt │ │ │ ├── MovieCollectionsRepository.kt │ │ │ ├── MovieDetailsRepository.kt │ │ │ ├── MovieStreamingsRepository.kt │ │ │ ├── MoviesRepository.kt │ │ │ ├── MyMoviesRepository.kt │ │ │ ├── RelatedMoviesRepository.kt │ │ │ ├── WatchlistMoviesRepository.kt │ │ │ └── ratings/ │ │ │ ├── MoviesExternalRatingsRepository.kt │ │ │ └── MoviesRatingsRepository.kt │ │ ├── settings/ │ │ │ ├── SettingsFiltersRepository.kt │ │ │ ├── SettingsRepository.kt │ │ │ ├── SettingsSortRepository.kt │ │ │ ├── SettingsSpoilersRepository.kt │ │ │ ├── SettingsViewModeRepository.kt │ │ │ └── SettingsWidgetsRepository.kt │ │ ├── shows/ │ │ │ ├── DiscoverShowsRepository.kt │ │ │ ├── HiddenShowsRepository.kt │ │ │ ├── MyShowsRepository.kt │ │ │ ├── RelatedShowsRepository.kt │ │ │ ├── ShowDetailsRepository.kt │ │ │ ├── ShowStreamingsRepository.kt │ │ │ ├── ShowsRepository.kt │ │ │ ├── WatchlistShowsRepository.kt │ │ │ └── ratings/ │ │ │ ├── ShowsExternalRatingsRepository.kt │ │ │ └── ShowsRatingsRepository.kt │ │ └── utilities/ │ │ └── PreferencesDelegates.kt │ └── test/ │ └── java/ │ └── com/ │ └── michaldrabik/ │ └── repository/ │ ├── DiscoverShowsRepositoryTest.kt │ ├── MyShowsRepositoryTest.kt │ ├── PeopleRepositoryTest.kt │ ├── RelatedShowsRepositoryTest.kt │ ├── SettingsRepositoryTest.kt │ ├── ShowDetailsRepositoryTest.kt │ ├── WatchlistShowsRepositoryTest.kt │ └── common/ │ └── BaseMockTest.kt ├── settings.gradle ├── ui-base/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_base/ │ │ ├── Analytics.kt │ │ ├── BaseAdapter.kt │ │ ├── BaseBottomSheetFragment.kt │ │ ├── BaseFragment.kt │ │ ├── BaseMovieAdapter.kt │ │ ├── Logger.kt │ │ ├── common/ │ │ │ ├── AppCountry.kt │ │ │ ├── AppScopeProvider.kt │ │ │ ├── FastLinearLayoutManager.kt │ │ │ ├── ListItem.kt │ │ │ ├── ListViewMode.kt │ │ │ ├── MovieListItem.kt │ │ │ ├── OnScrollResetListener.kt │ │ │ ├── OnSearchClickListener.kt │ │ │ ├── OnShowsMoviesSyncedListener.kt │ │ │ ├── OnTabReselectedListener.kt │ │ │ ├── OnTraktAuthorizeListener.kt │ │ │ ├── SafeOnClickListener.kt │ │ │ ├── WidgetsProvider.kt │ │ │ ├── behaviour/ │ │ │ │ ├── ScrollableViewBehaviour.kt │ │ │ │ └── SearchViewBehaviour.kt │ │ │ ├── sheets/ │ │ │ │ ├── context_menu/ │ │ │ │ │ ├── ContextMenuBottomSheet.kt │ │ │ │ │ ├── events/ │ │ │ │ │ │ ├── FinishUiEvent.kt │ │ │ │ │ │ └── RemoveTraktUiEvent.kt │ │ │ │ │ ├── movie/ │ │ │ │ │ │ ├── MovieContextMenuBottomSheet.kt │ │ │ │ │ │ ├── MovieContextMenuUiState.kt │ │ │ │ │ │ ├── MovieContextMenuViewModel.kt │ │ │ │ │ │ ├── cases/ │ │ │ │ │ │ │ ├── MovieContextMenuHiddenCase.kt │ │ │ │ │ │ │ ├── MovieContextMenuLoadItemCase.kt │ │ │ │ │ │ │ ├── MovieContextMenuMyMoviesCase.kt │ │ │ │ │ │ │ ├── MovieContextMenuPinnedCase.kt │ │ │ │ │ │ │ └── MovieContextMenuWatchlistCase.kt │ │ │ │ │ │ └── helpers/ │ │ │ │ │ │ └── MovieContextItem.kt │ │ │ │ │ └── show/ │ │ │ │ │ ├── ShowContextMenuBottomSheet.kt │ │ │ │ │ ├── ShowContextMenuUiState.kt │ │ │ │ │ ├── ShowContextMenuViewModel.kt │ │ │ │ │ ├── cases/ │ │ │ │ │ │ ├── ShowContextMenuHiddenCase.kt │ │ │ │ │ │ ├── ShowContextMenuLoadItemCase.kt │ │ │ │ │ │ ├── ShowContextMenuMyShowsCase.kt │ │ │ │ │ │ ├── ShowContextMenuOnHoldCase.kt │ │ │ │ │ │ ├── ShowContextMenuPinnedCase.kt │ │ │ │ │ │ └── ShowContextMenuWatchlistCase.kt │ │ │ │ │ └── helpers/ │ │ │ │ │ └── ShowContextItem.kt │ │ │ │ ├── links/ │ │ │ │ │ ├── LinksBottomSheet.kt │ │ │ │ │ ├── LinksViewModel.kt │ │ │ │ │ └── views/ │ │ │ │ │ └── LinkItemView.kt │ │ │ │ ├── ratings/ │ │ │ │ │ ├── RatingsBottomSheet.kt │ │ │ │ │ ├── RatingsSheetViewModel.kt │ │ │ │ │ ├── RatingsUiEvents.kt │ │ │ │ │ ├── RatingsUiState.kt │ │ │ │ │ └── cases/ │ │ │ │ │ ├── RatingsEpisodeCase.kt │ │ │ │ │ ├── RatingsMovieCase.kt │ │ │ │ │ ├── RatingsSeasonCase.kt │ │ │ │ │ └── RatingsShowCase.kt │ │ │ │ ├── remove_trakt/ │ │ │ │ │ ├── RemoveTraktBottomSheet.kt │ │ │ │ │ ├── remove_trakt_hidden/ │ │ │ │ │ │ ├── RemoveTraktHiddenBottomSheet.kt │ │ │ │ │ │ ├── RemoveTraktHiddenUiState.kt │ │ │ │ │ │ ├── RemoveTraktHiddenViewModel.kt │ │ │ │ │ │ └── cases/ │ │ │ │ │ │ └── RemoveTraktHiddenCase.kt │ │ │ │ │ ├── remove_trakt_progress/ │ │ │ │ │ │ ├── RemoveTraktProgressBottomSheet.kt │ │ │ │ │ │ ├── RemoveTraktProgressUiState.kt │ │ │ │ │ │ ├── RemoveTraktProgressViewModel.kt │ │ │ │ │ │ └── cases/ │ │ │ │ │ │ └── RemoveTraktProgressCase.kt │ │ │ │ │ └── remove_trakt_watchlist/ │ │ │ │ │ ├── RemoveTraktWatchlistBottomSheet.kt │ │ │ │ │ ├── RemoveTraktWatchlistUiState.kt │ │ │ │ │ ├── RemoveTraktWatchlistViewModel.kt │ │ │ │ │ └── cases/ │ │ │ │ │ └── RemoveTraktWatchlistCase.kt │ │ │ │ └── sort_order/ │ │ │ │ ├── SortOrderBottomSheet.kt │ │ │ │ └── views/ │ │ │ │ └── SortOrderItemView.kt │ │ │ └── views/ │ │ │ ├── EmptySearchView.kt │ │ │ ├── FoldableTextView.kt │ │ │ ├── ModeTabsView.kt │ │ │ ├── MovieView.kt │ │ │ ├── PremiumAdView.kt │ │ │ ├── RateValueView.kt │ │ │ ├── RatingsStripView.kt │ │ │ ├── ScrollableImageView.kt │ │ │ ├── ScrollableTabLayout.kt │ │ │ ├── SearchLocalView.kt │ │ │ ├── SearchView.kt │ │ │ ├── SecretTextView.kt │ │ │ ├── ShowView.kt │ │ │ └── tips/ │ │ │ ├── TipOverlayView.kt │ │ │ └── TipView.kt │ │ ├── dates/ │ │ │ ├── AppDateFormat.kt │ │ │ └── DateFormatProvider.kt │ │ ├── events/ │ │ │ ├── Event.kt │ │ │ └── EventsManager.kt │ │ ├── fcm/ │ │ │ └── NotificationChannel.kt │ │ ├── network/ │ │ │ ├── NetworkCallbackAdapter.kt │ │ │ └── NetworkStatusProvider.kt │ │ ├── notifications/ │ │ │ ├── AnnouncementManager.kt │ │ │ ├── AnnouncementWorker.kt │ │ │ └── schedulers/ │ │ │ ├── MovieAnnouncementScheduler.kt │ │ │ └── ShowAnnouncementScheduler.kt │ │ ├── sync/ │ │ │ ├── ShowsMoviesSyncWorker.kt │ │ │ └── runners/ │ │ │ ├── MoviesSyncRunner.kt │ │ │ └── ShowsSyncRunner.kt │ │ ├── trakt/ │ │ │ ├── TraktNotificationWorker.kt │ │ │ ├── TraktSyncRunner.kt │ │ │ ├── TraktSyncWorker.kt │ │ │ ├── exports/ │ │ │ │ ├── TraktExportListsRunner.kt │ │ │ │ ├── TraktExportWatchedRunner.kt │ │ │ │ └── TraktExportWatchlistRunner.kt │ │ │ ├── imports/ │ │ │ │ ├── TraktImportListsRunner.kt │ │ │ │ ├── TraktImportWatchedRunner.kt │ │ │ │ └── TraktImportWatchlistRunner.kt │ │ │ └── quicksync/ │ │ │ ├── QuickSyncManager.kt │ │ │ ├── QuickSyncWorker.kt │ │ │ └── runners/ │ │ │ ├── QuickSyncListsRunner.kt │ │ │ ├── QuickSyncRunner.kt │ │ │ └── cases/ │ │ │ ├── QuickSyncDuplicateEpisodesCase.kt │ │ │ └── QuickSyncDuplicateMoviesCase.kt │ │ ├── utilities/ │ │ │ ├── DurationPrinter.kt │ │ │ ├── FragmentViewBindingDelegate.kt │ │ │ ├── ModeHost.kt │ │ │ ├── MoviesStatusHost.kt │ │ │ ├── NavigationHost.kt │ │ │ ├── NetworkIconProvider.kt │ │ │ ├── SnackbarHost.kt │ │ │ ├── TipsHost.kt │ │ │ ├── events/ │ │ │ │ ├── Event.kt │ │ │ │ └── MessageEvent.kt │ │ │ ├── extensions/ │ │ │ │ ├── BuildExtensions.kt │ │ │ │ ├── BundleExtensions.kt │ │ │ │ ├── ContextExtensions.kt │ │ │ │ ├── Extensions.kt │ │ │ │ ├── FlowCombineExtensions.kt │ │ │ │ ├── GlideExtensions.kt │ │ │ │ ├── LayoutExtensions.kt │ │ │ │ ├── LifecycleExtensions.kt │ │ │ │ ├── SnackbarExtensions.kt │ │ │ │ ├── UiExtensions.kt │ │ │ │ ├── ViewModelExtensions.kt │ │ │ │ └── WebExtensions.kt │ │ │ └── ui/ │ │ │ └── EqualSpacingItemDecoration.kt │ │ └── viewmodel/ │ │ ├── ChannelsDelegate.kt │ │ └── DefaultChannelsDelegate.kt │ └── res/ │ ├── anim/ │ │ ├── anim_recycler_fall_down.xml │ │ ├── anim_recycler_fall_down_fast.xml │ │ ├── anim_recycler_fall_down_item.xml │ │ ├── anim_recycler_fall_down_item_fast.xml │ │ ├── anim_slide_in_from_left.xml │ │ ├── anim_slide_in_from_right.xml │ │ ├── anim_slide_out_from_left.xml │ │ └── anim_slide_out_from_right.xml │ ├── anim-ar/ │ │ ├── anim_slide_in_from_left.xml │ │ ├── anim_slide_in_from_right.xml │ │ ├── anim_slide_out_from_left.xml │ │ └── anim_slide_out_from_right.xml │ ├── color/ │ │ ├── selector_chip_background.xml │ │ ├── selector_chip_stroke.xml │ │ ├── selector_chip_text.xml │ │ ├── selector_discover_chip_background.xml │ │ ├── selector_discover_chip_text.xml │ │ ├── selector_main_button.xml │ │ └── selector_main_checkbox.xml │ ├── color-notnight/ │ │ ├── selector_chip_background.xml │ │ ├── selector_chip_stroke.xml │ │ └── selector_main_checkbox.xml │ ├── drawable/ │ │ ├── bg_badge.xml │ │ ├── bg_bottom_sheet.xml │ │ ├── bg_dialog.xml │ │ ├── bg_filters_sheet.xml │ │ ├── bg_item_menu_elevation.xml │ │ ├── bg_item_menu_placeholder.xml │ │ ├── bg_link_item.xml │ │ ├── bg_link_item_ripple.xml │ │ ├── bg_media_view_elevation.xml │ │ ├── bg_media_view_elevation_card.xml │ │ ├── bg_media_view_placeholder.xml │ │ ├── bg_media_view_ripple.xml │ │ ├── bg_premium_ad.xml │ │ ├── bg_snackbar_error.xml │ │ ├── bg_snackbar_info.xml │ │ ├── bg_sort_item_badge.xml │ │ ├── bg_text_on_surface.xml │ │ ├── bg_tip_view.xml │ │ ├── divider_item_menu.xml │ │ ├── ic_abc.xml │ │ ├── ic_amc.xml │ │ ├── ic_anim_search_to_close.xml │ │ ├── ic_arrow_alt.xml │ │ ├── ic_arrow_alt_down.xml │ │ ├── ic_arrow_alt_up.xml │ │ ├── ic_arrow_back.xml │ │ ├── ic_arrow_right.xml │ │ ├── ic_bookmark.xml │ │ ├── ic_bookmark_full.xml │ │ ├── ic_calendar.xml │ │ ├── ic_check.xml │ │ ├── ic_check_small.xml │ │ ├── ic_circle.xml │ │ ├── ic_clock.xml │ │ ├── ic_clock_compact.xml │ │ ├── ic_clock_small.xml │ │ ├── ic_close.xml │ │ ├── ic_comment.xml │ │ ├── ic_crown.xml │ │ ├── ic_crown_small.xml │ │ ├── ic_custom_image.xml │ │ ├── ic_delete.xml │ │ ├── ic_duckduck.xml │ │ ├── ic_eye.xml │ │ ├── ic_eye_no.xml │ │ ├── ic_film.xml │ │ ├── ic_giphy.xml │ │ ├── ic_github.xml │ │ ├── ic_google.xml │ │ ├── ic_history.xml │ │ ├── ic_imdb.xml │ │ ├── ic_info.xml │ │ ├── ic_insight.xml │ │ ├── ic_link.xml │ │ ├── ic_link_color.xml │ │ ├── ic_list_alt.xml │ │ ├── ic_lists.xml │ │ ├── ic_news.xml │ │ ├── ic_notification_bell.xml │ │ ├── ic_open_browser.xml │ │ ├── ic_pause.xml │ │ ├── ic_person_outline.xml │ │ ├── ic_pin.xml │ │ ├── ic_play.xml │ │ ├── ic_remove_list.xml │ │ ├── ic_search.xml │ │ ├── ic_settings.xml │ │ ├── ic_share.xml │ │ ├── ic_star.xml │ │ ├── ic_star_empty.xml │ │ ├── ic_star_small.xml │ │ ├── ic_stars_round.xml │ │ ├── ic_television.xml │ │ ├── ic_tmdb.xml │ │ ├── ic_trakt.xml │ │ ├── ic_twitter.xml │ │ ├── ic_view_grid.xml │ │ ├── ic_view_list.xml │ │ ├── ic_wikipedia.xml │ │ └── ic_youtube.xml │ ├── drawable-ar/ │ │ └── ic_arrow_back.xml │ ├── drawable-notnight/ │ │ ├── ic_github.xml │ │ └── ic_wikipedia.xml │ ├── layout/ │ │ ├── view_context_menu.xml │ │ ├── view_links.xml │ │ ├── view_links_item.xml │ │ ├── view_mode_tabs.xml │ │ ├── view_premium_ad.xml │ │ ├── view_premium_ad_list.xml │ │ ├── view_rate_sheet.xml │ │ ├── view_rate_value.xml │ │ ├── view_ratings_strip.xml │ │ ├── view_remove_trakt_hidden.xml │ │ ├── view_remove_trakt_progress.xml │ │ ├── view_remove_trakt_watchlist.xml │ │ ├── view_search.xml │ │ ├── view_search_empty.xml │ │ ├── view_search_local.xml │ │ ├── view_sort_order.xml │ │ ├── view_sort_order_item.xml │ │ ├── view_tip.xml │ │ └── view_tip_overlay.xml │ ├── layout-sw600dp/ │ │ └── view_ratings_strip.xml │ ├── values/ │ │ ├── attrs.xml │ │ ├── bool.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── misc.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── values-ar/ │ │ ├── dimens.xml │ │ └── strings.xml │ ├── values-de/ │ │ └── strings.xml │ ├── values-es/ │ │ └── strings.xml │ ├── values-fi/ │ │ └── strings.xml │ ├── values-fr/ │ │ └── strings.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-notnight/ │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── misc.xml │ │ └── styles.xml │ ├── values-notnight-v27/ │ │ └── styles.xml │ ├── values-pl/ │ │ └── strings.xml │ ├── values-pt/ │ │ └── strings.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-sw600dp/ │ │ ├── bool.xml │ │ ├── dimens.xml │ │ └── styles.xml │ ├── values-tr/ │ │ └── strings.xml │ ├── values-uk/ │ │ └── strings.xml │ ├── values-vi/ │ │ └── strings.xml │ └── values-zh/ │ └── strings.xml ├── ui-comments/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_comments/ │ │ ├── CommentItemDiffCallback.kt │ │ ├── CommentView.kt │ │ ├── CommentsAdapter.kt │ │ ├── fragment/ │ │ │ ├── CommentsFragment.kt │ │ │ ├── CommentsUiState.kt │ │ │ ├── CommentsViewModel.kt │ │ │ └── cases/ │ │ │ ├── DeleteCommentCase.kt │ │ │ ├── LoadCommentsCase.kt │ │ │ └── LoadRepliesCase.kt │ │ ├── post/ │ │ │ ├── PostCommentBottomSheet.kt │ │ │ ├── PostCommentUiState.kt │ │ │ └── PostCommentViewModel.kt │ │ └── utilities/ │ │ └── Extensions.kt │ └── res/ │ ├── color/ │ │ ├── selector_comment_button.xml │ │ └── selector_comment_input.xml │ ├── drawable/ │ │ ├── bg_comment_rating.xml │ │ ├── divider_comments_list.xml │ │ ├── ic_add_comment.xml │ │ ├── ic_delete.xml │ │ └── ic_reply.xml │ ├── layout/ │ │ ├── fragment_comments.xml │ │ ├── view_comment.xml │ │ └── view_post_comment.xml │ ├── layout-sw600dp/ │ │ └── view_comment.xml │ ├── values/ │ │ ├── dimens.xml │ │ └── strings.xml │ ├── values-ar/ │ │ └── strings.xml │ ├── values-de/ │ │ └── strings.xml │ ├── values-es/ │ │ └── strings.xml │ ├── values-fi/ │ │ └── strings.xml │ ├── values-fr/ │ │ └── strings.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-pl/ │ │ └── strings.xml │ ├── values-pt/ │ │ └── strings.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-sw600dp/ │ │ └── dimens.xml │ ├── values-tr/ │ │ └── strings.xml │ ├── values-uk/ │ │ └── strings.xml │ ├── values-vi/ │ │ └── strings.xml │ └── values-zh/ │ └── strings.xml ├── ui-discover/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── michaldrabik/ │ │ │ └── ui_discover/ │ │ │ ├── DiscoverFragment.kt │ │ │ ├── DiscoverUiState.kt │ │ │ ├── DiscoverViewModel.kt │ │ │ ├── cases/ │ │ │ │ ├── DiscoverFiltersCase.kt │ │ │ │ ├── DiscoverShowsCase.kt │ │ │ │ └── DiscoverTwitterCase.kt │ │ │ ├── di/ │ │ │ │ └── DiscoverModule.kt │ │ │ ├── filters/ │ │ │ │ ├── feed/ │ │ │ │ │ ├── DiscoverFiltersFeedBottomSheet.kt │ │ │ │ │ ├── DiscoverFiltersFeedUiEvent.kt │ │ │ │ │ ├── DiscoverFiltersFeedUiState.kt │ │ │ │ │ └── DiscoverFiltersFeedViewModel.kt │ │ │ │ ├── genres/ │ │ │ │ │ ├── DiscoverFiltersGenresBottomSheet.kt │ │ │ │ │ ├── DiscoverFiltersGenresUiEvent.kt │ │ │ │ │ ├── DiscoverFiltersGenresUiState.kt │ │ │ │ │ └── DiscoverFiltersGenresViewModel.kt │ │ │ │ ├── networks/ │ │ │ │ │ ├── DiscoverFiltersNetworksBottomSheet.kt │ │ │ │ │ ├── DiscoverFiltersNetworksUiEvent.kt │ │ │ │ │ ├── DiscoverFiltersNetworksUiState.kt │ │ │ │ │ └── DiscoverFiltersNetworksViewModel.kt │ │ │ │ └── views/ │ │ │ │ └── DiscoverFiltersView.kt │ │ │ ├── helpers/ │ │ │ │ ├── DiscoverLayoutManagerProvider.kt │ │ │ │ └── itemtype/ │ │ │ │ ├── ImageTypeProvider.kt │ │ │ │ ├── PhoneImageTypeProvider.kt │ │ │ │ └── TabletImageTypeProvider.kt │ │ │ ├── recycler/ │ │ │ │ ├── DiscoverAdapter.kt │ │ │ │ ├── DiscoverItemDiffCallback.kt │ │ │ │ └── DiscoverListItem.kt │ │ │ └── views/ │ │ │ ├── ShowFanartView.kt │ │ │ ├── ShowPosterView.kt │ │ │ ├── ShowPremiumView.kt │ │ │ └── ShowTwitterView.kt │ │ └── res/ │ │ ├── color/ │ │ │ └── selector_filters_button.xml │ │ ├── drawable/ │ │ │ ├── bg_twitter.xml │ │ │ └── bg_twitter_cancel.xml │ │ ├── layout/ │ │ │ ├── fragment_discover.xml │ │ │ ├── view_discover_filters.xml │ │ │ ├── view_discover_filters_feed.xml │ │ │ ├── view_discover_filters_genres.xml │ │ │ ├── view_discover_filters_networks.xml │ │ │ ├── view_show_fanart.xml │ │ │ ├── view_show_poster.xml │ │ │ ├── view_show_premium.xml │ │ │ └── view_show_twitter.xml │ │ ├── values/ │ │ │ └── strings.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-fi/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ └── values-zh/ │ │ └── strings.xml │ └── test/ │ └── java/ │ ├── BaseMockTest.kt │ ├── TestData.kt │ └── com/ │ └── michaldrabik/ │ └── ui_discover/ │ └── DiscoverViewModelTest.kt ├── ui-discover-movies/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_discover_movies/ │ │ ├── DiscoverMoviesFragment.kt │ │ ├── DiscoverMoviesUiState.kt │ │ ├── DiscoverMoviesViewModel.kt │ │ ├── cases/ │ │ │ ├── DiscoverFiltersCase.kt │ │ │ └── DiscoverMoviesCase.kt │ │ ├── di/ │ │ │ └── DiscoverMoviesModule.kt │ │ ├── filters/ │ │ │ ├── feed/ │ │ │ │ ├── DiscoverMoviesFiltersFeedBottomSheet.kt │ │ │ │ ├── DiscoverMoviesFiltersFeedUiEvent.kt │ │ │ │ ├── DiscoverMoviesFiltersFeedUiState.kt │ │ │ │ └── DiscoverMoviesFiltersFeedViewModel.kt │ │ │ ├── genres/ │ │ │ │ ├── DiscoverMoviesFiltersGenresBottomSheet.kt │ │ │ │ ├── DiscoverMoviesFiltersGenresUiEvent.kt │ │ │ │ ├── DiscoverMoviesFiltersGenresUiState.kt │ │ │ │ └── DiscoverMoviesFiltersGenresViewModel.kt │ │ │ └── views/ │ │ │ └── DiscoverMoviesFiltersView.kt │ │ ├── helpers/ │ │ │ ├── DiscoverMoviesLayoutManagerProvider.kt │ │ │ └── itemtype/ │ │ │ ├── ImageTypeProvider.kt │ │ │ ├── PhoneImageTypeProvider.kt │ │ │ └── TabletImageTypeProvider.kt │ │ ├── recycler/ │ │ │ ├── DiscoverMovieItemDiffCallback.kt │ │ │ ├── DiscoverMovieListItem.kt │ │ │ └── DiscoverMoviesAdapter.kt │ │ └── views/ │ │ ├── MovieFanartView.kt │ │ ├── MoviePosterView.kt │ │ └── MoviePremiumView.kt │ └── res/ │ ├── layout/ │ │ ├── fragment_discover_movies.xml │ │ ├── view_discover_movies_filters.xml │ │ ├── view_discover_movies_filters_feed.xml │ │ ├── view_discover_movies_filters_genres.xml │ │ ├── view_movie_fanart.xml │ │ ├── view_movie_poster.xml │ │ └── view_movie_premium.xml │ ├── values/ │ │ └── strings.xml │ ├── values-ar/ │ │ └── strings.xml │ ├── values-de/ │ │ └── strings.xml │ ├── values-es/ │ │ └── strings.xml │ ├── values-fi/ │ │ └── strings.xml │ ├── values-fr/ │ │ └── strings.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-pl/ │ │ └── strings.xml │ ├── values-pt/ │ │ └── strings.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-tr/ │ │ └── strings.xml │ ├── values-uk/ │ │ └── strings.xml │ ├── values-vi/ │ │ └── strings.xml │ └── values-zh/ │ └── strings.xml ├── ui-episodes/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_episodes/ │ │ └── details/ │ │ ├── EpisodeDetailsBottomSheet.kt │ │ ├── EpisodeDetailsUiState.kt │ │ ├── EpisodeDetailsViewModel.kt │ │ └── cases/ │ │ ├── EpisodeDetailsSeasonCase.kt │ │ └── EpisodeDetailsWatchedCase.kt │ └── res/ │ ├── color/ │ │ └── selector_comment_button.xml │ ├── drawable/ │ │ ├── bg_bottom_sheet_placeholder.xml │ │ └── divider_comments_list.xml │ ├── layout/ │ │ └── view_episode_details.xml │ ├── values/ │ │ ├── dimens.xml │ │ ├── integers.xml │ │ └── strings.xml │ ├── values-ar/ │ │ └── strings.xml │ ├── values-de/ │ │ ├── integers.xml │ │ └── strings.xml │ ├── values-es/ │ │ ├── integers.xml │ │ └── strings.xml │ ├── values-fi/ │ │ └── strings.xml │ ├── values-fr/ │ │ ├── integers.xml │ │ └── strings.xml │ ├── values-it/ │ │ ├── integers.xml │ │ └── strings.xml │ ├── values-pl/ │ │ └── strings.xml │ ├── values-pt/ │ │ ├── integers.xml │ │ └── strings.xml │ ├── values-ru/ │ │ ├── integers.xml │ │ └── strings.xml │ ├── values-sw600dp/ │ │ └── dimens.xml │ ├── values-tr/ │ │ ├── integers.xml │ │ └── strings.xml │ ├── values-uk/ │ │ └── strings.xml │ ├── values-vi/ │ │ └── strings.xml │ └── values-zh/ │ └── strings.xml ├── ui-gallery/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_gallery/ │ │ ├── custom/ │ │ │ ├── CustomImagesBottomSheet.kt │ │ │ ├── CustomImagesUiState.kt │ │ │ └── CustomImagesViewModel.kt │ │ └── fanart/ │ │ ├── ArtGalleryFragment.kt │ │ ├── ArtGalleryUiState.kt │ │ ├── ArtGalleryViewModel.kt │ │ ├── cases/ │ │ │ └── ArtLoadImagesCase.kt │ │ └── recycler/ │ │ ├── ArtGalleryAdapter.kt │ │ ├── ImageItemDiffCallback.kt │ │ └── views/ │ │ ├── ArtGalleryFanartView.kt │ │ ├── ArtGalleryPosterView.kt │ │ └── ArtGalleryUrlView.kt │ └── res/ │ ├── color/ │ │ └── selector_url_input_layout.xml │ ├── drawable/ │ │ ├── bg_custom_image_frame.xml │ │ ├── bg_delete_circle.xml │ │ ├── bg_indicator_circle.xml │ │ ├── ic_custom_image2x.xml │ │ ├── ic_delete.xml │ │ └── ic_download.xml │ ├── drawable-notnight/ │ │ └── bg_indicator_circle.xml │ ├── layout/ │ │ ├── fragment_art_gallery.xml │ │ ├── view_custom_images.xml │ │ ├── view_gallery_fanart_image.xml │ │ ├── view_gallery_poster_image.xml │ │ └── view_gallery_url_dialog.xml │ ├── values/ │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── values-ar/ │ │ └── strings.xml │ ├── values-de/ │ │ └── strings.xml │ ├── values-es/ │ │ └── strings.xml │ ├── values-fi/ │ │ └── strings.xml │ ├── values-fr/ │ │ └── strings.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-notnight/ │ │ └── styles.xml │ ├── values-pl/ │ │ └── strings.xml │ ├── values-pt/ │ │ └── strings.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-sw600dp/ │ │ └── dimens.xml │ ├── values-tr/ │ │ └── strings.xml │ ├── values-uk/ │ │ └── strings.xml │ ├── values-vi/ │ │ └── strings.xml │ └── values-zh/ │ └── strings.xml ├── ui-lists/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_lists/ │ │ ├── create/ │ │ │ ├── CreateListBottomSheet.kt │ │ │ ├── CreateListUiState.kt │ │ │ ├── CreateListViewModel.kt │ │ │ └── cases/ │ │ │ ├── CreateListCase.kt │ │ │ └── ListDetailsCase.kt │ │ ├── details/ │ │ │ ├── ListDetailsFragment.kt │ │ │ ├── ListDetailsUiEvents.kt │ │ │ ├── ListDetailsUiState.kt │ │ │ ├── ListDetailsViewModel.kt │ │ │ ├── cases/ │ │ │ │ ├── ListDetailsItemsCase.kt │ │ │ │ ├── ListDetailsMainCase.kt │ │ │ │ ├── ListDetailsSortCase.kt │ │ │ │ ├── ListDetailsTipsCase.kt │ │ │ │ ├── ListDetailsTranslationsCase.kt │ │ │ │ └── ListDetailsViewModeCase.kt │ │ │ ├── helpers/ │ │ │ │ ├── ListDetailsSorter.kt │ │ │ │ ├── ListItemDragListener.kt │ │ │ │ ├── ListItemSwipeListener.kt │ │ │ │ ├── ReorderListCallback.kt │ │ │ │ └── ReorderListCallbackAdapter.kt │ │ │ ├── recycler/ │ │ │ │ ├── ListDetailsAdapter.kt │ │ │ │ ├── ListDetailsDiffCallback.kt │ │ │ │ ├── ListDetailsItem.kt │ │ │ │ └── helpers/ │ │ │ │ ├── ListDetailsGridItemDecoration.kt │ │ │ │ ├── ListDetailsLayoutManagerProvider.kt │ │ │ │ └── ListDetailsListItemDecoration.kt │ │ │ └── views/ │ │ │ ├── ListDetailsDeleteConfirmView.kt │ │ │ ├── ListDetailsFilterView.kt │ │ │ ├── ListDetailsItemView.kt │ │ │ ├── ListDetailsMovieItemView.kt │ │ │ ├── ListDetailsShowItemView.kt │ │ │ ├── compact/ │ │ │ │ ├── ListDetailsCompactMovieItemView.kt │ │ │ │ └── ListDetailsCompactShowItemView.kt │ │ │ └── grid/ │ │ │ ├── ListDetailsGridItemView.kt │ │ │ └── ListDetailsGridTitleItemView.kt │ │ ├── lists/ │ │ │ ├── ListsFragment.kt │ │ │ ├── ListsUiState.kt │ │ │ ├── ListsViewModel.kt │ │ │ ├── cases/ │ │ │ │ ├── MainListsCase.kt │ │ │ │ └── SortOrderListsCase.kt │ │ │ ├── helpers/ │ │ │ │ ├── ListsItemImage.kt │ │ │ │ ├── ListsLayoutManagerProvider.kt │ │ │ │ └── ListsSorter.kt │ │ │ ├── recycler/ │ │ │ │ ├── ListsAdapter.kt │ │ │ │ ├── ListsItem.kt │ │ │ │ └── ListsItemDiffCallback.kt │ │ │ └── views/ │ │ │ ├── ListsFiltersView.kt │ │ │ ├── ListsItemView.kt │ │ │ └── ListsTripleImageView.kt │ │ └── manage/ │ │ ├── ManageListsBottomSheet.kt │ │ ├── ManageListsUiState.kt │ │ ├── ManageListsViewModel.kt │ │ ├── cases/ │ │ │ └── ManageListsCase.kt │ │ ├── recycler/ │ │ │ ├── ManageListsAdapter.kt │ │ │ ├── ManageListsItem.kt │ │ │ └── ManageListsItemDiffCallback.kt │ │ └── views/ │ │ └── ManageListsItemView.kt │ └── res/ │ ├── color/ │ │ ├── selector_create_list_button.xml │ │ ├── selector_create_list_input.xml │ │ ├── selector_list_chip_background.xml │ │ └── selector_list_chip_text.xml │ ├── color-notnight/ │ │ └── selector_list_chip_background.xml │ ├── drawable/ │ │ ├── bg_rank.xml │ │ ├── ic_edit.xml │ │ ├── ic_handle.xml │ │ ├── ic_list_create.xml │ │ ├── ic_more.xml │ │ └── ic_reorder.xml │ ├── layout/ │ │ ├── fragment_list_details.xml │ │ ├── fragment_lists.xml │ │ ├── layout_list_details_empty.xml │ │ ├── layout_list_details_item_grid.xml │ │ ├── layout_lists_empty.xml │ │ ├── layout_manage_lists_empty.xml │ │ ├── view_create_list.xml │ │ ├── view_list_delete_confirm.xml │ │ ├── view_list_details_filters.xml │ │ ├── view_list_details_item_grid.xml │ │ ├── view_list_details_item_grid_title.xml │ │ ├── view_list_details_movie_item.xml │ │ ├── view_list_details_movie_item_compact.xml │ │ ├── view_list_details_show_item.xml │ │ ├── view_list_details_show_item_compact.xml │ │ ├── view_lists_filters.xml │ │ ├── view_lists_item.xml │ │ ├── view_manage_lists.xml │ │ ├── view_manage_lists_item.xml │ │ └── view_triple_image.xml │ ├── menu/ │ │ └── menu_list_details.xml │ ├── values/ │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── values-ar/ │ │ └── strings.xml │ ├── values-de/ │ │ └── strings.xml │ ├── values-es/ │ │ └── strings.xml │ ├── values-fi/ │ │ └── strings.xml │ ├── values-fr/ │ │ └── strings.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-notnight/ │ │ └── dimens.xml │ ├── values-pl/ │ │ └── strings.xml │ ├── values-pt/ │ │ └── strings.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-sw600dp/ │ │ └── dimens.xml │ ├── values-tr/ │ │ └── strings.xml │ ├── values-uk/ │ │ └── strings.xml │ ├── values-vi/ │ │ └── strings.xml │ └── values-zh/ │ └── strings.xml ├── ui-model/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_model/ │ │ ├── AirTime.kt │ │ ├── CalendarMode.kt │ │ ├── Comment.kt │ │ ├── CustomList.kt │ │ ├── DiscoverFilters.kt │ │ ├── DiscoverSortOrder.kt │ │ ├── Episode.kt │ │ ├── EpisodeBundle.kt │ │ ├── Genre.kt │ │ ├── Ids.kt │ │ ├── Image.kt │ │ ├── ImageFamily.kt │ │ ├── ImageSource.kt │ │ ├── ImageStatus.kt │ │ ├── ImageType.kt │ │ ├── Movie.kt │ │ ├── MovieCollection.kt │ │ ├── MovieStatus.kt │ │ ├── MyMoviesSection.kt │ │ ├── MyShowsSection.kt │ │ ├── Network.kt │ │ ├── NewsItem.kt │ │ ├── NotificationDelay.kt │ │ ├── Person.kt │ │ ├── PersonCredit.kt │ │ ├── PremiumFeature.kt │ │ ├── ProgressNextEpisodeType.kt │ │ ├── ProgressType.kt │ │ ├── RatingState.kt │ │ ├── Ratings.kt │ │ ├── RecentSearch.kt │ │ ├── SearchResult.kt │ │ ├── Season.kt │ │ ├── SeasonBundle.kt │ │ ├── SeasonTranslation.kt │ │ ├── Settings.kt │ │ ├── Show.kt │ │ ├── ShowStatus.kt │ │ ├── SortOrder.kt │ │ ├── SortType.kt │ │ ├── SpoilersSettings.kt │ │ ├── StreamingService.kt │ │ ├── Tip.kt │ │ ├── TraktRating.kt │ │ ├── TraktSyncSchedule.kt │ │ ├── Translation.kt │ │ └── User.kt │ └── res/ │ ├── values/ │ │ └── strings.xml │ ├── values-ar/ │ │ └── strings.xml │ ├── values-de/ │ │ └── strings.xml │ ├── values-es/ │ │ └── strings.xml │ ├── values-fi/ │ │ └── strings.xml │ ├── values-fr/ │ │ └── strings.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-pl/ │ │ └── strings.xml │ ├── values-pt/ │ │ └── strings.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-tr/ │ │ └── strings.xml │ ├── values-uk/ │ │ └── strings.xml │ ├── values-vi/ │ │ └── strings.xml │ └── values-zh/ │ └── strings.xml ├── ui-movie/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_movie/ │ │ ├── MovieDetailsFragment.kt │ │ ├── MovieDetailsUiEvents.kt │ │ ├── MovieDetailsUiState.kt │ │ ├── MovieDetailsViewModel.kt │ │ ├── cases/ │ │ │ ├── MovieDetailsHiddenCase.kt │ │ │ ├── MovieDetailsListsCase.kt │ │ │ ├── MovieDetailsMainCase.kt │ │ │ ├── MovieDetailsMyMoviesCase.kt │ │ │ ├── MovieDetailsTranslationCase.kt │ │ │ └── MovieDetailsWatchlistCase.kt │ │ ├── helpers/ │ │ │ ├── MovieDetailsMeta.kt │ │ │ └── MovieLink.kt │ │ ├── sections/ │ │ │ ├── collections/ │ │ │ │ ├── details/ │ │ │ │ │ ├── MovieDetailsCollectionBottomSheet.kt │ │ │ │ │ ├── MovieDetailsCollectionUiState.kt │ │ │ │ │ ├── MovieDetailsCollectionViewModel.kt │ │ │ │ │ ├── cases/ │ │ │ │ │ │ ├── MovieDetailsCollectionDetailsCase.kt │ │ │ │ │ │ ├── MovieDetailsCollectionImagesCase.kt │ │ │ │ │ │ ├── MovieDetailsCollectionMoviesCase.kt │ │ │ │ │ │ └── MovieDetailsCollectionTranslationsCase.kt │ │ │ │ │ └── recycler/ │ │ │ │ │ ├── MovieDetailsCollectionAdapter.kt │ │ │ │ │ ├── MovieDetailsCollectionItem.kt │ │ │ │ │ ├── MovieDetailsCollectionItemDiffCallback.kt │ │ │ │ │ └── views/ │ │ │ │ │ ├── MovieDetailsCollectionHeaderView.kt │ │ │ │ │ ├── MovieDetailsCollectionItemView.kt │ │ │ │ │ └── MovieDetailsCollectionLoadingView.kt │ │ │ │ └── list/ │ │ │ │ ├── MovieDetailsCollectionsFragment.kt │ │ │ │ ├── MovieDetailsCollectionsUiState.kt │ │ │ │ ├── MovieDetailsCollectionsViewModel.kt │ │ │ │ ├── cases/ │ │ │ │ │ └── MovieDetailsCollectionsCase.kt │ │ │ │ └── recycler/ │ │ │ │ ├── MovieCollectionAdapter.kt │ │ │ │ ├── MovieCollectionDiffCallback.kt │ │ │ │ └── MovieCollectionItemView.kt │ │ │ ├── people/ │ │ │ │ ├── MovieDetailsPeopleFragment.kt │ │ │ │ ├── MovieDetailsPeopleUiState.kt │ │ │ │ ├── MovieDetailsPeopleViewModel.kt │ │ │ │ ├── cases/ │ │ │ │ │ └── MovieDetailsPeopleCase.kt │ │ │ │ └── recycler/ │ │ │ │ ├── ActorView.kt │ │ │ │ └── ActorsAdapter.kt │ │ │ ├── ratings/ │ │ │ │ ├── MovieDetailsRatingsFragment.kt │ │ │ │ ├── MovieDetailsRatingsUiState.kt │ │ │ │ ├── MovieDetailsRatingsViewModel.kt │ │ │ │ └── cases/ │ │ │ │ ├── MovieDetailsRatingCase.kt │ │ │ │ └── MovieDetailsRatingSpoilersCase.kt │ │ │ ├── related/ │ │ │ │ ├── MovieDetailsRelatedFragment.kt │ │ │ │ ├── MovieDetailsRelatedUiState.kt │ │ │ │ ├── MovieDetailsRelatedViewModel.kt │ │ │ │ ├── cases/ │ │ │ │ │ └── MovieDetailsRelatedCase.kt │ │ │ │ └── recycler/ │ │ │ │ ├── RelatedItemDiffCallback.kt │ │ │ │ ├── RelatedListItem.kt │ │ │ │ ├── RelatedMovieAdapter.kt │ │ │ │ └── RelatedMovieView.kt │ │ │ └── streamings/ │ │ │ ├── MovieDetailsStreamingsFragment.kt │ │ │ ├── MovieDetailsStreamingsUiState.kt │ │ │ ├── MovieDetailsStreamingsViewModel.kt │ │ │ └── cases/ │ │ │ └── MovieDetailsStreamingCase.kt │ │ └── views/ │ │ └── AddToMoviesButton.kt │ └── res/ │ ├── drawable/ │ │ ├── bg_check_ripple.xml │ │ ├── bg_collection.xml │ │ ├── bg_collection_ripple.xml │ │ ├── bg_rank.xml │ │ └── divider_horizontal_list.xml │ ├── layout/ │ │ ├── fragment_movie_details.xml │ │ ├── fragment_movie_details_collection.xml │ │ ├── fragment_movie_details_people.xml │ │ ├── fragment_movie_details_ratings.xml │ │ ├── fragment_movie_details_related.xml │ │ ├── fragment_movie_details_streamings.xml │ │ ├── view_actor_movie.xml │ │ ├── view_add_to_movies_button.xml │ │ ├── view_movie_collection.xml │ │ ├── view_movie_collection_details.xml │ │ ├── view_movie_collection_items_list.xml │ │ ├── view_movie_collection_list_header.xml │ │ ├── view_movie_collection_list_item.xml │ │ ├── view_movie_collection_list_loading.xml │ │ └── view_related_movie.xml │ ├── layout-sw600dp/ │ │ ├── fragment_movie_details.xml │ │ ├── fragment_movie_details_collection.xml │ │ ├── fragment_movie_details_people.xml │ │ ├── fragment_movie_details_related.xml │ │ ├── fragment_movie_details_streamings.xml │ │ └── view_movie_collection.xml │ ├── values/ │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── values-ar/ │ │ └── strings.xml │ ├── values-de/ │ │ └── strings.xml │ ├── values-es/ │ │ └── strings.xml │ ├── values-fi/ │ │ └── strings.xml │ ├── values-fr/ │ │ ├── strings.xml │ │ └── styles.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-pl/ │ │ └── strings.xml │ ├── values-pt/ │ │ └── strings.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-sw600dp/ │ │ └── dimens.xml │ ├── values-tr/ │ │ └── strings.xml │ ├── values-uk/ │ │ └── strings.xml │ ├── values-vi/ │ │ └── strings.xml │ └── values-zh/ │ └── strings.xml ├── ui-my-movies/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_my_movies/ │ │ ├── common/ │ │ │ ├── helpers/ │ │ │ │ ├── CollectionItemFilter.kt │ │ │ │ └── CollectionItemSorter.kt │ │ │ ├── layout/ │ │ │ │ ├── CollectionMovieGridItemDecoration.kt │ │ │ │ ├── CollectionMovieLayoutManagerProvider.kt │ │ │ │ └── CollectionMovieListItemDecoration.kt │ │ │ ├── recycler/ │ │ │ │ ├── CollectionAdapter.kt │ │ │ │ ├── CollectionItemDiffCallback.kt │ │ │ │ └── CollectionListItem.kt │ │ │ └── views/ │ │ │ ├── CollectionMovieCompactView.kt │ │ │ ├── CollectionMovieFiltersView.kt │ │ │ ├── CollectionMovieGridTitleView.kt │ │ │ ├── CollectionMovieGridView.kt │ │ │ └── CollectionMovieView.kt │ │ ├── filters/ │ │ │ ├── CollectionFiltersOrigin.kt │ │ │ ├── CollectionFiltersUiEvent.kt │ │ │ └── genre/ │ │ │ ├── CollectionFiltersGenreBottomSheet.kt │ │ │ ├── CollectionFiltersGenreUiState.kt │ │ │ └── CollectionFiltersGenreViewModel.kt │ │ ├── hidden/ │ │ │ ├── HiddenFragment.kt │ │ │ ├── HiddenUiState.kt │ │ │ ├── HiddenViewModel.kt │ │ │ └── cases/ │ │ │ ├── HiddenLoadMoviesCase.kt │ │ │ ├── HiddenRatingsCase.kt │ │ │ ├── HiddenSortOrderCase.kt │ │ │ └── HiddenViewModeCase.kt │ │ ├── main/ │ │ │ ├── FollowedMoviesFragment.kt │ │ │ ├── FollowedMoviesUiEvent.kt │ │ │ ├── FollowedMoviesUiState.kt │ │ │ ├── FollowedMoviesViewModel.kt │ │ │ └── FollowedPagesAdapter.kt │ │ ├── mymovies/ │ │ │ ├── MyMoviesFragment.kt │ │ │ ├── MyMoviesUiState.kt │ │ │ ├── MyMoviesViewModel.kt │ │ │ ├── cases/ │ │ │ │ ├── MyMoviesLoadCase.kt │ │ │ │ ├── MyMoviesRatingsCase.kt │ │ │ │ ├── MyMoviesSortingCase.kt │ │ │ │ └── MyMoviesViewModeCase.kt │ │ │ ├── helpers/ │ │ │ │ └── MyMoviesSorter.kt │ │ │ ├── recycler/ │ │ │ │ ├── MyMoviesAdapter.kt │ │ │ │ ├── MyMoviesItem.kt │ │ │ │ ├── MyMoviesItemDiffCallback.kt │ │ │ │ └── MyMoviesLayoutManagerProvider.kt │ │ │ ├── utilities/ │ │ │ │ ├── MyMoviesGridItemDecoration.kt │ │ │ │ └── MyMoviesListItemDecoration.kt │ │ │ └── views/ │ │ │ ├── MyMovieAllCompactView.kt │ │ │ ├── MyMovieAllGridTitleView.kt │ │ │ ├── MyMovieAllGridView.kt │ │ │ ├── MyMovieAllView.kt │ │ │ ├── MyMovieFanartView.kt │ │ │ ├── MyMovieHeaderView.kt │ │ │ └── MyMoviesRecentsView.kt │ │ └── watchlist/ │ │ ├── WatchlistFragment.kt │ │ ├── WatchlistUiState.kt │ │ ├── WatchlistViewModel.kt │ │ ├── cases/ │ │ │ ├── WatchlistFiltersCase.kt │ │ │ ├── WatchlistLoadMoviesCase.kt │ │ │ ├── WatchlistRatingsCase.kt │ │ │ ├── WatchlistSortOrderCase.kt │ │ │ └── WatchlistViewModeCase.kt │ │ └── recycler/ │ │ └── WatchlistListItem.kt │ └── res/ │ ├── drawable/ │ │ ├── divider_my_movies_fanart.xml │ │ └── divider_my_movies_horizontal.xml │ ├── layout/ │ │ ├── fragment_followed_movies.xml │ │ ├── fragment_hidden_movies.xml │ │ ├── fragment_my_movies.xml │ │ ├── fragment_watchlist_movies.xml │ │ ├── layout_hidden_empty.xml │ │ ├── layout_my_movies_empty.xml │ │ ├── layout_watchlist_movies_empty.xml │ │ ├── view_collection_movie.xml │ │ ├── view_collection_movie_compact.xml │ │ ├── view_collection_movie_grid.xml │ │ ├── view_collection_movie_grid_title.xml │ │ ├── view_filters_genres.xml │ │ ├── view_movies_filters.xml │ │ ├── view_my_movies_all.xml │ │ ├── view_my_movies_fanart.xml │ │ ├── view_my_movies_header.xml │ │ └── view_my_movies_recents.xml │ ├── values/ │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── values-ar/ │ │ ├── dimens.xml │ │ └── strings.xml │ ├── values-de/ │ │ ├── dimens.xml │ │ └── strings.xml │ ├── values-es/ │ │ └── strings.xml │ ├── values-fi/ │ │ └── strings.xml │ ├── values-fr/ │ │ └── strings.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-pl/ │ │ ├── dimens.xml │ │ └── strings.xml │ ├── values-pt/ │ │ └── strings.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-sw600dp/ │ │ └── dimens.xml │ ├── values-tr/ │ │ └── strings.xml │ ├── values-uk/ │ │ └── strings.xml │ ├── values-vi/ │ │ └── strings.xml │ └── values-zh/ │ └── strings.xml ├── ui-my-shows/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_my_shows/ │ │ ├── common/ │ │ │ ├── filters/ │ │ │ │ ├── CollectionFiltersOrigin.kt │ │ │ │ ├── CollectionFiltersUiEvent.kt │ │ │ │ ├── genre/ │ │ │ │ │ ├── CollectionFiltersGenreBottomSheet.kt │ │ │ │ │ ├── CollectionFiltersGenreUiState.kt │ │ │ │ │ └── CollectionFiltersGenreViewModel.kt │ │ │ │ └── network/ │ │ │ │ ├── CollectionFiltersNetworkBottomSheet.kt │ │ │ │ ├── CollectionFiltersNetworkUiState.kt │ │ │ │ └── CollectionFiltersNetworkViewModel.kt │ │ │ ├── layout/ │ │ │ │ ├── CollectionShowGridItemDecoration.kt │ │ │ │ ├── CollectionShowLayoutManagerProvider.kt │ │ │ │ └── CollectionShowListItemDecoration.kt │ │ │ ├── recycler/ │ │ │ │ ├── CollectionAdapter.kt │ │ │ │ ├── CollectionItemDiffCallback.kt │ │ │ │ └── CollectionListItem.kt │ │ │ └── views/ │ │ │ ├── CollectionShowCompactView.kt │ │ │ ├── CollectionShowFiltersView.kt │ │ │ ├── CollectionShowGridTitleView.kt │ │ │ ├── CollectionShowGridView.kt │ │ │ └── CollectionShowView.kt │ │ ├── hidden/ │ │ │ ├── HiddenFragment.kt │ │ │ ├── HiddenUiState.kt │ │ │ ├── HiddenViewModel.kt │ │ │ ├── cases/ │ │ │ │ ├── HiddenLoadShowsCase.kt │ │ │ │ ├── HiddenRatingsCase.kt │ │ │ │ ├── HiddenSortOrderCase.kt │ │ │ │ ├── HiddenTranslationsCase.kt │ │ │ │ └── HiddenViewModeCase.kt │ │ │ └── helpers/ │ │ │ └── HiddenItemSorter.kt │ │ ├── main/ │ │ │ ├── FollowedPagesAdapter.kt │ │ │ ├── FollowedShowsFragment.kt │ │ │ ├── FollowedShowsUiEvent.kt │ │ │ ├── FollowedShowsUiState.kt │ │ │ └── FollowedShowsViewModel.kt │ │ ├── myshows/ │ │ │ ├── MyShowsFragment.kt │ │ │ ├── MyShowsUiState.kt │ │ │ ├── MyShowsViewModel.kt │ │ │ ├── cases/ │ │ │ │ ├── MyShowsLoadShowsCase.kt │ │ │ │ ├── MyShowsRatingsCase.kt │ │ │ │ ├── MyShowsSortingCase.kt │ │ │ │ ├── MyShowsTranslationsCase.kt │ │ │ │ └── MyShowsViewModeCase.kt │ │ │ ├── filters/ │ │ │ │ ├── MyShowsFiltersBottomSheet.kt │ │ │ │ ├── MyShowsFiltersUiEvent.kt │ │ │ │ ├── MyShowsFiltersUiState.kt │ │ │ │ ├── MyShowsFiltersViewModel.kt │ │ │ │ └── views/ │ │ │ │ └── MyShowsFilterItemView.kt │ │ │ ├── helpers/ │ │ │ │ └── MyShowsItemSorter.kt │ │ │ ├── recycler/ │ │ │ │ ├── MyShowsAdapter.kt │ │ │ │ ├── MyShowsItem.kt │ │ │ │ ├── MyShowsItemDiffCallback.kt │ │ │ │ └── MyShowsLayoutManagerProvider.kt │ │ │ └── views/ │ │ │ ├── MyShowAllCompactView.kt │ │ │ ├── MyShowAllView.kt │ │ │ ├── MyShowFanartView.kt │ │ │ ├── MyShowGridTitleView.kt │ │ │ ├── MyShowGridView.kt │ │ │ ├── MyShowHeaderView.kt │ │ │ └── MyShowsRecentsView.kt │ │ ├── utilities/ │ │ │ ├── MyShowsGridItemDecoration.kt │ │ │ └── MyShowsListItemDecoration.kt │ │ └── watchlist/ │ │ ├── WatchlistFragment.kt │ │ ├── WatchlistUiState.kt │ │ ├── WatchlistViewModel.kt │ │ ├── cases/ │ │ │ ├── WatchlistFiltersCase.kt │ │ │ ├── WatchlistLoadShowsCase.kt │ │ │ ├── WatchlistRatingsCase.kt │ │ │ ├── WatchlistSortOrderCase.kt │ │ │ ├── WatchlistTranslationsCase.kt │ │ │ └── WatchlistViewModeCase.kt │ │ └── helpers/ │ │ ├── WatchlistItemFilter.kt │ │ └── WatchlistItemSorter.kt │ └── res/ │ ├── layout/ │ │ ├── fragment_followed_shows.xml │ │ ├── fragment_hidden.xml │ │ ├── fragment_my_shows.xml │ │ ├── fragment_watchlist.xml │ │ ├── layout_archive_empty.xml │ │ ├── layout_my_shows_empty.xml │ │ ├── layout_watchlist_empty.xml │ │ ├── view_collection_show.xml │ │ ├── view_collection_show_compact.xml │ │ ├── view_collection_show_grid.xml │ │ ├── view_collection_show_grid_title.xml │ │ ├── view_filters_genres.xml │ │ ├── view_filters_networks.xml │ │ ├── view_my_shows_fanart.xml │ │ ├── view_my_shows_header.xml │ │ ├── view_my_shows_recents.xml │ │ ├── view_my_shows_type_filter_item.xml │ │ ├── view_my_shows_type_filters.xml │ │ └── view_shows_filters.xml │ ├── values/ │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── values-ar/ │ │ ├── dimens.xml │ │ └── strings.xml │ ├── values-de/ │ │ ├── dimens.xml │ │ └── strings.xml │ ├── values-es/ │ │ └── strings.xml │ ├── values-fi/ │ │ └── strings.xml │ ├── values-fr/ │ │ └── strings.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-pl/ │ │ ├── dimens.xml │ │ └── strings.xml │ ├── values-pt/ │ │ └── strings.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-sw600dp/ │ │ └── dimens.xml │ ├── values-tr/ │ │ └── strings.xml │ ├── values-uk/ │ │ └── strings.xml │ ├── values-vi/ │ │ └── strings.xml │ └── values-zh/ │ └── strings.xml ├── ui-navigation/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com.michaldrabik.ui_navigation.java/ │ │ └── NavigationArgs.kt │ └── res/ │ ├── anim/ │ │ ├── anim_fade_in.xml │ │ ├── anim_fade_out.xml │ │ ├── anim_in_from_left.xml │ │ ├── anim_in_from_right.xml │ │ ├── anim_out_from_left.xml │ │ └── anim_out_from_right.xml │ ├── anim-ar/ │ │ ├── anim_in_from_left.xml │ │ ├── anim_in_from_right.xml │ │ ├── anim_out_from_left.xml │ │ └── anim_out_from_right.xml │ └── navigation/ │ └── navigation_graph.xml ├── ui-news/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── michaldrabik/ │ │ │ └── ui_news/ │ │ │ ├── NewsFragment.kt │ │ │ ├── NewsUiState.kt │ │ │ ├── NewsViewModel.kt │ │ │ ├── cases/ │ │ │ │ ├── NewsFiltersCase.kt │ │ │ │ ├── NewsLoadItemsCase.kt │ │ │ │ └── NewsViewTypeCase.kt │ │ │ ├── providers/ │ │ │ │ └── NewsLayoutManagerProvider.kt │ │ │ ├── recycler/ │ │ │ │ ├── NewsAdapter.kt │ │ │ │ ├── NewsListItem.kt │ │ │ │ └── NewsListItemDiffCallback.kt │ │ │ └── views/ │ │ │ ├── NewsFiltersView.kt │ │ │ ├── NewsHeaderView.kt │ │ │ └── item/ │ │ │ ├── NewsItemCardView.kt │ │ │ ├── NewsItemRowView.kt │ │ │ └── NewsItemViewType.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── bg_news_card_view_elevation.xml │ │ │ ├── bg_news_card_view_placeholder.xml │ │ │ ├── bg_play_circle.xml │ │ │ ├── divider_news.xml │ │ │ ├── ic_play_arrow.xml │ │ │ ├── ic_view_cards.xml │ │ │ └── iv_view_list.xml │ │ ├── layout/ │ │ │ ├── fragment_news.xml │ │ │ ├── layout_news_empty.xml │ │ │ ├── view_news_filters.xml │ │ │ ├── view_news_header.xml │ │ │ ├── view_news_item.xml │ │ │ └── view_news_item_card.xml │ │ ├── values/ │ │ │ ├── dimens.xml │ │ │ └── strings.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-fi/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-sw600dp/ │ │ │ └── dimens.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ └── values-zh/ │ │ └── strings.xml │ └── test/ │ └── java/ │ └── com/ │ └── michaldrabik/ │ └── ui_news/ │ └── ExampleUnitTest.kt ├── ui-people/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── michaldrabik/ │ │ │ └── ui_people/ │ │ │ ├── details/ │ │ │ │ ├── PersonDetailsArgs.kt │ │ │ │ ├── PersonDetailsBottomSheet.kt │ │ │ │ ├── PersonDetailsUiEvent.kt │ │ │ │ ├── PersonDetailsUiState.kt │ │ │ │ ├── PersonDetailsViewModel.kt │ │ │ │ ├── cases/ │ │ │ │ │ ├── PersonDetailsCreditsCase.kt │ │ │ │ │ ├── PersonDetailsImagesCase.kt │ │ │ │ │ ├── PersonDetailsLoadCase.kt │ │ │ │ │ └── PersonDetailsTranslationsCase.kt │ │ │ │ ├── links/ │ │ │ │ │ └── PersonLinksBottomSheet.kt │ │ │ │ └── recycler/ │ │ │ │ ├── PersonDetailsAdapter.kt │ │ │ │ ├── PersonDetailsItem.kt │ │ │ │ ├── PersonDetailsItemDiffCallback.kt │ │ │ │ └── views/ │ │ │ │ ├── PersonDetailsBioView.kt │ │ │ │ ├── PersonDetailsCreditsItemView.kt │ │ │ │ ├── PersonDetailsFiltersView.kt │ │ │ │ ├── PersonDetailsHeaderView.kt │ │ │ │ ├── PersonDetailsInfoView.kt │ │ │ │ └── PersonDetailsLoadingView.kt │ │ │ ├── gallery/ │ │ │ │ ├── PersonGalleryFragment.kt │ │ │ │ ├── PersonGalleryUiState.kt │ │ │ │ ├── PersonGalleryViewModel.kt │ │ │ │ ├── cases/ │ │ │ │ │ └── PersonGalleryImagesCase.kt │ │ │ │ └── recycler/ │ │ │ │ ├── ImageItemDiffCallback.kt │ │ │ │ ├── PersonGalleryAdapter.kt │ │ │ │ └── views/ │ │ │ │ └── PersonGalleryImageView.kt │ │ │ └── list/ │ │ │ ├── PeopleListBottomSheet.kt │ │ │ ├── PeopleListUiState.kt │ │ │ ├── PeopleListViewModel.kt │ │ │ ├── cases/ │ │ │ │ └── PeopleListItemsCase.kt │ │ │ └── recycler/ │ │ │ ├── PeopleItemDiffCallback.kt │ │ │ ├── PeopleListAdapter.kt │ │ │ ├── PeopleListItem.kt │ │ │ └── views/ │ │ │ ├── PeopleListHeaderView.kt │ │ │ └── PeopleListItemView.kt │ │ └── res/ │ │ ├── color/ │ │ │ ├── selector_search_chip_background.xml │ │ │ └── selector_search_chip_text.xml │ │ ├── drawable/ │ │ │ ├── bg_indicator_circle.xml │ │ │ ├── bg_person_image_elevation.xml │ │ │ └── bg_person_placeholder.xml │ │ ├── drawable-notnight/ │ │ │ └── bg_indicator_circle.xml │ │ ├── layout/ │ │ │ ├── fragment_person_gallery.xml │ │ │ ├── view_people_list.xml │ │ │ ├── view_people_list_header.xml │ │ │ ├── view_people_list_item.xml │ │ │ ├── view_person_details.xml │ │ │ ├── view_person_details_bio.xml │ │ │ ├── view_person_details_credits_item.xml │ │ │ ├── view_person_details_filters.xml │ │ │ ├── view_person_details_header.xml │ │ │ ├── view_person_details_info.xml │ │ │ ├── view_person_details_loading.xml │ │ │ ├── view_person_gallery_image.xml │ │ │ └── view_person_links.xml │ │ ├── layout-sw600dp/ │ │ │ └── view_person_details_credits_item.xml │ │ ├── values/ │ │ │ ├── dimens.xml │ │ │ └── strings.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-fi/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-sw600dp/ │ │ │ └── dimens.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ └── values-zh/ │ │ └── strings.xml │ └── test/ │ └── java/ │ └── com/ │ └── michaldrabik/ │ └── ui_people/ │ └── ExampleUnitTest.kt ├── ui-premium/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_premium/ │ │ ├── PremiumFragment.kt │ │ ├── PremiumUiEvent.kt │ │ ├── PremiumUiState.kt │ │ ├── PremiumViewModel.kt │ │ └── views/ │ │ └── PurchaseItemView.kt │ └── res/ │ ├── drawable/ │ │ ├── divider_purchase_items.xml │ │ ├── ic_flash.xml │ │ ├── ic_genie.xml │ │ ├── ic_homer.xml │ │ ├── ic_spongebob.xml │ │ ├── ic_walter_white.xml │ │ ├── ic_yoda.xml │ │ └── ic_zoidberg.xml │ ├── layout/ │ │ ├── fragment_premium.xml │ │ └── view_purchase_item.xml │ ├── values/ │ │ ├── strings.xml │ │ └── styles.xml │ ├── values-ar/ │ │ └── strings.xml │ ├── values-de/ │ │ └── strings.xml │ ├── values-es/ │ │ └── strings.xml │ ├── values-fi/ │ │ └── strings.xml │ ├── values-fr/ │ │ └── strings.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-pl/ │ │ └── strings.xml │ ├── values-pt/ │ │ └── strings.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-tr/ │ │ └── strings.xml │ ├── values-uk/ │ │ └── strings.xml │ ├── values-vi/ │ │ └── strings.xml │ └── values-zh/ │ └── strings.xml ├── ui-progress/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_progress/ │ │ ├── calendar/ │ │ │ ├── CalendarFragment.kt │ │ │ ├── CalendarUiState.kt │ │ │ ├── CalendarViewModel.kt │ │ │ ├── cases/ │ │ │ │ ├── CalendarRatingsCase.kt │ │ │ │ └── items/ │ │ │ │ ├── CalendarFutureCase.kt │ │ │ │ ├── CalendarItemsCase.kt │ │ │ │ └── CalendarRecentsCase.kt │ │ │ ├── helpers/ │ │ │ │ ├── WatchlistAppender.kt │ │ │ │ ├── filters/ │ │ │ │ │ ├── CalendarFilter.kt │ │ │ │ │ ├── CalendarFutureFilter.kt │ │ │ │ │ └── CalendarRecentsFilter.kt │ │ │ │ └── groupers/ │ │ │ │ ├── CalendarFutureGrouper.kt │ │ │ │ ├── CalendarGrouper.kt │ │ │ │ └── CalendarRecentsGrouper.kt │ │ │ ├── recycler/ │ │ │ │ ├── CalendarAdapter.kt │ │ │ │ ├── CalendarItemDiffCallback.kt │ │ │ │ └── CalendarListItem.kt │ │ │ └── views/ │ │ │ ├── CalendarHeaderView.kt │ │ │ └── CalendarItemView.kt │ │ ├── helpers/ │ │ │ ├── ProgressItemsSorter.kt │ │ │ ├── ProgressLayoutManagerProvider.kt │ │ │ ├── TopOverscrollAdapter.kt │ │ │ └── TranslationsBundle.kt │ │ ├── main/ │ │ │ ├── ProgressMainFragment.kt │ │ │ ├── ProgressMainUiEvents.kt │ │ │ ├── ProgressMainUiState.kt │ │ │ ├── ProgressMainViewModel.kt │ │ │ ├── adapters/ │ │ │ │ └── ProgressMainAdapter.kt │ │ │ └── cases/ │ │ │ └── ProgressMainEpisodesCase.kt │ │ └── progress/ │ │ ├── ProgressFragment.kt │ │ ├── ProgressUiState.kt │ │ ├── ProgressViewModel.kt │ │ ├── cases/ │ │ │ ├── ProgressFiltersCase.kt │ │ │ ├── ProgressHeadersCase.kt │ │ │ ├── ProgressItemsCase.kt │ │ │ ├── ProgressRatingsCase.kt │ │ │ └── ProgressSortOrderCase.kt │ │ ├── recycler/ │ │ │ ├── ProgressAdapter.kt │ │ │ ├── ProgressItemDiffCallback.kt │ │ │ └── ProgressListItem.kt │ │ └── views/ │ │ ├── ProgressFiltersView.kt │ │ ├── ProgressHeaderView.kt │ │ └── ProgressItemView.kt │ └── res/ │ ├── layout/ │ │ ├── fragment_calendar.xml │ │ ├── fragment_progress.xml │ │ ├── fragment_progress_main.xml │ │ ├── layout_calendar_empty.xml │ │ ├── layout_progress_empty.xml │ │ ├── layout_recents_empty.xml │ │ ├── view_calendar_header.xml │ │ ├── view_calendar_item.xml │ │ ├── view_progress_filters.xml │ │ ├── view_progress_header.xml │ │ └── view_progress_item.xml │ ├── values/ │ │ ├── dimens.xml │ │ └── strings.xml │ ├── values-ar/ │ │ ├── dimens.xml │ │ └── strings.xml │ ├── values-de/ │ │ ├── dimens.xml │ │ └── strings.xml │ ├── values-es/ │ │ └── strings.xml │ ├── values-fi/ │ │ └── strings.xml │ ├── values-fr/ │ │ └── strings.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-pl/ │ │ ├── dimens.xml │ │ └── strings.xml │ ├── values-pt/ │ │ └── strings.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-sw600dp/ │ │ └── dimens.xml │ ├── values-tr/ │ │ └── strings.xml │ ├── values-uk/ │ │ └── strings.xml │ ├── values-vi/ │ │ └── strings.xml │ └── values-zh/ │ └── strings.xml ├── ui-progress-movies/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── michaldrabik/ │ │ │ └── ui_progress_movies/ │ │ │ ├── calendar/ │ │ │ │ ├── CalendarMoviesFragment.kt │ │ │ │ ├── CalendarMoviesUiState.kt │ │ │ │ ├── CalendarMoviesViewModel.kt │ │ │ │ ├── cases/ │ │ │ │ │ ├── CalendarMoviesRatingsCase.kt │ │ │ │ │ └── items/ │ │ │ │ │ ├── CalendarMoviesFutureCase.kt │ │ │ │ │ ├── CalendarMoviesItemsCase.kt │ │ │ │ │ └── CalendarMoviesRecentsCase.kt │ │ │ │ ├── helpers/ │ │ │ │ │ ├── filters/ │ │ │ │ │ │ ├── CalendarFilter.kt │ │ │ │ │ │ ├── CalendarFutureFilter.kt │ │ │ │ │ │ └── CalendarRecentsFilter.kt │ │ │ │ │ ├── groupers/ │ │ │ │ │ │ ├── CalendarFutureGrouper.kt │ │ │ │ │ │ ├── CalendarGrouper.kt │ │ │ │ │ │ └── CalendarRecentsGrouper.kt │ │ │ │ │ └── sorter/ │ │ │ │ │ ├── CalendarFutureSorter.kt │ │ │ │ │ ├── CalendarRecentsSorter.kt │ │ │ │ │ └── CalendarSorter.kt │ │ │ │ ├── recycler/ │ │ │ │ │ ├── CalendarMovieItemDiffCallback.kt │ │ │ │ │ ├── CalendarMovieListItem.kt │ │ │ │ │ └── CalendarMoviesAdapter.kt │ │ │ │ └── views/ │ │ │ │ ├── CalendarMoviesHeaderView.kt │ │ │ │ └── CalendarMoviesItemView.kt │ │ │ ├── helpers/ │ │ │ │ ├── ProgressMoviesItemsSorter.kt │ │ │ │ ├── ProgressMoviesLayoutManagerProvider.kt │ │ │ │ └── TopOverscrollAdapter.kt │ │ │ ├── main/ │ │ │ │ ├── ProgressMoviesMainAdapter.kt │ │ │ │ ├── ProgressMoviesMainFragment.kt │ │ │ │ ├── ProgressMoviesMainUiEvents.kt │ │ │ │ ├── ProgressMoviesMainUiState.kt │ │ │ │ ├── ProgressMoviesMainViewModel.kt │ │ │ │ └── cases/ │ │ │ │ └── ProgressMoviesMainCase.kt │ │ │ └── progress/ │ │ │ ├── ProgressMoviesFragment.kt │ │ │ ├── ProgressMoviesUiState.kt │ │ │ ├── ProgressMoviesViewModel.kt │ │ │ ├── cases/ │ │ │ │ ├── ProgressMoviesItemsCase.kt │ │ │ │ ├── ProgressMoviesPinnedCase.kt │ │ │ │ └── ProgressMoviesSortCase.kt │ │ │ ├── recycler/ │ │ │ │ ├── ProgressMovieItemDiffCallback.kt │ │ │ │ ├── ProgressMovieListItem.kt │ │ │ │ └── ProgressMoviesAdapter.kt │ │ │ └── views/ │ │ │ ├── ProgressMoviesFiltersView.kt │ │ │ └── ProgressMoviesItemView.kt │ │ └── res/ │ │ ├── layout/ │ │ │ ├── fragment_calendar_movies.xml │ │ │ ├── fragment_progress_main_movies.xml │ │ │ ├── fragment_progress_movies.xml │ │ │ ├── layout_calendar_movies_future_empty.xml │ │ │ ├── layout_calendar_movies_recents_empty.xml │ │ │ ├── layout_progress_movies_empty.xml │ │ │ ├── view_calendar_movies_header.xml │ │ │ ├── view_progress_movies_calendar_item.xml │ │ │ ├── view_progress_movies_filters.xml │ │ │ └── view_progress_movies_main_item.xml │ │ ├── values/ │ │ │ ├── dimens.xml │ │ │ └── strings.xml │ │ ├── values-ar/ │ │ │ ├── dimens.xml │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ ├── dimens.xml │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-fi/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ ├── dimens.xml │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-sw600dp/ │ │ │ └── dimens.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ └── values-zh/ │ │ └── strings.xml │ └── test/ │ └── java/ │ └── com/ │ └── michaldrabik/ │ └── ui_progress_movies/ │ ├── BaseMockTest.kt │ ├── calendar/ │ │ ├── CalendarMoviesViewModelTest.kt │ │ ├── cases/ │ │ │ └── CalendarMoviesRatingsCaseTest.kt │ │ └── helpers/ │ │ ├── filters/ │ │ │ ├── CalendarFutureFilterTest.kt │ │ │ └── CalendarRecentsFilterTest.kt │ │ ├── groupers/ │ │ │ ├── CalendarFutureGrouperTest.kt │ │ │ └── CalendarRecentsGrouperTest.kt │ │ └── sorter/ │ │ ├── CalendarFutureSorterTest.kt │ │ └── CalendarRecentsSorterTest.kt │ ├── main/ │ │ ├── ProgressMoviesMainViewModelTest.kt │ │ └── cases/ │ │ └── ProgressMoviesMainCaseTest.kt │ └── progress/ │ ├── ProgressMoviesViewModelTest.kt │ └── cases/ │ ├── ProgressMoviesItemsCaseTest.kt │ ├── ProgressMoviesPinnedCaseTest.kt │ └── ProgressMoviesSortCaseTest.kt ├── ui-search/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── michaldrabik/ │ │ │ └── ui_search/ │ │ │ ├── SearchFragment.kt │ │ │ ├── SearchUiState.kt │ │ │ ├── SearchViewModel.kt │ │ │ ├── cases/ │ │ │ │ ├── SearchFiltersCase.kt │ │ │ │ ├── SearchInvalidateItemCase.kt │ │ │ │ ├── SearchQueryCase.kt │ │ │ │ ├── SearchRecentsCase.kt │ │ │ │ ├── SearchSortingCase.kt │ │ │ │ ├── SearchSuggestionsCase.kt │ │ │ │ └── SearchTranslationsCase.kt │ │ │ ├── recycler/ │ │ │ │ ├── SearchAdapter.kt │ │ │ │ ├── SearchItemDiffCallback.kt │ │ │ │ ├── SearchListItem.kt │ │ │ │ └── suggestions/ │ │ │ │ ├── SuggestionAdapter.kt │ │ │ │ └── SuggestionItemDiffCallback.kt │ │ │ ├── utilities/ │ │ │ │ ├── SearchLayoutManagerProvider.kt │ │ │ │ ├── SearchOptions.kt │ │ │ │ └── TextWatcherAdapter.kt │ │ │ └── views/ │ │ │ ├── InitialSearchView.kt │ │ │ ├── RecentSearchView.kt │ │ │ ├── SearchFiltersView.kt │ │ │ ├── SearchItemView.kt │ │ │ └── SearchSuggestionView.kt │ │ └── res/ │ │ ├── color/ │ │ │ ├── selector_search_chip_background.xml │ │ │ └── selector_search_chip_text.xml │ │ ├── color-notnight/ │ │ │ └── selector_search_chip_background.xml │ │ ├── drawable/ │ │ │ └── bg_suggestions.xml │ │ ├── layout/ │ │ │ ├── fragment_search.xml │ │ │ ├── view_search_filters.xml │ │ │ ├── view_search_initial.xml │ │ │ ├── view_search_recent.xml │ │ │ ├── view_show_search.xml │ │ │ └── view_suggestion_search.xml │ │ ├── values/ │ │ │ ├── dimens.xml │ │ │ └── strings.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-fi/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-sw600dp/ │ │ │ └── dimens.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ └── values-zh/ │ │ └── strings.xml │ └── test/ │ └── java/ │ └── com.michaldrabik.ui_search/ │ ├── BaseMockTest.kt │ ├── SearchViewModelTest.kt │ ├── cases/ │ │ ├── SearchFiltersCaseTest.kt │ │ ├── SearchQueryCaseTest.kt │ │ ├── SearchRecentsCaseTest.kt │ │ ├── SearchSortingCaseTest.kt │ │ ├── SearchSuggestionsCaseTest.kt │ │ └── SearchTranslationsCaseTest.kt │ └── helpers/ │ └── TestData.kt ├── ui-settings/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_settings/ │ │ └── ExampleInstrumentedTest.kt │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── michaldrabik/ │ │ │ └── ui_settings/ │ │ │ ├── SettingsFragment.kt │ │ │ ├── SettingsUiState.kt │ │ │ ├── SettingsViewModel.kt │ │ │ ├── helpers/ │ │ │ │ ├── AppLanguage.kt │ │ │ │ ├── AppTheme.kt │ │ │ │ └── WidgetTransparency.kt │ │ │ └── sections/ │ │ │ ├── general/ │ │ │ │ ├── SettingsGeneralFragment.kt │ │ │ │ ├── SettingsGeneralUiState.kt │ │ │ │ ├── SettingsGeneralViewModel.kt │ │ │ │ └── cases/ │ │ │ │ ├── SettingsGeneralMainCase.kt │ │ │ │ ├── SettingsGeneralStreamingsCase.kt │ │ │ │ └── SettingsGeneralThemesCase.kt │ │ │ ├── misc/ │ │ │ │ ├── SettingsMiscFragment.kt │ │ │ │ ├── SettingsMiscUiState.kt │ │ │ │ ├── SettingsMiscViewModel.kt │ │ │ │ └── cases/ │ │ │ │ ├── SettingsMiscCacheCase.kt │ │ │ │ └── SettingsMiscUserCase.kt │ │ │ ├── notifications/ │ │ │ │ ├── SettingsNotificationsFragment.kt │ │ │ │ ├── SettingsNotificationsUiEvent.kt │ │ │ │ ├── SettingsNotificationsUiState.kt │ │ │ │ ├── SettingsNotificationsViewModel.kt │ │ │ │ ├── cases/ │ │ │ │ │ └── SettingsNotificationsMainCase.kt │ │ │ │ └── views/ │ │ │ │ └── NotificationsRationaleView.kt │ │ │ ├── spoilers/ │ │ │ │ ├── SettingsSpoilersFragment.kt │ │ │ │ ├── SettingsSpoilersUiState.kt │ │ │ │ ├── SettingsSpoilersViewModel.kt │ │ │ │ ├── episodes/ │ │ │ │ │ ├── SpoilersEpisodesBottomSheet.kt │ │ │ │ │ ├── SpoilersEpisodesUiState.kt │ │ │ │ │ └── SpoilersEpisodesViewModel.kt │ │ │ │ ├── helpers/ │ │ │ │ │ └── SettingsSpoilersHelper.kt │ │ │ │ ├── movies/ │ │ │ │ │ ├── SpoilersMoviesBottomSheet.kt │ │ │ │ │ ├── SpoilersMoviesUiState.kt │ │ │ │ │ └── SpoilersMoviesViewModel.kt │ │ │ │ └── shows/ │ │ │ │ ├── SpoilersShowsBottomSheet.kt │ │ │ │ ├── SpoilersShowsUiState.kt │ │ │ │ └── SpoilersShowsViewModel.kt │ │ │ ├── trakt/ │ │ │ │ ├── SettingsTraktFragment.kt │ │ │ │ ├── SettingsTraktUiEvent.kt │ │ │ │ ├── SettingsTraktUiState.kt │ │ │ │ ├── SettingsTraktViewModel.kt │ │ │ │ ├── cases/ │ │ │ │ │ ├── SettingsRatingsCase.kt │ │ │ │ │ ├── SettingsTraktCase.kt │ │ │ │ │ └── SettingsTraktMainCase.kt │ │ │ │ └── views/ │ │ │ │ └── TraktNotificationsRationaleView.kt │ │ │ └── widgets/ │ │ │ ├── SettingsWidgetsFragment.kt │ │ │ ├── SettingsWidgetsUiState.kt │ │ │ ├── SettingsWidgetsViewModel.kt │ │ │ └── cases/ │ │ │ ├── SettingsWidgetsMainCase.kt │ │ │ └── SettingsWidgetsThemesCase.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ └── bg_premium_ad.xml │ │ ├── layout/ │ │ │ ├── fragment_settings.xml │ │ │ ├── fragment_settings_general.xml │ │ │ ├── fragment_settings_misc.xml │ │ │ ├── fragment_settings_notifications.xml │ │ │ ├── fragment_settings_spoilers.xml │ │ │ ├── fragment_settings_trakt.xml │ │ │ ├── fragment_settings_widgets.xml │ │ │ ├── sheet_spoilers_episodes.xml │ │ │ ├── sheet_spoilers_movies.xml │ │ │ ├── sheet_spoilers_shows.xml │ │ │ ├── view_notifications_rationale.xml │ │ │ └── view_trakt_notifications_rationale.xml │ │ ├── values/ │ │ │ ├── dimens.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-fi/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-sw600dp/ │ │ │ ├── dimens.xml │ │ │ └── styles.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ └── values-zh/ │ │ └── strings.xml │ └── test/ │ └── java/ │ └── com/ │ └── michaldrabik/ │ └── ui_settings/ │ └── ExampleUnitTest.kt ├── ui-show/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_show/ │ │ ├── ShowDetailsFragment.kt │ │ ├── ShowDetailsUiEvents.kt │ │ ├── ShowDetailsUiState.kt │ │ ├── ShowDetailsViewModel.kt │ │ ├── cases/ │ │ │ ├── ShowDetailsHiddenCase.kt │ │ │ ├── ShowDetailsListsCase.kt │ │ │ ├── ShowDetailsMainCase.kt │ │ │ ├── ShowDetailsMyShowsCase.kt │ │ │ ├── ShowDetailsTranslationCase.kt │ │ │ └── ShowDetailsWatchlistCase.kt │ │ ├── episodes/ │ │ │ ├── ShowDetailsEpisodesFragment.kt │ │ │ ├── ShowDetailsEpisodesUiEvents.kt │ │ │ ├── ShowDetailsEpisodesUiState.kt │ │ │ ├── ShowDetailsEpisodesViewModel.kt │ │ │ ├── cases/ │ │ │ │ ├── EpisodesAnnouncementsCase.kt │ │ │ │ ├── EpisodesLoadShowCase.kt │ │ │ │ ├── EpisodesMarkWatchedCase.kt │ │ │ │ ├── EpisodesRatingCase.kt │ │ │ │ ├── EpisodesSetEpisodeWatchedCase.kt │ │ │ │ ├── EpisodesSetSeasonWatchedCase.kt │ │ │ │ └── EpisodesTranslationCase.kt │ │ │ └── recycler/ │ │ │ ├── EpisodeListItem.kt │ │ │ ├── EpisodeListItemDiffCallback.kt │ │ │ ├── EpisodeView.kt │ │ │ └── EpisodesAdapter.kt │ │ ├── helpers/ │ │ │ └── ShowDetailsMeta.kt │ │ ├── quicksetup/ │ │ │ ├── QuickSetupAdapter.kt │ │ │ ├── QuickSetupItemDiffCallback.kt │ │ │ ├── QuickSetupListItem.kt │ │ │ ├── QuickSetupView.kt │ │ │ └── views/ │ │ │ ├── QuickSetupHeaderView.kt │ │ │ └── QuickSetupItemView.kt │ │ ├── sections/ │ │ │ ├── nextepisode/ │ │ │ │ ├── ShowDetailsNextEpisodeFragment.kt │ │ │ │ ├── ShowDetailsNextEpisodeUiState.kt │ │ │ │ ├── ShowDetailsNextEpisodeViewModel.kt │ │ │ │ ├── cases/ │ │ │ │ │ ├── ShowDetailsNextEpisodeCase.kt │ │ │ │ │ ├── ShowDetailsTranslationCase.kt │ │ │ │ │ └── ShowDetailsWatchedCase.kt │ │ │ │ └── helpers/ │ │ │ │ └── NextEpisodeBundle.kt │ │ │ ├── people/ │ │ │ │ ├── ShowDetailsPeopleFragment.kt │ │ │ │ ├── ShowDetailsPeopleUiState.kt │ │ │ │ ├── ShowDetailsPeopleViewModel.kt │ │ │ │ ├── cases/ │ │ │ │ │ └── ShowDetailsPeopleCase.kt │ │ │ │ └── recycler/ │ │ │ │ ├── ActorView.kt │ │ │ │ └── ActorsAdapter.kt │ │ │ ├── ratings/ │ │ │ │ ├── ShowDetailsRatingsFragment.kt │ │ │ │ ├── ShowDetailsRatingsUiState.kt │ │ │ │ ├── ShowDetailsRatingsViewModel.kt │ │ │ │ ├── ShowLink.kt │ │ │ │ └── cases/ │ │ │ │ ├── ShowDetailsRatingCase.kt │ │ │ │ └── ShowDetailsRatingSpoilersCase.kt │ │ │ ├── related/ │ │ │ │ ├── ShowDetailsRelatedFragment.kt │ │ │ │ ├── ShowDetailsRelatedUiState.kt │ │ │ │ ├── ShowDetailsRelatedViewModel.kt │ │ │ │ ├── cases/ │ │ │ │ │ └── ShowDetailsRelatedCase.kt │ │ │ │ └── recycler/ │ │ │ │ ├── RelatedItemDiffCallback.kt │ │ │ │ ├── RelatedListItem.kt │ │ │ │ ├── RelatedShowAdapter.kt │ │ │ │ └── RelatedShowView.kt │ │ │ ├── seasons/ │ │ │ │ ├── ShowDetailsSeasonsFragment.kt │ │ │ │ ├── ShowDetailsSeasonsUiEvents.kt │ │ │ │ ├── ShowDetailsSeasonsUiState.kt │ │ │ │ ├── ShowDetailsSeasonsViewModel.kt │ │ │ │ ├── cases/ │ │ │ │ │ ├── ShowDetailsLoadSeasonsCase.kt │ │ │ │ │ ├── ShowDetailsQuickProgressCase.kt │ │ │ │ │ └── ShowDetailsWatchedSeasonCase.kt │ │ │ │ ├── helpers/ │ │ │ │ │ ├── SeasonsBundle.kt │ │ │ │ │ └── SeasonsCache.kt │ │ │ │ └── recycler/ │ │ │ │ ├── SeasonListItem.kt │ │ │ │ ├── SeasonListItemDiffCallback.kt │ │ │ │ ├── SeasonView.kt │ │ │ │ ├── SeasonsAdapter.kt │ │ │ │ └── helpers/ │ │ │ │ ├── SeasonsGridItemDecoration.kt │ │ │ │ └── SeasonsLayoutManagerProvider.kt │ │ │ └── streamings/ │ │ │ ├── ShowDetailsStreamingsFragment.kt │ │ │ ├── ShowDetailsStreamingsUiState.kt │ │ │ ├── ShowDetailsStreamingsViewModel.kt │ │ │ └── cases/ │ │ │ └── ShowDetailsStreamingCase.kt │ │ └── views/ │ │ └── AddToShowsButton.kt │ └── res/ │ ├── drawable/ │ │ ├── divider_horizontal_list.xml │ │ ├── ic_arrow_right.xml │ │ ├── ic_locked.xml │ │ ├── ic_quick_setup.xml │ │ └── ic_unlocked.xml │ ├── drawable-ar/ │ │ └── ic_arrow_right.xml │ ├── layout/ │ │ ├── fragment_show_details.xml │ │ ├── fragment_show_details_episodes.xml │ │ ├── fragment_show_details_next_episode.xml │ │ ├── fragment_show_details_people.xml │ │ ├── fragment_show_details_ratings.xml │ │ ├── fragment_show_details_related.xml │ │ ├── fragment_show_details_seasons.xml │ │ ├── fragment_show_details_streamings.xml │ │ ├── view_actor.xml │ │ ├── view_add_to_shows_button.xml │ │ ├── view_episode.xml │ │ ├── view_quick_setup.xml │ │ ├── view_quick_setup_header.xml │ │ ├── view_quick_setup_item.xml │ │ ├── view_related_show.xml │ │ └── view_season.xml │ ├── layout-sw600dp/ │ │ ├── fragment_show_details.xml │ │ ├── fragment_show_details_next_episode.xml │ │ ├── fragment_show_details_people.xml │ │ ├── fragment_show_details_related.xml │ │ ├── fragment_show_details_seasons.xml │ │ └── view_season.xml │ ├── values/ │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── values-ar/ │ │ └── strings.xml │ ├── values-de/ │ │ └── strings.xml │ ├── values-es/ │ │ ├── dimens.xml │ │ └── strings.xml │ ├── values-fi/ │ │ └── strings.xml │ ├── values-fr/ │ │ ├── strings.xml │ │ └── styles.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-pl/ │ │ └── strings.xml │ ├── values-pt/ │ │ ├── dimens.xml │ │ └── strings.xml │ ├── values-ru/ │ │ └── strings.xml │ ├── values-sw600dp/ │ │ └── dimens.xml │ ├── values-tr/ │ │ └── strings.xml │ ├── values-uk/ │ │ └── strings.xml │ ├── values-vi/ │ │ └── strings.xml │ └── values-zh/ │ └── strings.xml ├── ui-statistics/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── michaldrabik/ │ │ │ └── ui_statistics/ │ │ │ ├── StatisticsFragment.kt │ │ │ ├── StatisticsUiState.kt │ │ │ ├── StatisticsViewModel.kt │ │ │ ├── cases/ │ │ │ │ └── StatisticsLoadRatingsCase.kt │ │ │ └── views/ │ │ │ ├── StatisticsTopGenresView.kt │ │ │ ├── StatisticsTotalEpisodesView.kt │ │ │ ├── StatisticsTotalTimeSpentView.kt │ │ │ ├── mostWatched/ │ │ │ │ ├── StatisticsMostWatchedItem.kt │ │ │ │ ├── StatisticsMostWatchedItemView.kt │ │ │ │ ├── StatisticsMostWatchedShowsView.kt │ │ │ │ └── recycler/ │ │ │ │ ├── MostWatchedAdapter.kt │ │ │ │ └── MostWatchedItemDiffCallback.kt │ │ │ └── ratings/ │ │ │ ├── StatisticsRateItemView.kt │ │ │ ├── StatisticsRatingsView.kt │ │ │ └── recycler/ │ │ │ ├── StatisticsRatingItem.kt │ │ │ ├── StatisticsRatingsAdapter.kt │ │ │ └── StatisticsRatingsDiffCallback.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── divider_statistics_most_watched.xml │ │ │ └── divider_statistics_ratings.xml │ │ ├── layout/ │ │ │ ├── fragment_statistics.xml │ │ │ ├── layout_statistics_empty.xml │ │ │ ├── view_statistics_card_most_watched_shows.xml │ │ │ ├── view_statistics_card_ratings.xml │ │ │ ├── view_statistics_card_top_genre.xml │ │ │ ├── view_statistics_card_total_episodes.xml │ │ │ ├── view_statistics_card_total_time.xml │ │ │ ├── view_statistics_most_watched_item.xml │ │ │ └── view_statistics_rate_item.xml │ │ ├── layout-sw600dp/ │ │ │ └── fragment_statistics.xml │ │ ├── values/ │ │ │ ├── dimens.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-fi/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-notnight/ │ │ │ └── dimens.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-sw600dp/ │ │ │ └── dimens.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ └── values-zh/ │ │ └── strings.xml │ └── test/ │ └── java/ │ ├── BaseMockTest.kt │ ├── TestData.kt │ └── com/ │ └── michaldrabik/ │ └── ui_statistics/ │ ├── StatisticsViewModelTest.kt │ └── cases/ │ └── StatisticsLoadRatingsCaseTest.kt ├── ui-statistics-movies/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── michaldrabik/ │ │ │ └── ui_statistics_movies/ │ │ │ ├── StatisticsMoviesFragment.kt │ │ │ ├── StatisticsMoviesUiState.kt │ │ │ ├── StatisticsMoviesViewModel.kt │ │ │ ├── cases/ │ │ │ │ └── StatisticsMoviesLoadRatingsCase.kt │ │ │ └── views/ │ │ │ ├── StatisticsMoviesTopGenresView.kt │ │ │ ├── StatisticsMoviesTotalMoviesView.kt │ │ │ ├── StatisticsMoviesTotalTimeSpentView.kt │ │ │ └── ratings/ │ │ │ ├── StatisticsMoviesRateItemView.kt │ │ │ ├── StatisticsMoviesRatingsView.kt │ │ │ └── recycler/ │ │ │ ├── StatisticsMoviesRatingItem.kt │ │ │ ├── StatisticsMoviesRatingsAdapter.kt │ │ │ └── StatisticsMoviesRatingsDiffCallback.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ └── divider_statistics_ratings.xml │ │ ├── layout/ │ │ │ ├── fragment_statistics_movies.xml │ │ │ ├── layout_statistics_movies_empty.xml │ │ │ ├── view_statistics_movies_card_ratings.xml │ │ │ ├── view_statistics_movies_card_top_genre.xml │ │ │ ├── view_statistics_movies_card_total_movies.xml │ │ │ ├── view_statistics_movies_card_total_time.xml │ │ │ └── view_statistics_movies_rate_item.xml │ │ ├── layout-sw600dp/ │ │ │ └── fragment_statistics_movies.xml │ │ ├── values/ │ │ │ ├── dimens.xml │ │ │ └── strings.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-fi/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-notnight/ │ │ │ └── dimens.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ └── values-zh/ │ │ └── strings.xml │ └── test/ │ └── java/ │ ├── BaseMockTest.kt │ └── com/ │ └── michaldrabik/ │ └── ui_statistics_movies/ │ ├── StatisticsMoviesViewModelTest.kt │ └── cases/ │ └── StatisticsMoviesLoadRatingsCaseTest.kt ├── ui-streamings/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_streamings/ │ │ ├── recycler/ │ │ │ ├── StreamingAdapter.kt │ │ │ └── StreamingItemDiffCallback.kt │ │ └── views/ │ │ └── StreamingView.kt │ └── res/ │ ├── drawable/ │ │ ├── bg_streaming.xml │ │ └── bg_streaming_ripple.xml │ ├── layout/ │ │ └── view_streaming.xml │ ├── layout-sw600dp/ │ │ └── view_streaming.xml │ ├── values/ │ │ └── dimens.xml │ └── values-sw600dp/ │ └── dimens.xml ├── ui-trakt-sync/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── michaldrabik/ │ │ │ └── ui_trakt_sync/ │ │ │ ├── TraktSyncFragment.kt │ │ │ ├── TraktSyncUiEvent.kt │ │ │ ├── TraktSyncUiState.kt │ │ │ ├── TraktSyncViewModel.kt │ │ │ ├── cases/ │ │ │ │ └── TraktSyncRatingsCase.kt │ │ │ └── views/ │ │ │ └── NotificationsRationaleView.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ └── ic_sync.xml │ │ ├── layout/ │ │ │ ├── fragment_trakt_sync.xml │ │ │ └── view_trakt_notifications_rationale.xml │ │ ├── values/ │ │ │ ├── dimens.xml │ │ │ └── strings.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-fi/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-sw600dp/ │ │ │ └── dimens.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ └── values-zh/ │ │ └── strings.xml │ └── test/ │ └── java/ │ ├── BaseMockTest.kt │ └── com/ │ └── michaldrabik/ │ └── ui_trakt_sync/ │ └── TraktSyncViewModelTest.kt ├── ui-widgets/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── michaldrabik/ │ │ └── ui_widgets/ │ │ ├── BaseWidgetProvider.kt │ │ ├── calendar/ │ │ │ ├── CalendarWidgetProvider.kt │ │ │ ├── CalendarWidgetService.kt │ │ │ └── CalendarWidgetViewsFactory.kt │ │ ├── calendar_movies/ │ │ │ ├── CalendarMoviesWidgetProvider.kt │ │ │ ├── CalendarMoviesWidgetService.kt │ │ │ └── CalendarMoviesWidgetViewsFactory.kt │ │ ├── progress/ │ │ │ ├── ProgressWidgetEpisodeCheckService.kt │ │ │ ├── ProgressWidgetProvider.kt │ │ │ ├── ProgressWidgetService.kt │ │ │ └── ProgressWidgetViewsFactory.kt │ │ ├── progress_movies/ │ │ │ ├── ProgressMoviesWidgetCheckService.kt │ │ │ ├── ProgressMoviesWidgetProvider.kt │ │ │ ├── ProgressMoviesWidgetService.kt │ │ │ └── ProgressMoviesWidgetViewsFactory.kt │ │ └── search/ │ │ └── SearchWidgetProvider.kt │ └── res/ │ ├── drawable/ │ │ ├── bg_widget.xml │ │ ├── bg_widget_0.xml │ │ ├── bg_widget_25.xml │ │ ├── bg_widget_50.xml │ │ ├── bg_widget_75.xml │ │ ├── bg_widget_check_button.xml │ │ ├── bg_widget_media_view_elevation.xml │ │ ├── bg_widget_search.xml │ │ └── bg_widget_toolbar.xml │ ├── layout/ │ │ ├── layout_widget_calendar.xml │ │ ├── layout_widget_calendar_item.xml │ │ ├── layout_widget_header.xml │ │ ├── layout_widget_movies_calendar.xml │ │ ├── layout_widget_movies_calendar_item.xml │ │ ├── layout_widget_movies_progress.xml │ │ ├── layout_widget_movies_progress_item.xml │ │ ├── layout_widget_progress.xml │ │ ├── layout_widget_progress_item.xml │ │ ├── widget_calendar_day.xml │ │ ├── widget_calendar_item_day.xml │ │ ├── widget_calendar_item_night.xml │ │ ├── widget_calendar_night.xml │ │ ├── widget_header_day.xml │ │ ├── widget_header_night.xml │ │ ├── widget_loading_item.xml │ │ ├── widget_movies_calendar_day.xml │ │ ├── widget_movies_calendar_item_day.xml │ │ ├── widget_movies_calendar_item_night.xml │ │ ├── widget_movies_calendar_night.xml │ │ ├── widget_movies_progress_day.xml │ │ ├── widget_movies_progress_item_day.xml │ │ ├── widget_movies_progress_item_night.xml │ │ ├── widget_movies_progress_night.xml │ │ ├── widget_progress_day.xml │ │ ├── widget_progress_item_day.xml │ │ ├── widget_progress_item_night.xml │ │ ├── widget_progress_night.xml │ │ └── widget_search.xml │ ├── values/ │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── values-ar/ │ │ └── strings.xml │ ├── values-de/ │ │ └── strings.xml │ ├── values-es/ │ │ └── strings.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-pl/ │ │ └── strings.xml │ ├── values-vi/ │ │ └── strings.xml │ └── xml/ │ ├── calendar_movies_widgets_provider.xml │ ├── calendar_widgets_provider.xml │ ├── progress_movies_widgets_provider.xml │ ├── progress_widgets_provider.xml │ └── search_widgets_provider.xml └── versions.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ [*.{kt,kts}] indent_size=2 insert_final_newline=true max_line_length=150 disabled_rules=import-ordering ================================================ FILE: .github/ISSUE_TEMPLATE/bug-problem-report.md ================================================ --- name: Bug/Problem Report about: Create a report of bug/problem you have encountered title: '' labels: '' assignees: '' --- If you have an idea for a new feature or how to improve the app please use [Discussions](https://github.com/michaldrabik/showly-2.0/discussions) for that purpose. **Describe the bug** A clear description of what the problem is. **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties /.idea .DS_Store /build /captures .externalNativeBuild .cxx ktlint2.jar /app/keystore.properties /app/keystore ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ ![Version](https://img.shields.io/github/v/tag/1RandomDev/showly-oss?style=flat&label=Version) ![Downloads](https://img.shields.io/github/downloads/1RandomDev/showly-oss/total?style=flat&label=Downloads) > [!NOTE] > Since the official Showly app is now open source (only APK on GitHub, not the PlayStore version), this fork becomes obsolete and will therefor be archived. You can download the latest OSS version from the [official repo](https://github.com/michaldrabik/showly/releases). # Showly OSS Showly 2.0 is modern, slick, open source Android TV Shows Tracker. This fork gets rid of all proprietary tracking libraries. Get it on GitHub Get it on IzzyOnDroid ## Screenshots
Screenshot 1 Screenshot 2 Screenshot 3 Screenshot 4
## Project Setup 1. Clone repository and open project in the latest version of Android Studio. 2. Create `keystore.properties` file and put it in the `/app` folder. 3. Add following properties into `keystore.properties` file (values are not important at this moment): ``` keyAlias=github keyPassword=github storePassword=github ``` 4. Add your [Trakt.tv](https://trakt.tv/oauth/applications), [TMDB](https://developers.themoviedb.org/3/), [OMDB](http://www.omdbapi.com) and [Reddit](https://www.reddit.com/prefs/apps) API keys as following properties into your `local.properties` file located in the root directory of the project: ``` traktClientId="your trakt client id" traktClientSecret="your trakt client secret" tmdbApiKey="your tmdb api key (v4)" omdbApiKey="your omdb api key" redditClientId="your reddit client id" ``` 5. Rebuild and start the app. ## Issues & Contributions Feel free to post problems with the app as Github [Issues](https://github.com/1RandomDev/showly-oss/issues). Features ideas should be posted as new Github [Discussion](https://github.com/michaldrabik/showly-2.0/discussions). Pull requests are welcome. Remember about leaving a comment in the relevant issue if you are working on something. ### Language Translations We're always looking for help with translating app into more languages.
If you are interested in helping now or in the future, please visit our CrowdIn project and join:
https://crwd.in/showly-android-app ## FAQ **1. Can I watch/stream/download shows and movies with Showly app?** No, that is not possible. Showly is a progress tracking type of app - not a streaming service. **2. Show/Episode/Movie I'm looking for seems to be missing. What can I do?** Showly uses [Trakt.tv](https://trakt.tv) as its main data source. If something is missing please use "Import Show" / "Import Movie" option located at the bottom of Trakt.tv website. It's also possible to contact Trakt.tv support about any related issue. ================================================ FILE: app/.gitignore ================================================ /build /release ================================================ FILE: app/build.gradle ================================================ plugins { id 'com.android.application' id 'kotlin-android' id 'com.google.devtools.ksp' id 'dagger.hilt.android.plugin' } apply from: '../versions.gradle' android { kotlinOptions { jvmTarget = "17" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } buildFeatures { buildConfig = true viewBinding true } hilt { enableExperimentalClasspathAggregation = true } compileSdkVersion versions.compileSdk defaultConfig { applicationId "com.michaldrabik.showly_oss" minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk compileSdkVersion versions.compileSdk compileSdkVersion versions.compileSdk versionCode versions.versionCode versionName versions.versionName resourceConfigurations += ['en', 'ar', 'de', 'es', 'fi', 'fr', 'it', 'pl', 'pt', 'ru', 'tr', 'zh', 'uk', 'vi'] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { debug { applicationIdSuffix '.debug' versionNameSuffix '-debug' minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' manifestPlaceholders = [ appIcon: "@mipmap/ic_launcher_gray", appIconRound: "@mipmap/ic_launcher_round_gray" ] } release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' manifestPlaceholders = [ appIcon: "@mipmap/ic_launcher", appIconRound: "@mipmap/ic_launcher_round" ] } } lint { checkReleaseBuilds false } namespace 'com.michaldrabik.showly_oss' } dependencies { implementation project(':common') implementation project(':data-remote') implementation project(':data-local') implementation project(':repository') implementation project(':ui-base') implementation project(':ui-model') implementation project(':ui-navigation') implementation project(':ui-trakt-sync') implementation project(':ui-discover') implementation project(':ui-discover-movies') implementation project(':ui-episodes') implementation project(':ui-comments') implementation project(':ui-lists') implementation project(':ui-show') implementation project(':ui-movie') implementation project(':ui-gallery') implementation project(':ui-my-shows') implementation project(':ui-my-movies') implementation project(':ui-search') implementation project(':ui-statistics') implementation project(':ui-statistics-movies') implementation project(':ui-settings') implementation project(':ui-progress') implementation project(':ui-progress-movies') implementation project(':ui-premium') implementation project(':ui-news') implementation project(':ui-widgets') implementation libs.hilt.android ksp libs.hilt.compiler implementation libs.hilt.work ksp libs.hilt.work.compiler testImplementation libs.bundles.testing androidTestImplementation libs.android.test.runner // debugImplementation "com.squareup.leakcanary:leakcanary-android:$versions.leakCanary" coreLibraryDesugaring libs.android.desugar } ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile -dontwarn okhttp3.internal.platform.ConscryptPlatform ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/assets/release_notes.txt ================================================ * Fix issue where actors list of shows and movies would not return to its scrolled position * Other bugfixes Do you enjoy Showly and want to support the one and only developer? Become a Premium user and gain access to cool bonus features! Check Settings to learn more! ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/App.kt ================================================ package com.michaldrabik.showly_oss import android.app.Application import android.app.NotificationChannel import android.os.Build import android.os.StrictMode import androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import com.jakewharton.processphoenix.ProcessPhoenix import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.common.AppScopeProvider import com.michaldrabik.ui_base.common.WidgetsProvider import com.michaldrabik.ui_base.utilities.extensions.notificationManager import com.michaldrabik.ui_model.Settings import com.michaldrabik.ui_widgets.calendar.CalendarWidgetProvider import com.michaldrabik.ui_widgets.calendar_movies.CalendarMoviesWidgetProvider import com.michaldrabik.ui_widgets.progress.ProgressWidgetProvider import com.michaldrabik.ui_widgets.progress_movies.ProgressMoviesWidgetProvider import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import timber.log.Timber import javax.inject.Inject import com.michaldrabik.ui_base.fcm.NotificationChannel as AppNotificationChannel @HiltAndroidApp class App : Application(), AppScopeProvider, Configuration.Provider, WidgetsProvider { override val appScope = MainScope() @Inject lateinit var workerFactory: HiltWorkerFactory @Inject lateinit var settingsRepository: SettingsRepository override fun onCreate() { fun setupSettings() = runBlocking { if (!settingsRepository.isInitialized()) { settingsRepository.update(Settings.createInitial()) } } fun setupStrictMode() { if (BuildConfig.DEBUG) { StrictMode .setThreadPolicy( StrictMode.ThreadPolicy.Builder() .detectAll() .penaltyLog() .build() ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { StrictMode.setVmPolicy( StrictMode.VmPolicy.Builder() .detectUnsafeIntentLaunch() .penaltyDeath() .build() ) } } } fun setupNotificationChannels() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return fun createChannel(channel: AppNotificationChannel) = NotificationChannel( /* id = */ channel.name, /* name = */ channel.displayName, /* importance = */ channel.importance ).apply { description = channel.description } notificationManager().run { createNotificationChannel(createChannel(AppNotificationChannel.GENERAL_INFO)) createNotificationChannel(createChannel(AppNotificationChannel.SHOWS_INFO)) createNotificationChannel(createChannel(AppNotificationChannel.EPISODES_ANNOUNCEMENTS)) createNotificationChannel(createChannel(AppNotificationChannel.MOVIES_ANNOUNCEMENTS)) } } fun setupTheme() { setDefaultNightMode(settingsRepository.theme) } super.onCreate() if (ProcessPhoenix.isPhoenixProcess(this)) return if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) } setupSettings() setupStrictMode() setupNotificationChannels() setupTheme() } override fun requestShowsWidgetsUpdate() { appScope.launch { ProgressWidgetProvider.requestUpdate(applicationContext) CalendarWidgetProvider.requestUpdate(applicationContext) } } override fun requestMoviesWidgetsUpdate() { appScope.launch { ProgressMoviesWidgetProvider.requestUpdate(applicationContext) CalendarMoviesWidgetProvider.requestUpdate(applicationContext) } } override fun getWorkManagerConfiguration() = Configuration.Builder() .setWorkerFactory(workerFactory) .build() } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/di/module/PreferencesModule.kt ================================================ package com.michaldrabik.showly_oss.di.module import android.content.Context import android.content.SharedPreferences import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Named import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) class PreferencesModule { @Provides @Singleton @Named("tipsPreferences") fun providesTutorialsPreferences(@ApplicationContext context: Context): SharedPreferences = context.applicationContext.getSharedPreferences( "PREFERENCES_TUTORIALS", Context.MODE_PRIVATE ) @Provides @Singleton @Named("watchlistPreferences") fun providesProgressShowsPreferences(@ApplicationContext context: Context): SharedPreferences = context.applicationContext.getSharedPreferences( "PREFERENCES_WATCHLIST", Context.MODE_PRIVATE ) @Provides @Singleton @Named("progressOnHoldPreferences") fun providesProgressShowsOnHoldPreferences(@ApplicationContext context: Context): SharedPreferences = context.applicationContext.getSharedPreferences( "PREFERENCES_PROGRESS_SHOWS_ON_HOLD", Context.MODE_PRIVATE ) @Provides @Singleton @Named("progressMoviesPreferences") fun providesProgressMoviesPreferences(@ApplicationContext context: Context): SharedPreferences = context.applicationContext.getSharedPreferences( "PREFERENCES_PROGRESS_MOVIES", Context.MODE_PRIVATE ) @Provides @Singleton @Named("miscPreferences") fun providesMiscPreferences(@ApplicationContext context: Context): SharedPreferences = context.applicationContext.getSharedPreferences( "PREFERENCES_MISC", Context.MODE_PRIVATE ) @Provides @Singleton @Named("spoilersPreferences") fun providesSpoilersPreferences(@ApplicationContext context: Context): SharedPreferences = context.applicationContext.getSharedPreferences( "PREFERENCES_SPOILERS", Context.MODE_PRIVATE ) } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/di/module/ServicesModule.kt ================================================ package com.michaldrabik.showly_oss.di.module import android.content.Context import android.net.ConnectivityManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) class ServicesModule { @Provides @Singleton fun providesConnectivityManager(@ApplicationContext context: Context) = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/di/module/WorkModule.kt ================================================ package com.michaldrabik.showly_oss.di.module import android.content.Context import androidx.work.WorkManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) class WorkModule { @Provides @Singleton fun providesWorkManager(@ApplicationContext context: Context) = WorkManager.getInstance(context) } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/fcm/FcmExtra.kt ================================================ package com.michaldrabik.showly_oss.fcm enum class FcmExtra(val key: String) { SHOW_ID("extra_show_id") } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/BaseActivity.kt ================================================ package com.michaldrabik.showly_oss.ui import android.annotation.SuppressLint import android.net.Uri import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.findNavController import com.michaldrabik.showly_oss.R import com.michaldrabik.showly_oss.fcm.FcmExtra import com.michaldrabik.ui_base.Logger import com.michaldrabik.ui_base.common.OnTraktAuthorizeListener import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_MOVIE_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_SHOW_ID import com.michaldrabik.ui_widgets.BaseWidgetProvider.Companion.EXTRA_MOVIE_ID import com.michaldrabik.ui_widgets.BaseWidgetProvider.Companion.EXTRA_SHOW_ID import com.michaldrabik.ui_widgets.search.SearchWidgetProvider abstract class BaseActivity : AppCompatActivity() { private val actionKeys = arrayOf( FcmExtra.SHOW_ID.key, EXTRA_SHOW_ID, EXTRA_MOVIE_ID ) protected fun findNavHostFragment() = supportFragmentManager.findFragmentById(R.id.navigationHost) as? NavHostFragment protected abstract fun handleSearchWidgetClick(bundle: Bundle?) fun handleNotification(extras: Bundle?, action: () -> Unit = {}) { if (extras == null) return if (extras.containsKey(SearchWidgetProvider.EXTRA_WIDGET_SEARCH_CLICK)) { handleSearchWidgetClick(extras) return } actionKeys.forEach { if (extras.containsKey(it)) { handleShowMovieExtra(extras, it, action) } } } @SuppressLint("RestrictedApi") private fun handleShowMovieExtra(extras: Bundle, key: String, action: () -> Unit) { val itemId = extras.getString(key)?.toLong() ?: -1 val bundle = Bundle().apply { putLong(ARG_SHOW_ID, itemId) putLong(ARG_MOVIE_ID, itemId) } findNavHostFragment()?.findNavController()?.run { try { val isShow = key in arrayOf(EXTRA_SHOW_ID, FcmExtra.SHOW_ID.key) if (isShow) { navigate(R.id.actionNavigateShowDetailsFragment, bundle) } else { navigate(R.id.actionNavigateMovieDetailsFragment, bundle) } extras.clear() action() } catch (error: Throwable) { Logger.record(error, "BaseActivity::handleShowMovieExtra()") } } } protected fun handleTraktAuthorization(authData: Uri?) { findNavHostFragment()?.findNavController()?.currentDestination?.id?.let { val navHost = supportFragmentManager.findFragmentById(R.id.navigationHost) navHost?.childFragmentManager?.primaryNavigationFragment?.let { if (authData.toString().startsWith("showlyoss://trakt")) { (it as? OnTraktAuthorizeListener)?.onAuthorizationResult(authData) } } } } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/MainActivity.kt ================================================ package com.michaldrabik.showly_oss.ui.main import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle import android.view.ViewGroup import android.view.animation.DecelerateInterpolator import androidx.activity.addCallback import androidx.activity.viewModels import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.work.WorkManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_INDEFINITE import com.michaldrabik.common.Config import com.michaldrabik.common.Mode import com.michaldrabik.common.Mode.MOVIES import com.michaldrabik.common.Mode.SHOWS import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.showly_oss.R import com.michaldrabik.showly_oss.databinding.ActivityMainBinding import com.michaldrabik.showly_oss.ui.BaseActivity import com.michaldrabik.showly_oss.ui.main.delegates.MainTipsDelegate import com.michaldrabik.showly_oss.ui.main.delegates.TipsDelegate import com.michaldrabik.showly_oss.ui.views.WhatsNewView import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkResolver import com.michaldrabik.ui_base.Logger import com.michaldrabik.ui_base.common.OnShowsMoviesSyncedListener import com.michaldrabik.ui_base.common.OnTabReselectedListener import com.michaldrabik.ui_base.events.Event import com.michaldrabik.ui_base.events.EventsManager import com.michaldrabik.ui_base.events.ShowsMoviesSyncComplete import com.michaldrabik.ui_base.events.TraktQuickSyncSuccess import com.michaldrabik.ui_base.events.TraktSyncAuthError import com.michaldrabik.ui_base.network.NetworkStatusProvider import com.michaldrabik.ui_base.sync.ShowsMoviesSyncWorker import com.michaldrabik.ui_base.utilities.ModeHost import com.michaldrabik.ui_base.utilities.MoviesStatusHost import com.michaldrabik.ui_base.utilities.NavigationHost import com.michaldrabik.ui_base.utilities.SnackbarHost import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.fadeIn import com.michaldrabik.ui_base.utilities.extensions.fadeOut import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.openWebUrl import com.michaldrabik.ui_base.utilities.extensions.showErrorSnackbar import com.michaldrabik.ui_base.utilities.extensions.showInfoSnackbar import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_settings.helpers.AppLanguage import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint class MainActivity : BaseActivity(), SnackbarHost, NavigationHost, ModeHost, MoviesStatusHost, TipsDelegate by MainTipsDelegate() { companion object { private const val NAVIGATION_TRANSITION_DURATION_MS = 350L private const val ARG_NAVIGATION_VISIBLE = "ARG_NAVIGATION_VISIBLE" } private val viewModel by viewModels() private lateinit var binding: ActivityMainBinding private val navigationHeightPad by lazy { dimenToPx(R.dimen.bottomNavigationHeightPadded) } private val navigationHeight by lazy { dimenToPx(R.dimen.bottomNavigationHeight) } private val decelerateInterpolator by lazy { DecelerateInterpolator(2F) } @Inject lateinit var workManager: WorkManager @Inject lateinit var eventsManager: EventsManager @Inject lateinit var deepLinkResolver: DeepLinkResolver @Inject lateinit var settingsRepository: SettingsRepository @Inject lateinit var networkStatusProvider: NetworkStatusProvider override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) //onUpdateDownloaded() TODO: Implement own updater from GitHub registerTipsDelegate(viewModel, binding) setupViewModel() setupNavigation() setupView() setupNetworkObserver() restoreState(savedInstanceState) onNewIntent(intent) } override fun onStart() { super.onStart() ShowsMoviesSyncWorker.schedule(workManager) } override fun onResume() { super.onResume() setupBackPressed() } override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) handleAppShortcut(intent) handleNotification(intent?.extras) { hideNavigation(false) } handleTraktAuthorization(intent?.data) handleDeepLink(intent) } override fun onDestroy() { lifecycle.removeObserver(networkStatusProvider) super.onDestroy() } private fun setupViewModel() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { viewModel.uiState.collect { render(it) } } launch { eventsManager.events.collect { handleEvent(it) } } } } viewModel.initialize() viewModel.refreshTraktSyncSchedule() } private fun setupView() { with(binding.bottomMenuView) { isModeMenuEnabled = hasMoviesEnabled() onModeSelected = { setMode(it) } } binding.viewMask.onClick { /* NOOP */ } } private fun setupNetworkObserver() { lifecycle.addObserver(networkStatusProvider) lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { networkStatusProvider.status.collect { binding.statusView.visibleIf(!it) binding.statusView.text = getString(R.string.errorNoInternetConnection) } } } } } private fun setupNavigation() { findNavControl()?.run { val graph = navInflater.inflate(R.navigation.navigation_graph).apply { val destination = when (viewModel.getMode()) { SHOWS -> R.id.progressMainFragment MOVIES -> R.id.progressMoviesMainFragment else -> throw IllegalStateException() } setStartDestination(destination) } setGraph(graph, Bundle.EMPTY) } with(binding.bottomMenuView.binding.bottomNavigationView) { setOnItemSelectedListener { item -> if (selectedItemId == item.itemId) { doForFragments { (it as? OnTabReselectedListener)?.onTabReselected() } return@setOnItemSelectedListener true } val target = when (item.itemId) { R.id.menuProgress -> getMenuProgressAction() R.id.menuDiscover -> getMenuDiscoverAction() R.id.menuCollection -> getMenuCollectionAction() R.id.menuNews -> R.id.actionNavigateNewsFragment else -> throw IllegalStateException("Invalid menu item.") } findNavControl()?.navigate(target) showNavigation(true) if (item.itemId == R.id.menuDiscover) { // Try showing rate app dialog when user navigates to Discover section. viewModel.checkRateApp() } return@setOnItemSelectedListener true } menu.findItem(R.id.menuNews).isVisible = viewModel.hasNewsEnabled() } } private fun setupBackPressed() { with(binding) { onBackPressedDispatcher.addCallback(this@MainActivity) { if (tutorialView.isVisible) { tutorialView.fadeOut() return@addCallback } findNavControl()?.run { when (currentDestination?.id) { R.id.discoverFragment, R.id.discoverMoviesFragment, R.id.followedShowsFragment, R.id.followedMoviesFragment, R.id.listsFragment, R.id.newsFragment, -> { bottomMenuView.binding.bottomNavigationView.selectedItemId = R.id.menuProgress } else -> { remove() super.onBackPressed() } } } } } } override fun hideNavigation(animate: Boolean) { with(binding) { hideAllTips() bottomMenuView.binding.bottomNavigationView.run { isEnabled = false isClickable = false } snackbarHost.translationY = navigationHeight.toFloat() bottomNavigationWrapper.animate().translationYBy(navigationHeightPad.toFloat()) .setDuration(if (animate) NAVIGATION_TRANSITION_DURATION_MS else 0) .setInterpolator(decelerateInterpolator).start() } } override fun showNavigation(animate: Boolean) { showAllTips() binding.bottomMenuView.binding.bottomNavigationView.run { isEnabled = true isClickable = true } binding.snackbarHost.translationY = 0F binding.bottomNavigationWrapper .animate() .translationY(0F) .setDuration(if (animate) NAVIGATION_TRANSITION_DURATION_MS else 0) .setInterpolator(decelerateInterpolator).start() } override fun navigateToDiscover() { binding.bottomMenuView.binding.bottomNavigationView.selectedItemId = R.id.menuDiscover } override fun setMode(mode: Mode, force: Boolean) { if (force || viewModel.getMode() != mode) { viewModel.setMode(mode) val target = when (binding.bottomMenuView.binding.bottomNavigationView.selectedItemId) { R.id.menuDiscover -> getMenuDiscoverAction() R.id.menuCollection -> getMenuCollectionAction() R.id.menuProgress -> getMenuProgressAction() R.id.menuNews -> R.id.actionNavigateNewsFragment else -> 0 } if (target != 0) { findNavControl()?.navigate(target) } } } override fun getMode() = viewModel.getMode() override fun hasMoviesEnabled() = viewModel.hasMoviesEnabled() private fun render(uiState: MainUiState) { with(binding) { uiState.run { isLoading.let { mainProgress.visibleIf(it) } showMask.let { viewMask.visibleIf(it) } isInitialRun?.let { if (it.consume() == true) { viewModel.checkInitialLanguage() } } showWhatsNew?.let { if (it.consume() == true) showWhatsNewDialog() } initialLanguage?.let { event -> event.consume()?.let { showWelcomeDialog(it) } } openLink?.let { event -> event.consume()?.let { bundle -> findNavHostFragment()?.findNavController()?.let { nav -> bundle.show?.let { deepLinkResolver.resolveDestination(nav, bottomMenuView.binding.bottomNavigationView, it) } bundle.movie?.let { deepLinkResolver.resolveDestination(nav, bottomMenuView.binding.bottomNavigationView, it) } } } } } } } private fun showWelcomeDialog(language: AppLanguage) { navigateToDiscover() with(binding.welcomeView) { setLanguage(language) fadeIn() onOkClickListener = { fadeOut() showMask(false) if (language != AppLanguage.ENGLISH) { showWelcomeLanguageDialog(language) } } } showMask(true) } private fun showWelcomeLanguageDialog(language: AppLanguage) { with(binding.welcomeLanguageView) { setLanguage(language) fadeIn() onYesClick = { viewModel.setLanguage(language) fadeOut() showMask(false) } onNoClick = { viewModel.setLanguage(AppLanguage.ENGLISH) fadeOut() showMask(false) } } showMask(true) } private fun showMask(show: Boolean) { binding.viewMask.visibleIf(show) if (!show) viewModel.clearMask() } @SuppressLint("MissingSuperCall") override fun onSaveInstanceState(outState: Bundle) { outState.putBoolean(ARG_NAVIGATION_VISIBLE, binding.bottomNavigationWrapper.translationY == 0F) super.onSaveInstanceState(outState) } private fun restoreState(savedInstanceState: Bundle?) { val isNavigationVisible = savedInstanceState?.getBoolean(ARG_NAVIGATION_VISIBLE, true) ?: true if (!isNavigationVisible) hideNavigation(true) } private fun doForFragments(action: (Fragment) -> Unit) { findNavControl()?.currentDestination?.id?.let { val navHost = supportFragmentManager.findFragmentById(R.id.navigationHost) navHost?.childFragmentManager?.primaryNavigationFragment?.let { action(it) } } } private fun handleEvent(event: Event) { when (event) { is ShowsMoviesSyncComplete -> { if (event.count > 0) { doForFragments { (it as? OnShowsMoviesSyncedListener)?.onShowsMoviesSyncFinished() } } viewModel.refreshAnnouncements() } is TraktQuickSyncSuccess -> { val message = resources.getQuantityString(R.plurals.textTraktQuickSyncComplete, event.count, event.count) provideSnackbarLayout().showInfoSnackbar(message) } is TraktSyncAuthError -> { provideSnackbarLayout().showErrorSnackbar(getString(R.string.errorTraktAuthorization)) } else -> Timber.d("Event ignored. Noop.") } } private fun handleAppShortcut(intent: Intent?) { when { intent == null -> return intent.extras?.containsKey("extraShortcutProgress") == true -> binding.bottomMenuView.binding.bottomNavigationView.selectedItemId = R.id.menuProgress intent.extras?.containsKey("extraShortcutDiscover") == true -> binding.bottomMenuView.binding.bottomNavigationView.selectedItemId = R.id.menuDiscover intent.extras?.containsKey("extraShortcutCollection") == true -> binding.bottomMenuView.binding.bottomNavigationView.selectedItemId = R.id.menuCollection intent.extras?.containsKey("extraShortcutSearch") == true -> { binding.bottomMenuView.binding.bottomNavigationView.selectedItemId = R.id.menuDiscover val action = when (viewModel.getMode()) { SHOWS -> R.id.actionDiscoverFragmentToSearchFragment MOVIES -> R.id.actionDiscoverMoviesFragmentToSearchFragment else -> throw IllegalStateException() } findNavControl()?.navigate(action) } } } override fun handleSearchWidgetClick(bundle: Bundle?) { findNavHostFragment()?.findNavController()?.run { try { when (currentDestination?.id) { R.id.searchFragment -> return@run R.id.showDetailsFragment, R.id.movieDetailsFragment -> navigateUp() } if (currentDestination?.id != R.id.discoverFragment) { binding.bottomMenuView.binding.bottomNavigationView.selectedItemId = R.id.menuDiscover } when (currentDestination?.id) { R.id.discoverFragment -> navigate(R.id.actionDiscoverFragmentToSearchFragment) R.id.discoverMoviesFragment -> navigate(R.id.actionDiscoverMoviesFragmentToSearchFragment) } bundle?.clear() } catch (error: Throwable) { Logger.record(error, "BaseActivity::handleSearchWidgetClick()") } } } private fun showWhatsNewDialog() { MaterialAlertDialogBuilder(this, R.style.AlertDialog).setBackground(ContextCompat.getDrawable(this, R.drawable.bg_dialog)) .setView(WhatsNewView(this)).setCancelable(false).setPositiveButton(R.string.textClose) { _, _ -> } .setNeutralButton("GitHub") { _, _ -> openWebUrl(Config.GITHUB_URL) }.show() } private fun getMenuDiscoverAction() = when (viewModel.getMode()) { SHOWS -> R.id.actionNavigateDiscoverFragment MOVIES -> R.id.actionNavigateDiscoverMoviesFragment else -> throw IllegalStateException() } private fun getMenuCollectionAction() = when (viewModel.getMode()) { SHOWS -> R.id.actionNavigateFollowedShowsFragment MOVIES -> R.id.actionNavigateFollowedMoviesFragment else -> throw IllegalStateException() } private fun getMenuProgressAction() = when (viewModel.getMode()) { SHOWS -> R.id.actionNavigateProgressFragment MOVIES -> R.id.actionNavigateProgressMoviesFragment else -> throw IllegalStateException() } private fun onUpdateDownloaded() { provideSnackbarLayout().showInfoSnackbar( message = getString(R.string.textUpdateDownloaded), actionText = R.string.textUpdateInstall, length = LENGTH_INDEFINITE ) { // TODO: Implement own updater from GitHub } } private fun handleDeepLink(intent: Intent?) { deepLinkResolver.findSource(intent)?.let { viewModel.openDeepLink(it) } } override fun findNavControl() = findNavHostFragment()?.findNavController() override fun provideSnackbarLayout(): ViewGroup = binding.snackbarHost } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/MainUiState.kt ================================================ package com.michaldrabik.showly_oss.ui.main import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkBundle import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_settings.helpers.AppLanguage // TODO Split events into their Channel data class MainUiState( val isLoading: Boolean = false, val isInitialRun: Event? = null, val showWhatsNew: Event? = null, val initialLanguage: Event? = null, val showRateApp: Event? = null, val showMask: Boolean = false, val openLink: Event? = null, ) ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/MainViewModel.kt ================================================ package com.michaldrabik.showly_oss.ui.main import android.annotation.SuppressLint import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.common.Mode import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.showly_oss.ui.main.cases.MainAnnouncementsCase import com.michaldrabik.showly_oss.ui.main.cases.MainClearingCase import com.michaldrabik.showly_oss.ui.main.cases.MainInitialsCase import com.michaldrabik.showly_oss.ui.main.cases.MainModesCase import com.michaldrabik.showly_oss.ui.main.cases.MainRateAppCase import com.michaldrabik.showly_oss.ui.main.cases.MainSettingsCase import com.michaldrabik.showly_oss.ui.main.cases.MainTipsCase import com.michaldrabik.showly_oss.ui.main.cases.MainTraktCase import com.michaldrabik.showly_oss.ui.main.cases.deeplink.MainDeepLinksCase import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkBundle import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkSource import com.michaldrabik.ui_base.Logger import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.utilities.extensions.combine import com.michaldrabik.ui_base.utilities.extensions.launchDelayed import com.michaldrabik.ui_base.utilities.extensions.rethrowCancellation import com.michaldrabik.ui_model.Tip import com.michaldrabik.ui_settings.helpers.AppLanguage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @SuppressLint("StaticFieldLeak") @HiltViewModel class MainViewModel @Inject constructor( private val initCase: MainInitialsCase, private val tipsCase: MainTipsCase, private val traktCase: MainTraktCase, private val clearingCase: MainClearingCase, private val settingsCase: MainSettingsCase, private val announcementsCase: MainAnnouncementsCase, private val modesCase: MainModesCase, private val rateAppCase: MainRateAppCase, private val linksCase: MainDeepLinksCase, private val settingsRepository: SettingsRepository, ) : ViewModel() { private val loadingState = MutableStateFlow(false) private val maskState = MutableStateFlow(false) private val initialRunEvent = MutableStateFlow?>(null) private val initialLanguageEvent = MutableStateFlow?>(null) private val whatsNewEvent = MutableStateFlow?>(null) private val rateAppEvent = MutableStateFlow?>(null) private val openLinkEvent = MutableStateFlow?>(null) fun initialize() { viewModelScope.launch { val isInitialRun = checkInitialRun() with(initCase) { preloadRatings() saveInstallTimestamp() } checkApi13Locale(isInitialRun) } } private suspend fun checkInitialRun(): Boolean { val isInitialRun = initCase.isInitialRun() if (isInitialRun) { initCase.setInitialRun(false) initCase.setInitialNotifications() initCase.setInitialCountry() } val showWhatsNew = initCase.showWhatsNew(isInitialRun) initialRunEvent.value = Event(isInitialRun) whatsNewEvent.value = Event(showWhatsNew) return isInitialRun } fun checkRateApp() { val showRateApp = rateAppCase.shouldShowRateApp() rateAppEvent.value = Event(showRateApp) } fun setLanguage(appLanguage: AppLanguage) = initCase.setLanguage(appLanguage) fun checkInitialLanguage() { viewModelScope.launch { val initialLanguage = initCase.checkInitialLanguage() initialLanguageEvent.value = Event(initialLanguage) maskState.value = true } } private fun checkApi13Locale(isInitialRun: Boolean) { if (!isInitialRun && !settingsRepository.isLocaleInitialised) { settingsRepository.isLocaleInitialised = true val locale = LocaleListCompat.forLanguageTags(settingsRepository.language) AppCompatDelegate.setApplicationLocales(locale) } } fun refreshAnnouncements() { viewModelScope.launch { announcementsCase.refreshAnnouncements() } } fun refreshTraktSyncSchedule() { viewModelScope.launch { traktCase.run { refreshTraktSyncSchedule() refreshTraktQuickSync() } } } fun setMode(mode: Mode) = modesCase.setMode(mode) fun getMode(): Mode = modesCase.getMode() fun isTipShown(tip: Tip) = tipsCase.isTipShown(tip) fun setTipShown(tip: Tip) = tipsCase.setTipShown(tip) fun hasMoviesEnabled(): Boolean = settingsCase.hasMoviesEnabled() fun hasNewsEnabled(): Boolean = settingsCase.hasNewsEnabled() fun completeAppRate() = rateAppCase.complete() fun clearMask() { maskState.value = false } fun openDeepLink(source: DeepLinkSource) { viewModelScope.launch { val progressJob = launchDelayed(750) { loadingState.value = true maskState.value = true } try { val result = when (source) { is DeepLinkSource.ImdbSource -> linksCase.findById(source.id) is DeepLinkSource.TmdbSource -> linksCase.findById(source.id, source.type) is DeepLinkSource.TraktSource -> linksCase.findById(source.id, source.type) } loadingState.value = false maskState.value = false openLinkEvent.value = Event(result) } catch (error: Throwable) { Logger.record(error, "MainViewModel::openDeepLink:$source") rethrowCancellation(error) } finally { progressJob.cancelAndJoin() } } } override fun onCleared() { clearingCase.clear() super.onCleared() } val uiState = combine( initialRunEvent, initialLanguageEvent, whatsNewEvent, rateAppEvent, openLinkEvent, loadingState, maskState ) { s1, s2, s3, s4, s5, s6, s7 -> MainUiState( isInitialRun = s1, initialLanguage = s2, showWhatsNew = s3, showRateApp = s4, openLink = s5, isLoading = s6, showMask = s7 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = MainUiState() ) } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/cases/MainAnnouncementsCase.kt ================================================ package com.michaldrabik.showly_oss.ui.main.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.ui_base.notifications.AnnouncementManager import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class MainAnnouncementsCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val announcementManager: AnnouncementManager, ) { suspend fun refreshAnnouncements() { withContext(dispatchers.IO) { announcementManager.refreshShowsAnnouncements() announcementManager.refreshMoviesAnnouncements() } } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/cases/MainClearingCase.kt ================================================ package com.michaldrabik.showly_oss.ui.main.cases import com.michaldrabik.repository.images.MovieImagesProvider import com.michaldrabik.repository.images.ShowImagesProvider import dagger.hilt.android.scopes.ViewModelScoped import timber.log.Timber import javax.inject.Inject @ViewModelScoped class MainClearingCase @Inject constructor( private val showImagesProvider: ShowImagesProvider, private val movieImagesProvider: MovieImagesProvider, ) { fun clear() { showImagesProvider.clear() movieImagesProvider.clear() Timber.d("Clearing...") } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/cases/MainInitialsCase.kt ================================================ package com.michaldrabik.showly_oss.ui.main.cases import android.content.Context import android.content.SharedPreferences import android.telephony.TelephonyManager import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.edit import androidx.core.os.LocaleListCompat import com.michaldrabik.common.Config import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.repository.RatingsRepository import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.showly_oss.BuildConfig import com.michaldrabik.ui_base.common.AppCountry import com.michaldrabik.ui_base.utilities.extensions.withApiAtLeast import com.michaldrabik.ui_settings.helpers.AppLanguage import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import timber.log.Timber import java.util.Locale import javax.inject.Inject import javax.inject.Named @ViewModelScoped class MainInitialsCase @Inject constructor( @ApplicationContext private val context: Context, private val userTraktManager: UserTraktManager, private val ratingsRepository: RatingsRepository, private val settingsRepository: SettingsRepository, @Named("miscPreferences") private var miscPreferences: SharedPreferences, ) { suspend fun setInitialRun(value: Boolean) { val settings = settingsRepository.load() settings.let { settingsRepository.update(it.copy(isInitialRun = value)) } } suspend fun isInitialRun(): Boolean { val settings = settingsRepository.load() return settings.isInitialRun } fun setInitialCountry() { var country = (context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager)?.simCountryIso if (country == null) { val locale = LocaleListCompat.getAdjustedDefault() country = if (locale.size() > 1) { locale.get(1)?.country } else { locale.get(0)?.country } } if (!country.isNullOrBlank()) { AppCountry.values().forEach { appCountry -> if (appCountry.code.equals(country, ignoreCase = true)) { settingsRepository.country = appCountry.code return } } } } suspend fun setInitialNotifications() { withApiAtLeast(33) { val settings = settingsRepository.load() settings.let { settingsRepository.update(it.copy(episodesNotificationsEnabled = false)) } } } fun setLanguage(appLanguage: AppLanguage) { settingsRepository.language = appLanguage.code val locales = LocaleListCompat.forLanguageTags(appLanguage.code) AppCompatDelegate.setApplicationLocales(locales) } fun checkInitialLanguage(): AppLanguage { val locales = LocaleListCompat.getAdjustedDefault() val appLanguages = AppLanguage.values() if (locales.size() == 1 && !locales[0]?.language.equals(Locale("en").language)) { appLanguages.forEach { appLanguage -> if (appLanguage.code.equals(locales[0]?.language, ignoreCase = true)) { return appLanguage } } } if (locales.size() > 1) { val languagesCodes = arrayOf(locales[0], locales[1]) .filterNotNull() .map { it.language.lowercase() } if (languagesCodes.any { it != Locale(Config.DEFAULT_LANGUAGE).language }) { val languageCodes = appLanguages.map { it.code } languagesCodes.forEach { language -> if (language in languageCodes) { return appLanguages.first { it.code == language } } } appLanguages .filter { it.code != Config.DEFAULT_LANGUAGE } .forEach { appLanguage -> if (appLanguage.code in languagesCodes) { return appLanguage } } } } return AppLanguage.ENGLISH } suspend fun preloadRatings() = supervisorScope { val errorHandler = CoroutineExceptionHandler { _, _ -> Timber.e("Failed to preload.") } if (!userTraktManager.isAuthorized()) { return@supervisorScope } userTraktManager.checkAuthorization() launch(errorHandler) { ratingsRepository.shows.preloadRatings() } if (settingsRepository.isMoviesEnabled) { launch(errorHandler) { ratingsRepository.movies.preloadRatings() } } } fun showWhatsNew(isInitialRun: Boolean): Boolean { val keyAppVersion = "APP_VERSION" val keyAppVersionName = "APP_VERSION_NAME" val version = miscPreferences.getInt(keyAppVersion, 0) val name = miscPreferences.getString(keyAppVersionName, "") fun isPatchUpdate(): Boolean { if (name.isNullOrBlank()) return false val major = name.split(".").getOrNull(0)?.toIntOrNull() val minor = name.split(".").getOrNull(1)?.toIntOrNull() val currentMajor = BuildConfig.VERSION_NAME.split(".").getOrNull(0)?.toIntOrNull() val currentMinor = BuildConfig.VERSION_NAME.split(".").getOrNull(1)?.toIntOrNull() if (major == currentMajor && minor == currentMinor) return true return false } miscPreferences.edit { putInt(keyAppVersion, BuildConfig.VERSION_CODE).apply() putString(keyAppVersionName, BuildConfig.VERSION_NAME).apply() } if (Config.SHOW_WHATS_NEW && BuildConfig.VERSION_CODE > version && BuildConfig.VERSION_NAME != name && !isInitialRun && !isPatchUpdate() ) { return true } return false } fun saveInstallTimestamp() { if (settingsRepository.installTimestamp == 0L) { settingsRepository.installTimestamp = nowUtcMillis() Timber.d("Installation timestamp saved: ${nowUtc()}") } } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/cases/MainModesCase.kt ================================================ package com.michaldrabik.showly_oss.ui.main.cases import com.michaldrabik.common.Mode import com.michaldrabik.common.Mode.MOVIES import com.michaldrabik.common.Mode.SHOWS import com.michaldrabik.repository.settings.SettingsRepository import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class MainModesCase @Inject constructor( private val settingsRepository: SettingsRepository, ) { fun setMode(mode: Mode) { settingsRepository.mode = mode } fun getMode(): Mode { val isMoviesEnabled = settingsRepository.isMoviesEnabled val isMovies = settingsRepository.mode == MOVIES return if (isMoviesEnabled && isMovies) MOVIES else SHOWS } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/cases/MainRateAppCase.kt ================================================ package com.michaldrabik.showly_oss.ui.main.cases import android.content.SharedPreferences import com.michaldrabik.common.extensions.nowUtcMillis import dagger.hilt.android.scopes.ViewModelScoped import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Named @ViewModelScoped class MainRateAppCase @Inject constructor( @Named("miscPreferences") private var miscPreferences: SharedPreferences ) { companion object { const val KEY_RATE_APP_COUNT = "KEY_RATE_APP_COUNT" const val KEY_RATE_APP_TIMESTAMP = "KEY_RATE_APP_TIMESTAMP" const val MAX_COUNT = 3 } fun shouldShowRateApp(): Boolean { val count = miscPreferences.getInt(KEY_RATE_APP_COUNT, 0) val timestamp = miscPreferences.getLong(KEY_RATE_APP_TIMESTAMP, -1) val isPastTwoWeeks = nowUtcMillis() - timestamp > TimeUnit.DAYS.toMillis(14) if (timestamp == -1L) { updateTimestamp(count) return false } if (count < MAX_COUNT && isPastTwoWeeks) { updateTimestamp(count) return true } return false } private fun updateTimestamp(count: Int) { miscPreferences.edit().apply { putInt(KEY_RATE_APP_COUNT, count) putLong(KEY_RATE_APP_TIMESTAMP, nowUtcMillis()) apply() } } fun complete() { val count = miscPreferences.getInt(KEY_RATE_APP_COUNT, 0) updateTimestamp(count + 1) } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/cases/MainSettingsCase.kt ================================================ package com.michaldrabik.showly_oss.ui.main.cases import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.showly_oss.BuildConfig import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class MainSettingsCase @Inject constructor( private val settingsRepository: SettingsRepository, ) { fun hasMoviesEnabled() = settingsRepository.isMoviesEnabled fun hasNewsEnabled(): Boolean { if (BuildConfig.DEBUG) return true return settingsRepository.isNewsEnabled && settingsRepository.isPremium } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/cases/MainTipsCase.kt ================================================ package com.michaldrabik.showly_oss.ui.main.cases import android.content.SharedPreferences import com.michaldrabik.common.Config import com.michaldrabik.showly_oss.BuildConfig import com.michaldrabik.ui_model.Tip import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject import javax.inject.Named @ViewModelScoped class MainTipsCase @Inject constructor( @Named("tipsPreferences") private val sharedPreferences: SharedPreferences ) { fun isTipShown(tip: Tip) = when { BuildConfig.DEBUG -> !Config.SHOW_TIPS_DEBUG else -> sharedPreferences.getBoolean(tip.name, false) } fun setTipShown(tip: Tip) { sharedPreferences.edit().putBoolean(tip.name, true).apply() } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/cases/MainTraktCase.kt ================================================ package com.michaldrabik.showly_oss.ui.main.cases import androidx.work.WorkManager import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.trakt.TraktSyncWorker import com.michaldrabik.ui_base.trakt.quicksync.QuickSyncManager import com.michaldrabik.ui_base.trakt.quicksync.QuickSyncWorker import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class MainTraktCase @Inject constructor( private val settingsRepository: SettingsRepository, private val quickSyncManager: QuickSyncManager, private val workManager: WorkManager, ) { suspend fun refreshTraktSyncSchedule() { if (!settingsRepository.isInitialized()) return val schedule = settingsRepository.load().traktSyncSchedule TraktSyncWorker.schedulePeriodic(workManager, schedule, cancelExisting = false) } suspend fun refreshTraktQuickSync() { if (quickSyncManager.isAnyScheduled()) { QuickSyncWorker.schedule(workManager) } } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/cases/deeplink/ImdbDeepLinkCase.kt ================================================ package com.michaldrabik.showly_oss.ui.main.cases.deeplink import com.michaldrabik.data_local.sources.MoviesLocalDataSource import com.michaldrabik.data_local.sources.ShowsLocalDataSource import com.michaldrabik.data_remote.trakt.TraktRemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.movies.MovieDetailsRepository import com.michaldrabik.repository.shows.ShowDetailsRepository import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkBundle import com.michaldrabik.ui_model.IdImdb import javax.inject.Inject class ImdbDeepLinkCase @Inject constructor( private val traktRemoteSource: TraktRemoteDataSource, private val showsLocalSource: ShowsLocalDataSource, private val moviesLocalSource: MoviesLocalDataSource, private val showDetailsRepository: ShowDetailsRepository, private val movieDetailsRepository: MovieDetailsRepository, private val mappers: Mappers ) { companion object { private const val SEARCH_ID_TYPE = "imdb" } suspend fun findById(imdbId: IdImdb): DeepLinkBundle { val show = showDetailsRepository.find(imdbId) if (show != null) { return DeepLinkBundle(show = show) } val movie = movieDetailsRepository.find(imdbId) if (movie != null) { return DeepLinkBundle(movie = movie) } val searchResult = traktRemoteSource.fetchSearchId(SEARCH_ID_TYPE, imdbId.id) if (searchResult.size == 1) { val showSearch = searchResult[0].show val movieSearch = searchResult[0].movie when { showSearch != null -> { val uiShow = mappers.show.fromNetwork(showSearch) showsLocalSource.upsert(listOf(mappers.show.toDatabase(uiShow))) return DeepLinkBundle(show = uiShow) } movieSearch != null -> { val uiMovie = mappers.movie.fromNetwork(movieSearch) moviesLocalSource.upsert(listOf(mappers.movie.toDatabase(uiMovie))) return DeepLinkBundle(movie = uiMovie) } } } return DeepLinkBundle.EMPTY } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/cases/deeplink/MainDeepLinksCase.kt ================================================ package com.michaldrabik.showly_oss.ui.main.cases.deeplink import com.michaldrabik.ui_model.IdImdb import com.michaldrabik.ui_model.IdSlug import com.michaldrabik.ui_model.IdTmdb import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class MainDeepLinksCase @Inject constructor( private val imdbDeepLinkCase: ImdbDeepLinkCase, private val tmdbDeepLinkCase: TmdbDeepLinkCase, private val traktDeepLinkCase: TraktDeepLinkCase, ) { suspend fun findById(imdbId: IdImdb) = imdbDeepLinkCase.findById(imdbId) suspend fun findById(tmdbId: IdTmdb, type: String) = tmdbDeepLinkCase.findById(tmdbId, type) suspend fun findById(traktSlug: IdSlug, type: String) = traktDeepLinkCase.findById(traktSlug, type) } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/cases/deeplink/TmdbDeepLinkCase.kt ================================================ package com.michaldrabik.showly_oss.ui.main.cases.deeplink import com.michaldrabik.data_local.sources.MoviesLocalDataSource import com.michaldrabik.data_local.sources.ShowsLocalDataSource import com.michaldrabik.data_remote.trakt.TraktRemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.movies.MovieDetailsRepository import com.michaldrabik.repository.shows.ShowDetailsRepository import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkBundle import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkResolver.Companion.TMDB_TYPE_MOVIE import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkResolver.Companion.TMDB_TYPE_TV import com.michaldrabik.ui_model.IdTmdb import javax.inject.Inject class TmdbDeepLinkCase @Inject constructor( private val traktRemoteSource: TraktRemoteDataSource, private val showsLocalSource: ShowsLocalDataSource, private val moviesLocalSource: MoviesLocalDataSource, private val showDetailsRepository: ShowDetailsRepository, private val movieDetailsRepository: MovieDetailsRepository, private val mappers: Mappers ) { companion object { private const val SEARCH_ID_TYPE = "tmdb" } suspend fun findById(tmdbId: IdTmdb, type: String): DeepLinkBundle { val localShow = showDetailsRepository.find(tmdbId) if (localShow != null && type == TMDB_TYPE_TV) { return DeepLinkBundle(show = localShow) } val localMovie = movieDetailsRepository.find(tmdbId) if (localMovie != null && type == TMDB_TYPE_MOVIE) { return DeepLinkBundle(movie = localMovie) } val searchResult = traktRemoteSource.fetchSearchId(SEARCH_ID_TYPE, tmdbId.id.toString()) if (searchResult.isNotEmpty()) { searchResult .filter { it.show != null || it.movie != null } .forEach { result -> val show = result.show val movie = result.movie if (show != null && type == TMDB_TYPE_TV) { val uiShow = mappers.show.fromNetwork(show) showsLocalSource.upsert(listOf(mappers.show.toDatabase(uiShow))) return DeepLinkBundle(show = uiShow) } if (movie != null && type == TMDB_TYPE_MOVIE) { val uiMovie = mappers.movie.fromNetwork(movie) moviesLocalSource.upsert(listOf(mappers.movie.toDatabase(uiMovie))) return DeepLinkBundle(movie = uiMovie) } } } return DeepLinkBundle.EMPTY } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/cases/deeplink/TraktDeepLinkCase.kt ================================================ package com.michaldrabik.showly_oss.ui.main.cases.deeplink import com.michaldrabik.data_local.sources.MoviesLocalDataSource import com.michaldrabik.data_local.sources.ShowsLocalDataSource import com.michaldrabik.data_remote.trakt.TraktRemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.movies.MovieDetailsRepository import com.michaldrabik.repository.shows.ShowDetailsRepository import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkBundle import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkResolver.Companion.TRAKT_TYPE_MOVIE import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkResolver.Companion.TRAKT_TYPE_TV import com.michaldrabik.ui_model.IdSlug import javax.inject.Inject class TraktDeepLinkCase @Inject constructor( private val traktRemoteSource: TraktRemoteDataSource, private val showsLocalSource: ShowsLocalDataSource, private val moviesLocalSource: MoviesLocalDataSource, private val showDetailsRepository: ShowDetailsRepository, private val movieDetailsRepository: MovieDetailsRepository, private val mappers: Mappers ) { suspend fun findById(traktSlug: IdSlug, type: String) = when (type) { TRAKT_TYPE_TV -> { val localShow = showDetailsRepository.find(traktSlug) if (localShow != null) { DeepLinkBundle(show = localShow) } try { val show = traktRemoteSource.fetchShow(traktSlug.id) val uiShow = mappers.show.fromNetwork(show) showsLocalSource.upsert(listOf(mappers.show.toDatabase(uiShow))) DeepLinkBundle(show = uiShow) } catch (error: Throwable) { DeepLinkBundle.EMPTY } } TRAKT_TYPE_MOVIE -> { val localMovie = movieDetailsRepository.find(traktSlug) if (localMovie != null) { DeepLinkBundle(movie = localMovie) } try { val movie = traktRemoteSource.fetchMovie(traktSlug.id) val uiMovie = mappers.movie.fromNetwork(movie) moviesLocalSource.upsert(listOf(mappers.movie.toDatabase(uiMovie))) DeepLinkBundle(movie = uiMovie) } catch (error: Throwable) { DeepLinkBundle.EMPTY } } else -> DeepLinkBundle.EMPTY } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/main/delegates/TipsDelegate.kt ================================================ package com.michaldrabik.showly_oss.ui.main.delegates import androidx.lifecycle.DefaultLifecycleObserver import com.michaldrabik.showly_oss.databinding.ActivityMainBinding import com.michaldrabik.showly_oss.ui.main.MainViewModel import com.michaldrabik.ui_base.utilities.TipsHost import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_model.Tip interface TipsDelegate : TipsHost { fun registerTipsDelegate( viewModel: MainViewModel, binding: ActivityMainBinding, ) fun showAllTips() fun hideAllTips() } class MainTipsDelegate : TipsDelegate, DefaultLifecycleObserver { private lateinit var viewModel: MainViewModel private lateinit var binding: ActivityMainBinding private val tips by lazy { mapOf( Tip.MENU_DISCOVER to binding.tutorialTipDiscover, Tip.MENU_MY_SHOWS to binding.tutorialTipMyShows, Tip.MENU_MODES to binding.tutorialTipModeMenu ) } override fun registerTipsDelegate( viewModel: MainViewModel, binding: ActivityMainBinding, ) { this.viewModel = viewModel this.binding = binding setupTips() } private fun setupTips() { tips.entries.forEach { (tip, view) -> view.visibleIf(!isTipShown(tip)) view.onClick { it.gone() showTip(tip) } } } override fun setTipShow(tip: Tip) = viewModel.setTipShown(tip) override fun isTipShown(tip: Tip) = viewModel.isTipShown(tip) override fun showTip(tip: Tip) { binding.tutorialView.showTip(tip) setTipShow(tip) } override fun showAllTips() { tips.entries.forEach { (tip, view) -> view.visibleIf(!isTipShown(tip)) } } override fun hideAllTips() { tips.values.forEach { it.gone() } } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/views/BottomMenuView.kt ================================================ package com.michaldrabik.showly_oss.ui.views import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.MotionEvent import android.view.MotionEvent.ACTION_CANCEL import android.view.MotionEvent.ACTION_DOWN import android.view.MotionEvent.ACTION_MOVE import android.view.MotionEvent.ACTION_UP import android.view.ViewGroup import android.view.ViewPropertyAnimator import android.widget.FrameLayout import androidx.core.view.children import com.google.android.material.bottomnavigation.BottomNavigationItemView import com.michaldrabik.common.Mode import com.michaldrabik.showly_oss.R import com.michaldrabik.showly_oss.databinding.ViewBottomMenuBinding import com.michaldrabik.ui_base.utilities.extensions.add import com.michaldrabik.ui_base.utilities.extensions.colorFromAttr import com.michaldrabik.ui_base.utilities.extensions.fadeIn import com.michaldrabik.ui_base.utilities.extensions.fadeOut import com.michaldrabik.ui_base.utilities.extensions.screenWidth import com.michaldrabik.ui_base.utilities.extensions.visible import kotlin.math.abs class BottomMenuView : FrameLayout { companion object { private const val SWIPE_MIN_THRESHOLD = 150F private const val FADE_DELAY = 150L } constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) val binding = ViewBottomMenuBinding.inflate(LayoutInflater.from(context), this) var isModeMenuEnabled = true var onModeSelected: ((Mode) -> Unit)? = null private val screenWidth by lazy { screenWidth() } private val itemIdleColor by lazy { context.colorFromAttr(R.attr.colorBottomMenuItem) } private val itemSelectedColor by lazy { context.colorFromAttr(R.attr.colorBottomMenuItemChecked) } private val animations = mutableListOf() private var touchX = 0F private var isModeMenu = false override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { if (!isModeMenuEnabled) return super.onInterceptTouchEvent(ev) when (ev?.actionMasked) { ACTION_DOWN -> { touchX = ev.x isModeMenu = false disableTooltips() } ACTION_MOVE -> { val delta = ev.x - touchX if (!isModeMenu && abs(delta) > SWIPE_MIN_THRESHOLD) { isModeMenu = true showModeMenu() } with(binding) { if (isModeMenu) { if (ev.x > screenWidth / 2) { bottomMenuModeShows.setTextColor(itemIdleColor) bottomMenuModeMovies.setTextColor(itemSelectedColor) } else { bottomMenuModeShows.setTextColor(itemSelectedColor) bottomMenuModeMovies.setTextColor(itemIdleColor) } } } } ACTION_UP, ACTION_CANCEL -> { if (isModeMenu) { hideModeMenu() isModeMenu = false when { ev.x > screenWidth / 2 -> onModeSelected?.invoke(Mode.MOVIES) else -> onModeSelected?.invoke(Mode.SHOWS) } } } } return super.onInterceptTouchEvent(ev) } private fun showModeMenu() { with(binding) { bottomMenuModeShows.setTextColor(itemIdleColor) bottomMenuModeMovies.setTextColor(itemIdleColor) bottomNavigationView.fadeOut(FADE_DELAY).add(animations) bottomMenuModeLayout.fadeIn(FADE_DELAY).add(animations) } } private fun hideModeMenu() { with(binding) { with(animations) { forEach { it?.setListener(object : AnimatorListenerAdapter() { override fun onAnimationCancel(animation: Animator) { bottomNavigationView.visible() bottomNavigationView.alpha = 1F } }) it?.cancel() } clear() } bottomNavigationView.fadeIn(FADE_DELAY).add(animations) bottomMenuModeLayout.fadeOut(FADE_DELAY).add(animations) } } private fun disableTooltips() { val content = binding.bottomNavigationView.getChildAt(0) if (content is ViewGroup) { content.children.forEach { if (it is BottomNavigationItemView) { it.setOnLongClickListener(null) } } } } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/views/WelcomeLanguageView.kt ================================================ package com.michaldrabik.showly_oss.ui.views import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import com.michaldrabik.showly_oss.R import com.michaldrabik.showly_oss.databinding.ViewWelcomeLanguageBinding import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_settings.helpers.AppLanguage class WelcomeLanguageView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewWelcomeLanguageBinding.inflate(LayoutInflater.from(context), this) var onYesClick: (() -> Unit)? = null var onNoClick: (() -> Unit)? = null init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) with(binding) { viewWelcomeLanguageYesButton.onClick { onYesClick?.invoke() } viewWelcomeLanguageLeaveButton.onClick { onNoClick?.invoke() } } } @SuppressLint("SetTextI18n") fun setLanguage(appLanguage: AppLanguage) { // This text will always be English. binding.viewWelcomeLanguageMessage.text = "It seems like your device\'s language is ${appLanguage.displayNameRaw}.\n" + "Would you like to use it in Showly app?" } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/views/WelcomeNoteView.kt ================================================ package com.michaldrabik.showly_oss.ui.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import com.michaldrabik.showly_oss.R import com.michaldrabik.showly_oss.databinding.ViewWelcomeNoteBinding import com.michaldrabik.ui_base.utilities.extensions.getLocaleStringResource import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_settings.helpers.AppLanguage import java.util.Locale class WelcomeNoteView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewWelcomeNoteBinding.inflate(LayoutInflater.from(context), this) var onOkClickListener: (() -> Unit)? = null init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) binding.viewWelcomeNoteYesButton.onClick { onOkClickListener?.invoke() } } fun setLanguage(language: AppLanguage) { if (language == AppLanguage.ENGLISH) return val locale = Locale(language.code) with(binding) { viewWelcomeNoteTitle.text = context.getLocaleStringResource(locale, R.string.textDisclaimerTitle) viewWelcomeNoteMessage.text = context.getLocaleStringResource(locale, R.string.textDisclaimerText) viewWelcomeNoteYesButton.text = context.getLocaleStringResource(locale, R.string.textDisclaimerConfirmText) } } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/ui/views/WhatsNewView.kt ================================================ package com.michaldrabik.showly_oss.ui.views import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.ScrollView import com.michaldrabik.showly_oss.BuildConfig import com.michaldrabik.showly_oss.R import com.michaldrabik.showly_oss.databinding.ViewWhatsNewBinding @SuppressLint("SetTextI18n") class WhatsNewView : ScrollView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewWhatsNewBinding.inflate(LayoutInflater.from(context), this) init { layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) val whatsNew = context.assets.open("release_notes.txt") .bufferedReader() .use { it.readText() } with(binding) { viewWhatsNewMessage.text = whatsNew viewWhatsNewSubtitle.text = context.getString(R.string.textWhatsNewSubtitle, BuildConfig.VERSION_NAME) } } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/utilities/deeplink/DeepLinkBundle.kt ================================================ package com.michaldrabik.showly_oss.utilities.deeplink import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Show data class DeepLinkBundle( val show: Show? = null, val movie: Movie? = null ) { companion object { val EMPTY = DeepLinkBundle() } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/utilities/deeplink/DeepLinkResolver.kt ================================================ package com.michaldrabik.showly_oss.utilities.deeplink import android.content.Intent import androidx.core.os.bundleOf import androidx.navigation.NavController import com.google.android.material.bottomnavigation.BottomNavigationView import com.michaldrabik.showly_oss.R import com.michaldrabik.showly_oss.utilities.deeplink.resolvers.ImdbSourceResolver import com.michaldrabik.showly_oss.utilities.deeplink.resolvers.TmdbSourceResolver import com.michaldrabik.showly_oss.utilities.deeplink.resolvers.TraktSourceResolver import com.michaldrabik.ui_base.Logger import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_navigation.java.NavigationArgs import javax.inject.Inject import javax.inject.Singleton @Singleton class DeepLinkResolver @Inject constructor() { companion object { const val TMDB_TYPE_TV = "tv" const val TMDB_TYPE_MOVIE = "movie" const val TRAKT_TYPE_TV = "shows" const val TRAKT_TYPE_MOVIE = "movies" } private val sourceResolvers = setOf( TraktSourceResolver(), ImdbSourceResolver(), TmdbSourceResolver() ) private val progressDestinations = arrayOf( R.id.progressMainFragment, R.id.progressMoviesMainFragment, ) private val mainDestinations = arrayOf( *progressDestinations, R.id.discoverFragment, R.id.discoverMoviesFragment, R.id.followedShowsFragment, R.id.followedMoviesFragment, R.id.listsFragment, R.id.newsFragment, ) fun findSource(intent: Intent?): DeepLinkSource? { val path = intent?.data?.pathSegments ?: emptyList() return sourceResolvers.firstNotNullOfOrNull { it.resolve(path) } } fun resolveDestination( navController: NavController, navigationView: BottomNavigationView, show: Show, ) { try { resetNavigation(navController, navigationView) val navBundle = bundleOf(NavigationArgs.ARG_SHOW_ID to show.traktId) val actionId = when (navController.currentDestination?.id) { R.id.progressMainFragment -> R.id.actionProgressFragmentToShowDetailsFragment R.id.progressMoviesMainFragment -> R.id.actionProgressMoviesFragmentToShowDetailsFragment else -> error("Unknown actionId. ActionId: ${navController.currentDestination?.id}") } navController.navigate(actionId, navBundle) } catch (error: Throwable) { Logger.record(error, "DeepLinkResolver::resolveDestination(show:${show.traktId})") } } fun resolveDestination( navController: NavController, navigationView: BottomNavigationView, movie: Movie, ) { try { resetNavigation(navController, navigationView) val navBundle = bundleOf(NavigationArgs.ARG_MOVIE_ID to movie.traktId) val actionId = when (navController.currentDestination?.id) { R.id.progressMainFragment -> R.id.actionProgressFragmentToMovieDetailsFragment R.id.progressMoviesMainFragment -> R.id.actionProgressMoviesFragmentToMovieDetailsFragment else -> error("Unknown actionId. ActionId: $navController.currentDestination?.id") } navController.navigate(actionId, navBundle) } catch (error: Throwable) { Logger.record(error, "DeepLinkResolver::resolveDestination(movie:${movie.traktId})") } } private fun resetNavigation(navController: NavController, navigationView: BottomNavigationView) { while (navController.currentDestination?.id !in mainDestinations) { navController.popBackStack() } if (navController.currentDestination?.id !in progressDestinations) { navigationView.selectedItemId = R.id.menuProgress } } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/utilities/deeplink/DeepLinkSource.kt ================================================ package com.michaldrabik.showly_oss.utilities.deeplink import com.michaldrabik.ui_model.IdImdb import com.michaldrabik.ui_model.IdSlug import com.michaldrabik.ui_model.IdTmdb sealed class DeepLinkSource { data class ImdbSource(val id: IdImdb) : DeepLinkSource() data class TmdbSource(val id: IdTmdb, val type: String) : DeepLinkSource() data class TraktSource(val id: IdSlug, val type: String) : DeepLinkSource() } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/utilities/deeplink/resolvers/ImdbSourceResolver.kt ================================================ package com.michaldrabik.showly_oss.utilities.deeplink.resolvers import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkSource import com.michaldrabik.ui_model.IdImdb class ImdbSourceResolver : SourceResolver { override fun resolve(linkPath: List): DeepLinkSource? { if (linkPath.size < 2 || !linkPath[1].startsWith("tt") || linkPath[1].length <= 2) { return null } return DeepLinkSource.ImdbSource(IdImdb(linkPath[1])) } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/utilities/deeplink/resolvers/SourceResolver.kt ================================================ package com.michaldrabik.showly_oss.utilities.deeplink.resolvers import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkSource interface SourceResolver { fun resolve(linkPath: List): DeepLinkSource? } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/utilities/deeplink/resolvers/TmdbSourceResolver.kt ================================================ package com.michaldrabik.showly_oss.utilities.deeplink.resolvers import androidx.core.text.isDigitsOnly import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkResolver import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkSource import com.michaldrabik.ui_model.IdTmdb class TmdbSourceResolver : SourceResolver { override fun resolve(linkPath: List): DeepLinkSource? { if (linkPath.size < 2 || !(linkPath[0] == DeepLinkResolver.TMDB_TYPE_TV || linkPath[0] == DeepLinkResolver.TMDB_TYPE_MOVIE) || linkPath[1].length <= 1 ) { return null } val id = linkPath[1].substringBefore("-").trim() val type = linkPath[0] return if (id.isDigitsOnly()) { DeepLinkSource.TmdbSource(IdTmdb(id.toLong()), type) } else { null } } } ================================================ FILE: app/src/main/java/com/michaldrabik/showly_oss/utilities/deeplink/resolvers/TraktSourceResolver.kt ================================================ package com.michaldrabik.showly_oss.utilities.deeplink.resolvers import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkResolver import com.michaldrabik.showly_oss.utilities.deeplink.DeepLinkSource import com.michaldrabik.ui_model.IdSlug class TraktSourceResolver : SourceResolver { override fun resolve(linkPath: List): DeepLinkSource? { if (linkPath.size < 2 || !(linkPath[0] == DeepLinkResolver.TRAKT_TYPE_TV || linkPath[0] == DeepLinkResolver.TRAKT_TYPE_MOVIE)) { return null } return DeepLinkSource.TraktSource(IdSlug(linkPath[1]), linkPath[0]) } } ================================================ FILE: app/src/main/play/release-notes/en-GB/default.txt ================================================ * Fix issue where actors list of shows and movies would not return to its scrolled position * Other bugfixes ================================================ FILE: app/src/main/res/drawable/bg_dialog.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_eye_off.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_languages.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_mono.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_bottom_menu.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: app/src/main/res/layout/view_bottom_menu.xml ================================================ ================================================ FILE: app/src/main/res/layout/view_welcome_language.xml ================================================ ================================================ FILE: app/src/main/res/layout/view_welcome_note.xml ================================================ ================================================ FILE: app/src/main/res/layout/view_whats_new.xml ================================================ ================================================ FILE: app/src/main/res/menu/bottom_navigation_menu.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/main/res/values/dimens.xml ================================================ @dimen/spaceBig 0.2 0.55 0.925 ================================================ FILE: app/src/main/res/values/ic_launcher_background.xml ================================================ #27282C ================================================ FILE: app/src/main/res/values/strings.xml ================================================ Showly OSS Progress Discover Collection News Enjoying Showly 2.0? Would you like to rate the app?\nIt only takes a few seconds. Thanks!\n\nThe app will not be closed. Sure, why not? Not now Welcome! Please note that you CAN\'T watch, stream or download shows and movies with this app.\n\nUse apps like Netflix to stream and watch content. OK, I understand Language can always be changed later via Settings. No, I prefer English Yes What\'s New Version %s Discover Collection Search Progress Update is now available. Install ================================================ FILE: app/src/main/res/values-ar/strings.xml ================================================ مستوى التقدم اكتشف المجموعة الأخبار هل تستمتع باستخدام Showly 2.0 ؟ أترغب بِتقييم التطبيق؟\nلن يستغرق التقييم سِوى ثوانٍ قليلة مِن وقتك. شكرًا!\n\nلن يُغلق التطبيق. بالطبع، لما لا؟ ليس الآن أهلًا بك! يرجى مُلاحظة أن هذا التطبيق غير مُخصص لِمُشاهدة أو تحميل الأفلام والمُسلسلات.\n\nيُمكنك اِستخدام تطبيقات مثل Netflix لهذه الأمور. حسنًا، فهمت ما الجديد الإصدار %s إكتشف المجموعة البحث مستوى التقدم ================================================ FILE: app/src/main/res/values-de/strings.xml ================================================ Fortschritt Entdecken Sammlung News Gefallt dir Showly 2.0? Möchtest du die App bewerten?\nEs dauert nur wenige Sekunden. Danke!\n\nDie App wird dabei nicht geschlossen. Sicher, wieso nicht? Nicht jetzt Willkommen! Bitte beachte, dass du mit dieser App keine Serien und Filme ansehen, streamen oder herunterladen kannst.\n\nVerwende Apps wie Netflix, um Inhalte zu streamen und anzusehen. OK, ich verstehe Was gibts Neues? Version %s Entdecken Sammlung Suche Fortschritt ================================================ FILE: app/src/main/res/values-es/strings.xml ================================================ Progreso Descubrir Colección Noticias ¿Disfrutando Showly 2.0? ¿Le gustaría calificar la aplicación?\nSolo toma unos segundos. ¡Gracias!\n\nLa aplicación no se cerrará. Sí, ¿por qué no? Ahora no Bienvenido! Ten en cuenta que NO PUEDES ver, transmitir o descargar series y películas con esta aplicación.\n\nUsa aplicaciones como Netflix para transmitir y ver el contenido. Vale, lo entiendo Novedades Versión %s Descubrir Colección Buscar Progreso ================================================ FILE: app/src/main/res/values-fi/strings.xml ================================================ Katselutila Etsi Kokoelma Uutiset Nautitko Showly 2.0:sta? Haluaisitko arvioida sovelluksen?\nTämä kestää vain muutaman sekunnin. Kiitos!\n\nSovellusta ei suljeta. Toki, miksipä en? En nyt Tervetuloa! Huomioi, että tällä sovelluksella ET VOI katsella, suoratoistaa tai ladata sarjoja ja elokuvia.\n\nKäytä suoratoistoon ja katseluun Netflixin kaltaisia sovelluksia. OK, ymmärrän Mitä uutta Versio %s Etsi Kokoelma Haku Katselutila ================================================ FILE: app/src/main/res/values-fr/strings.xml ================================================ Progression Découvrir Collection Actualités Aimez-vous Showly 2.0 ? Souhaitez-vous noter l\'application ?\nCela ne prendrait que quelques secondes. Merci !\n\nL\'application ne se fermera pas. Bien sûr, pourquoi pas ? Pas maintenant Bienvenue ! Veuillez prendre note que vous NE POUVEZ PAS regarder ou télécharger des séries ou des films avec cette application.\n\nUtilisez des applications telles que Netflix pour regarder du contenu. Compris Quoi de neuf Version %s Découvrir Collection Recherche Progression ================================================ FILE: app/src/main/res/values-it/strings.xml ================================================ Progressi Scopri Raccolta Notizie Ti piace Showly 2.0? Ti piacerebbe valutare questa app?\nCi vorranno solo pochi secondi. Grazie!\n\nL\'app non verrà chiusa. Certo, perché no? Non ora Benvenuto/a! Si prega di notare che NON È POSSIBILE guardare, riprodurre o scaricare show e film con questa app.\n\nUsa app come Netflix per riprodurre e guardare i contenuti. OK, ho capito Novità Versione %s Scopri Raccolta Cerca Progressi ================================================ FILE: app/src/main/res/values-pl/strings.xml ================================================ Postęp Odkrywaj Kolekcja Newsy Podoba Ci się Showly 2.0? Czy chciałbyś ocenić aplikację?\nZajmie to tylko kilka sekund. Dziękuję!\n\nAplikacja pozostanie otwarta. Jasne, czemu nie? Nie teraz Witaj! Pamiętaj, że NIE MOŻESZ oglądać, streamować lub pobierać seriali i filmów z pomocą tej aplikacji.\n\nMożesz użyć aplikacji takich jak Netflix aby streamować i oglądać treści. OK, rozumiem Co Nowego? Wersja %s Odkrywaj Kolekcja Szukaj Postęp ================================================ FILE: app/src/main/res/values-pt/strings.xml ================================================ Progresso Explorar Coleção Notícias Curtindo o Showly 2.0? Gostaria de avaliar o aplicativo?\nLeva apenas alguns segundos. Obrigado!\n\nO aplicativo não será fechado. Claro, por que não? Agora não Bem-vindo! Observe que você não pode assistir, transmitir ou baixar programas e filmes com este aplicativo.\n\nUse aplicativos como o Netflix para transmitir e assistir conteúdo. OK, eu entendo O que há de novo Versão %s Explorar Coleção Pesquisar Progresso ================================================ FILE: app/src/main/res/values-ru/strings.xml ================================================ Прогресс Открытия Коллекция Новости Вам нравится Showly 2.0? Вы хотите оценить приложение?\nЭто займёт всего несколько секунд. Спасибо!\n\nПриложение не будет закрыто. Конечно, почему нет? Позже Добро пожаловать! Обратите внимание, что приложение НЕ ПРЕДНАЗНАЧЕНО для просмотра или скачивания сериалов и фильмов.\n\nИспользуйте приложения по типу Netflix, чтобы скачивать и просматривать контент. Ок, я понял Что нового Версия %s Открытия Коллекция Поиск Прогресс ================================================ FILE: app/src/main/res/values-sw600dp/dimens.xml ================================================ 112dp 0.32 0.55 0.77 ================================================ FILE: app/src/main/res/values-tr/strings.xml ================================================ İlerleme Keşfet Koleksiyon Haberler Showly 2.0 uygulamasından memnun musunuz? Uygulamayı derecelendirmek ister misiniz?\nSadece birkaç saniye sürer. Teşekkürler!\n\n Uygulama kapatılmayacaktır. Tabii ki, neden olmasın? Şimdi değil Hoş geldiniz! Lütfen bu uygulama ile dizileri ve filmleri izleyemeyeceğinizi veya indiremeyeceğinizi unutmayın!\n\n İçerikleri izlemek için Netflix gibi uygulamaları kullanın. Tamam, anladım Neler Yeni %s Sürümü Keşfet Koleksiyon Ara İlerleme ================================================ FILE: app/src/main/res/values-uk/strings.xml ================================================ Прогрес Огляд Колекція Новини Подобається Showly 2.0? Ви хотіли б оцінити додаток?\nЦе займає лише декілька секунд. Дякуємо!\n\nДодаток не буде закрито. Авжеж, чому ні? Не зараз Вітаємо! Будь ласка, зверніть увагу, що ви НЕ МОЖЕТЕ дивитися або завантажувати серіали та фільми з цим додатком.\n\nВикористовуйте додатки, такі як Netflix для завантаження та перегляду вмісту. ОК, зрозуміло Що нового Версія: %s Огляд Колекція Пошук Прогрес ================================================ FILE: app/src/main/res/values-vi/strings.xml ================================================ Showly OSS Tiến độ Khám phá Bộ sưu tập Tin tức Bạn có thích Showly 2.0 không? Bạn có muốn xếp hạng ứng dụng không?\nViệc này chỉ mất vài giây. Cảm ơn!\n\nỨng dụng sẽ không bị đóng. Chắc chắn rồi, tại sao không? Không phải bây giờ Chào mừng! Xin lưu ý rằng bạn KHÔNG THỂ xem, phát trực tuyến hoặc tải xuống các chương trình và phim bằng ứng dụng này.\n\nSử dụng các ứng dụng như Netflix để phát trực tuyến và xem nội dung. Được rồi, tôi hiểu Có gì mới Phiên bản %s Khám phá Bộ sưu tập Tìm kiếm Tiến độ Hiện đã có bản cập nhật. Cài đặt ================================================ FILE: app/src/main/res/values-zh/strings.xml ================================================ 进度 发现 合集 新闻 喜欢 Showly 2.0 吗? 您愿意给应用评分吗?\n仅需几秒,感谢!\n\n应用不会被关闭。 好,没问题。 稍后 欢迎! 请注意您 不能 使用此应用观看或下载剧集和电影。\n\n使用 Netflix 等应用来观看。 好的,我已了解 更新内容 版本:%s 发现 合集 搜索 进度 ================================================ FILE: app/src/main/res/xml/locales_config.xml ================================================ ================================================ FILE: app/src/main/res/xml/shortcuts.xml ================================================ ================================================ FILE: app/src/test/java/BaseMockTest.kt ================================================ import io.mockk.MockKAnnotations import io.mockk.mockkStatic import org.junit.Before @Suppress("EXPERIMENTAL_API_USAGE") abstract class BaseMockTest { @Before open fun setUp() { MockKAnnotations.init(this) mockkStatic("androidx.room.RoomDatabaseKt") } } ================================================ FILE: app/src/test/java/com/michaldrabik/showly_oss/ui/main/cases/MainRateAppCaseTest.kt ================================================ package com.michaldrabik.showly_oss.ui.main.cases import BaseMockTest import android.content.SharedPreferences import com.google.common.truth.Truth.assertThat import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.showly_oss.ui.main.cases.MainRateAppCase.Companion.KEY_RATE_APP_COUNT import com.michaldrabik.showly_oss.ui.main.cases.MainRateAppCase.Companion.KEY_RATE_APP_TIMESTAMP import io.mockk.Runs import io.mockk.confirmVerified import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just import io.mockk.verify import org.junit.Before import org.junit.Test import java.util.concurrent.TimeUnit class MainRateAppCaseTest : BaseMockTest() { @MockK lateinit var sharedPreferences: SharedPreferences @MockK lateinit var sharedPreferencesEditor: SharedPreferences.Editor private lateinit var SUT: MainRateAppCase @Before override fun setUp() { super.setUp() every { sharedPreferencesEditor.apply() } just Runs every { sharedPreferencesEditor.putInt(any(), any()) } returns sharedPreferencesEditor every { sharedPreferencesEditor.putLong(any(), any()) } returns sharedPreferencesEditor every { sharedPreferences.edit() } returns sharedPreferencesEditor SUT = MainRateAppCase(sharedPreferences) } @Test fun `Should return false if check is initial`() { every { sharedPreferences.getInt(KEY_RATE_APP_COUNT, any()) } returns 0 every { sharedPreferences.getLong(KEY_RATE_APP_TIMESTAMP, any()) } returns -1L val result = SUT.shouldShowRateApp() assertThat(result).isFalse() verify { sharedPreferencesEditor.putInt(KEY_RATE_APP_COUNT, 0) } verify { sharedPreferencesEditor.putLong(KEY_RATE_APP_TIMESTAMP, any()) } verify { sharedPreferencesEditor.apply() } confirmVerified(sharedPreferencesEditor) } @Test fun `Should return false if not enough days have passed`() { every { sharedPreferences.getInt(KEY_RATE_APP_COUNT, any()) } returns 1 every { sharedPreferences.getLong(KEY_RATE_APP_TIMESTAMP, any()) } returns (nowUtcMillis() - TimeUnit.DAYS.toMillis(5)) val result = SUT.shouldShowRateApp() assertThat(result).isFalse() verify(exactly = 0) { sharedPreferencesEditor.putInt(KEY_RATE_APP_COUNT, 2) } verify(exactly = 0) { sharedPreferencesEditor.putLong(KEY_RATE_APP_TIMESTAMP, any()) } verify(exactly = 0) { sharedPreferencesEditor.apply() } confirmVerified(sharedPreferencesEditor) } @Test fun `Should return false if not enough days have passed before last reminder`() { every { sharedPreferences.getInt(KEY_RATE_APP_COUNT, any()) } returns 2 every { sharedPreferences.getLong(KEY_RATE_APP_TIMESTAMP, any()) } returns (nowUtcMillis() - TimeUnit.DAYS.toMillis(12)) val result = SUT.shouldShowRateApp() assertThat(result).isFalse() verify(exactly = 0) { sharedPreferencesEditor.putInt(KEY_RATE_APP_COUNT, 3) } verify(exactly = 0) { sharedPreferencesEditor.putLong(KEY_RATE_APP_TIMESTAMP, any()) } verify(exactly = 0) { sharedPreferencesEditor.apply() } confirmVerified(sharedPreferencesEditor) } @Test fun `Should return true if enough days have passed before another reminder`() { every { sharedPreferences.getInt(KEY_RATE_APP_COUNT, any()) } returns 2 every { sharedPreferences.getLong(KEY_RATE_APP_TIMESTAMP, any()) } returns (nowUtcMillis() - TimeUnit.DAYS.toMillis(15)) val result = SUT.shouldShowRateApp() assertThat(result).isTrue() verify(exactly = 1) { sharedPreferencesEditor.putInt(KEY_RATE_APP_COUNT, 2) } verify(exactly = 1) { sharedPreferencesEditor.putLong(KEY_RATE_APP_TIMESTAMP, any()) } verify(exactly = 1) { sharedPreferencesEditor.apply() } confirmVerified(sharedPreferencesEditor) } // @Test // fun `Should return true if count is one before last one and 10 days passed`() { // every { sharedPreferences.getInt(KEY_RATE_APP_COUNT, any()) } returns 1 // every { sharedPreferences.getLong(KEY_RATE_APP_TIMESTAMP, any()) } returns (nowUtcMillis() - TimeUnit.DAYS.toMillis(11)) // // val result = SUT.shouldShowRateApp() // // assertThat(result).isTrue() // verify { sharedPreferencesEditor.putInt(KEY_RATE_APP_COUNT, 2) } // verify { sharedPreferencesEditor.putLong(KEY_RATE_APP_TIMESTAMP, any()) } // verify { sharedPreferencesEditor.apply() } // // confirmVerified(sharedPreferencesEditor) // } // // @Test // fun `Should return true if count is last one and 14 days passed`() { // every { sharedPreferences.getInt(KEY_RATE_APP_COUNT, any()) } returns 2 // every { sharedPreferences.getLong(KEY_RATE_APP_TIMESTAMP, any()) } returns (nowUtcMillis() - TimeUnit.DAYS.toMillis(15)) // // val result = SUT.shouldShowRateApp() // // assertThat(result).isTrue() // verify { sharedPreferencesEditor.putInt(KEY_RATE_APP_COUNT, 3) } // verify { sharedPreferencesEditor.putLong(KEY_RATE_APP_TIMESTAMP, any()) } // verify { sharedPreferencesEditor.apply() } // confirmVerified(sharedPreferencesEditor) // } } ================================================ FILE: app/src/test/java/com/michaldrabik/showly_oss/ui/main/cases/MainTipsCaseTest.kt ================================================ package com.michaldrabik.showly_oss.ui.main.cases import BaseMockTest import android.content.SharedPreferences import com.google.common.truth.Truth.assertThat import com.michaldrabik.showly_oss.BuildConfig import com.michaldrabik.ui_model.Tip import io.mockk.Runs import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just import io.mockk.verifyAll import org.junit.Before import org.junit.Test class MainTipsCaseTest : BaseMockTest() { @MockK lateinit var sharedPreferences: SharedPreferences @MockK lateinit var sharedPreferencesEditor: SharedPreferences.Editor private lateinit var SUT: MainTipsCase @Before override fun setUp() { super.setUp() every { sharedPreferencesEditor.apply() } just Runs every { sharedPreferencesEditor.putBoolean(any(), any()) } returns sharedPreferencesEditor every { sharedPreferences.edit() } returns sharedPreferencesEditor SUT = MainTipsCase(sharedPreferences) } @Test fun `Should return true if tip has been show`() { val tip = Tip.MENU_DISCOVER every { sharedPreferences.getBoolean(tip.name, false) } returns true assertThat(SUT.isTipShown(tip)).isTrue() } @Test fun `Should return false if tips has not been shown`() { val tip = Tip.MENU_DISCOVER every { sharedPreferences.getBoolean(tip.name, false) } returns false if (BuildConfig.DEBUG) { assertThat(SUT.isTipShown(tip)).isTrue() } else { assertThat(SUT.isTipShown(tip)).isFalse() } } @Test fun `Should store tips shown info properly`() { val tip = Tip.MENU_DISCOVER SUT.setTipShown(tip) verifyAll { sharedPreferencesEditor.putBoolean(tip.name, true) sharedPreferencesEditor.apply() } } } ================================================ FILE: assets/codestyle.xml ================================================ ================================================ FILE: assets/screenshots/screenshots.xcf ================================================ [File too large to display: 28.7 MB] ================================================ FILE: build.gradle ================================================ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { apply from: "./versions.gradle" repositories { google() mavenCentral() maven { url "https://plugins.gradle.org/m2/" } } dependencies { classpath libs.gradle classpath libs.gradle.kotlin.plugin classpath libs.gradle.ktlint classpath libs.hilt.plugin } } plugins { id "com.github.triplet.play" version "3.8.1" apply false id 'com.google.devtools.ksp' version '1.9.0-1.0.13' apply false } allprojects { repositories { google() mavenCentral() maven { url 'https://jitpack.io' } } apply plugin: "org.jlleitschuh.gradle.ktlint" apply plugin: "com.google.devtools.ksp" } task clean(type: Delete) { delete rootProject.buildDir } ================================================ FILE: common/.gitignore ================================================ /build ================================================ FILE: common/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply from: '../versions.gradle' android { kotlinOptions { jvmTarget = "17" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } compileSdkVersion versions.compileSdk defaultConfig { minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk compileSdkVersion versions.compileSdk testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false } } namespace 'com.michaldrabik.common' } dependencies { api libs.coroutines implementation libs.retrofit implementation libs.hilt.android ksp libs.hilt.compiler implementation libs.coroutinesTest coreLibraryDesugaring libs.android.desugar } ================================================ FILE: common/src/debug/java/com/michaldrabik/common/ConfigVariant.kt ================================================ package com.michaldrabik.common import java.util.concurrent.TimeUnit.MINUTES object ConfigVariant { val SHOW_SYNC_COOLDOWN by lazy { MINUTES.toMillis(5) } val MOVIE_SYNC_COOLDOWN by lazy { MINUTES.toMillis(5) } val TRANSLATION_SYNC_SHOW_MOVIE_COOLDOWN by lazy { MINUTES.toMillis(5) } val TRANSLATION_SYNC_EPISODE_COOLDOWN by lazy { MINUTES.toMillis(5) } val RATINGS_CACHE_DURATION by lazy { MINUTES.toMillis(3) } val STREAMINGS_CACHE_DURATION by lazy { MINUTES.toMillis(3) } val COLLECTIONS_CACHE_DURATION by lazy { MINUTES.toMillis(3) } val TWITTER_AD_DELAY by lazy { MINUTES.toMillis(1) } val PREMIUM_AD_DELAY by lazy { MINUTES.toMillis(1) } } ================================================ FILE: common/src/main/AndroidManifest.xml ================================================ ================================================ FILE: common/src/main/java/com/michaldrabik/common/Config.kt ================================================ package com.michaldrabik.common import java.util.concurrent.TimeUnit.DAYS import java.util.concurrent.TimeUnit.HOURS object Config { const val TVDB_IMAGE_BASE_BANNERS_URL = "https://artworks.thetvdb.com/banners/" const val TVDB_IMAGE_BASE_POSTER_URL = "${TVDB_IMAGE_BASE_BANNERS_URL}posters/" const val TVDB_IMAGE_BASE_FANART_URL = "${TVDB_IMAGE_BASE_BANNERS_URL}fanart/original/" private const val TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/" const val TMDB_IMAGE_BASE_POSTER_URL = "${TMDB_IMAGE_BASE_URL}w342" const val TMDB_IMAGE_BASE_FANART_URL = "${TMDB_IMAGE_BASE_URL}w1280" const val TMDB_IMAGE_BASE_PROFILE_URL = "${TMDB_IMAGE_BASE_URL}w1280" const val TMDB_IMAGE_BASE_PROFILE_THUMB_URL = "${TMDB_IMAGE_BASE_URL}w342" const val TMDB_IMAGE_BASE_STILL_URL = "${TMDB_IMAGE_BASE_URL}original" const val TMDB_IMAGE_BASE_ACTOR_URL = "${TMDB_IMAGE_BASE_URL}h632" const val TMDB_IMAGE_BASE_LOGO_URL = "${TMDB_IMAGE_BASE_URL}original" const val AWS_IMAGE_BASE_URL = "https://showly2.s3.eu-west-2.amazonaws.com/images/" const val TRAKT_URL = "https://www.trakt.tv/" const val JUST_WATCH_URL = "https://www.justwatch.com/" const val TMDB_URL = "https://www.themoviedb.org/" const val GITHUB_RELEASE_URL = "https://github.com/1RandomDev/showly-oss/releases/latest" const val GITHUB_ISSUE_URL = "https://github.com/1RandomDev/showly-oss/issues" const val GITHUB_URL = "https://github.com/1RandomDev/showly-oss" const val MAIN_GRID_SPAN = 3 const val MAIN_GRID_SPAN_TABLET = 6 const val LISTS_GRID_SPAN = 4 const val LISTS_GRID_SPAN_TABLET = 6 const val IMAGE_FADE_DURATION_MS = 150 const val SEARCH_RECENTS_AMOUNT = 5 const val FANART_GALLERY_IMAGES_LIMIT = 20 const val PULL_TO_REFRESH_COOLDOWN_MS = 10_000 const val DEFAULT_LANGUAGE = "en" const val DEFAULT_COUNTRY = "us" const val DEFAULT_DATE_FORMAT = "DEFAULT_24" const val DEFAULT_NEWS_VIEW_TYPE = "ROW" const val DEFAULT_LIST_VIEW_MODE = "LIST_NORMAL" const val DEFAULT_LISTS_GRID_SPAN = 2 const val HOST_ACTIVITY_NAME = "com.michaldrabik.showly_oss.ui.main.MainActivity" const val SHOW_WHATS_NEW = true const val SHOW_TIPS_DEBUG = false const val SHOW_PREMIUM = true val PROGRESS_UPCOMING_OPTIONS = arrayOf(0, 7, 14, 30, 45, 60, 90) val MY_SHOWS_RECENTS_OPTIONS = arrayOf(0, 2, 4, 6, 8) val DISCOVER_SHOWS_CACHE_DURATION by lazy { HOURS.toMillis(12) } val DISCOVER_MOVIES_CACHE_DURATION by lazy { HOURS.toMillis(12) } val RELATED_CACHE_DURATION by lazy { DAYS.toMillis(7) } val SHOW_DETAILS_CACHE_DURATION by lazy { DAYS.toMillis(3) } val MOVIE_DETAILS_CACHE_DURATION by lazy { DAYS.toMillis(3) } val ACTORS_CACHE_DURATION by lazy { DAYS.toMillis(5) } val NEW_BADGE_DURATION by lazy { DAYS.toMillis(3) } val PEOPLE_CREDITS_CACHE_DURATION by lazy { DAYS.toMillis(7) } val PEOPLE_IMAGES_CACHE_DURATION by lazy { DAYS.toMillis(7) } const val SPOILERS_HIDE_SYMBOL = "•" const val SPOILERS_RATINGS_HIDE_SYMBOL = "•.•" const val SPOILERS_RATINGS_VOTES_HIDE_SYMBOL = "•.• (•••••)" val SPOILERS_REGEX = "\\S".toRegex() } ================================================ FILE: common/src/main/java/com/michaldrabik/common/Mode.kt ================================================ package com.michaldrabik.common enum class Mode(val type: String) { SHOWS("show"), MOVIES("movie"); companion object { fun fromType(type: String) = values().first { it.type == type } fun getAll() = listOf(SHOWS, MOVIES) } } ================================================ FILE: common/src/main/java/com/michaldrabik/common/di/CommonBindingModule.kt ================================================ package com.michaldrabik.common.di import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.common.dispatchers.DefaultCoroutineDispatchers import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) internal abstract class CommonBindingModule { @Binds abstract fun bindCoroutineDispatchers( dispatchers: DefaultCoroutineDispatchers ): CoroutineDispatchers } ================================================ FILE: common/src/main/java/com/michaldrabik/common/dispatchers/CoroutineDispatchers.kt ================================================ package com.michaldrabik.common.dispatchers import kotlinx.coroutines.CoroutineDispatcher interface CoroutineDispatchers { val Main: CoroutineDispatcher val IO: CoroutineDispatcher val Default: CoroutineDispatcher val Unconfined: CoroutineDispatcher } ================================================ FILE: common/src/main/java/com/michaldrabik/common/dispatchers/DefaultCoroutineDispatchers.kt ================================================ package com.michaldrabik.common.dispatchers import kotlinx.coroutines.Dispatchers import javax.inject.Inject import javax.inject.Singleton @Singleton internal class DefaultCoroutineDispatchers @Inject constructor() : CoroutineDispatchers { override val Main = Dispatchers.Main override val IO = Dispatchers.IO override val Default = Dispatchers.Default override val Unconfined = Dispatchers.Unconfined } ================================================ FILE: common/src/main/java/com/michaldrabik/common/errors/ErrorHelper.kt ================================================ package com.michaldrabik.common.errors import com.michaldrabik.common.errors.ShowlyError.AccountLimitsError import com.michaldrabik.common.errors.ShowlyError.AccountLockedError import com.michaldrabik.common.errors.ShowlyError.CoroutineCancellation import com.michaldrabik.common.errors.ShowlyError.ResourceConflictError import com.michaldrabik.common.errors.ShowlyError.ResourceNotFoundError import com.michaldrabik.common.errors.ShowlyError.UnauthorizedError import com.michaldrabik.common.errors.ShowlyError.UnknownError import com.michaldrabik.common.errors.ShowlyError.UnknownHttpError import com.michaldrabik.common.errors.ShowlyError.ValidationError import retrofit2.HttpException import kotlin.coroutines.cancellation.CancellationException object ErrorHelper { fun parse(error: Throwable): ShowlyError = when (error) { is ShowlyError -> error is HttpException -> { when (error.code()) { in arrayOf(401, 403) -> UnauthorizedError(error.message) 404 -> ResourceNotFoundError 409 -> ResourceConflictError 420 -> AccountLimitsError 422 -> ValidationError 423 -> AccountLockedError else -> UnknownHttpError(error.message) } } is CancellationException -> CoroutineCancellation else -> UnknownError(error.message) } } ================================================ FILE: common/src/main/java/com/michaldrabik/common/errors/ShowlyError.kt ================================================ package com.michaldrabik.common.errors sealed class ShowlyError(errorMessage: String?) : Throwable(errorMessage) { object ValidationError : ShowlyError("ValidationError") object ResourceConflictError : ShowlyError("ResourceConflictError") object ResourceNotFoundError : ShowlyError("ResourceNotFoundError") object AccountLockedError : ShowlyError("AccountLockedError") object AccountLimitsError : ShowlyError("AccountLimitsError") data class UnauthorizedError(val errorMessage: String?) : ShowlyError(errorMessage) data class UnknownHttpError( val errorMessage: String? ) : ShowlyError(errorMessage) data class UnknownError( val errorMessage: String? ) : ShowlyError(errorMessage) object CoroutineCancellation : ShowlyError("") } ================================================ FILE: common/src/main/java/com/michaldrabik/common/extensions/DateExtensions.kt ================================================ package com.michaldrabik.common.extensions import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit.DAYS fun nowUtc(): ZonedDateTime = ZonedDateTime.now(ZoneOffset.UTC) fun nowUtcDay(): LocalDate = LocalDate.now() fun nowUtcMillis(): Long = nowUtc().toMillis() fun dateFromMillis(millis: Long): ZonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.of("UTC")) fun dateIsoStringFromMillis(millis: Long): String = dateFromMillis(millis).format(DateTimeFormatter.ISO_INSTANT) fun ZonedDateTime.toMillis() = this.toInstant().toEpochMilli() fun ZonedDateTime.toLocalZone(): ZonedDateTime = this.withZoneSameInstant(ZoneId.systemDefault()) fun ZonedDateTime.isSameDayOrAfter(date: ZonedDateTime): Boolean = this.isEqual(date.truncatedTo(DAYS)) || this.isAfter(date.truncatedTo(DAYS)) fun String?.toZonedDateTime(): ZonedDateTime? = if (this.isNullOrBlank()) null else ZonedDateTime.parse(this) ================================================ FILE: common/src/release/java/com/michaldrabik/common/ConfigVariant.kt ================================================ package com.michaldrabik.common import java.util.concurrent.TimeUnit.DAYS import java.util.concurrent.TimeUnit.HOURS import java.util.concurrent.TimeUnit.MINUTES object ConfigVariant { val SHOW_SYNC_COOLDOWN by lazy { HOURS.toMillis(12) } val MOVIE_SYNC_COOLDOWN by lazy { DAYS.toMillis(3) } val TRANSLATION_SYNC_SHOW_MOVIE_COOLDOWN by lazy { DAYS.toMillis(5) } val TRANSLATION_SYNC_EPISODE_COOLDOWN by lazy { DAYS.toMillis(3) } val RATINGS_CACHE_DURATION by lazy { DAYS.toMillis(3) } val STREAMINGS_CACHE_DURATION by lazy { DAYS.toMillis(3) } val COLLECTIONS_CACHE_DURATION by lazy { DAYS.toMillis(7) } val REMOTE_CONFIG_FETCH_INTERVAL by lazy { MINUTES.toSeconds(60) } val TWITTER_AD_DELAY by lazy { DAYS.toMillis(5) } val PREMIUM_AD_DELAY by lazy { DAYS.toMillis(10) } } ================================================ FILE: common-test/.gitignore ================================================ /build ================================================ FILE: common-test/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply from: '../versions.gradle' android { kotlinOptions { jvmTarget = "17" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } compileSdkVersion versions.compileSdk defaultConfig { minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk compileSdkVersion versions.compileSdk testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false } } namespace 'com.michaldrabik.test_base' } dependencies { implementation project(':common') implementation libs.coroutinesTest implementation libs.junit coreLibraryDesugaring libs.android.desugar } ================================================ FILE: common-test/src/main/java/com/michaldrabik/common_test/MainDispatcherRule.kt ================================================ package com.michaldrabik.common_test import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.rules.TestWatcher import org.junit.runner.Description @OptIn(ExperimentalCoroutinesApi::class) class MainDispatcherRule( private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), ) : TestWatcher() { override fun starting(description: Description) { super.starting(description) Dispatchers.setMain(testDispatcher) } override fun finished(description: Description) { super.finished(description) Dispatchers.resetMain() } } ================================================ FILE: common-test/src/main/java/com/michaldrabik/common_test/UnconfinedCoroutineDispatchers.kt ================================================ package com.michaldrabik.common_test import com.michaldrabik.common.dispatchers.CoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @OptIn(ExperimentalCoroutinesApi::class) class UnconfinedCoroutineDispatchers : CoroutineDispatchers { override val Main = UnconfinedTestDispatcher() override val IO = UnconfinedTestDispatcher() override val Default = UnconfinedTestDispatcher() override val Unconfined = UnconfinedTestDispatcher() } ================================================ FILE: crowdin.yml ================================================ files: - source: /**/values/strings.xml translation: /**/values-%two_letters_code%/%original_file_name% translate_attributes: 0 content_segmentation: 0 ================================================ FILE: data-local/.gitignore ================================================ /build ================================================ FILE: data-local/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'dagger.hilt.android.plugin' apply from: '../versions.gradle' android { kotlinOptions { jvmTarget = "17" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } compileSdkVersion versions.compileSdk defaultConfig { minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk compileSdkVersion versions.compileSdk testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false } } namespace 'com.michaldrabik.data_local' } dependencies { api libs.android.room.ktx api libs.android.room.runtime ksp libs.android.room.compiler implementation libs.timber implementation libs.hilt.android ksp libs.hilt.compiler testImplementation libs.junit androidTestImplementation libs.truth androidTestImplementation libs.android.test.runner androidTestImplementation libs.android.test.truth coreLibraryDesugaring libs.android.desugar } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/BaseDaoTest.kt ================================================ @file:Suppress("DEPRECATION") package com.michaldrabik.data_local.database.dao import androidx.room.Room import androidx.test.platform.app.InstrumentationRegistry import com.michaldrabik.data_local.database.AppDatabase import org.junit.After import org.junit.Before abstract class BaseDaoTest { protected lateinit var database: AppDatabase @Before fun initDb() { database = Room.inMemoryDatabaseBuilder( InstrumentationRegistry.getInstrumentation().targetContext.applicationContext, AppDatabase::class.java ).build() } @After fun closeDb() = database.close() } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/DiscoverShowsDaoTest.kt ================================================ @file:Suppress("DEPRECATION") package com.michaldrabik.data_local.database.dao import androidx.test.runner.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.dao.helpers.TestData import com.michaldrabik.data_local.database.model.DiscoverShow import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class DiscoverShowsDaoTest : BaseDaoTest() { private val show by lazy { TestData.createShow().copy(idTrakt = 11) } private val discoverShow by lazy { DiscoverShow(1, show.idTrakt, 999, 999) } @Before fun setUp() { runBlocking { database.showsDao().upsert(listOf(show)) } } @Test fun shouldInsertSingleEntity() { runBlocking { database.discoverShowsDao().upsert(listOf(discoverShow)) val result = database.discoverShowsDao().getAll() assertThat(result).hasSize(1) assertThat(result.first()).isEqualTo(discoverShow) } } @Test fun shouldInsertMultipleEntities() { runBlocking { val show1 = discoverShow.copy(id = 11) val show2 = discoverShow.copy(id = 12) database.discoverShowsDao().upsert(listOf(show1, show2)) val result = database.discoverShowsDao().getAll() assertThat(result).containsExactlyElementsIn(listOf(show1, show2)) } } @Test fun shouldUpdateEntitiesIfExist() { runBlocking { val show1 = discoverShow.copy(id = 99) val show2 = discoverShow.copy(id = 23) val show1Updated = show1.copy(updatedAt = 1000) val show2Updated = show2.copy(updatedAt = 1000) database.discoverShowsDao().upsert(listOf(show1, show2)) database.discoverShowsDao().upsert(listOf(show1Updated, show2Updated)) val result = database.discoverShowsDao().getAll() assertThat(result).containsExactlyElementsIn(listOf(show1Updated, show2Updated)) } } @Test fun shouldReplaceEntities() { runBlocking { val show1 = discoverShow.copy(id = 11) val show2 = discoverShow.copy(id = 22) val show3 = discoverShow.copy(id = 33) database.discoverShowsDao().upsert(listOf(show1, show2, show3)) assertThat(database.discoverShowsDao().getAll()).containsExactly(show1, show2, show3) val show4 = discoverShow.copy(id = 44) database.discoverShowsDao().replace(listOf(show4)) assertThat(database.discoverShowsDao().getAll()).containsExactly(show4) } } @Test fun shouldReturnMostRecentEntity() { runBlocking { val show1 = discoverShow.copy(id = 11, createdAt = 2) val show2 = discoverShow.copy(id = 22, createdAt = 3) val show3 = discoverShow.copy(id = 33, createdAt = 1) database.discoverShowsDao().upsert(listOf(show1, show2, show3)) val mostRecent = database.discoverShowsDao().getMostRecent() assertThat(mostRecent).isEqualTo(show2) } } } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/EpisodesDaoTest.kt ================================================ @file:Suppress("DEPRECATION") package com.michaldrabik.data_local.database.dao import androidx.test.runner.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.dao.helpers.TestData import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class EpisodesDaoTest : BaseDaoTest() { @Test fun shouldStoreEpisodesForSeason() { runBlocking { val season = TestData.createSeason() val episode1 = TestData.createEpisode().copy(idTrakt = 1) val episode2 = TestData.createEpisode().copy(idTrakt = 2) database.seasonsDao().upsert(listOf(season)) database.episodesDao().upsert(listOf(episode1, episode2)) val result = database.episodesDao().getAllForSeason(1) assertThat(result).containsExactlyElementsIn(listOf(episode1, episode2)) } } @Test fun shouldUpdateEpisodeIfAlreadyExists() { runBlocking { val season = TestData.createSeason() val episode1 = TestData.createEpisode().copy(idTrakt = 1) val episode2 = TestData.createEpisode().copy(idTrakt = 2) database.seasonsDao().upsert(listOf(season)) database.episodesDao().upsert(listOf(episode1, episode2)) val result = database.episodesDao().getAllForSeason(1) assertThat(result).containsExactlyElementsIn(listOf(episode1, episode2)) val updated = episode2.copy(title = "Updated") database.episodesDao().upsert(listOf(episode1, updated)) val result2 = database.episodesDao().getAllForSeason(1) assertThat(result2).containsExactlyElementsIn(listOf(episode1, updated)) } } @Test fun shouldStoreEpisodesForShows() { runBlocking { val show1 = TestData.createShow().copy(idTrakt = 1) val show2 = TestData.createShow().copy(idTrakt = 2) val season1 = TestData.createSeason().copy(idShowTrakt = show1.idTrakt) val season2 = TestData.createSeason().copy(idShowTrakt = show2.idTrakt) val episode1 = TestData.createEpisode().copy( idTrakt = 1, idShowTrakt = show1.idTrakt, idSeason = season1.idTrakt ) val episode2 = TestData.createEpisode().copy( idTrakt = 2, idShowTrakt = show2.idTrakt, idSeason = season2.idTrakt ) database.showsDao().upsert(listOf(show1, show2)) database.seasonsDao().upsert(listOf(season1, season2)) database.episodesDao().upsert(listOf(episode1, episode2)) val result2 = database.episodesDao().getAllByShowId(2) assertThat(result2).containsExactlyElementsIn(listOf(episode2)) } } @Test fun shouldReturnWatchedIdsForShow() { runBlocking { val show = TestData.createShow().copy(idTrakt = 1) val season1 = TestData.createSeason().copy(idShowTrakt = show.idTrakt) val season2 = TestData.createSeason().copy(idShowTrakt = show.idTrakt) val episode1 = TestData.createEpisode().copy( idTrakt = 1, idShowTrakt = show.idTrakt, idSeason = season1.idTrakt, isWatched = true ) val episode2 = TestData.createEpisode().copy( idTrakt = 2, idShowTrakt = show.idTrakt, idSeason = season2.idTrakt, isWatched = false ) database.showsDao().upsert(listOf(show)) database.seasonsDao().upsert(listOf(season1, season2)) database.episodesDao().upsert(listOf(episode1, episode2)) val result = database.episodesDao().getAllWatchedIdsForShows(listOf(show.idTrakt)) assertThat(result).containsExactlyElementsIn(listOf(episode1.idTrakt)) } } @Test fun shouldDeleteAllUnwatchedForShow() { runBlocking { val show = TestData.createShow().copy(idTrakt = 1) val season1 = TestData.createSeason().copy(idShowTrakt = show.idTrakt) val season2 = TestData.createSeason().copy(idShowTrakt = show.idTrakt) val episode1 = TestData.createEpisode().copy( idTrakt = 1, idShowTrakt = show.idTrakt, idSeason = season1.idTrakt, isWatched = true ) val episode2 = TestData.createEpisode().copy( idTrakt = 2, idShowTrakt = show.idTrakt, idSeason = season2.idTrakt, isWatched = false ) database.showsDao().upsert(listOf(show)) database.seasonsDao().upsert(listOf(season1, season2)) database.episodesDao().upsert(listOf(episode1, episode2)) val result = database.episodesDao().getAllByShowId(show.idTrakt) assertThat(result).containsExactlyElementsIn(listOf(episode1, episode2)) database.episodesDao().deleteAllUnwatchedForShow(show.idTrakt) val result2 = database.episodesDao().getAllByShowId(show.idTrakt) assertThat(result2).containsExactlyElementsIn(listOf(episode1)) } } } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/EpisodesSyncLogDaoTest.kt ================================================ @file:Suppress("DEPRECATION") package com.michaldrabik.data_local.database.dao import androidx.test.runner.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.model.EpisodesSyncLog import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class EpisodesSyncLogDaoTest : BaseDaoTest() { @Test fun shouldInsertAndSaveData() { runBlocking { val testLog = EpisodesSyncLog(1, 999) database.episodesSyncLogDao().upsert(testLog) val result = database.episodesSyncLogDao().getAll().first() assertThat(result).isEqualTo(testLog) } } @Test fun shouldUpdateRowIfAlreadyExists() { runBlocking { val testLog = EpisodesSyncLog(1, 999) database.episodesSyncLogDao().upsert(testLog) val result = database.episodesSyncLogDao().getAll().first() assertThat(result).isEqualTo(testLog) val testLog2 = testLog.copy(syncedAt = 888) database.episodesSyncLogDao().upsert(testLog2) val result2 = database.episodesSyncLogDao().getAll().first() assertThat(result2).isEqualTo(testLog2) } } } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/MyShowsDaoTest.kt ================================================ @file:Suppress("DEPRECATION") package com.michaldrabik.data_local.database.dao import androidx.test.runner.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.dao.helpers.TestData import com.michaldrabik.data_local.database.model.MyShow import com.michaldrabik.data_local.database.model.Show import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class MyShowsDaoTest : BaseDaoTest() { private val shows = mutableListOf() @Before fun setUp() = runBlocking { shows.add(TestData.createShow().copy(idTrakt = 1)) shows.add(TestData.createShow().copy(idTrakt = 2)) shows.add(TestData.createShow().copy(idTrakt = 3)) database.showsDao().upsert(shows) } @Test fun shouldInsertAndStoreEntities() { runBlocking { val myShow = MyShow.fromTraktId(shows[0].idTrakt, 0, 0) database.myShowsDao().insert(listOf(myShow)) val result = database.myShowsDao().getAll() assertThat(result).containsExactlyElementsIn(listOf(shows[0])) } } @Test fun shouldReturnIdsOnly() { runBlocking { val myShow1 = MyShow.fromTraktId(shows[0].idTrakt, 0, 0) val myShow2 = MyShow.fromTraktId(shows[1].idTrakt, 0, 0) database.myShowsDao().insert(listOf(myShow1)) database.myShowsDao().insert(listOf(myShow2)) val result = database.myShowsDao().getAllTraktIds() assertThat(result).containsExactlyElementsIn(listOf(shows[0].idTrakt, shows[1].idTrakt)) } } @Test fun shouldReturnMostRecentAddedShows() { runBlocking { val myShow1 = MyShow.fromTraktId(shows[0].idTrakt, 0, 0) val myShow2 = MyShow.fromTraktId(shows[1].idTrakt, 999, 999) database.myShowsDao().insert(listOf(myShow1)) database.myShowsDao().insert(listOf(myShow2)) val result = database.myShowsDao().getAllRecent(10) assertThat(result[0]).isEqualTo(shows[1]) assertThat(result[1]).isEqualTo(shows[0]) } } @Test fun shouldReturnById() { runBlocking { val myShow1 = MyShow.fromTraktId(shows[0].idTrakt, 0, 0) val myShow2 = MyShow.fromTraktId(shows[1].idTrakt, 0, 0) database.myShowsDao().insert(listOf(myShow1)) database.myShowsDao().insert(listOf(myShow2)) val result = database.myShowsDao().getById(shows[1].idTrakt) assertThat(result).isEqualTo(shows[1]) } } @Test fun shouldDeleteByIdWithoutDeletingParent() { runBlocking { val myShow1 = MyShow.fromTraktId(shows[1].idTrakt, 0, 0) val showsSize = shows.size database.myShowsDao().insert(listOf(myShow1)) database.myShowsDao().deleteById(shows[1].idTrakt) val result = database.myShowsDao().getById(shows[1].idTrakt) assertThat(result).isNull() assertThat(shows).hasSize(showsSize) } } } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/RecentSearchDaoTest.kt ================================================ @file:Suppress("DEPRECATION") package com.michaldrabik.data_local.database.dao import androidx.test.runner.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.model.RecentSearch import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class RecentSearchDaoTest : BaseDaoTest() { private val items = mutableListOf() @Before fun setUp() { val item = RecentSearch(1, "Text1", 1, 1) val item2 = RecentSearch(2, "Text2", 2, 2) val item3 = RecentSearch(3, "Text3", 3, 3) items.addAll(listOf(item, item2, item3)) } @Test fun shouldInsertAndStoreEntities() { runBlocking { database.recentSearchDao().upsert(items) val result = database.recentSearchDao().getAll(10) assertThat(result).containsExactlyElementsIn(items) } } @Test fun shouldReturnMostRecentSearchesFirst() { runBlocking { database.recentSearchDao().upsert(items) val result = database.recentSearchDao().getAll(10) assertThat(result).containsExactlyElementsIn(items) assertThat(result).isInOrder(compareByDescending { it.createdAt }) } } @Test fun shouldReturnLimitedResults() { runBlocking { database.recentSearchDao().upsert(items) val result = database.recentSearchDao().getAll(2) assertThat(result).hasSize(2) } } } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/RelatedShowsDaoTest.kt ================================================ @file:Suppress("DEPRECATION") package com.michaldrabik.data_local.database.dao import androidx.test.runner.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.dao.helpers.TestData import com.michaldrabik.data_local.database.model.RelatedShow import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class RelatedShowsDaoTest : BaseDaoTest() { @Test fun shouldInsertAndDeleteSingleEntity() { runBlocking { val show = TestData.createShow().copy(idTrakt = 99) val relatedShow = RelatedShow(1, 1, 99, 999) database.showsDao().upsert(listOf(show)) database.relatedShowsDao().insert(listOf(relatedShow)) val result = database.relatedShowsDao().getAll() assertThat(result).hasSize(1) assertThat(result.first()).isEqualTo(relatedShow) database.relatedShowsDao().deleteById(99) val result2 = database.relatedShowsDao().getAll() assertThat(result2).isEmpty() assertThat(database.showsDao().getById(99)).isEqualTo(show) } } @Test fun shouldReturnRelatedShowsForId() { runBlocking { val show1 = TestData.createShow().copy(idTrakt = 1, updatedAt = 100) val show2 = TestData.createShow().copy(idTrakt = 2, updatedAt = 100) val show3 = TestData.createShow().copy(idTrakt = 3, updatedAt = 100) val relatedShow1 = RelatedShow(1, 2, 1, 200) val relatedShow2 = RelatedShow(2, 3, 1, 200) val relatedShow3 = RelatedShow(3, 1, 2, 200) database.showsDao().upsert(listOf(show1, show2, show3)) database.relatedShowsDao().insert(listOf(relatedShow1, relatedShow2, relatedShow3)) val result = database.relatedShowsDao().getAllById(1) assertThat(result).containsExactlyElementsIn(listOf(relatedShow1, relatedShow2)) } } } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/SeasonsDaoTest.kt ================================================ @file:Suppress("DEPRECATION") package com.michaldrabik.data_local.database.dao import androidx.test.runner.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.dao.helpers.TestData import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class SeasonsDaoTest : BaseDaoTest() { @Test fun shouldInsertAndStoreSingleEntity() { runBlocking { val season = TestData.createSeason() database.seasonsDao().upsert(listOf(season)) val result = database.seasonsDao().getAllByShowId(1) assertThat(result).containsExactlyElementsIn(listOf(season)) } } @Test fun shouldInsertAndStoreMultipleEntities() { runBlocking { val season1 = TestData.createSeason().copy(idTrakt = 1) val season2 = TestData.createSeason().copy(idTrakt = 2) database.seasonsDao().upsert(listOf(season1, season2)) val result = database.seasonsDao().getAllByShowId(1) assertThat(result).containsExactlyElementsIn(listOf(season1, season2)) } } @Test fun shouldReturnEntityById() { runBlocking { val season1 = TestData.createSeason().copy(idTrakt = 1) val season2 = TestData.createSeason().copy(idTrakt = 2) database.seasonsDao().upsert(listOf(season1, season2)) val result = database.seasonsDao().getById(2) assertThat(result).isEqualTo(season2) } } @Test fun shouldReturnEntitiesByIds() { runBlocking { val season1 = TestData.createSeason().copy(idTrakt = 1) val season2 = TestData.createSeason().copy(idTrakt = 2, idShowTrakt = 2) val season3 = TestData.createSeason().copy(idTrakt = 3) database.seasonsDao().upsert(listOf(season1, season2, season3)) val result = database.seasonsDao().getAllByShowId(1) assertThat(result).containsExactlyElementsIn(listOf(season1, season3)) } } @Test fun shouldOnlyReturnWatchedSeasons() { runBlocking { val season1 = TestData.createSeason().copy(idTrakt = 1) val season2 = TestData.createSeason().copy(idTrakt = 2) val season3 = TestData.createSeason().copy(idTrakt = 3, isWatched = true) database.seasonsDao().upsert(listOf(season1, season2, season3)) val result = database.seasonsDao().getAllWatchedIdsForShows(listOf(1)) assertThat(result).hasSize(1) assertThat(result[0]).isEqualTo(3) } } @Test fun shouldReturnNullIfNoEntity() { runBlocking { val season1 = TestData.createSeason() database.seasonsDao().upsert(listOf(season1)) val result = database.seasonsDao().getById(2) assertThat(result).isNull() } } } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/SettingsDaoTest.kt ================================================ @file:Suppress("DEPRECATION") package com.michaldrabik.data_local.database.dao import androidx.test.runner.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.dao.helpers.TestData import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class SettingsDaoTest : BaseDaoTest() { @Test fun shouldInsertAndSaveData() { runBlocking { val settings = TestData.createSettings() database.settingsDao().upsert(settings) val result = database.settingsDao().getAll() assertThat(result).isEqualTo(settings) } } @Test fun shouldReturnNullIfNoEntity() { runBlocking { val settings = database.settingsDao().getAll() assertThat(settings).isNull() } } @Test fun shouldUpdateRowIfAlreadyExists() { runBlocking { val settings = TestData.createSettings() database.settingsDao().upsert(settings) assertThat(database.settingsDao().getAll()).isEqualTo(settings) val settings2 = settings.copy(myShowsEndedSortBy = "sort") database.settingsDao().upsert(settings2) assertThat(database.settingsDao().getAll()).isEqualTo(settings2) } } } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/ShowImagesDaoTest.kt ================================================ @file:Suppress("DEPRECATION") package com.michaldrabik.data_local.database.dao import androidx.test.runner.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.model.ShowImage import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ShowImagesDaoTest : BaseDaoTest() { private val image1 = ShowImage(1, 1, 1, "poster", "show", "url", "thumbUrl", "tmdb") private val image2 = ShowImage(2, 2, 2, "poster", "episode", "url", "thumbUrl", "tmdb") private val image3 = ShowImage(3, 1, 1, "fanart", "show", "url", "thumbUrl", "tmdb") private val image4 = ShowImage(4, 2, 2, "fanart", "episode", "url", "thumbUrl", "tmdb") @Before fun setUp() = runBlocking { database.showImagesDao().upsert(image1) database.showImagesDao().upsert(image2) database.showImagesDao().upsert(image3) database.showImagesDao().upsert(image4) } @Test fun shouldReturnImageByTypeForShow() = runBlocking { val result = database.showImagesDao().getByShowId(1, "poster") val result2 = database.showImagesDao().getByShowId(1, "fanart") assertThat(result).isEqualTo(image1) assertThat(result2).isEqualTo(image3) } @Test fun shouldReturnImageByTypeForEpisode() = runBlocking { val result = database.showImagesDao().getByEpisodeId(2, "poster") val result2 = database.showImagesDao().getByEpisodeId(2, "fanart") assertThat(result).isEqualTo(image2) assertThat(result2).isEqualTo(image4) } @Test fun shouldUpsertShowImage() = runBlocking { val result = database.showImagesDao().getByShowId(10, "fanart") assertThat(result).isNull() val image = ShowImage(10, 10, 10, "fanart", "show", "url", "thumbUrl", "tmdb") database.showImagesDao().insertShowImage(image) val result2 = database.showImagesDao().getByShowId(10, "fanart") assertThat(result2).isEqualTo(image) val image2 = ShowImage(10, 10, 10, "fanart", "show", "url2", "thumbUrl2", "tmdb") database.showImagesDao().insertShowImage(image2) val result3 = database.showImagesDao().getByShowId(10, "fanart") assertThat(result3).isEqualTo(image2) } @Test fun shouldUpsertEpisodeImage() = runBlocking { val result = database.showImagesDao().getByEpisodeId(10, "fanart") assertThat(result).isNull() val image = ShowImage(10, 10, 10, "fanart", "episode", "url", "thumbUrl", "tmdb") database.showImagesDao().insertEpisodeImage(image) val result2 = database.showImagesDao().getByEpisodeId(10, "fanart") assertThat(result2).isEqualTo(image) val image2 = ShowImage(10, 10, 10, "fanart", "episode", "url2", "thumbUrl2", "tmdb") database.showImagesDao().insertEpisodeImage(image2) val result3 = database.showImagesDao().getByEpisodeId(10, "fanart") assertThat(result3).isEqualTo(image2) } @Test fun shouldDeleteByShowId() = runBlocking { assertThat(database.showImagesDao().getByShowId(1, "poster")).isEqualTo(image1) assertThat(database.showImagesDao().getByShowId(1, "fanart")).isEqualTo(image3) database.showImagesDao().deleteByShowId(1, "poster") database.showImagesDao().deleteByShowId(1, "fanart") assertThat(database.showImagesDao().getByShowId(1, "poster")).isNull() assertThat(database.showImagesDao().getByShowId(1, "fanart")).isNull() // Ensure nothing else is deleted assertThat(database.showImagesDao().getByEpisodeId(2, "poster")).isEqualTo(image2) assertThat(database.showImagesDao().getByEpisodeId(2, "fanart")).isEqualTo(image4) } @Test fun shouldDeleteByEpisodeId() = runBlocking { assertThat(database.showImagesDao().getByEpisodeId(2, "poster")).isEqualTo(image2) assertThat(database.showImagesDao().getByEpisodeId(2, "fanart")).isEqualTo(image4) database.showImagesDao().deleteByEpisodeId(2, "poster") database.showImagesDao().deleteByEpisodeId(2, "fanart") assertThat(database.showImagesDao().getByEpisodeId(2, "poster")).isNull() assertThat(database.showImagesDao().getByEpisodeId(2, "fanart")).isNull() // Ensure nothing else is deleted assertThat(database.showImagesDao().getByShowId(1, "poster")).isEqualTo(image1) assertThat(database.showImagesDao().getByShowId(1, "fanart")).isEqualTo(image3) } } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/ShowsDaoTest.kt ================================================ @file:Suppress("DEPRECATION") package com.michaldrabik.data_local.database.dao import androidx.test.runner.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.dao.helpers.TestData import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ShowsDaoTest : BaseDaoTest() { @Test fun shouldInsertAndStoreSingleEntity() { runBlocking { val show = TestData.createShow() database.showsDao().upsert(listOf(show)) val result = database.showsDao().getAll() assertThat(result).hasSize(1) assertThat(result.first()).isEqualTo(show) } } @Test fun shouldInsertAndStoreMultipleEntities() { runBlocking { val show1 = TestData.createShow().copy(idTrakt = 1) val show2 = TestData.createShow().copy(idTrakt = 2) database.showsDao().upsert(listOf(show1, show2)) val result = database.showsDao().getAll() assertThat(result).hasSize(2) assertThat(result[0]).isEqualTo(show1) assertThat(result[1]).isEqualTo(show2) } } @Test fun shouldReturnEntityById() { runBlocking { val show1 = TestData.createShow().copy(idTrakt = 1) val show2 = TestData.createShow().copy(idTrakt = 2) database.showsDao().upsert(listOf(show1, show2)) val result = database.showsDao().getById(2) assertThat(result).isEqualTo(show2) } } @Test fun shouldReturnEntitiesByIds() { runBlocking { val show1 = TestData.createShow().copy(idTrakt = 1) val show2 = TestData.createShow().copy(idTrakt = 2) val show3 = TestData.createShow().copy(idTrakt = 3) database.showsDao().upsert(listOf(show1, show2, show3)) val result = database.showsDao().getAll(listOf(1, 3)) assertThat(result).containsExactlyElementsIn(listOf(show1, show3)) } } @Test fun shouldReturnNullIfNoEntity() { runBlocking { val show1 = TestData.createShow() database.showsDao().upsert(listOf(show1)) val result = database.showsDao().getById(2) assertThat(result).isNull() } } } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/UserDaoTest.kt ================================================ @file:Suppress("DEPRECATION") package com.michaldrabik.data_local.database.dao import androidx.test.runner.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.model.User import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class UserDaoTest : BaseDaoTest() { @Test fun shouldInsertAndSaveData() { runBlocking { val testUser = User(1, "tvdbToken", 999, "test", "test", 999, "", "", 999) database.userDao().upsert(testUser) val result = database.userDao().get() assertThat(result).isEqualTo(testUser) } } @Test fun shouldUpdateRowIfAlreadyExists() { runBlocking { val testUser = User(1, "tvdbToken", 999, "test", "test", 999, "", "", 999) database.userDao().upsert(testUser) assertThat(database.userDao().get()).isEqualTo(testUser) val testUser2 = testUser.copy(tvdbToken = "otherToken", tvdbTokenTimestamp = 888) database.userDao().upsert(testUser2) assertThat(database.userDao().get()).isEqualTo(testUser2) } } } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/WatchlistShowsDaoTest.kt ================================================ @file:Suppress("DEPRECATION") package com.michaldrabik.data_local.database.dao import androidx.test.runner.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.dao.helpers.TestData import com.michaldrabik.data_local.database.model.WatchlistShow import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class WatchlistShowsDaoTest : BaseDaoTest() { @Test fun shouldInsertAndStoreSingleEntity() { runBlocking { val show = TestData.createShow() val seeLaterShow = WatchlistShow.fromTraktId(show.idTrakt, 999) database.showsDao().upsert(listOf(show)) database.watchlistShowsDao().insert(seeLaterShow) val result = database.watchlistShowsDao().getAll() assertThat(result).containsExactlyElementsIn(listOf(show.copy(updatedAt = 999, createdAt = 999))) } } @Test fun shouldInsertAndStoreSingleEntityById() { runBlocking { val show = TestData.createShow() val show2 = TestData.createShow().copy(idTrakt = 2) val seeLaterShow = WatchlistShow.fromTraktId(show.idTrakt, 999) val seeLaterShow2 = WatchlistShow.fromTraktId(show2.idTrakt, 999) database.showsDao().upsert(listOf(show, show2)) database.watchlistShowsDao().insert(seeLaterShow) database.watchlistShowsDao().insert(seeLaterShow2) val result = database.watchlistShowsDao().getById(2) assertThat(result).isEqualTo(show2) } } @Test fun shouldDeleteSingleEntityById() { runBlocking { val show = TestData.createShow() val show2 = TestData.createShow().copy(idTrakt = 2) val seeLaterShow = WatchlistShow.fromTraktId(show.idTrakt, 999) val seeLaterShow2 = WatchlistShow.fromTraktId(show2.idTrakt, 999) database.showsDao().upsert(listOf(show, show2)) database.watchlistShowsDao().insert(seeLaterShow) database.watchlistShowsDao().insert(seeLaterShow2) database.watchlistShowsDao().deleteById(2) val result = database.watchlistShowsDao().getAll() assertThat(result).containsExactlyElementsIn(listOf(show.copy(updatedAt = 999, createdAt = 999))) } } } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/converters/DateConverterTest.kt ================================================ @file:Suppress("DEPRECATION") package com.michaldrabik.data_local.database.dao.converters import com.google.common.truth.Truth.assertThat import androidx.test.runner.AndroidJUnit4 import com.michaldrabik.data_local.database.converters.DateConverter import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import java.time.ZoneId import java.time.ZonedDateTime @RunWith(AndroidJUnit4::class) class DateConverterTest { private val SUT by lazy { DateConverter() } @Before fun setUp() { } @Test fun shouldConvertTimestampToDate() { val date = SUT.stringToDate(1573120000000) // Thu Nov 07 2019 09:46:40 assertThat(date).isEqualTo(ZonedDateTime.of(2019, 11, 7, 9, 46, 40, 0, ZoneId.of("UTC"))) } @Test fun shouldConvertDateToTimestamp() { val timestamp = SUT.dateToString(ZonedDateTime.of(2019, 11, 7, 9, 46, 40, 0, ZoneId.of("UTC"))) assertThat(timestamp).isEqualTo(1573120000000) } } ================================================ FILE: data-local/src/androidTest/java/com/michaldrabik/data_local/database/dao/helpers/TestData.kt ================================================ package com.michaldrabik.data_local.database.dao.helpers import com.michaldrabik.data_local.database.model.Episode import com.michaldrabik.data_local.database.model.Season import com.michaldrabik.data_local.database.model.Settings import com.michaldrabik.data_local.database.model.Show object TestData { fun createShow() = Show( 1, 1, 1, "idImdb", "idSlug", 1, "Title", 2000, "Overview", "FirstAired", 60, "AirtimeDay", "AirtimeTime", "AirtimeTimezone", "Certification", "Network", "Country", "Trailer", "Homepage", "Status", 0F, 0L, 0L, "Genres", 0, 0, 0 ) fun createSettings() = Settings( 1, false, true, true, 0, 2, "", "", "", "", false, false, false, false, false, false, false, "", true, "OFF", "", "", false, false, "", "", false, false, false, "", "", "", "", "", true, true, true, true, true, "", true ) fun createEpisode() = Episode(1, 1, 1, 1, "", 1, 1, 1, 1, "", "", null, 0, 0F, 60, 0, false) fun createSeason() = Season(1, 1, 1, "", "", null, 0, 0, 0f, false) } ================================================ FILE: data-local/src/main/AndroidManifest.xml ================================================ ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/LocalDataSource.kt ================================================ package com.michaldrabik.data_local import com.michaldrabik.data_local.sources.ArchiveMoviesLocalDataSource import com.michaldrabik.data_local.sources.ArchiveShowsLocalDataSource import com.michaldrabik.data_local.sources.CustomImagesLocalDataSource import com.michaldrabik.data_local.sources.CustomListsItemsLocalDataSource import com.michaldrabik.data_local.sources.CustomListsLocalDataSource import com.michaldrabik.data_local.sources.DiscoverMoviesLocalDataSource import com.michaldrabik.data_local.sources.DiscoverShowsLocalDataSource import com.michaldrabik.data_local.sources.EpisodeTranslationsLocalDataSource import com.michaldrabik.data_local.sources.EpisodesLocalDataSource import com.michaldrabik.data_local.sources.EpisodesSyncLogLocalDataSource import com.michaldrabik.data_local.sources.MovieImagesLocalDataSource import com.michaldrabik.data_local.sources.MovieRatingsLocalDataSource import com.michaldrabik.data_local.sources.MovieStreamingsLocalDataSource import com.michaldrabik.data_local.sources.MovieTranslationsLocalDataSource import com.michaldrabik.data_local.sources.MoviesLocalDataSource import com.michaldrabik.data_local.sources.MoviesSyncLogLocalDataSource import com.michaldrabik.data_local.sources.MyMoviesLocalDataSource import com.michaldrabik.data_local.sources.MyShowsLocalDataSource import com.michaldrabik.data_local.sources.NewsLocalDataSource import com.michaldrabik.data_local.sources.PeopleCreditsLocalDataSource import com.michaldrabik.data_local.sources.PeopleImagesLocalDataSource import com.michaldrabik.data_local.sources.PeopleLocalDataSource import com.michaldrabik.data_local.sources.PeopleShowsMoviesLocalDataSource import com.michaldrabik.data_local.sources.RatingsLocalDataSource import com.michaldrabik.data_local.sources.RecentSearchLocalDataSource import com.michaldrabik.data_local.sources.RelatedMoviesLocalDataSource import com.michaldrabik.data_local.sources.RelatedShowsLocalDataSource import com.michaldrabik.data_local.sources.SeasonsLocalDataSource import com.michaldrabik.data_local.sources.SettingsLocalDataSource import com.michaldrabik.data_local.sources.ShowImagesLocalDataSource import com.michaldrabik.data_local.sources.ShowRatingsLocalDataSource import com.michaldrabik.data_local.sources.ShowStreamingsLocalDataSource import com.michaldrabik.data_local.sources.ShowTranslationsLocalDataSource import com.michaldrabik.data_local.sources.ShowsLocalDataSource import com.michaldrabik.data_local.sources.TraktSyncLogLocalDataSource import com.michaldrabik.data_local.sources.TraktSyncQueueLocalDataSource import com.michaldrabik.data_local.sources.TranslationsMoviesSyncLogLocalDataSource import com.michaldrabik.data_local.sources.TranslationsShowsSyncLogLocalDataSource import com.michaldrabik.data_local.sources.UserLocalDataSource import com.michaldrabik.data_local.sources.WatchlistMoviesLocalDataSource import com.michaldrabik.data_local.sources.WatchlistShowsLocalDataSource import javax.inject.Inject import javax.inject.Singleton /** * Provides local data sources access points. */ // TODO Refactor. Split or remove this wrapper at all. Clients do not need to be exposed to everything. interface LocalDataSource { val archiveMovies: ArchiveMoviesLocalDataSource val archiveShows: ArchiveShowsLocalDataSource val customImages: CustomImagesLocalDataSource val customLists: CustomListsLocalDataSource val customListsItems: CustomListsItemsLocalDataSource val discoverMovies: DiscoverMoviesLocalDataSource val discoverShows: DiscoverShowsLocalDataSource val episodes: EpisodesLocalDataSource val episodesSyncLog: EpisodesSyncLogLocalDataSource val episodesTranslations: EpisodeTranslationsLocalDataSource val movieImages: MovieImagesLocalDataSource val movieRatings: MovieRatingsLocalDataSource val movieStreamings: MovieStreamingsLocalDataSource val movieTranslations: MovieTranslationsLocalDataSource val movies: MoviesLocalDataSource val moviesSyncLog: MoviesSyncLogLocalDataSource val myMovies: MyMoviesLocalDataSource val myShows: MyShowsLocalDataSource val news: NewsLocalDataSource val people: PeopleLocalDataSource val peopleCredits: PeopleCreditsLocalDataSource val peopleImages: PeopleImagesLocalDataSource val peopleShowsMovies: PeopleShowsMoviesLocalDataSource val ratings: RatingsLocalDataSource val recentSearch: RecentSearchLocalDataSource val relatedMovies: RelatedMoviesLocalDataSource val relatedShows: RelatedShowsLocalDataSource val seasons: SeasonsLocalDataSource val settings: SettingsLocalDataSource val showImages: ShowImagesLocalDataSource val showRatings: ShowRatingsLocalDataSource val showStreamings: ShowStreamingsLocalDataSource val showTranslations: ShowTranslationsLocalDataSource val shows: ShowsLocalDataSource val traktSyncLog: TraktSyncLogLocalDataSource val traktSyncQueue: TraktSyncQueueLocalDataSource val translationsMoviesSyncLog: TranslationsMoviesSyncLogLocalDataSource val translationsShowsSyncLog: TranslationsShowsSyncLogLocalDataSource val user: UserLocalDataSource val watchlistMovies: WatchlistMoviesLocalDataSource val watchlistShows: WatchlistShowsLocalDataSource } @Singleton internal class MainLocalDataSource @Inject constructor( override val archiveMovies: ArchiveMoviesLocalDataSource, override val archiveShows: ArchiveShowsLocalDataSource, override val customImages: CustomImagesLocalDataSource, override val customLists: CustomListsLocalDataSource, override val customListsItems: CustomListsItemsLocalDataSource, override val discoverMovies: DiscoverMoviesLocalDataSource, override val discoverShows: DiscoverShowsLocalDataSource, override val episodes: EpisodesLocalDataSource, override val episodesSyncLog: EpisodesSyncLogLocalDataSource, override val episodesTranslations: EpisodeTranslationsLocalDataSource, override val movieImages: MovieImagesLocalDataSource, override val movieRatings: MovieRatingsLocalDataSource, override val movieStreamings: MovieStreamingsLocalDataSource, override val movieTranslations: MovieTranslationsLocalDataSource, override val movies: MoviesLocalDataSource, override val moviesSyncLog: MoviesSyncLogLocalDataSource, override val myMovies: MyMoviesLocalDataSource, override val myShows: MyShowsLocalDataSource, override val news: NewsLocalDataSource, override val people: PeopleLocalDataSource, override val peopleCredits: PeopleCreditsLocalDataSource, override val peopleImages: PeopleImagesLocalDataSource, override val peopleShowsMovies: PeopleShowsMoviesLocalDataSource, override val ratings: RatingsLocalDataSource, override val recentSearch: RecentSearchLocalDataSource, override val relatedMovies: RelatedMoviesLocalDataSource, override val relatedShows: RelatedShowsLocalDataSource, override val seasons: SeasonsLocalDataSource, override val settings: SettingsLocalDataSource, override val showImages: ShowImagesLocalDataSource, override val showRatings: ShowRatingsLocalDataSource, override val showStreamings: ShowStreamingsLocalDataSource, override val showTranslations: ShowTranslationsLocalDataSource, override val shows: ShowsLocalDataSource, override val traktSyncLog: TraktSyncLogLocalDataSource, override val traktSyncQueue: TraktSyncQueueLocalDataSource, override val translationsMoviesSyncLog: TranslationsMoviesSyncLogLocalDataSource, override val translationsShowsSyncLog: TranslationsShowsSyncLogLocalDataSource, override val user: UserLocalDataSource, override val watchlistMovies: WatchlistMoviesLocalDataSource, override val watchlistShows: WatchlistShowsLocalDataSource ) : LocalDataSource ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/AppDatabase.kt ================================================ package com.michaldrabik.data_local.database import androidx.room.Database import androidx.room.RoomDatabase import com.michaldrabik.data_local.database.dao.ArchiveMoviesDao import com.michaldrabik.data_local.database.dao.ArchiveShowsDao import com.michaldrabik.data_local.database.dao.CustomImagesDao import com.michaldrabik.data_local.database.dao.CustomListsDao import com.michaldrabik.data_local.database.dao.CustomListsItemsDao import com.michaldrabik.data_local.database.dao.DiscoverMoviesDao import com.michaldrabik.data_local.database.dao.DiscoverShowsDao import com.michaldrabik.data_local.database.dao.EpisodeTranslationsDao import com.michaldrabik.data_local.database.dao.EpisodesDao import com.michaldrabik.data_local.database.dao.EpisodesSyncLogDao import com.michaldrabik.data_local.database.dao.MovieCollectionsDao import com.michaldrabik.data_local.database.dao.MovieCollectionsItemsDao import com.michaldrabik.data_local.database.dao.MovieImagesDao import com.michaldrabik.data_local.database.dao.MovieRatingsDao import com.michaldrabik.data_local.database.dao.MovieStreamingsDao import com.michaldrabik.data_local.database.dao.MovieTranslationsDao import com.michaldrabik.data_local.database.dao.MoviesDao import com.michaldrabik.data_local.database.dao.MoviesSyncLogDao import com.michaldrabik.data_local.database.dao.MyMoviesDao import com.michaldrabik.data_local.database.dao.MyShowsDao import com.michaldrabik.data_local.database.dao.NewsDao import com.michaldrabik.data_local.database.dao.PeopleCreditsDao import com.michaldrabik.data_local.database.dao.PeopleDao import com.michaldrabik.data_local.database.dao.PeopleImagesDao import com.michaldrabik.data_local.database.dao.PeopleShowsMoviesDao import com.michaldrabik.data_local.database.dao.RatingsDao import com.michaldrabik.data_local.database.dao.RecentSearchDao import com.michaldrabik.data_local.database.dao.RelatedMoviesDao import com.michaldrabik.data_local.database.dao.RelatedShowsDao import com.michaldrabik.data_local.database.dao.SeasonsDao import com.michaldrabik.data_local.database.dao.SettingsDao import com.michaldrabik.data_local.database.dao.ShowImagesDao import com.michaldrabik.data_local.database.dao.ShowRatingsDao import com.michaldrabik.data_local.database.dao.ShowStreamingsDao import com.michaldrabik.data_local.database.dao.ShowTranslationsDao import com.michaldrabik.data_local.database.dao.ShowsDao import com.michaldrabik.data_local.database.dao.TraktSyncLogDao import com.michaldrabik.data_local.database.dao.TraktSyncQueueDao import com.michaldrabik.data_local.database.dao.TranslationsMoviesSyncLogDao import com.michaldrabik.data_local.database.dao.TranslationsSyncLogDao import com.michaldrabik.data_local.database.dao.UserDao import com.michaldrabik.data_local.database.dao.WatchlistMoviesDao import com.michaldrabik.data_local.database.dao.WatchlistShowsDao import com.michaldrabik.data_local.database.migrations.DATABASE_VERSION import com.michaldrabik.data_local.database.model.ArchiveMovie import com.michaldrabik.data_local.database.model.ArchiveShow import com.michaldrabik.data_local.database.model.CustomImage import com.michaldrabik.data_local.database.model.CustomList import com.michaldrabik.data_local.database.model.CustomListItem import com.michaldrabik.data_local.database.model.DiscoverMovie import com.michaldrabik.data_local.database.model.DiscoverShow import com.michaldrabik.data_local.database.model.Episode import com.michaldrabik.data_local.database.model.EpisodeTranslation import com.michaldrabik.data_local.database.model.EpisodesSyncLog import com.michaldrabik.data_local.database.model.Movie import com.michaldrabik.data_local.database.model.MovieCollection import com.michaldrabik.data_local.database.model.MovieCollectionItem import com.michaldrabik.data_local.database.model.MovieImage import com.michaldrabik.data_local.database.model.MovieRatings import com.michaldrabik.data_local.database.model.MovieStreaming import com.michaldrabik.data_local.database.model.MovieTranslation import com.michaldrabik.data_local.database.model.MoviesSyncLog import com.michaldrabik.data_local.database.model.MyMovie import com.michaldrabik.data_local.database.model.MyShow import com.michaldrabik.data_local.database.model.News import com.michaldrabik.data_local.database.model.Person import com.michaldrabik.data_local.database.model.PersonCredits import com.michaldrabik.data_local.database.model.PersonImage import com.michaldrabik.data_local.database.model.PersonShowMovie import com.michaldrabik.data_local.database.model.Rating import com.michaldrabik.data_local.database.model.RecentSearch import com.michaldrabik.data_local.database.model.RelatedMovie import com.michaldrabik.data_local.database.model.RelatedShow import com.michaldrabik.data_local.database.model.Season import com.michaldrabik.data_local.database.model.Settings import com.michaldrabik.data_local.database.model.Show import com.michaldrabik.data_local.database.model.ShowImage import com.michaldrabik.data_local.database.model.ShowRatings import com.michaldrabik.data_local.database.model.ShowStreaming import com.michaldrabik.data_local.database.model.ShowTranslation import com.michaldrabik.data_local.database.model.TraktSyncLog import com.michaldrabik.data_local.database.model.TraktSyncQueue import com.michaldrabik.data_local.database.model.TranslationsMoviesSyncLog import com.michaldrabik.data_local.database.model.TranslationsSyncLog import com.michaldrabik.data_local.database.model.User import com.michaldrabik.data_local.database.model.WatchlistMovie import com.michaldrabik.data_local.database.model.WatchlistShow @Database( version = DATABASE_VERSION, entities = [ Show::class, Movie::class, DiscoverShow::class, DiscoverMovie::class, MyShow::class, MyMovie::class, WatchlistShow::class, WatchlistMovie::class, ArchiveShow::class, ArchiveMovie::class, RelatedShow::class, RelatedMovie::class, ShowImage::class, MovieImage::class, User::class, Season::class, Person::class, PersonShowMovie::class, PersonCredits::class, PersonImage::class, Episode::class, Settings::class, RecentSearch::class, EpisodesSyncLog::class, MoviesSyncLog::class, TranslationsSyncLog::class, TranslationsMoviesSyncLog::class, TraktSyncQueue::class, TraktSyncLog::class, ShowTranslation::class, MovieTranslation::class, EpisodeTranslation::class, CustomImage::class, CustomList::class, CustomListItem::class, News::class, Rating::class, ShowRatings::class, MovieRatings::class, ShowStreaming::class, MovieStreaming::class, MovieCollection::class, MovieCollectionItem::class, ], exportSchema = false ) abstract class AppDatabase : RoomDatabase() { abstract fun showsDao(): ShowsDao abstract fun moviesDao(): MoviesDao abstract fun discoverShowsDao(): DiscoverShowsDao abstract fun discoverMoviesDao(): DiscoverMoviesDao abstract fun myShowsDao(): MyShowsDao abstract fun myMoviesDao(): MyMoviesDao abstract fun watchlistShowsDao(): WatchlistShowsDao abstract fun watchlistMoviesDao(): WatchlistMoviesDao abstract fun archiveShowsDao(): ArchiveShowsDao abstract fun archiveMoviesDao(): ArchiveMoviesDao abstract fun relatedShowsDao(): RelatedShowsDao abstract fun relatedMoviesDao(): RelatedMoviesDao abstract fun showImagesDao(): ShowImagesDao abstract fun movieImagesDao(): MovieImagesDao abstract fun customImagesDao(): CustomImagesDao abstract fun userDao(): UserDao abstract fun recentSearchDao(): RecentSearchDao abstract fun episodesDao(): EpisodesDao abstract fun seasonsDao(): SeasonsDao abstract fun peopleDao(): PeopleDao abstract fun peopleShowsMoviesDao(): PeopleShowsMoviesDao abstract fun peopleCreditsDao(): PeopleCreditsDao abstract fun peopleImagesDao(): PeopleImagesDao abstract fun settingsDao(): SettingsDao abstract fun traktSyncLogDao(): TraktSyncLogDao abstract fun moviesSyncLogDao(): MoviesSyncLogDao abstract fun episodesSyncLogDao(): EpisodesSyncLogDao abstract fun translationsSyncLogDao(): TranslationsSyncLogDao abstract fun translationsMoviesSyncLogDao(): TranslationsMoviesSyncLogDao abstract fun traktSyncQueueDao(): TraktSyncQueueDao abstract fun showTranslationsDao(): ShowTranslationsDao abstract fun movieTranslationsDao(): MovieTranslationsDao abstract fun ratingsDao(): RatingsDao abstract fun showRatingsDao(): ShowRatingsDao abstract fun movieRatingsDao(): MovieRatingsDao abstract fun showStreamingsDao(): ShowStreamingsDao abstract fun movieStreamingsDao(): MovieStreamingsDao abstract fun movieCollectionsDao(): MovieCollectionsDao abstract fun movieCollectionsItemsDao(): MovieCollectionsItemsDao abstract fun episodeTranslationsDao(): EpisodeTranslationsDao abstract fun customListsDao(): CustomListsDao abstract fun customListsItemsDao(): CustomListsItemsDao abstract fun newsDao(): NewsDao } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/converters/DateConverter.kt ================================================ package com.michaldrabik.data_local.database.converters import androidx.room.TypeConverter import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime class DateConverter { @TypeConverter fun stringToDate(value: Long?) = value?.let { ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.of("UTC")) } @TypeConverter fun dateToString(date: ZonedDateTime?) = date?.toInstant()?.toEpochMilli() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/ArchiveMoviesDao.kt ================================================ // ktlint-disable max-line-length package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.ArchiveMovie import com.michaldrabik.data_local.database.model.Movie import com.michaldrabik.data_local.sources.ArchiveMoviesLocalDataSource @Dao interface ArchiveMoviesDao : ArchiveMoviesLocalDataSource { @Query("SELECT movies.*, movies_archive.created_at AS created_at, movies_archive.updated_at AS updated_at FROM movies INNER JOIN movies_archive USING(id_trakt)") override suspend fun getAll(): List @Query("SELECT movies.*, movies_archive.created_at AS created_at, movies_archive.updated_at AS updated_at FROM movies INNER JOIN movies_archive USING(id_trakt) WHERE id_trakt IN (:ids)") override suspend fun getAll(ids: List): List @Query("SELECT movies.id_trakt FROM movies INNER JOIN movies_archive USING(id_trakt)") override suspend fun getAllTraktIds(): List @Query("SELECT movies.* FROM movies INNER JOIN movies_archive USING(id_trakt) WHERE id_trakt == :traktId") override suspend fun getById(traktId: Long): Movie? @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun insert(movie: ArchiveMovie) @Query("DELETE FROM movies_archive WHERE id_trakt == :traktId") override suspend fun deleteById(traktId: Long) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/ArchiveShowsDao.kt ================================================ // ktlint-disable max-line-length package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.ArchiveShow import com.michaldrabik.data_local.database.model.Show import com.michaldrabik.data_local.sources.ArchiveShowsLocalDataSource @Dao interface ArchiveShowsDao : ArchiveShowsLocalDataSource { @Query("SELECT shows.*, shows_archive.created_at AS created_at, shows_archive.updated_at AS updated_at FROM shows INNER JOIN shows_archive USING(id_trakt)") override suspend fun getAll(): List @Query("SELECT shows.*, shows_archive.created_at AS created_at, shows_archive.updated_at AS updated_at FROM shows INNER JOIN shows_archive USING(id_trakt) WHERE id_trakt IN (:ids)") override suspend fun getAll(ids: List): List @Query("SELECT shows.id_trakt FROM shows INNER JOIN shows_archive USING(id_trakt)") override suspend fun getAllTraktIds(): List @Query("SELECT shows.* FROM shows INNER JOIN shows_archive USING(id_trakt) WHERE id_trakt == :traktId") override suspend fun getById(traktId: Long): Show? @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun insert(show: ArchiveShow) @Query("DELETE FROM shows_archive WHERE id_trakt == :traktId") override suspend fun deleteById(traktId: Long) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/BaseDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Update interface BaseDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(items: List): List @Update(onConflict = OnConflictStrategy.REPLACE) suspend fun update(items: List) @Delete suspend fun delete(items: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/CustomImagesDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.CustomImage import com.michaldrabik.data_local.sources.CustomImagesLocalDataSource @Dao interface CustomImagesDao : CustomImagesLocalDataSource { @Query("SELECT * FROM custom_images WHERE id_trakt = :traktId AND type = :type AND family = :family") override suspend fun getById(traktId: Long, family: String, type: String): CustomImage? @Query("DELETE FROM custom_images WHERE id_trakt = :traktId AND type = :type AND family = :family") override suspend fun deleteById(traktId: Long, family: String, type: String) @Transaction override suspend fun insertImage(image: CustomImage) { val localImage = getById(image.idTrakt, image.family, image.type) if (localImage != null) { val updated = image.copy(id = localImage.id) upsert(updated) return } upsert(image) } @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun upsert(image: CustomImage) @Query("DELETE FROM custom_images") override suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/CustomListsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import com.michaldrabik.data_local.database.model.CustomList import com.michaldrabik.data_local.sources.CustomListsLocalDataSource @Dao interface CustomListsDao : CustomListsLocalDataSource { @Insert(onConflict = OnConflictStrategy.IGNORE) override suspend fun insert(items: List): List @Update(onConflict = OnConflictStrategy.REPLACE) override suspend fun update(items: List) @Query("SELECT * FROM custom_lists ORDER BY created_at DESC") override suspend fun getAll(): List @Query("SELECT * FROM custom_lists WHERE id == :id") override suspend fun getById(id: Long): CustomList? @Query("UPDATE custom_lists SET id_trakt = :idTrakt, id_slug = :idSlug, updated_at = :timestamp WHERE id == :id") override suspend fun updateTraktId(id: Long, idTrakt: Long, idSlug: String, timestamp: Long) @Query("UPDATE custom_lists SET updated_at = :timestamp WHERE id == :id") override suspend fun updateTimestamp(id: Long, timestamp: Long) @Query("UPDATE custom_lists SET sort_by_local = :sortBy, sort_how_local = :sortHow, updated_at = :timestamp WHERE id == :id") override suspend fun updateSortByLocal(id: Long, sortBy: String, sortHow: String, timestamp: Long) @Query("UPDATE custom_lists SET filter_type_local = :filterType, updated_at = :timestamp WHERE id == :id") override suspend fun updateFilterTypeLocal(id: Long, filterType: String, timestamp: Long) @Query("DELETE FROM custom_lists WHERE id == :id") override suspend fun deleteById(id: Long) @Query("DELETE FROM custom_lists") override suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/CustomListsItemsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import androidx.room.Update import com.michaldrabik.data_local.database.model.CustomListItem import com.michaldrabik.data_local.sources.CustomListsItemsLocalDataSource @Dao interface CustomListsItemsDao : CustomListsItemsLocalDataSource { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(items: List): List @Update(onConflict = OnConflictStrategy.REPLACE) override suspend fun update(items: List) @Query("SELECT id_list FROM custom_list_item WHERE id_trakt = :idTrakt AND type = :type") override suspend fun getListsForItem(idTrakt: Long, type: String): List @Query("SELECT * FROM custom_list_item WHERE id_list = :idList AND id_trakt = :idTrakt AND type = :type") override suspend fun getByIdTrakt(idList: Long, idTrakt: Long, type: String): CustomListItem? @Query("SELECT * FROM custom_list_item WHERE id_list = :idList ORDER BY rank ASC") override suspend fun getItemsById(idList: Long): List @Query("SELECT * FROM custom_list_item WHERE id_list = :idList ORDER BY rank ASC LIMIT :limit") override suspend fun getItemsForListImages(idList: Long, limit: Int): List @Query("SELECT rank FROM custom_list_item WHERE id_list = :idList ORDER BY rank DESC LIMIT 1") override suspend fun getRankForList(idList: Long): Long? @Transaction override suspend fun insertItem(item: CustomListItem) { val localItem = getByIdTrakt(item.idList, item.idTrakt, item.type) if (localItem != null) return val rank = getRankForList(item.idList) ?: 0L val rankedItem = item.copy(rank = rank + 1L) insert(listOf(rankedItem)) } @Query("DELETE FROM custom_list_item WHERE id_list = :idList AND id_trakt == :idTrakt AND type = :type") override suspend fun deleteItem(idList: Long, idTrakt: Long, type: String) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/DiscoverMoviesDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.DiscoverMovie import com.michaldrabik.data_local.sources.DiscoverMoviesLocalDataSource @Dao interface DiscoverMoviesDao : DiscoverMoviesLocalDataSource { @Query("SELECT * FROM movies_discover ORDER BY id") override suspend fun getAll(): List @Query("SELECT * from movies_discover ORDER BY created_at DESC LIMIT 1") override suspend fun getMostRecent(): DiscoverMovie? @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun upsert(movies: List) @Query("DELETE FROM movies_discover") override suspend fun deleteAll() @Transaction override suspend fun replace(movies: List) { deleteAll() upsert(movies) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/DiscoverShowsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.DiscoverShow import com.michaldrabik.data_local.sources.DiscoverShowsLocalDataSource @Dao interface DiscoverShowsDao : DiscoverShowsLocalDataSource { @Query("SELECT * FROM shows_discover ORDER BY id") override suspend fun getAll(): List @Query("SELECT * from shows_discover ORDER BY created_at DESC LIMIT 1") override suspend fun getMostRecent(): DiscoverShow? @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun upsert(shows: List) @Query("DELETE FROM shows_discover") override suspend fun deleteAll() @Transaction override suspend fun replace(shows: List) { deleteAll() upsert(shows) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/EpisodeTranslationsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.EpisodeTranslation import com.michaldrabik.data_local.sources.EpisodeTranslationsLocalDataSource @Dao interface EpisodeTranslationsDao : BaseDao, EpisodeTranslationsLocalDataSource { @Query("SELECT * FROM episodes_translations WHERE id_trakt == :traktEpisodeId AND id_trakt_show == :traktShowId AND language == :language") override suspend fun getById(traktEpisodeId: Long, traktShowId: Long, language: String): EpisodeTranslation? @Query("SELECT * FROM episodes_translations WHERE id_trakt IN (:traktEpisodeIds) AND id_trakt_show == :traktShowId AND language == :language") override suspend fun getByIds(traktEpisodeIds: List, traktShowId: Long, language: String): List @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun insertSingle(translation: EpisodeTranslation) @Query("DELETE FROM episodes_translations WHERE language IN (:languages)") override suspend fun deleteByLanguage(languages: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/EpisodesDao.kt ================================================ // ktlint-disable max-line-length package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.Episode import com.michaldrabik.data_local.sources.EpisodesLocalDataSource @Dao interface EpisodesDao : EpisodesLocalDataSource { @Insert(onConflict = REPLACE) override suspend fun upsert(episodes: List) @Transaction override suspend fun upsertChunked(items: List) { val chunks = items.chunked(500) chunks.forEach { chunk -> upsert(chunk) } } @Query("SELECT EXISTS(SELECT 1 FROM episodes WHERE id_show_trakt = :showTraktId AND id_trakt = :episodeTraktId AND is_watched = 1)") override suspend fun isEpisodeWatched(showTraktId: Long, episodeTraktId: Long): Boolean @Query("SELECT * FROM episodes WHERE id_season = :seasonTraktId") override suspend fun getAllForSeason(seasonTraktId: Long): List @Query("SELECT * FROM episodes WHERE id_show_trakt = :showTraktId") override suspend fun getAllByShowId(showTraktId: Long): List @Query("SELECT * FROM episodes WHERE id_show_trakt = :showTraktId AND season_number = :seasonNumber") override suspend fun getAllByShowId(showTraktId: Long, seasonNumber: Int): List @Transaction override suspend fun getAllByShowsIds(showTraktIds: List): List { val result = mutableListOf() val chunks = showTraktIds.chunked(50) chunks.forEach { chunk -> result += getAllByShowsIdsChunk(chunk) } return result } @Transaction @Query("SELECT * FROM episodes WHERE id_show_trakt IN (:showTraktIds)") override suspend fun getAllByShowsIdsChunk(showTraktIds: List): List @Query("SELECT * from episodes where id_show_trakt = :showTraktId AND is_watched = 0 AND season_number != 0 AND first_aired <= :toTime ORDER BY season_number ASC, episode_number ASC LIMIT 1") override suspend fun getFirstUnwatched(showTraktId: Long, toTime: Long): Episode? @Query("SELECT * from episodes where id_show_trakt = :showTraktId AND is_watched = 0 AND season_number != 0 AND first_aired > :fromTime AND first_aired <= :toTime ORDER BY season_number ASC, episode_number ASC LIMIT 1") override suspend fun getFirstUnwatched(showTraktId: Long, fromTime: Long, toTime: Long): Episode? @Query( "SELECT * from episodes where id_show_trakt = :showTraktId " + "AND is_watched = 0 " + "AND season_number != 0 " + "AND ((season_number * 10000) + episode_number) > ((:seasonNumber * 10000) + :episodeNumber) " + "AND first_aired <= :toTime " + "ORDER BY season_number ASC, episode_number ASC LIMIT 1" ) override suspend fun getFirstUnwatchedAfterEpisode(showTraktId: Long, seasonNumber: Int, episodeNumber: Int, toTime: Long): Episode? @Query("SELECT * from episodes where id_show_trakt = :showTraktId AND is_watched = 1 AND season_number != 0 ORDER BY last_watched_at DESC LIMIT 1") override suspend fun getLastWatched(showTraktId: Long): Episode? @Query("SELECT COUNT(id_trakt) FROM episodes WHERE id_show_trakt = :showTraktId AND first_aired < :toTime AND season_number != 0") override suspend fun getTotalCount(showTraktId: Long, toTime: Long): Int @Query("SELECT COUNT(id_trakt) FROM episodes WHERE id_show_trakt = :showTraktId AND season_number != 0") override suspend fun getTotalCount(showTraktId: Long): Int @Query("SELECT COUNT(id_trakt) FROM episodes WHERE id_show_trakt = :showTraktId AND is_watched = 1 AND first_aired < :toTime AND season_number != 0") override suspend fun getWatchedCount(showTraktId: Long, toTime: Long): Int @Query("SELECT COUNT(id_trakt) FROM episodes WHERE id_show_trakt = :showTraktId AND is_watched = 1 AND season_number != 0") override suspend fun getWatchedCount(showTraktId: Long): Int @Query("SELECT * FROM episodes WHERE id_show_trakt IN(:showsIds) AND is_watched = 1") override suspend fun getAllWatchedForShows(showsIds: List): List @Query("SELECT id_trakt FROM episodes WHERE id_show_trakt IN(:showsIds) AND is_watched = 1") override suspend fun getAllWatchedIdsForShows(showsIds: List): List @Query("DELETE FROM episodes WHERE id_show_trakt = :showTraktId AND is_watched = 0") override suspend fun deleteAllUnwatchedForShow(showTraktId: Long) @Query("DELETE FROM episodes WHERE id_show_trakt = :showTraktId") override suspend fun deleteAllForShow(showTraktId: Long) @Delete override suspend fun delete(items: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/EpisodesSyncLogDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.EpisodesSyncLog import com.michaldrabik.data_local.sources.EpisodesSyncLogLocalDataSource @Dao interface EpisodesSyncLogDao : EpisodesSyncLogLocalDataSource { @Query("SELECT * from sync_episodes_log") override suspend fun getAll(): List @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun upsert(log: EpisodesSyncLog) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/MovieCollectionsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.MovieCollection import com.michaldrabik.data_local.sources.MovieCollectionsLocalDataSource @Dao interface MovieCollectionsDao : BaseDao, MovieCollectionsLocalDataSource { @Query("SELECT * FROM movies_collections WHERE id_trakt == :traktId") override suspend fun getById(traktId: Long): MovieCollection? @Query("SELECT * FROM movies_collections WHERE id_trakt_movie == :movieTraktId") override suspend fun getByMovieId(movieTraktId: Long): List @Transaction override suspend fun replaceByMovieId( movieTraktId: Long, entities: List, ) { val deleteCollections = getByMovieId(movieTraktId).map { it.idTrakt } deleteCollectionsItems(deleteCollections) deleteCollections(deleteCollections) insert(entities) } override suspend fun insertAll(items: List) { insert(items) } @Query("DELETE FROM movies_collections WHERE id_trakt IN (:collectionIds)") suspend fun deleteCollections(collectionIds: List) @Query("DELETE FROM movies_collections_items WHERE id_trakt_collection IN (:collectionIds)") suspend fun deleteCollectionsItems(collectionIds: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/MovieCollectionsItemsDao.kt ================================================ // ktlint-disable max-line-length package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.Movie import com.michaldrabik.data_local.database.model.MovieCollectionItem import com.michaldrabik.data_local.sources.MovieCollectionsItemsLocalDataSource @Dao interface MovieCollectionsItemsDao : BaseDao, MovieCollectionsItemsLocalDataSource { @Query("SELECT movies.*, movies_collections_items.created_at, movies_collections_items.updated_at FROM movies INNER JOIN movies_collections_items USING(id_trakt) WHERE id_trakt_collection == :collectionId ORDER BY rank ASC") override suspend fun getById(collectionId: Long): List @Transaction override suspend fun replace( collectionId: Long, items: List, ) { deleteById(collectionId) insert(items) } @Query("DELETE FROM movies_collections_items WHERE id_trakt_collection == :collectionId") override suspend fun deleteById(collectionId: Long) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/MovieImagesDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.MovieImage import com.michaldrabik.data_local.sources.MovieImagesLocalDataSource @Dao interface MovieImagesDao : MovieImagesLocalDataSource { @Query("SELECT * FROM movies_images WHERE id_tmdb = :tmdbId AND type = :type") override suspend fun getByMovieId(tmdbId: Long, type: String): MovieImage? @Transaction override suspend fun insertMovieImage(image: MovieImage) { val localImage = getByMovieId(image.idTmdb, image.type) if (localImage != null) { val updated = image.copy(id = localImage.id) upsert(updated) return } upsert(image) } @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun upsert(image: MovieImage) @Query("DELETE FROM movies_images WHERE id_tmdb = :id AND type = :type") override suspend fun deleteByMovieId(id: Long, type: String) @Query("DELETE FROM movies_images WHERE type = 'poster'") override suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/MovieRatingsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.MovieRatings import com.michaldrabik.data_local.sources.MovieRatingsLocalDataSource @Dao interface MovieRatingsDao : BaseDao, MovieRatingsLocalDataSource { @Transaction override suspend fun upsert(entity: MovieRatings) { val local = getById(entity.idTrakt) if (local != null) { update( listOf( local.copy( trakt = entity.trakt, imdb = entity.imdb, metascore = entity.metascore, rottenTomatoes = entity.rottenTomatoes, rottenTomatoesUrl = entity.rottenTomatoesUrl, updatedAt = entity.updatedAt ) ) ) return } insert(listOf(entity)) } @Query("SELECT * FROM movies_ratings WHERE id_trakt == :traktId") override suspend fun getById(traktId: Long): MovieRatings? } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/MovieStreamingsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.MovieStreaming import com.michaldrabik.data_local.sources.MovieStreamingsLocalDataSource @Dao interface MovieStreamingsDao : BaseDao, MovieStreamingsLocalDataSource { @Transaction override suspend fun replace(traktId: Long, entities: List) { deleteById(traktId) insert(entities) } @Query("SELECT * FROM movies_streamings WHERE id_trakt == :traktId") override suspend fun getById(traktId: Long): List @Query("DELETE FROM movies_streamings WHERE id_trakt == :traktId") override suspend fun deleteById(traktId: Long) @Query("DELETE FROM movies_streamings") override suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/MovieTranslationsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.MovieTranslation import com.michaldrabik.data_local.sources.MovieTranslationsLocalDataSource @Dao interface MovieTranslationsDao : BaseDao, MovieTranslationsLocalDataSource { @Query("SELECT * FROM movies_translations WHERE id_trakt == :traktId AND language == :language") override suspend fun getById(traktId: Long, language: String): MovieTranslation? @Query("SELECT * FROM movies_translations WHERE language == :language") override suspend fun getAll(language: String): List @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun insertSingle(translation: MovieTranslation) @Query("DELETE FROM movies_translations WHERE language IN (:languages)") override suspend fun deleteByLanguage(languages: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/MoviesDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.Movie import com.michaldrabik.data_local.sources.MoviesLocalDataSource @Dao interface MoviesDao : BaseDao, MoviesLocalDataSource { @Query("SELECT * FROM movies") override suspend fun getAll(): List @Query("SELECT * FROM movies WHERE id_trakt IN (:ids)") override suspend fun getAll(ids: List): List @Transaction override suspend fun getAllChunked(ids: List): List = ids .chunked(500) .fold( mutableListOf() ) { acc, chunk -> acc += getAll(chunk) acc } @Query("SELECT * FROM movies WHERE id_trakt == :traktId") override suspend fun getById(traktId: Long): Movie? @Query("SELECT * FROM movies WHERE id_tmdb == :tmdbId") override suspend fun getByTmdbId(tmdbId: Long): Movie? @Query("SELECT * FROM movies WHERE id_slug == :slug") override suspend fun getBySlug(slug: String): Movie? @Query("SELECT * FROM movies WHERE id_imdb == :imdbId") override suspend fun getById(imdbId: String): Movie? @Query("DELETE FROM movies where id_trakt == :traktId") override suspend fun deleteById(traktId: Long) @Transaction override suspend fun upsert(movies: List) { val result = insert(movies) val updateList = mutableListOf() result.forEachIndexed { index, id -> if (id == -1L) { updateList.add(movies[index]) } } if (updateList.isNotEmpty()) { update(updateList) } } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/MoviesSyncLogDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.MoviesSyncLog import com.michaldrabik.data_local.sources.MoviesSyncLogLocalDataSource @Dao interface MoviesSyncLogDao : MoviesSyncLogLocalDataSource { @Query("SELECT * from sync_movies_log") override suspend fun getAll(): List @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun upsert(log: MoviesSyncLog) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/MyMoviesDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.Movie import com.michaldrabik.data_local.database.model.MyMovie import com.michaldrabik.data_local.sources.MyMoviesLocalDataSource @Dao interface MyMoviesDao : MyMoviesLocalDataSource { @Query("SELECT movies.*, movies_my_movies.updated_at AS updated_at FROM movies INNER JOIN movies_my_movies USING(id_trakt)") override suspend fun getAll(): List @Query( "SELECT movies.*, movies_my_movies.updated_at AS updated_at FROM movies " + "INNER JOIN movies_my_movies USING(id_trakt) WHERE id_trakt IN (:ids)" ) override suspend fun getAll(ids: List): List @Query("SELECT movies.* FROM movies INNER JOIN movies_my_movies USING(id_trakt) ORDER BY movies_my_movies.updated_at DESC LIMIT :limit") override suspend fun getAllRecent(limit: Int): List @Query("SELECT movies.id_trakt FROM movies INNER JOIN movies_my_movies USING(id_trakt)") override suspend fun getAllTraktIds(): List @Query("SELECT movies.* FROM movies INNER JOIN movies_my_movies USING(id_trakt) WHERE id_trakt == :traktId") override suspend fun getById(traktId: Long): Movie? @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun insert(movies: List) @Query("DELETE FROM movies_my_movies WHERE id_trakt == :traktId") override suspend fun deleteById(traktId: Long) @Query("SELECT EXISTS(SELECT 1 FROM movies_my_movies WHERE id_trakt = :traktId LIMIT 1);") override suspend fun checkExists(traktId: Long): Boolean } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/MyShowsDao.kt ================================================ // ktlint-disable max-line-length package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.MyShow import com.michaldrabik.data_local.database.model.Show import com.michaldrabik.data_local.sources.MyShowsLocalDataSource @Dao interface MyShowsDao : MyShowsLocalDataSource { @Query("SELECT shows.*, shows_my_shows.created_at AS created_at, shows_my_shows.last_watched_at AS updated_at FROM shows INNER JOIN shows_my_shows USING(id_trakt)") override suspend fun getAll(): List @Query("SELECT shows.*, shows_my_shows.created_at AS created_at, shows_my_shows.last_watched_at AS updated_at FROM shows INNER JOIN shows_my_shows USING(id_trakt) WHERE id_trakt IN (:ids)") override suspend fun getAll(ids: List): List @Query("SELECT shows.* FROM shows INNER JOIN shows_my_shows USING(id_trakt) ORDER BY shows_my_shows.created_at DESC LIMIT :limit") override suspend fun getAllRecent(limit: Int): List @Query("SELECT shows.id_trakt FROM shows INNER JOIN shows_my_shows USING(id_trakt)") override suspend fun getAllTraktIds(): List @Query("SELECT shows.* FROM shows INNER JOIN shows_my_shows USING(id_trakt) WHERE id_trakt == :traktId") override suspend fun getById(traktId: Long): Show? @Query("UPDATE shows_my_shows SET last_watched_at = :watchedAt WHERE id_trakt == :traktId") override suspend fun updateWatchedAt(traktId: Long, watchedAt: Long) @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun insert(shows: List) @Query("DELETE FROM shows_my_shows WHERE id_trakt == :traktId") override suspend fun deleteById(traktId: Long) @Query("SELECT EXISTS(SELECT 1 FROM shows_my_shows WHERE id_trakt = :traktId LIMIT 1);") override suspend fun checkExists(traktId: Long): Boolean } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/NewsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.News import com.michaldrabik.data_local.sources.NewsLocalDataSource @Dao interface NewsDao : BaseDao, NewsLocalDataSource { @Query("SELECT * FROM news WHERE type == :type ORDER BY dated_at DESC") override suspend fun getAllByType(type: String): List @Transaction override suspend fun replaceForType(items: List, type: String) { deleteAllByType(type) insert(items) } @Query("DELETE FROM news WHERE type == :type") override suspend fun deleteAllByType(type: String): Int } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/PeopleCreditsDao.kt ================================================ package com.michaldrabik.data_local.database.dao /* ktlint-disable */ import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.Movie import com.michaldrabik.data_local.database.model.PersonCredits import com.michaldrabik.data_local.database.model.Show import com.michaldrabik.data_local.sources.PeopleCreditsLocalDataSource @Dao interface PeopleCreditsDao : BaseDao, PeopleCreditsLocalDataSource { @Query( "SELECT shows.*, people_credits.created_at AS created_at, people_credits.updated_at AS updated_at FROM shows " + "INNER JOIN people_credits ON people_credits.id_trakt_show = shows.id_trakt WHERE people_credits.id_trakt_person = :personTraktId" ) override suspend fun getAllShowsForPerson(personTraktId: Long): List @Query( "SELECT movies.*, people_credits.created_at AS created_at, people_credits.updated_at AS updated_at FROM movies " + "INNER JOIN people_credits ON people_credits.id_trakt_movie = movies.id_trakt WHERE people_credits.id_trakt_person = :personTraktId" ) override suspend fun getAllMoviesForPerson(personTraktId: Long): List @Query("SELECT updated_at FROM people_credits WHERE id_trakt_person = :personTraktId LIMIT 1") override suspend fun getTimestampForPerson(personTraktId: Long): Long? @Query("DELETE FROM people_credits WHERE id_trakt_person == :personTraktId") override suspend fun deleteAllForPerson(personTraktId: Long) @Transaction override suspend fun insertSingle(personTraktId: Long, credits: List) { deleteAllForPerson(personTraktId) insert(credits) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/PeopleDao.kt ================================================ package com.michaldrabik.data_local.database.dao /* ktlint-disable */ import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.Person import com.michaldrabik.data_local.sources.PeopleLocalDataSource @Dao interface PeopleDao : BaseDao, PeopleLocalDataSource { @Transaction override suspend fun upsert(people: List) { val result = insert(people) val updateList = mutableListOf() result.forEachIndexed { index, id -> if (id == -1L) { updateList.add(people[index]) } } if (updateList.isNotEmpty()) update(updateList) } @Query("SELECT * FROM people WHERE id_tmdb = :tmdbId") override suspend fun getById(tmdbId: Long): Person? @Query("SELECT people.*, people_shows_movies.department AS department, people_shows_movies.character AS character, people_shows_movies.job AS job, people_shows_movies.episodes_count AS episodes_count FROM people INNER JOIN people_shows_movies ON people_shows_movies.id_tmdb_person = people.id_tmdb WHERE people_shows_movies.id_trakt_show = :showTraktId") override suspend fun getAllForShow(showTraktId: Long): List @Query("SELECT people.*, people_shows_movies.department AS department, people_shows_movies.character AS character, people_shows_movies.job AS job, people_shows_movies.episodes_count AS episodes_count FROM people INNER JOIN people_shows_movies ON people_shows_movies.id_tmdb_person = people.id_tmdb WHERE people_shows_movies.id_trakt_movie = :movieTraktId") override suspend fun getAllForMovie(movieTraktId: Long): List @Query("SELECT * FROM people") override suspend fun getAll(): List @Query("UPDATE people SET id_trakt = :idTrakt WHERE id_tmdb = :idTmdb") override suspend fun updateTraktId(idTrakt: Long, idTmdb: Long) @Query("UPDATE people SET biography_translation = NULL, details_updated_at = NULL") override suspend fun deleteTranslations() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/PeopleImagesDao.kt ================================================ package com.michaldrabik.data_local.database.dao /* ktlint-disable */ import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.PersonImage import com.michaldrabik.data_local.sources.PeopleImagesLocalDataSource @Dao interface PeopleImagesDao : BaseDao, PeopleImagesLocalDataSource { @Query("SELECT updated_at FROM people_images WHERE id_tmdb = :personTmdbId LIMIT 1") override suspend fun getTimestampForPerson(personTmdbId: Long): Long? @Query("SELECT * FROM people_images WHERE id_tmdb = :personTmdbId") override suspend fun getAll(personTmdbId: Long): List @Query("DELETE FROM people_images WHERE id_tmdb == :personTmdbId") override suspend fun deleteAllForPerson(personTmdbId: Long) @Transaction override suspend fun insertSingle(personTmdbId: Long, images: List) { deleteAllForPerson(personTmdbId) insert(images) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/PeopleShowsMoviesDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.PersonShowMovie import com.michaldrabik.data_local.sources.PeopleShowsMoviesLocalDataSource @Dao interface PeopleShowsMoviesDao : BaseDao, PeopleShowsMoviesLocalDataSource { @Query("SELECT updated_at FROM people_shows_movies WHERE id_trakt_show == :showTraktId LIMIT 1") override suspend fun getTimestampForShow(showTraktId: Long): Long? @Query("SELECT updated_at FROM people_shows_movies WHERE id_trakt_movie == :movieTraktId LIMIT 1") override suspend fun getTimestampForMovie(movieTraktId: Long): Long? @Query("DELETE FROM people_shows_movies WHERE id_trakt_show == :showTraktId") override suspend fun deleteAllForShow(showTraktId: Long) @Query("DELETE FROM people_shows_movies WHERE id_trakt_movie == :movieTraktId") override suspend fun deleteAllForMovie(movieTraktId: Long) @Transaction override suspend fun insertForShow(people: List, showTraktId: Long) { deleteAllForShow(showTraktId) insert(people) } @Transaction override suspend fun insertForMovie(people: List, movieTraktId: Long) { deleteAllForMovie(movieTraktId) insert(people) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/RatingsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.Rating import com.michaldrabik.data_local.sources.RatingsLocalDataSource @Dao interface RatingsDao : BaseDao, RatingsLocalDataSource { @Query("SELECT * FROM ratings") override suspend fun getAll(): List @Query("SELECT * FROM ratings WHERE type == :type") override suspend fun getAllByType(type: String): List @Query("SELECT * FROM ratings WHERE id_trakt IN (:idsTrakt) AND type == :type") override suspend fun getAllByType(idsTrakt: List, type: String): List @Query("DELETE FROM ratings WHERE type == :type") override suspend fun deleteAllByType(type: String) @Query("DELETE FROM ratings WHERE id_trakt == :traktId AND type == :type") override suspend fun deleteByType(traktId: Long, type: String) @Transaction override suspend fun replaceAll(ratings: List, type: String) { deleteAllByType(type) insert(ratings) } @Transaction override suspend fun replace(rating: Rating) { deleteByType(rating.idTrakt, rating.type) insert(listOf(rating)) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/RecentSearchDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.RecentSearch import com.michaldrabik.data_local.sources.RecentSearchLocalDataSource @Dao interface RecentSearchDao : RecentSearchLocalDataSource { @Query("SELECT * FROM recent_searches ORDER BY created_at DESC LIMIT :limit") override suspend fun getAll(limit: Int): List @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun upsert(searches: List) @Query("DELETE FROM recent_searches") override suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/RelatedMoviesDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.RelatedMovie import com.michaldrabik.data_local.sources.RelatedMoviesLocalDataSource @Dao interface RelatedMoviesDao : RelatedMoviesLocalDataSource { @Insert(onConflict = OnConflictStrategy.IGNORE) override suspend fun insert(items: List): List @Query("SELECT * FROM movies_related WHERE id_trakt_related_movie == :traktId") override suspend fun getAllById(traktId: Long): List @Query("SELECT * FROM movies_related") override suspend fun getAll(): List @Query("DELETE FROM movies_related WHERE id_trakt_related_movie == :traktId") override suspend fun deleteById(traktId: Long) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/RelatedShowsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.RelatedShow import com.michaldrabik.data_local.sources.RelatedShowsLocalDataSource @Dao interface RelatedShowsDao : RelatedShowsLocalDataSource { @Insert(onConflict = OnConflictStrategy.IGNORE) override suspend fun insert(items: List): List @Query("SELECT * FROM shows_related WHERE id_trakt_related_show == :traktId") override suspend fun getAllById(traktId: Long): List @Query("SELECT * FROM shows_related") override suspend fun getAll(): List @Query("DELETE FROM shows_related WHERE id_trakt_related_show == :traktId") override suspend fun deleteById(traktId: Long) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/SeasonsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import androidx.room.Update import com.michaldrabik.data_local.database.model.Season import com.michaldrabik.data_local.sources.SeasonsLocalDataSource @Dao interface SeasonsDao : SeasonsLocalDataSource { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(items: List): List @Update(onConflict = OnConflictStrategy.REPLACE) override suspend fun update(items: List) @Delete override suspend fun delete(items: List) @Transaction override suspend fun getAllByShowsIds(traktIds: List): List { val result = mutableListOf() val chunks = traktIds.chunked(50) chunks.forEach { chunk -> result += getAllByShowsIdsChunk(chunk) } return result } @Query("SELECT * FROM seasons WHERE id_show_trakt IN (:traktIds)") override suspend fun getAllByShowsIdsChunk(traktIds: List): List @Query("SELECT * FROM seasons WHERE id_show_trakt IN (:traktIds) AND is_watched = 1") override suspend fun getAllWatchedForShows(traktIds: List): List @Query("SELECT id_trakt FROM seasons WHERE id_show_trakt IN (:traktIds) AND is_watched = 1") override suspend fun getAllWatchedIdsForShows(traktIds: List): List @Query("SELECT * FROM seasons WHERE id_show_trakt = :traktId") override suspend fun getAllByShowId(traktId: Long): List @Query("SELECT * FROM seasons WHERE id_trakt = :traktId") override suspend fun getById(traktId: Long): Season? @Transaction override suspend fun upsert(items: List) { val result = insert(items) val updateList = mutableListOf() result.forEachIndexed { index, id -> if (id == -1L) updateList.add(items[index]) } if (updateList.isNotEmpty()) update(updateList) } @Query("DELETE FROM seasons WHERE id_show_trakt = :showTraktId") override suspend fun deleteAllForShow(showTraktId: Long) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/SettingsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.Settings import com.michaldrabik.data_local.sources.SettingsLocalDataSource @Dao interface SettingsDao : SettingsLocalDataSource { @Query("SELECT * FROM settings") override suspend fun getAll(): Settings @Query("SELECT COUNT(*) FROM settings") override suspend fun getCount(): Int @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun upsert(settings: Settings) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/ShowImagesDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.ShowImage import com.michaldrabik.data_local.sources.ShowImagesLocalDataSource @Dao interface ShowImagesDao : ShowImagesLocalDataSource { @Query("SELECT * FROM shows_images WHERE id_tmdb = :tmdbId AND type = :type AND family = 'show'") override suspend fun getByShowId(tmdbId: Long, type: String): ShowImage? @Query("SELECT * FROM shows_images WHERE id_tmdb = :tmdbId AND type = :type AND family = 'episode'") override suspend fun getByEpisodeId(tmdbId: Long, type: String): ShowImage? @Transaction override suspend fun insertShowImage(image: ShowImage) { val localImage = getByShowId(image.idTmdb, image.type) if (localImage != null) { val updated = image.copy(id = localImage.id) upsert(updated) return } upsert(image) } @Transaction override suspend fun insertEpisodeImage(image: ShowImage) { val localImage = getByEpisodeId(image.idTmdb, image.type) if (localImage != null) { val updated = image.copy(id = localImage.id) upsert(updated) return } upsert(image) } @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun upsert(image: ShowImage) @Query("DELETE FROM shows_images WHERE id_tmdb = :id AND type = :type AND family = 'show'") override suspend fun deleteByShowId(id: Long, type: String) @Query("DELETE FROM shows_images WHERE id_tmdb = :id AND type = :type AND family = 'episode'") override suspend fun deleteByEpisodeId(id: Long, type: String) @Query("DELETE FROM shows_images WHERE type = 'poster'") override suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/ShowRatingsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.ShowRatings import com.michaldrabik.data_local.sources.ShowRatingsLocalDataSource @Dao interface ShowRatingsDao : BaseDao, ShowRatingsLocalDataSource { @Transaction override suspend fun upsert(entity: ShowRatings) { val local = getById(entity.idTrakt) if (local != null) { update( listOf( local.copy( trakt = entity.trakt, imdb = entity.imdb, metascore = entity.metascore, rottenTomatoes = entity.rottenTomatoes, rottenTomatoesUrl = entity.rottenTomatoesUrl, updatedAt = entity.updatedAt ) ) ) return } insert(listOf(entity)) } @Query("SELECT * FROM shows_ratings WHERE id_trakt == :traktId") override suspend fun getById(traktId: Long): ShowRatings? } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/ShowStreamingsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.ShowStreaming import com.michaldrabik.data_local.sources.ShowStreamingsLocalDataSource @Dao interface ShowStreamingsDao : BaseDao, ShowStreamingsLocalDataSource { @Transaction override suspend fun replace(traktId: Long, entities: List) { deleteById(traktId) insert(entities) } @Query("SELECT * FROM shows_streamings WHERE id_trakt == :traktId") override suspend fun getById(traktId: Long): List @Query("DELETE FROM shows_streamings WHERE id_trakt == :traktId") override suspend fun deleteById(traktId: Long) @Query("DELETE FROM shows_streamings") override suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/ShowTranslationsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query import com.michaldrabik.data_local.database.model.ShowTranslation import com.michaldrabik.data_local.sources.ShowTranslationsLocalDataSource @Dao interface ShowTranslationsDao : BaseDao, ShowTranslationsLocalDataSource { @Query("SELECT * FROM shows_translations WHERE id_trakt == :traktId AND language == :language") override suspend fun getById(traktId: Long, language: String): ShowTranslation? @Query("SELECT * FROM shows_translations WHERE language == :language") override suspend fun getAll(language: String): List @Insert(onConflict = REPLACE) override suspend fun insertSingle(translation: ShowTranslation) @Query("DELETE FROM shows_translations WHERE language IN (:languages)") override suspend fun deleteByLanguage(languages: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/ShowsDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.Show import com.michaldrabik.data_local.sources.ShowsLocalDataSource @Dao interface ShowsDao : BaseDao, ShowsLocalDataSource { @Query("SELECT * FROM shows") override suspend fun getAll(): List @Query("SELECT * FROM shows WHERE id_trakt IN (:ids)") override suspend fun getAll(ids: List): List @Transaction override suspend fun getAllChunked(ids: List): List = ids .chunked(500) .fold(mutableListOf()) { acc, chunk -> acc += getAll(chunk) acc } @Query("SELECT * FROM shows WHERE id_trakt == :traktId") override suspend fun getById(traktId: Long): Show? @Query("SELECT * FROM shows WHERE id_tmdb == :tmdbId") override suspend fun getByTmdbId(tmdbId: Long): Show? @Query("SELECT * FROM shows WHERE id_slug == :slug") override suspend fun getBySlug(slug: String): Show? @Query("SELECT * FROM shows WHERE id_imdb == :imdbId") override suspend fun getById(imdbId: String): Show? @Query("DELETE FROM shows where id_trakt == :traktId") override suspend fun deleteById(traktId: Long) @Transaction override suspend fun upsert(shows: List) { val result = insert(shows) val updateList = mutableListOf() result.forEachIndexed { index, id -> if (id == -1L) updateList.add(shows[index]) } if (updateList.isNotEmpty()) update(updateList) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/TraktSyncLogDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import com.michaldrabik.data_local.database.model.TraktSyncLog import com.michaldrabik.data_local.sources.TraktSyncLogLocalDataSource @Dao interface TraktSyncLogDao : TraktSyncLogLocalDataSource { @Query("SELECT * FROM sync_trakt_log WHERE type == 'show'") override suspend fun getAllShows(): List @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun insert(log: TraktSyncLog) @Query("UPDATE sync_trakt_log SET synced_at = :syncedAt WHERE id_trakt == :idTrakt AND type == :type") override suspend fun update(idTrakt: Long, type: String, syncedAt: Long): Int @Query("DELETE FROM sync_trakt_log") override suspend fun deleteAll() @Transaction override suspend fun upsertShow(idTrakt: Long, syncedAt: Long) { val result = update(idTrakt, "show", syncedAt) if (result <= 0) { insert(TraktSyncLog(0, idTrakt, "show", syncedAt)) } } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/TraktSyncQueueDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.TraktSyncQueue import com.michaldrabik.data_local.sources.TraktSyncQueueLocalDataSource @Dao interface TraktSyncQueueDao : TraktSyncQueueLocalDataSource { @Insert(onConflict = OnConflictStrategy.IGNORE) override suspend fun insert(items: List): List @Delete override suspend fun delete(items: List) @Query("SELECT * FROM trakt_sync_queue ORDER BY created_at ASC") override suspend fun getAll(): List @Query("SELECT * FROM trakt_sync_queue WHERE type IN (:types) ORDER BY created_at ASC") override suspend fun getAll(types: List): List @Query("DELETE FROM trakt_sync_queue WHERE id_trakt IN (:idsTrakt) AND type = :type") override suspend fun deleteAll(idsTrakt: List, type: String): Int @Query("DELETE FROM trakt_sync_queue WHERE type = :type") override suspend fun deleteAll(type: String): Int @Query("DELETE FROM trakt_sync_queue") override suspend fun deleteAll() @Query("DELETE FROM trakt_sync_queue WHERE id_list = :idList") override suspend fun deleteAllForList(idList: Long): Int @Query("DELETE FROM trakt_sync_queue WHERE id_trakt = :idTrakt AND id_list = :idList AND type = :type AND operation = :operation") override suspend fun delete( idTrakt: Long, idList: Long, type: String, operation: String ): Int } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/TranslationsMoviesSyncLogDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.TranslationsMoviesSyncLog import com.michaldrabik.data_local.sources.TranslationsMoviesSyncLogLocalDataSource @Dao interface TranslationsMoviesSyncLogDao : TranslationsMoviesSyncLogLocalDataSource { @Query("SELECT * from sync_movies_translations_log") override suspend fun getAll(): List @Query("SELECT * from sync_movies_translations_log WHERE id_movie_trakt == :idTrakt") override suspend fun getById(idTrakt: Long): TranslationsMoviesSyncLog? @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun upsert(log: TranslationsMoviesSyncLog) @Query("DELETE FROM sync_movies_translations_log") override suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/TranslationsSyncLogDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.TranslationsSyncLog import com.michaldrabik.data_local.sources.TranslationsShowsSyncLogLocalDataSource @Dao interface TranslationsSyncLogDao : TranslationsShowsSyncLogLocalDataSource { @Query("SELECT * from sync_translations_log") override suspend fun getAll(): List @Query("SELECT * from sync_translations_log WHERE id_show_trakt == :idTrakt") override suspend fun getById(idTrakt: Long): TranslationsSyncLog? @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun upsert(log: TranslationsSyncLog) @Query("DELETE FROM sync_translations_log") override suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/UserDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.User import com.michaldrabik.data_local.sources.UserLocalDataSource @Dao interface UserDao : UserLocalDataSource { @Query("SELECT * FROM user WHERE id == 1") override suspend fun get(): User? @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun upsert(user: User) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/WatchlistMoviesDao.kt ================================================ package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.Movie import com.michaldrabik.data_local.database.model.WatchlistMovie import com.michaldrabik.data_local.sources.WatchlistMoviesLocalDataSource @Dao interface WatchlistMoviesDao : WatchlistMoviesLocalDataSource { @Query("SELECT movies.*, movies_see_later.created_at, movies_see_later.updated_at FROM movies INNER JOIN movies_see_later USING(id_trakt)") override suspend fun getAll(): List @Query("SELECT movies.id_trakt FROM movies INNER JOIN movies_see_later USING(id_trakt)") override suspend fun getAllTraktIds(): List @Query("SELECT movies.* FROM movies INNER JOIN movies_see_later ON movies_see_later.id_trakt == movies.id_trakt WHERE movies.id_trakt == :traktId") override suspend fun getById(traktId: Long): Movie? @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun insert(movie: WatchlistMovie) @Query("DELETE FROM movies_see_later WHERE id_trakt == :traktId") override suspend fun deleteById(traktId: Long) @Query("SELECT EXISTS(SELECT 1 FROM movies_see_later WHERE id_trakt = :traktId LIMIT 1);") override suspend fun checkExists(traktId: Long): Boolean } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/dao/WatchlistShowsDao.kt ================================================ // ktlint-disable max-line-length package com.michaldrabik.data_local.database.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.michaldrabik.data_local.database.model.Show import com.michaldrabik.data_local.database.model.WatchlistShow import com.michaldrabik.data_local.sources.WatchlistShowsLocalDataSource @Dao interface WatchlistShowsDao : WatchlistShowsLocalDataSource { @Query("SELECT shows.*, shows_see_later.created_at AS created_at, shows_see_later.updated_at AS updated_at FROM shows INNER JOIN shows_see_later USING(id_trakt)") override suspend fun getAll(): List @Query("SELECT shows.id_trakt FROM shows INNER JOIN shows_see_later USING(id_trakt)") override suspend fun getAllTraktIds(): List @Query("SELECT shows.* FROM shows INNER JOIN shows_see_later ON shows_see_later.id_trakt == shows.id_trakt WHERE shows.id_trakt == :traktId") override suspend fun getById(traktId: Long): Show? @Insert(onConflict = OnConflictStrategy.REPLACE) override suspend fun insert(show: WatchlistShow) @Query("DELETE FROM shows_see_later WHERE id_trakt == :traktId") override suspend fun deleteById(traktId: Long) @Query("SELECT EXISTS(SELECT 1 FROM shows_see_later WHERE id_trakt = :traktId LIMIT 1);") override suspend fun checkExists(traktId: Long): Boolean } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/migrations/Migrations.kt ================================================ package com.michaldrabik.data_local.database.migrations import android.content.Context import android.content.Context.MODE_PRIVATE import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase const val DATABASE_VERSION = 38 const val DATABASE_NAME = "SHOWLY2_DB_2" class Migrations(context: Context) { private val migration2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE settings ADD COLUMN show_anticipated_shows INTEGER NOT NULL DEFAULT 1") } } private val migration3 = object : Migration(2, 3) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE actors ADD COLUMN id_imdb TEXT") } } private val migration4 = object : Migration(3, 4) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE settings ADD COLUMN trakt_sync_schedule TEXT NOT NULL DEFAULT 'OFF'") } } private val migration5 = object : Migration(4, 5) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE settings ADD COLUMN my_shows_running_is_enabled INTEGER NOT NULL DEFAULT 1") database.execSQL("ALTER TABLE settings ADD COLUMN my_shows_incoming_is_enabled INTEGER NOT NULL DEFAULT 1") database.execSQL("ALTER TABLE settings ADD COLUMN my_shows_ended_is_enabled INTEGER NOT NULL DEFAULT 1") database.execSQL("ALTER TABLE settings ADD COLUMN my_shows_recent_is_enabled INTEGER NOT NULL DEFAULT 1") } } private val migration6 = object : Migration(5, 6) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("CREATE INDEX index_episodes_id_show_trakt ON episodes(id_show_trakt)") database.execSQL("CREATE INDEX index_seasons_id_show_trakt ON seasons(id_show_trakt)") } } private val migration7 = object : Migration(6, 7) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE settings ADD COLUMN discover_filter_genres TEXT NOT NULL DEFAULT ''") database.execSQL("ALTER TABLE settings ADD COLUMN discover_filter_feed TEXT NOT NULL DEFAULT 'HOT'") database.execSQL("ALTER TABLE settings ADD COLUMN trakt_quick_sync_enabled INTEGER NOT NULL DEFAULT 0") database.execSQL( "CREATE TABLE IF NOT EXISTS `trakt_sync_queue` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL, " + "`type` TEXT NOT NULL, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL)" ) } } private val migration8 = object : Migration(7, 8) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE settings ADD COLUMN watchlist_sort_by TEXT NOT NULL DEFAULT 'NAME'") } } private val migration9 = object : Migration(8, 9) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE settings ADD COLUMN trakt_quick_remove_enabled INTEGER NOT NULL DEFAULT 0") } } private val migration10 = object : Migration(9, 10) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( "CREATE TABLE IF NOT EXISTS `shows_archive` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL, " + "FOREIGN KEY(`id_trakt`) REFERENCES `shows`(`id_trakt`) ON DELETE CASCADE)" ) database.execSQL("CREATE UNIQUE INDEX index_shows_archive_id_trakt ON shows_archive(id_trakt)") database.execSQL("ALTER TABLE settings ADD COLUMN archive_shows_sort_by TEXT NOT NULL DEFAULT 'NAME'") } } private val migration11 = object : Migration(10, 11) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE settings ADD COLUMN archive_shows_include_statistics INTEGER NOT NULL DEFAULT 1") } } private val migration12 = object : Migration(11, 12) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE settings ADD COLUMN special_seasons_enabled INTEGER NOT NULL DEFAULT 0") } } private val migration13 = object : Migration(12, 13) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( "CREATE TABLE IF NOT EXISTS `shows_translations` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL, `title` TEXT NOT NULL, `language` TEXT NOT NULL, `overview` TEXT NOT NULL, " + "`created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, " + "FOREIGN KEY(`id_trakt`) REFERENCES `shows`(`id_trakt`) ON DELETE CASCADE)" ) database.execSQL("CREATE UNIQUE INDEX index_shows_translations_id_trakt ON shows_translations(id_trakt)") database.execSQL( "CREATE TABLE IF NOT EXISTS `episodes_translations` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL, `id_trakt_show` INTEGER NOT NULL, " + "`title` TEXT NOT NULL, `language` TEXT NOT NULL, `overview` TEXT NOT NULL, " + "`created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, " + "FOREIGN KEY(`id_trakt_show`) REFERENCES `shows`(`id_trakt`) ON DELETE CASCADE)" ) database.execSQL("CREATE UNIQUE INDEX index_episodes_translations_id_trakt ON episodes_translations(id_trakt)") database.execSQL("CREATE INDEX index_episodes_translations_id_trakt_show ON episodes_translations(id_trakt_show)") database.execSQL( "CREATE TABLE IF NOT EXISTS `sync_translations_log` (" + "`id_show_trakt` INTEGER PRIMARY KEY NOT NULL, " + "`synced_at` INTEGER NOT NULL)" ) } } private val migration14 = object : Migration(13, 14) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE shows_images ADD COLUMN source TEXT NOT NULL DEFAULT 'tvdb'") } } private val migration15 = object : Migration(14, 15) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE settings ADD COLUMN show_anticipated_movies INTEGER NOT NULL DEFAULT 0") database.execSQL("ALTER TABLE settings ADD COLUMN discover_movies_filter_genres TEXT NOT NULL DEFAULT ''") database.execSQL("ALTER TABLE settings ADD COLUMN discover_movies_filter_feed TEXT NOT NULL DEFAULT 'HOT'") database.execSQL("ALTER TABLE settings ADD COLUMN my_movies_all_sort_by TEXT NOT NULL DEFAULT 'NAME'") database.execSQL("ALTER TABLE settings ADD COLUMN see_later_movies_sort_by TEXT NOT NULL DEFAULT 'NAME'") database.execSQL("ALTER TABLE settings ADD COLUMN progress_movies_sort_by TEXT NOT NULL DEFAULT 'NAME'") database.execSQL( "CREATE TABLE IF NOT EXISTS `movies` (" + "`id_trakt` INTEGER PRIMARY KEY NOT NULL, " + "`id_tmdb` INTEGER NOT NULL DEFAULT -1, " + "`id_imdb` TEXT NOT NULL DEFAULT '', " + "`id_slug` TEXT NOT NULL DEFAULT '', " + "`title` TEXT NOT NULL DEFAULT '', " + "`year` INTEGER NOT NULL DEFAULT -1, " + "`overview` TEXT NOT NULL DEFAULT '', " + "`released` TEXT NOT NULL DEFAULT '', " + "`runtime` INTEGER NOT NULL DEFAULT -1, " + "`country` TEXT NOT NULL DEFAULT '', " + "`trailer` TEXT NOT NULL DEFAULT '', " + "`language` TEXT NOT NULL DEFAULT '', " + "`homepage` TEXT NOT NULL DEFAULT '', " + "`status` TEXT NOT NULL DEFAULT '', " + "`rating` REAL NOT NULL DEFAULT -1, " + "`votes` INTEGER NOT NULL DEFAULT -1, " + "`comment_count` INTEGER NOT NULL DEFAULT -1, " + "`genres` TEXT NOT NULL DEFAULT '', " + "`updated_at` INTEGER NOT NULL DEFAULT -1)" ) database.execSQL( "CREATE TABLE IF NOT EXISTS `movies_discover` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL DEFAULT -1, " + "`created_at` INTEGER NOT NULL DEFAULT -1, " + "`updated_at` INTEGER NOT NULL DEFAULT -1, " + "FOREIGN KEY(`id_trakt`) REFERENCES `movies`(`id_trakt`) ON DELETE CASCADE)" ) database.execSQL("CREATE INDEX index_discover_movies_id_trakt ON movies_discover(id_trakt)") database.execSQL( "CREATE TABLE IF NOT EXISTS `movies_images` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_tmdb` INTEGER NOT NULL DEFAULT -1, " + "`type` TEXT NOT NULL DEFAULT '', " + "`file_url` TEXT NOT NULL DEFAULT '', " + "`source` TEXT NOT NULL DEFAULT 'tmdb')" ) database.execSQL( "CREATE TABLE IF NOT EXISTS `movies_translations` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL, `title` TEXT NOT NULL, " + "`language` TEXT NOT NULL, " + "`overview` TEXT NOT NULL, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL, " + "FOREIGN KEY(`id_trakt`) REFERENCES `movies`(`id_trakt`) ON DELETE CASCADE)" ) database.execSQL("CREATE UNIQUE INDEX index_movies_translations_id_trakt ON movies_translations(id_trakt)") database.execSQL( "CREATE TABLE IF NOT EXISTS `sync_movies_translations_log` (" + "`id_movie_trakt` INTEGER PRIMARY KEY NOT NULL, " + "`synced_at` INTEGER NOT NULL)" ) database.execSQL( "CREATE TABLE IF NOT EXISTS `movies_related` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL DEFAULT -1, " + "`id_trakt_related_movie` INTEGER NOT NULL DEFAULT -1, " + "`updated_at` INTEGER NOT NULL DEFAULT -1, " + "FOREIGN KEY(`id_trakt_related_movie`) REFERENCES `movies`(`id_trakt`) ON DELETE CASCADE)" ) database.execSQL("CREATE INDEX index_movies_related_id_trakt ON movies_related(id_trakt_related_movie)") database.execSQL("ALTER TABLE actors ADD COLUMN id_tmdb_movie INTEGER NOT NULL DEFAULT -1") database.execSQL("ALTER TABLE actors ADD COLUMN id_tmdb INTEGER NOT NULL DEFAULT -1") database.execSQL( "CREATE TABLE IF NOT EXISTS `movies_my_movies` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL DEFAULT -1, " + "`created_at` INTEGER NOT NULL DEFAULT -1, " + "`updated_at` INTEGER NOT NULL DEFAULT -1, " + "FOREIGN KEY(`id_trakt`) REFERENCES `movies`(`id_trakt`) ON DELETE CASCADE)" ) database.execSQL("CREATE INDEX index_movies_my_movies_id_trakt ON movies_my_movies(id_trakt)") database.execSQL( "CREATE TABLE IF NOT EXISTS `movies_see_later` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL DEFAULT -1, " + "`created_at` INTEGER NOT NULL DEFAULT -1, " + "`updated_at` INTEGER NOT NULL DEFAULT -1, " + "FOREIGN KEY(`id_trakt`) REFERENCES `movies`(`id_trakt`) ON DELETE CASCADE)" ) database.execSQL("CREATE INDEX index_movies_see_later_id_trakt ON movies_see_later(id_trakt)") database.execSQL( "CREATE TABLE IF NOT EXISTS `sync_movies_log` (" + "`id_movie_trakt` INTEGER PRIMARY KEY NOT NULL DEFAULT -1, " + "`synced_at` INTEGER NOT NULL DEFAULT 0)" ) database.execSQL( "CREATE TABLE IF NOT EXISTS `sync_trakt_log` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL, " + "`type` TEXT NOT NULL, " + "`synced_at` INTEGER NOT NULL)" ) database.execSQL("CREATE INDEX index_sync_trakt_log_id_trakt ON sync_trakt_log(id_trakt)") database.execSQL("CREATE INDEX index_sync_trakt_log_type ON sync_trakt_log(type)") database.execSQL("CREATE UNIQUE INDEX index_sync_trakt_log_id_trakt_type ON sync_trakt_log(id_trakt, type)") } } private val migration16 = object : Migration(15, 16) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE settings ADD COLUMN show_collection_shows INTEGER NOT NULL DEFAULT 1") database.execSQL("ALTER TABLE settings ADD COLUMN show_collection_movies INTEGER NOT NULL DEFAULT 1") } } private val migration17 = object : Migration(16, 17) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE settings ADD COLUMN widgets_show_label INTEGER NOT NULL DEFAULT 1") } } private val migration18 = object : Migration(17, 18) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL("ALTER TABLE actors ADD COLUMN id_tmdb_show INTEGER NOT NULL DEFAULT -1") execSQL("DELETE FROM actors") execSQL("ALTER TABLE shows_images ADD COLUMN id_tmdb INTEGER NOT NULL DEFAULT -1") execSQL("DELETE FROM shows_images WHERE source = 'tvdb' OR family = 'movie'") execSQL("CREATE INDEX index_shows_images_tmdb_id_type_family ON shows_images(id_tmdb, type, family)") execSQL("CREATE INDEX index_movies_images_tmdb_id_type ON movies_images(id_tmdb, type)") } } } private val migration19 = object : Migration(18, 19) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL("ALTER TABLE settings ADD COLUMN my_movies_recent_is_enabled INTEGER NOT NULL DEFAULT 1") } } } private val migration20 = object : Migration(19, 20) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL( "CREATE TABLE IF NOT EXISTS `custom_images` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL, " + "`family` TEXT NOT NULL, " + "`type` TEXT NOT NULL, " + "`file_url` TEXT NOT NULL)" ) execSQL("CREATE INDEX index_custom_images_trakt_id_family_type ON custom_images(id_trakt, family, type)") } } } private val migration21 = object : Migration(20, 21) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL("ALTER TABLE settings ADD COLUMN quick_rate_enabled INTEGER NOT NULL DEFAULT 0") } } } private val migration22 = object : Migration(21, 22) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL("ALTER TABLE shows ADD COLUMN created_at INTEGER NOT NULL DEFAULT -1") val cursor = database.query("SELECT id_trakt, updated_at FROM shows") while (cursor.moveToNext()) { val id = cursor.getLong(cursor.getColumnIndexOrThrow("id_trakt")) val updatedAt = cursor.getLong(cursor.getColumnIndexOrThrow("updated_at")) execSQL("UPDATE shows SET created_at = $updatedAt WHERE id_trakt == $id") } } } } private val migration23 = object : Migration(22, 23) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL( "CREATE TABLE IF NOT EXISTS `custom_lists` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER, " + "`id_slug` TEXT NOT NULL, " + "`name` TEXT NOT NULL, " + "`description` TEXT, " + "`privacy` TEXT NOT NULL, " + "`display_numbers` INTEGER NOT NULL, " + "`allow_comments` INTEGER NOT NULL, " + "`sort_by` TEXT NOT NULL, " + "`sort_how` TEXT NOT NULL, " + "`sort_by_local` TEXT NOT NULL, " + "`sort_how_local` TEXT NOT NULL, " + "`filter_type_local` TEXT NOT NULL, " + "`item_count` INTEGER NOT NULL, " + "`comment_count` INTEGER NOT NULL, " + "`likes` INTEGER NOT NULL, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL" + ")" ) execSQL("CREATE UNIQUE INDEX index_custom_lists_id_trakt ON custom_lists(id_trakt)") execSQL("ALTER TABLE settings ADD COLUMN lists_sort_by TEXT NOT NULL DEFAULT 'DATE_UPDATED'") } } } private val migration24 = object : Migration(23, 24) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL( "CREATE TABLE IF NOT EXISTS `custom_list_item` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_list` INTEGER NOT NULL, " + "`id_trakt` INTEGER NOT NULL, " + "`type` TEXT NOT NULL, " + "`rank` INTEGER NOT NULL, " + "`listed_at` INTEGER NOT NULL, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL, " + "FOREIGN KEY(`id_list`) REFERENCES `custom_lists`(`id`) ON DELETE CASCADE" + ")" ) execSQL("CREATE INDEX index_custom_list_item_id_list ON custom_list_item(id_list)") execSQL("CREATE INDEX index_custom_list_item_id_trakt_type ON custom_list_item(id_trakt, type)") execSQL("CREATE UNIQUE INDEX index_custom_list_item_id_list_id_trakt_type ON custom_list_item(id_list, id_trakt, type)") execSQL("ALTER TABLE trakt_sync_queue ADD COLUMN id_list INTEGER") execSQL("ALTER TABLE trakt_sync_queue ADD COLUMN operation TEXT NOT NULL DEFAULT ''") } } } private val migration25 = object : Migration(24, 25) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL( "CREATE TABLE IF NOT EXISTS `news` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_news` TEXT NOT NULL, " + "`title` TEXT NOT NULL, " + "`url` TEXT NOT NULL, " + "`type` TEXT NOT NULL, " + "`image` TEXT, " + "`score` INTEGER NOT NULL, " + "`dated_at` INTEGER NOT NULL, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL " + ")" ) execSQL("ALTER TABLE user ADD COLUMN reddit_token TEXT NOT NULL DEFAULT ''") execSQL("ALTER TABLE user ADD COLUMN reddit_token_timestamp INTEGER NOT NULL DEFAULT 0") } } } private val migration26 = object : Migration(25, 26) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL("ALTER TABLE settings ADD COLUMN progress_upcoming_enabled INTEGER NOT NULL DEFAULT 1") } } } private val migration27 = object : Migration(26, 27) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL( "CREATE TABLE IF NOT EXISTS `movies_ratings` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL, " + "`trakt` TEXT, " + "`imdb` TEXT, " + "`metascore` TEXT, " + "`rotten_tomatoes` TEXT, " + "`rotten_tomatoes_url` TEXT, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL, " + "FOREIGN KEY(`id_trakt`) REFERENCES `movies`(`id_trakt`) ON DELETE CASCADE)" ) execSQL("CREATE UNIQUE INDEX index_movies_ratings_id_trakt ON movies_ratings(id_trakt)") execSQL( "CREATE TABLE IF NOT EXISTS `shows_ratings` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL, " + "`trakt` TEXT, " + "`imdb` TEXT, " + "`metascore` TEXT, " + "`rotten_tomatoes` TEXT, " + "`rotten_tomatoes_url` TEXT, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL, " + "FOREIGN KEY(`id_trakt`) REFERENCES `shows`(`id_trakt`) ON DELETE CASCADE)" ) execSQL("CREATE UNIQUE INDEX index_shows_ratings_id_trakt ON shows_ratings(id_trakt)") } } } private val migration28 = object : Migration(27, 28) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL( "CREATE TABLE IF NOT EXISTS `movies_streamings` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL, " + "`id_tmdb` INTEGER NOT NULL, " + "`type` TEXT, " + "`provider_id` INTEGER, " + "`provider_name` TEXT, " + "`display_priority` INTEGER, " + "`logo_path` TEXT, " + "`link` TEXT, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL, " + "FOREIGN KEY(`id_trakt`) REFERENCES `movies`(`id_trakt`) ON DELETE CASCADE)" ) execSQL("CREATE INDEX index_movies_streamings_id_trakt ON movies_streamings(id_trakt)") execSQL("CREATE INDEX index_movies_streamings_id_tmdb ON movies_streamings(id_tmdb)") execSQL( "CREATE TABLE IF NOT EXISTS `shows_streamings` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL, " + "`id_tmdb` INTEGER NOT NULL, " + "`type` TEXT, " + "`provider_id` INTEGER, " + "`provider_name` TEXT, " + "`display_priority` INTEGER, " + "`logo_path` TEXT, " + "`link` TEXT, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL, " + "FOREIGN KEY(`id_trakt`) REFERENCES `shows`(`id_trakt`) ON DELETE CASCADE)" ) execSQL("CREATE INDEX index_shows_streamings_id_trakt ON shows_streamings(id_trakt)") execSQL("CREATE INDEX index_shows_streamings_id_tmdb ON shows_streamings(id_tmdb)") } } } private val migration29 = object : Migration(28, 29) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL("ALTER TABLE movies ADD COLUMN created_at INTEGER NOT NULL DEFAULT -1") val cursor = database.query("SELECT id_trakt, updated_at FROM movies") while (cursor.moveToNext()) { val id = cursor.getLong(cursor.getColumnIndexOrThrow("id_trakt")) val updatedAt = cursor.getLong(cursor.getColumnIndexOrThrow("updated_at")) execSQL("UPDATE movies SET created_at = $updatedAt WHERE id_trakt == $id") } execSQL( "CREATE TABLE IF NOT EXISTS `movies_archive` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL, " + "FOREIGN KEY(`id_trakt`) REFERENCES `movies`(`id_trakt`) ON DELETE CASCADE)" ) execSQL("CREATE UNIQUE INDEX index_movies_archive_id_trakt ON movies_archive(id_trakt)") } } } private val migration30 = object : Migration(29, 30) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL("DROP TABLE actors") execSQL( "CREATE TABLE IF NOT EXISTS `people` (" + "`id_tmdb` INTEGER PRIMARY KEY NOT NULL, " + "`id_trakt` INTEGER, " + "`id_imdb` TEXT, " + "`name` TEXT NOT NULL, " + "`department` TEXT NOT NULL, " + "`biography` TEXT, " + "`biography_translation` TEXT, " + "`birthday` TEXT, " + "`character` TEXT, " + "`job` TEXT, " + "`episodes_count` INTEGER, " + "`birthplace` TEXT, " + "`deathday` TEXT, " + "`image_path` TEXT, " + "`homepage` TEXT, " + "`created_at` INTEGER NOT NULL, " + "`details_updated_at` INTEGER, " + "`updated_at` INTEGER NOT NULL)" ) execSQL("CREATE INDEX index_people_id_trakt ON people(id_trakt)") execSQL("CREATE UNIQUE INDEX index_people_id_tmdb ON people(id_tmdb)") execSQL( "CREATE TABLE IF NOT EXISTS `people_shows_movies` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_tmdb_person` INTEGER NOT NULL, " + "`mode` TEXT NOT NULL, " + "`department` TEXT NOT NULL, " + "`character` TEXT, " + "`job` TEXT, " + "`episodes_count` INTEGER NOT NULL, " + "`id_trakt_show` INTEGER, " + "`id_trakt_movie` INTEGER, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL, " + "FOREIGN KEY(`id_tmdb_person`) REFERENCES `people`(`id_tmdb`) ON DELETE CASCADE)" ) execSQL("CREATE INDEX index_people_shows_movies_id_show_mode ON people_shows_movies(id_trakt_show, mode)") execSQL("CREATE INDEX index_people_shows_movies_id_movie_mode ON people_shows_movies(id_trakt_movie, mode)") execSQL( "CREATE TABLE IF NOT EXISTS `people_credits` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt_person` INTEGER NOT NULL, " + "`id_trakt_show` INTEGER, " + "`id_trakt_movie` INTEGER, " + "`type` TEXT NOT NULL, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL, " + "FOREIGN KEY(`id_trakt_show`) REFERENCES `shows`(`id_trakt`) ON DELETE CASCADE, " + "FOREIGN KEY(`id_trakt_movie`) REFERENCES `movies`(`id_trakt`) ON DELETE CASCADE)" ) execSQL("CREATE INDEX index_people_credits_id_person ON people_credits(id_trakt_person)") execSQL( "CREATE TABLE IF NOT EXISTS `people_images` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_tmdb` INTEGER NOT NULL, " + "`file_path` TEXT NOT NULL, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL)" ) execSQL("CREATE INDEX index_people_images_id_person ON people_images(id_tmdb)") } } } private val migration31 = object : Migration(30, 31) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL( "CREATE TABLE IF NOT EXISTS `ratings` (" + "`id_trakt` INTEGER NOT NULL, " + "`type` TEXT NOT NULL, " + "`rating` INTEGER NOT NULL, " + "`season_number` INTEGER, " + "`episode_number` INTEGER, " + "`rated_at` INTEGER NOT NULL, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL, " + "PRIMARY KEY (id_trakt, type))" ) execSQL("CREATE INDEX index_ratings_id_trakt_type ON ratings(id_trakt, type)") execSQL("ALTER TABLE seasons ADD COLUMN rating REAL") execSQL("CREATE INDEX index_people_credits_show ON people_credits(id_trakt_show)") execSQL("CREATE INDEX index_people_credits_movies ON people_credits(id_trakt_movie)") execSQL("CREATE INDEX index_people_shows_movies_tmdb_person ON people_shows_movies(id_tmdb_person)") } } } private val migration32 = object : Migration(31, 32) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE episodes ADD COLUMN episode_number_abs INTEGER") } } private val migration33 = object : Migration(32, 33) { override fun migrate(database: SupportSQLiteDatabase) { val preferences = context.applicationContext.getSharedPreferences("PREFERENCES_NETWORK", MODE_PRIVATE) val preferencesEditor = preferences.edit() val cursor = database.query("SELECT trakt_token, trakt_refresh_token FROM user") while (cursor.moveToNext()) { val accessToken = cursor.getString(cursor.getColumnIndexOrThrow("trakt_token")) val refreshToken = cursor.getString(cursor.getColumnIndexOrThrow("trakt_refresh_token")) accessToken?.let { preferencesEditor.putString("TRAKT_ACCESS_TOKEN", it) } refreshToken?.let { preferencesEditor.putString("TRAKT_REFRESH_TOKEN", it) } } preferencesEditor.apply() } } private val migration34 = object : Migration(33, 34) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL("ALTER TABLE episodes ADD COLUMN last_watched_at INTEGER") execSQL("ALTER TABLE shows_my_shows ADD COLUMN last_watched_at INTEGER") val cursor = database.query("SELECT id_trakt, updated_at FROM shows_my_shows") while (cursor.moveToNext()) { val idShow = cursor.getLong(cursor.getColumnIndexOrThrow("id_trakt")) val updatedAt = cursor.getLong(cursor.getColumnIndexOrThrow("updated_at")) val epCursor = database.query( "SELECT season_number, episode_number FROM episodes WHERE id_show_trakt == $idShow AND is_watched == 1 " + "ORDER BY season_number DESC, episode_number DESC LIMIT 1" ) while (epCursor.moveToNext()) { val episodeNumber = epCursor.getLong(epCursor.getColumnIndexOrThrow("episode_number")) val seasonNumber = epCursor.getLong(epCursor.getColumnIndexOrThrow("season_number")) if (updatedAt > 0 && episodeNumber > 0 && seasonNumber > 0) { execSQL( "UPDATE episodes SET last_watched_at = $updatedAt " + "WHERE id_show_trakt == $idShow " + "AND is_watched == 1 " + "AND episode_number == $episodeNumber AND season_number == $seasonNumber" ) execSQL("UPDATE shows_my_shows SET last_watched_at = $updatedAt WHERE id_trakt == $idShow") } } } } } } private val migration35 = object : Migration(34, 35) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE settings ADD COLUMN discover_filter_networks TEXT NOT NULL DEFAULT ''") } } private val migration36 = object : Migration(35, 36) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { execSQL( "CREATE TABLE IF NOT EXISTS `movies_collections` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL, " + "`id_trakt_movie` INTEGER NOT NULL, " + "`name` TEXT NOT NULL, " + "`description` TEXT NOT NULL, " + "`item_count` INTEGER NOT NULL, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL, " + "FOREIGN KEY(`id_trakt_movie`) REFERENCES `movies`(`id_trakt`) ON DELETE CASCADE)" ) execSQL("CREATE INDEX index_movies_collections_id_trakt ON movies_collections(id_trakt)") execSQL("CREATE INDEX index_movies_collections_id_trakt_movie ON movies_collections(id_trakt_movie)") execSQL( "CREATE TABLE IF NOT EXISTS `movies_collections_items` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`id_trakt` INTEGER NOT NULL, " + "`id_trakt_collection` INTEGER NOT NULL, " + "`rank` INTEGER NOT NULL, " + "`created_at` INTEGER NOT NULL, " + "`updated_at` INTEGER NOT NULL, " + "FOREIGN KEY(`id_trakt`) REFERENCES `movies`(`id_trakt`) ON DELETE CASCADE)" ) execSQL("CREATE INDEX index_movies_collections_items_id_trakt ON movies_collections_items(id_trakt)") execSQL("CREATE INDEX index_movies_collections_items_id_trakt_collection ON movies_collections_items(id_trakt_collection)") } } } private val migration37 = object : Migration(36, 37) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { val cursor = database.query("SELECT my_shows_recent_is_enabled, my_movies_recent_is_enabled FROM settings") while (cursor.moveToNext()) { val myShowsRecentsEnabled = cursor.getInt(cursor.getColumnIndexOrThrow("my_shows_recent_is_enabled")) val myMoviesRecentsEnabled = cursor.getInt(cursor.getColumnIndexOrThrow("my_movies_recent_is_enabled")) if (myShowsRecentsEnabled != 1 && myMoviesRecentsEnabled != 1) { execSQL("UPDATE settings SET my_shows_recent_amount = 0") } } } } } private val migration38 = object : Migration(37, 38) { override fun migrate(database: SupportSQLiteDatabase) { with(database) { val cursor = database.query("SELECT progress_upcoming_enabled FROM settings") while (cursor.moveToNext()) { val isEnabled = cursor.getInt(cursor.getColumnIndexOrThrow("progress_upcoming_enabled")) if (isEnabled != 1) { val preferences = context.applicationContext.getSharedPreferences("PREFERENCES_MISC", MODE_PRIVATE) preferences.edit().apply { putLong("PROGRESS_UPCOMING_DAYS", 0) apply() } } } } } } fun getAll() = listOf( migration2, migration3, migration4, migration5, migration6, migration7, migration8, migration9, migration10, migration11, migration12, migration13, migration14, migration15, migration16, migration17, migration18, migration19, migration20, migration21, migration22, migration23, migration24, migration25, migration26, migration27, migration28, migration29, migration30, migration31, migration32, migration33, migration34, migration35, migration36, migration37, migration38 ) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/ArchiveMovie.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey @Entity( tableName = "movies_archive", indices = [Index(value = ["id_trakt"], unique = true)], foreignKeys = [ ForeignKey( entity = Movie::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) data class ArchiveMovie( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long ) { companion object { fun fromTraktId(traktId: Long, createdAt: Long) = ArchiveMovie( idTrakt = traktId, createdAt = createdAt, updatedAt = createdAt ) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/ArchiveShow.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey @Entity( tableName = "shows_archive", indices = [Index(value = ["id_trakt"], unique = true)], foreignKeys = [ ForeignKey( entity = Show::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) data class ArchiveShow( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long ) { companion object { fun fromTraktId(traktId: Long, createdAt: Long) = ArchiveShow( idTrakt = traktId, createdAt = createdAt, updatedAt = createdAt ) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/CustomImage.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @Entity( tableName = "custom_images", indices = [ Index(value = ["id_trakt", "family", "type"]) ] ) data class CustomImage( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "family") val family: String, @ColumnInfo(name = "type") val type: String, @ColumnInfo(name = "file_url") val fileUrl: String ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/CustomList.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @Entity( tableName = "custom_lists", indices = [ Index(value = ["id_trakt"], unique = true) ] ) data class CustomList( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt") val idTrakt: Long?, @ColumnInfo(name = "id_slug") val idSlug: String, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "description") val description: String?, @ColumnInfo(name = "privacy") val privacy: String, @ColumnInfo(name = "display_numbers") val displayNumbers: Boolean, @ColumnInfo(name = "allow_comments") val allowComments: Boolean, @ColumnInfo(name = "sort_by") val sortBy: String, @ColumnInfo(name = "sort_how") val sortHow: String, @ColumnInfo(name = "sort_by_local") val sortByLocal: String, @ColumnInfo(name = "sort_how_local") val sortHowLocal: String, @ColumnInfo(name = "filter_type_local") val filterTypeLocal: String, @ColumnInfo(name = "item_count") val itemCount: Long, @ColumnInfo(name = "comment_count") val commentCount: Long, @ColumnInfo(name = "likes") val likes: Long, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/CustomListItem.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey @Entity( tableName = "custom_list_item", indices = [ Index(value = ["id_list"], unique = false), Index(value = ["id_trakt", "type"], unique = false), Index(value = ["id_list", "id_trakt", "type"], unique = true) ], foreignKeys = [ ForeignKey( entity = CustomList::class, parentColumns = arrayOf("id"), childColumns = arrayOf("id_list"), onDelete = ForeignKey.CASCADE ) ] ) data class CustomListItem( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_list") val idList: Long, @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "type") val type: String, @ColumnInfo(name = "rank") val rank: Long, @ColumnInfo(name = "listed_at") val listedAt: Long, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/DiscoverMovie.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey @Entity( tableName = "movies_discover", foreignKeys = [ ForeignKey( entity = Movie::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) data class DiscoverMovie( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt", defaultValue = "-1", index = true) val idTrakt: Long, @ColumnInfo(name = "created_at", defaultValue = "-1") val createdAt: Long, @ColumnInfo(name = "updated_at", defaultValue = "-1") val updatedAt: Long ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/DiscoverShow.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey @Entity( tableName = "shows_discover", foreignKeys = [ ForeignKey( entity = Show::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) data class DiscoverShow( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt", defaultValue = "-1", index = true) val idTrakt: Long, @ColumnInfo(name = "created_at", defaultValue = "-1") val createdAt: Long, @ColumnInfo(name = "updated_at", defaultValue = "-1") val updatedAt: Long ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/Episode.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.ForeignKey.Companion.CASCADE import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.michaldrabik.data_local.database.converters.DateConverter import java.time.ZonedDateTime @Entity( tableName = "episodes", foreignKeys = [ ForeignKey( entity = Season::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_season"), onDelete = CASCADE ) ], indices = [ Index("id_season"), Index("id_show_trakt") ] ) @TypeConverters(DateConverter::class) data class Episode( @PrimaryKey @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "id_season") val idSeason: Long, @ColumnInfo(name = "id_show_trakt") val idShowTrakt: Long, @ColumnInfo(name = "id_show_tvdb") val idShowTvdb: Long, @ColumnInfo(name = "id_show_imdb") val idShowImdb: String, @ColumnInfo(name = "id_show_tmdb") val idShowTmdb: Long, @ColumnInfo(name = "season_number") val seasonNumber: Int, @ColumnInfo(name = "episode_number") val episodeNumber: Int, @ColumnInfo(name = "episode_number_abs") val episodeNumberAbs: Int?, @ColumnInfo(name = "episode_overview") val episodeOverview: String, @ColumnInfo(name = "episode_title") val title: String, @ColumnInfo(name = "first_aired") val firstAired: ZonedDateTime?, @ColumnInfo(name = "comments_count") val commentsCount: Int, @ColumnInfo(name = "rating") val rating: Float, @ColumnInfo(name = "runtime") val runtime: Int, @ColumnInfo(name = "votes_count") val votesCount: Int, @ColumnInfo(name = "is_watched") val isWatched: Boolean, @ColumnInfo(name = "last_watched_at") val lastWatchedAt: ZonedDateTime?, ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/EpisodeTranslation.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey @Entity( tableName = "episodes_translations", indices = [ Index(value = ["id_trakt"], unique = true), Index(value = ["id_trakt_show"]) ], foreignKeys = [ ForeignKey( entity = Show::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt_show"), onDelete = ForeignKey.CASCADE ) ] ) data class EpisodeTranslation( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "id_trakt_show") val idTraktShow: Long, @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "language") val language: String, @ColumnInfo(name = "overview") val overview: String, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long ) { companion object { fun fromTraktId( traktEpisodeId: Long, traktShowId: Long, title: String, language: String, overview: String, createdAt: Long ) = EpisodeTranslation( idTrakt = traktEpisodeId, idTraktShow = traktShowId, title = title, language = language, overview = overview, createdAt = createdAt, updatedAt = createdAt ) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/EpisodesSyncLog.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "sync_episodes_log") data class EpisodesSyncLog( @PrimaryKey @ColumnInfo(name = "id_show_trakt", defaultValue = "-1") val idTrakt: Long, @ColumnInfo(name = "synced_at", defaultValue = "0") val syncedAt: Long ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/Movie.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "movies") data class Movie( @PrimaryKey @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "id_tmdb", defaultValue = "-1") val idTmdb: Long, @ColumnInfo(name = "id_imdb", defaultValue = "") val idImdb: String, @ColumnInfo(name = "id_slug", defaultValue = "") val idSlug: String, @ColumnInfo(name = "title", defaultValue = "") val title: String, @ColumnInfo(name = "year", defaultValue = "-1") val year: Int, @ColumnInfo(name = "overview", defaultValue = "") val overview: String, @ColumnInfo(name = "released", defaultValue = "") val released: String, @ColumnInfo(name = "runtime", defaultValue = "-1") val runtime: Int, @ColumnInfo(name = "country", defaultValue = "") val country: String, @ColumnInfo(name = "trailer", defaultValue = "") val trailer: String, @ColumnInfo(name = "language", defaultValue = "") val language: String, @ColumnInfo(name = "homepage", defaultValue = "") val homepage: String, @ColumnInfo(name = "status", defaultValue = "") val status: String, @ColumnInfo(name = "rating", defaultValue = "-1") val rating: Float, @ColumnInfo(name = "votes", defaultValue = "-1") val votes: Long, @ColumnInfo(name = "comment_count", defaultValue = "-1") val commentCount: Long, @ColumnInfo(name = "genres", defaultValue = "") val genres: String, @ColumnInfo(name = "updated_at", defaultValue = "-1") val updatedAt: Long, @ColumnInfo(name = "created_at", defaultValue = "-1") val createdAt: Long ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/MovieCollection.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.michaldrabik.data_local.database.converters.DateConverter import java.time.ZonedDateTime @Entity( tableName = "movies_collections", indices = [ Index(value = ["id_trakt"]), Index(value = ["id_trakt_movie"]), ], foreignKeys = [ ForeignKey( entity = Movie::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt_movie"), onDelete = ForeignKey.CASCADE ) ] ) @TypeConverters(DateConverter::class) data class MovieCollection( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "id_trakt_movie") val idTraktMovie: Long, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "description") val description: String, @ColumnInfo(name = "item_count") val itemCount: Int, @ColumnInfo(name = "created_at") val createdAt: ZonedDateTime, @ColumnInfo(name = "updated_at") val updatedAt: ZonedDateTime, ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/MovieCollectionItem.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.michaldrabik.data_local.database.converters.DateConverter import java.time.ZonedDateTime @Entity( tableName = "movies_collections_items", indices = [ Index(value = ["id_trakt"]), Index(value = ["id_trakt_collection"]), ], foreignKeys = [ ForeignKey( entity = Movie::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) @TypeConverters(DateConverter::class) data class MovieCollectionItem( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "id_trakt_collection") val idTraktCollection: Long, @ColumnInfo(name = "rank") val rank: Int, @ColumnInfo(name = "created_at") val createdAt: ZonedDateTime, @ColumnInfo(name = "updated_at") val updatedAt: ZonedDateTime, ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/MovieImage.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @Entity( tableName = "movies_images", indices = [ Index(value = ["id_tmdb", "type"]) ] ) data class MovieImage( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_tmdb", defaultValue = "-1") val idTmdb: Long, @ColumnInfo(name = "type", defaultValue = "") val type: String, @ColumnInfo(name = "file_url", defaultValue = "") val fileUrl: String, @ColumnInfo(name = "source", defaultValue = "tmdb") val source: String ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/MovieRatings.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey @Entity( tableName = "movies_ratings", indices = [Index(value = ["id_trakt"], unique = true)], foreignKeys = [ ForeignKey( entity = Movie::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) data class MovieRatings( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "trakt") val trakt: String?, @ColumnInfo(name = "imdb") val imdb: String?, @ColumnInfo(name = "metascore") val metascore: String?, @ColumnInfo(name = "rotten_tomatoes") val rottenTomatoes: String?, @ColumnInfo(name = "rotten_tomatoes_url") val rottenTomatoesUrl: String?, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long, ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/MovieStreaming.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.michaldrabik.data_local.database.converters.DateConverter import java.time.ZonedDateTime @Entity( tableName = "movies_streamings", indices = [ Index(value = ["id_trakt"]), Index(value = ["id_tmdb"]), ], foreignKeys = [ ForeignKey( entity = Movie::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) @TypeConverters(DateConverter::class) data class MovieStreaming( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "id_tmdb") val idTmdb: Long, @ColumnInfo(name = "type") val type: String?, @ColumnInfo(name = "provider_id") val providerId: Long?, @ColumnInfo(name = "provider_name") val providerName: String?, @ColumnInfo(name = "display_priority") val displayPriority: Long?, @ColumnInfo(name = "logo_path") val logoPath: String?, @ColumnInfo(name = "link") val link: String?, @ColumnInfo(name = "created_at") val createdAt: ZonedDateTime, @ColumnInfo(name = "updated_at") val updatedAt: ZonedDateTime, ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/MovieTranslation.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey @Entity( tableName = "movies_translations", indices = [Index(value = ["id_trakt"], unique = true)], foreignKeys = [ ForeignKey( entity = Movie::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) data class MovieTranslation( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "language") val language: String, @ColumnInfo(name = "overview") val overview: String, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long ) { companion object { fun fromTraktId( traktId: Long, title: String, language: String, overview: String, createdAt: Long ) = MovieTranslation( idTrakt = traktId, title = title, language = language, overview = overview, createdAt = createdAt, updatedAt = createdAt ) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/MoviesSyncLog.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "sync_movies_log") data class MoviesSyncLog( @PrimaryKey @ColumnInfo(name = "id_movie_trakt", defaultValue = "-1") val idTrakt: Long, @ColumnInfo(name = "synced_at", defaultValue = "0") val syncedAt: Long ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/MyMovie.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey @Entity( tableName = "movies_my_movies", foreignKeys = [ ForeignKey( entity = Movie::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) data class MyMovie( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt", defaultValue = "-1", index = true) val idTrakt: Long, @ColumnInfo(name = "created_at", defaultValue = "-1") val createdAt: Long, @ColumnInfo(name = "updated_at", defaultValue = "-1") val updatedAt: Long ) { companion object { fun fromTraktId(traktId: Long, timestamp: Long) = MyMovie( idTrakt = traktId, createdAt = timestamp, updatedAt = timestamp ) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/MyShow.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey @Entity( tableName = "shows_my_shows", foreignKeys = [ ForeignKey( entity = Show::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) data class MyShow( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt", defaultValue = "-1", index = true) val idTrakt: Long, @ColumnInfo(name = "created_at", defaultValue = "-1") val createdAt: Long, @ColumnInfo(name = "updated_at", defaultValue = "-1") val updatedAt: Long, @ColumnInfo(name = "last_watched_at") val lastWatchedAt: Long? ) { companion object { fun fromTraktId( traktId: Long, createdAt: Long, updatedAt: Long, watchedAt: Long ) = MyShow( idTrakt = traktId, createdAt = createdAt, updatedAt = updatedAt, lastWatchedAt = watchedAt ) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/News.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity( tableName = "news" ) data class News( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_news") val idNews: String, @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "url") val url: String, @ColumnInfo(name = "type") val type: String, @ColumnInfo(name = "image") val image: String?, @ColumnInfo(name = "score") val score: Long, @ColumnInfo(name = "dated_at") val datedAt: Long, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long, ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/Person.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.michaldrabik.data_local.database.converters.DateConverter import java.time.ZonedDateTime @Entity( tableName = "people", indices = [ Index(value = ["id_trakt"]), Index(value = ["id_tmdb"], unique = true), ] ) @TypeConverters(DateConverter::class) data class Person( @PrimaryKey @ColumnInfo(name = "id_tmdb") val idTmdb: Long, @ColumnInfo(name = "id_trakt") val idTrakt: Long?, @ColumnInfo(name = "id_imdb") val idImdb: String?, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "department") val department: String, @ColumnInfo(name = "biography") val biography: String?, @ColumnInfo(name = "biography_translation") val biographyTranslation: String?, @ColumnInfo(name = "birthday") val birthday: String?, @ColumnInfo(name = "birthplace") val birthplace: String?, @ColumnInfo(name = "character") val character: String?, @ColumnInfo(name = "episodes_count") val episodesCount: Int?, @ColumnInfo(name = "job") val job: String?, @ColumnInfo(name = "deathday") val deathday: String?, @ColumnInfo(name = "image_path") val image: String?, @ColumnInfo(name = "homepage") val homepage: String?, @ColumnInfo(name = "created_at") val createdAt: ZonedDateTime, @ColumnInfo(name = "updated_at") val updatedAt: ZonedDateTime, @ColumnInfo(name = "details_updated_at") val detailsUpdatedAt: ZonedDateTime?, ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/PersonCredits.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.michaldrabik.data_local.database.converters.DateConverter import java.time.ZonedDateTime @Entity( tableName = "people_credits", foreignKeys = [ ForeignKey( entity = Show::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt_show"), onDelete = ForeignKey.CASCADE ), ForeignKey( entity = Movie::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt_movie"), onDelete = ForeignKey.CASCADE ) ], indices = [ Index(value = ["id_trakt_person"]), Index(value = ["id_trakt_show"]), Index(value = ["id_trakt_movie"]), ] ) @TypeConverters(DateConverter::class) data class PersonCredits( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long, @ColumnInfo(name = "id_trakt_person") val idTraktPerson: Long, @ColumnInfo(name = "id_trakt_show") val idTraktShow: Long?, @ColumnInfo(name = "id_trakt_movie") val idTraktMovie: Long?, @ColumnInfo(name = "type") val type: String, @ColumnInfo(name = "created_at") val createdAt: ZonedDateTime, @ColumnInfo(name = "updated_at") val updatedAt: ZonedDateTime ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/PersonImage.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.michaldrabik.data_local.database.converters.DateConverter import java.time.ZonedDateTime @Entity( tableName = "people_images", indices = [ Index(value = ["id_tmdb"]) ] ) @TypeConverters(DateConverter::class) data class PersonImage( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_tmdb") val idTmdb: Long, @ColumnInfo(name = "file_path") val filePath: String, @ColumnInfo(name = "created_at") val createdAt: ZonedDateTime, @ColumnInfo(name = "updated_at") val updatedAt: ZonedDateTime ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/PersonShowMovie.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.michaldrabik.data_local.database.converters.DateConverter import java.time.ZonedDateTime @Entity( tableName = "people_shows_movies", foreignKeys = [ ForeignKey( entity = Person::class, parentColumns = arrayOf("id_tmdb"), childColumns = arrayOf("id_tmdb_person"), onDelete = ForeignKey.CASCADE ) ], indices = [ Index(value = ["id_tmdb_person"]), Index(value = ["id_trakt_show", "mode"]), Index(value = ["id_trakt_movie", "mode"]) ] ) @TypeConverters(DateConverter::class) data class PersonShowMovie( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long, @ColumnInfo(name = "id_tmdb_person") val idTmdbPerson: Long, @ColumnInfo(name = "mode") val mode: String, @ColumnInfo(name = "department") val department: String, @ColumnInfo(name = "character") val character: String?, @ColumnInfo(name = "job") val job: String?, @ColumnInfo(name = "episodes_count") val episodesCount: Int, @ColumnInfo(name = "id_trakt_show") val idTraktShow: Long?, @ColumnInfo(name = "id_trakt_movie") val idTraktMovie: Long?, @ColumnInfo(name = "created_at") val createdAt: ZonedDateTime, @ColumnInfo(name = "updated_at") val updatedAt: ZonedDateTime ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/Rating.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.TypeConverters import com.michaldrabik.data_local.database.converters.DateConverter import java.time.ZonedDateTime @Entity( tableName = "ratings", primaryKeys = ["id_trakt", "type"], indices = [ Index(value = ["id_trakt", "type"], unique = false), ] ) @TypeConverters(DateConverter::class) data class Rating( @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "type") val type: String, @ColumnInfo(name = "rating") val rating: Int, @ColumnInfo(name = "season_number") val seasonNumber: Int?, @ColumnInfo(name = "episode_number") val episodeNumber: Int?, @ColumnInfo(name = "rated_at") val ratedAt: ZonedDateTime, @ColumnInfo(name = "created_at") val createdAt: ZonedDateTime, @ColumnInfo(name = "updated_at") val updatedAt: ZonedDateTime ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/RecentSearch.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "recent_searches") data class RecentSearch( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long, @ColumnInfo(name = "text", defaultValue = "") val text: String, @ColumnInfo(name = "created_at", defaultValue = "-1") val createdAt: Long, @ColumnInfo(name = "updated_at", defaultValue = "-1") val updatedAt: Long ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/RelatedMovie.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.ForeignKey.Companion.CASCADE import androidx.room.PrimaryKey @Entity( tableName = "movies_related", foreignKeys = [ ForeignKey( entity = Movie::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt_related_movie"), onDelete = CASCADE ) ] ) data class RelatedMovie( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt", defaultValue = "-1") val idTrakt: Long, @ColumnInfo(name = "id_trakt_related_movie", defaultValue = "-1", index = true) val idTraktRelatedMovie: Long, @ColumnInfo(name = "updated_at", defaultValue = "-1") val updatedAt: Long ) { companion object { fun fromTraktId(traktId: Long, relatedTraktId: Long, nowUtcMillis: Long) = RelatedMovie( idTrakt = traktId, idTraktRelatedMovie = relatedTraktId, updatedAt = nowUtcMillis ) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/RelatedShow.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.ForeignKey.Companion.CASCADE import androidx.room.PrimaryKey @Entity( tableName = "shows_related", foreignKeys = [ ForeignKey( entity = Show::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt_related_show"), onDelete = CASCADE ) ] ) data class RelatedShow( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt", defaultValue = "-1") val idTrakt: Long, @ColumnInfo(name = "id_trakt_related_show", defaultValue = "-1", index = true) val idTraktRelatedShow: Long, @ColumnInfo(name = "updated_at", defaultValue = "-1") val updatedAt: Long ) { companion object { fun fromTraktId(traktId: Long, relatedShowTraktId: Long, nowUtcMillis: Long): RelatedShow { return RelatedShow( idTrakt = traktId, idTraktRelatedShow = relatedShowTraktId, updatedAt = nowUtcMillis ) } } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/Season.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.michaldrabik.data_local.database.converters.DateConverter import java.time.ZonedDateTime @Entity( tableName = "seasons", indices = [Index("id_show_trakt")] ) @TypeConverters(DateConverter::class) data class Season( @PrimaryKey @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "id_show_trakt") val idShowTrakt: Long, @ColumnInfo(name = "season_number") val seasonNumber: Int, @ColumnInfo(name = "season_title") val seasonTitle: String, @ColumnInfo(name = "season_overview") val seasonOverview: String, @ColumnInfo(name = "season_first_aired") val seasonFirstAired: ZonedDateTime?, @ColumnInfo(name = "episodes_count") val episodesCount: Int, @ColumnInfo(name = "episodes_aired_count") val episodesAiredCount: Int, @ColumnInfo(name = "rating") val rating: Float?, @ColumnInfo(name = "is_watched") val isWatched: Boolean ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/Settings.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "settings") data class Settings( @PrimaryKey @ColumnInfo(name = "id") val id: Long = 1, @ColumnInfo(name = "is_initial_run", defaultValue = "0") val isInitialRun: Boolean, @ColumnInfo(name = "push_notifications_enabled", defaultValue = "1") val pushNotificationsEnabled: Boolean, // Removed @ColumnInfo(name = "episodes_notifications_enabled", defaultValue = "1") val episodesNotificationsEnabled: Boolean, @ColumnInfo(name = "episodes_notifications_delay", defaultValue = "0") val episodesNotificationsDelay: Long, @ColumnInfo(name = "my_shows_recent_amount", defaultValue = "6") val myShowsRecentsAmount: Int, @ColumnInfo(name = "my_shows_running_sort_by", defaultValue = "NAME") val myShowsRunningSortBy: String, @ColumnInfo(name = "my_shows_incoming_sort_by", defaultValue = "NAME") val myShowsIncomingSortBy: String, @ColumnInfo(name = "my_shows_ended_sort_by", defaultValue = "NAME") val myShowsEndedSortBy: String, @ColumnInfo(name = "my_shows_all_sort_by", defaultValue = "NAME") val myShowsAllSortBy: String, @ColumnInfo(name = "my_shows_running_is_collapsed", defaultValue = "0") val myShowsRunningIsCollapsed: Boolean, @ColumnInfo(name = "my_shows_incoming_is_collapsed", defaultValue = "0") val myShowsIncomingIsCollapsed: Boolean, @ColumnInfo(name = "my_shows_ended_is_collapsed", defaultValue = "0") val myShowsEndedIsCollapsed: Boolean, @ColumnInfo(name = "my_shows_running_is_enabled", defaultValue = "1") val myShowsRunningIsEnabled: Boolean, @ColumnInfo(name = "my_shows_incoming_is_enabled", defaultValue = "1") val myShowsIncomingIsEnabled: Boolean, @ColumnInfo(name = "my_shows_ended_is_enabled", defaultValue = "1") val myShowsEndedIsEnabled: Boolean, @ColumnInfo(name = "my_shows_recent_is_enabled", defaultValue = "1") val myShowsRecentIsEnabled: Boolean, @ColumnInfo(name = "see_later_shows_sort_by", defaultValue = "NAME") val seeLaterShowsSortBy: String, @ColumnInfo(name = "show_anticipated_shows", defaultValue = "1") val showAnticipatedShows: Boolean, @ColumnInfo(name = "discover_filter_genres", defaultValue = "") val discoverFilterGenres: String, @ColumnInfo(name = "discover_filter_networks", defaultValue = "") val discoverFilterNetworks: String, @ColumnInfo(name = "discover_filter_feed", defaultValue = "HOT") val discoverFilterFeed: String, @ColumnInfo(name = "trakt_sync_schedule", defaultValue = "OFF") val traktSyncSchedule: String, @ColumnInfo(name = "trakt_quick_sync_enabled", defaultValue = "0") val traktQuickSyncEnabled: Boolean, @ColumnInfo(name = "trakt_quick_remove_enabled", defaultValue = "0") val traktQuickRemoveEnabled: Boolean, @ColumnInfo(name = "watchlist_sort_by", defaultValue = "NAME") val watchlistSortBy: String, @ColumnInfo(name = "archive_shows_sort_by", defaultValue = "NAME") val archiveShowsSortBy: String, @ColumnInfo(name = "archive_shows_include_statistics", defaultValue = "1") val archiveShowsIncludeStatistics: Boolean, @ColumnInfo(name = "special_seasons_enabled", defaultValue = "0") val specialSeasonsEnabled: Boolean, @ColumnInfo(name = "show_anticipated_movies", defaultValue = "0") val showAnticipatedMovies: Boolean, @ColumnInfo(name = "discover_movies_filter_genres", defaultValue = "") val discoverMoviesFilterGenres: String, @ColumnInfo(name = "discover_movies_filter_feed", defaultValue = "HOT") val discoverMoviesFilterFeed: String, @ColumnInfo(name = "my_movies_all_sort_by", defaultValue = "NAME") val myMoviesAllSortBy: String, @ColumnInfo(name = "see_later_movies_sort_by", defaultValue = "NAME") val seeLaterMoviesSortBy: String, @ColumnInfo(name = "progress_movies_sort_by", defaultValue = "NAME") val progressMoviesSortBy: String, @ColumnInfo(name = "show_collection_shows", defaultValue = "1") val showCollectionShows: Boolean, @ColumnInfo(name = "show_collection_movies", defaultValue = "1") val showCollectionMovies: Boolean, @ColumnInfo(name = "widgets_show_label", defaultValue = "1") val widgetsShowLabel: Boolean, @ColumnInfo(name = "my_movies_recent_is_enabled", defaultValue = "1") val myMoviesRecentIsEnabled: Boolean, @ColumnInfo(name = "quick_rate_enabled", defaultValue = "0") val quickRateEnabled: Boolean, @ColumnInfo(name = "lists_sort_by", defaultValue = "DATE_UPDATED") val listsSortBy: String, @ColumnInfo(name = "progress_upcoming_enabled", defaultValue = "1") val progressUpcomingEnabled: Boolean, ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/Show.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "shows") data class Show( @PrimaryKey @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "id_tvdb", defaultValue = "-1") val idTvdb: Long, @ColumnInfo(name = "id_tmdb", defaultValue = "-1") val idTmdb: Long, @ColumnInfo(name = "id_imdb", defaultValue = "") val idImdb: String, @ColumnInfo(name = "id_slug", defaultValue = "") val idSlug: String, @ColumnInfo(name = "id_tvrage", defaultValue = "-1") val idTvrage: Long, @ColumnInfo(name = "title", defaultValue = "") val title: String, @ColumnInfo(name = "year", defaultValue = "-1") val year: Int, @ColumnInfo(name = "overview", defaultValue = "") val overview: String, @ColumnInfo(name = "first_aired", defaultValue = "") val firstAired: String, @ColumnInfo(name = "runtime", defaultValue = "-1") val runtime: Int, @ColumnInfo(name = "airtime_day", defaultValue = "") val airtimeDay: String, @ColumnInfo(name = "airtime_time", defaultValue = "") val airtimeTime: String, @ColumnInfo(name = "airtime_timezone", defaultValue = "") val airtimeTimezone: String, @ColumnInfo(name = "certification", defaultValue = "") val certification: String, @ColumnInfo(name = "network", defaultValue = "") val network: String, @ColumnInfo(name = "country", defaultValue = "") val country: String, @ColumnInfo(name = "trailer", defaultValue = "") val trailer: String, @ColumnInfo(name = "homepage", defaultValue = "") val homepage: String, @ColumnInfo(name = "status", defaultValue = "") val status: String, @ColumnInfo(name = "rating", defaultValue = "-1") val rating: Float, @ColumnInfo(name = "votes", defaultValue = "-1") val votes: Long, @ColumnInfo(name = "comment_count", defaultValue = "-1") val commentCount: Long, @ColumnInfo(name = "genres", defaultValue = "") val genres: String, @ColumnInfo(name = "aired_episodes", defaultValue = "-1") val airedEpisodes: Int, @ColumnInfo(name = "created_at", defaultValue = "-1") val createdAt: Long, @ColumnInfo(name = "updated_at", defaultValue = "-1") val updatedAt: Long ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/ShowImage.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @Entity( tableName = "shows_images", indices = [ Index(value = ["id_tmdb", "type", "family"]) ] ) data class ShowImage( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_tvdb", defaultValue = "-1") val idTvdb: Long, @ColumnInfo(name = "id_tmdb", defaultValue = "-1") val idTmdb: Long, @ColumnInfo(name = "type", defaultValue = "") val type: String, @ColumnInfo(name = "family", defaultValue = "") val family: String, @ColumnInfo(name = "file_url", defaultValue = "") val fileUrl: String, @ColumnInfo(name = "thumbnail_url", defaultValue = "") val thumbnailUrl: String, @ColumnInfo(name = "source", defaultValue = "tvdb") val source: String ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/ShowRatings.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey @Entity( tableName = "shows_ratings", indices = [Index(value = ["id_trakt"], unique = true)], foreignKeys = [ ForeignKey( entity = Show::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) data class ShowRatings( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "trakt") val trakt: String?, @ColumnInfo(name = "imdb") val imdb: String?, @ColumnInfo(name = "metascore") val metascore: String?, @ColumnInfo(name = "rotten_tomatoes") val rottenTomatoes: String?, @ColumnInfo(name = "rotten_tomatoes_url") val rottenTomatoesUrl: String?, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long, ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/ShowStreaming.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.michaldrabik.data_local.database.converters.DateConverter import java.time.ZonedDateTime @Entity( tableName = "shows_streamings", indices = [ Index(value = ["id_trakt"]), Index(value = ["id_tmdb"]), ], foreignKeys = [ ForeignKey( entity = Show::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) @TypeConverters(DateConverter::class) data class ShowStreaming( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "id_tmdb") val idTmdb: Long, @ColumnInfo(name = "type") val type: String?, @ColumnInfo(name = "provider_id") val providerId: Long?, @ColumnInfo(name = "provider_name") val providerName: String?, @ColumnInfo(name = "display_priority") val displayPriority: Long?, @ColumnInfo(name = "logo_path") val logoPath: String?, @ColumnInfo(name = "link") val link: String?, @ColumnInfo(name = "created_at") val createdAt: ZonedDateTime, @ColumnInfo(name = "updated_at") val updatedAt: ZonedDateTime, ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/ShowTranslation.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey @Entity( tableName = "shows_translations", indices = [Index(value = ["id_trakt"], unique = true)], foreignKeys = [ ForeignKey( entity = Show::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) data class ShowTranslation( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "language") val language: String, @ColumnInfo(name = "overview") val overview: String, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long ) { companion object { fun fromTraktId( traktId: Long, title: String, language: String, overview: String, createdAt: Long ) = ShowTranslation( idTrakt = traktId, title = title, language = language, overview = overview, createdAt = createdAt, updatedAt = createdAt ) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/TraktSyncLog.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @Entity( tableName = "sync_trakt_log", indices = [ Index(value = ["id_trakt", "type"], unique = true) ] ) data class TraktSyncLog( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt", index = true) val idTrakt: Long, @ColumnInfo(name = "type", index = true) val type: String, @ColumnInfo(name = "synced_at") val syncedAt: Long ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/TraktSyncQueue.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "trakt_sync_queue") data class TraktSyncQueue( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long, @ColumnInfo(name = "id_trakt") val idTrakt: Long, @ColumnInfo(name = "id_list") val idList: Long?, @ColumnInfo(name = "type") val type: String, @ColumnInfo(name = "operation") val operation: String, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long ) { companion object { fun createEpisode( episodeTraktId: Long, showTraktId: Long?, createdAt: Long, updatedAt: Long, clearProgress: Boolean ): TraktSyncQueue { val operation = if (clearProgress) Operation.ADD_WITH_CLEAR else Operation.ADD return TraktSyncQueue(0, episodeTraktId, showTraktId, Type.EPISODE.slug, operation.slug, createdAt, updatedAt) } fun createShowWatchlist( idTrakt: Long, createdAt: Long, updatedAt: Long ) = TraktSyncQueue(0, idTrakt, null, Type.SHOW_WATCHLIST.slug, Operation.ADD.slug, createdAt, updatedAt) fun createMovie( idTrakt: Long, createdAt: Long, updatedAt: Long ) = TraktSyncQueue(0, idTrakt, null, Type.MOVIE.slug, Operation.ADD.slug, createdAt, updatedAt) fun createMovieWatchlist( idTrakt: Long, createdAt: Long, updatedAt: Long ) = TraktSyncQueue(0, idTrakt, null, Type.MOVIE_WATCHLIST.slug, Operation.ADD.slug, createdAt, updatedAt) fun createListShow( idTrakt: Long, idList: Long, operation: Operation, createdAt: Long, updatedAt: Long ) = TraktSyncQueue(0, idTrakt, idList, Type.LIST_ITEM_SHOW.slug, operation.slug, createdAt, updatedAt) fun createListMovie( idTrakt: Long, idList: Long, operation: Operation, createdAt: Long, updatedAt: Long ) = TraktSyncQueue(0, idTrakt, idList, Type.LIST_ITEM_MOVIE.slug, operation.slug, createdAt, updatedAt) fun createHiddenShow( idTrakt: Long, operation: Operation, createdAt: Long, updatedAt: Long ) = TraktSyncQueue(0, idTrakt, null, Type.HIDDEN_SHOW.slug, operation.slug, createdAt, updatedAt) fun createHiddenMovie( idTrakt: Long, operation: Operation, createdAt: Long, updatedAt: Long ) = TraktSyncQueue(0, idTrakt, null, Type.HIDDEN_MOVIE.slug, operation.slug, createdAt, updatedAt) } enum class Type(val slug: String) { EPISODE("episode"), SHOW_WATCHLIST("show_watchlist"), MOVIE("movie"), MOVIE_WATCHLIST("movie_watchlist"), LIST_ITEM_SHOW("list_item_show"), LIST_ITEM_MOVIE("list_item_movie"), HIDDEN_SHOW("hidden_show"), HIDDEN_MOVIE("hidden_movie") } enum class Operation(val slug: String) { ADD("add"), ADD_WITH_CLEAR("add_clear"), REMOVE("remove") } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/TranslationsMoviesSyncLog.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "sync_movies_translations_log") data class TranslationsMoviesSyncLog( @PrimaryKey @ColumnInfo(name = "id_movie_trakt") val idTrakt: Long, @ColumnInfo(name = "synced_at") val syncedAt: Long ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/TranslationsSyncLog.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "sync_translations_log") data class TranslationsSyncLog( @PrimaryKey @ColumnInfo(name = "id_show_trakt") val idTrakt: Long, @ColumnInfo(name = "synced_at") val syncedAt: Long ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/User.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "user") data class User( @PrimaryKey @ColumnInfo(name = "id") val id: Long = 1, @ColumnInfo(name = "tvdb_token", defaultValue = "") val tvdbToken: String, @ColumnInfo(name = "tvdb_token_timestamp", defaultValue = "0") val tvdbTokenTimestamp: Long, @ColumnInfo(name = "trakt_token", defaultValue = "") val traktToken: String, @ColumnInfo(name = "trakt_refresh_token", defaultValue = "") val traktRefreshToken: String, @ColumnInfo(name = "trakt_token_timestamp", defaultValue = "0") val traktTokenTimestamp: Long, @ColumnInfo(name = "trakt_username", defaultValue = "") val traktUsername: String, @ColumnInfo(name = "reddit_token", defaultValue = "") val redditToken: String, @ColumnInfo(name = "reddit_token_timestamp", defaultValue = "0") val redditTokenTimestamp: Long, ) ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/WatchlistMovie.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey @Entity( tableName = "movies_see_later", foreignKeys = [ ForeignKey( entity = Movie::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) data class WatchlistMovie( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt", defaultValue = "-1", index = true) val idTrakt: Long, @ColumnInfo(name = "created_at", defaultValue = "-1") val createdAt: Long, @ColumnInfo(name = "updated_at", defaultValue = "-1") val updatedAt: Long ) { companion object { fun fromTraktId(traktId: Long, nowUtcMillis: Long) = WatchlistMovie(idTrakt = traktId, createdAt = nowUtcMillis, updatedAt = nowUtcMillis) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/database/model/WatchlistShow.kt ================================================ package com.michaldrabik.data_local.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey @Entity( tableName = "shows_see_later", foreignKeys = [ ForeignKey( entity = Show::class, parentColumns = arrayOf("id_trakt"), childColumns = arrayOf("id_trakt"), onDelete = ForeignKey.CASCADE ) ] ) data class WatchlistShow( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0, @ColumnInfo(name = "id_trakt", defaultValue = "-1", index = true) val idTrakt: Long, @ColumnInfo(name = "created_at", defaultValue = "-1") val createdAt: Long, @ColumnInfo(name = "updated_at", defaultValue = "-1") val updatedAt: Long ) { companion object { fun fromTraktId(traktId: Long, nowUtcMillis: Long) = WatchlistShow(idTrakt = traktId, createdAt = nowUtcMillis, updatedAt = nowUtcMillis) } } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/di/LocalDataModule.kt ================================================ package com.michaldrabik.data_local.di import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.MainLocalDataSource import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class LocalDataModule { @Binds @Singleton internal abstract fun providesLocalDataSource(source: MainLocalDataSource): LocalDataSource } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/di/SourcesModule.kt ================================================ package com.michaldrabik.data_local.di import com.michaldrabik.data_local.database.AppDatabase import com.michaldrabik.data_local.sources.ArchiveMoviesLocalDataSource import com.michaldrabik.data_local.sources.ArchiveShowsLocalDataSource import com.michaldrabik.data_local.sources.CustomImagesLocalDataSource import com.michaldrabik.data_local.sources.CustomListsItemsLocalDataSource import com.michaldrabik.data_local.sources.CustomListsLocalDataSource import com.michaldrabik.data_local.sources.DiscoverMoviesLocalDataSource import com.michaldrabik.data_local.sources.DiscoverShowsLocalDataSource import com.michaldrabik.data_local.sources.EpisodeTranslationsLocalDataSource import com.michaldrabik.data_local.sources.EpisodesLocalDataSource import com.michaldrabik.data_local.sources.EpisodesSyncLogLocalDataSource import com.michaldrabik.data_local.sources.MovieCollectionsItemsLocalDataSource import com.michaldrabik.data_local.sources.MovieCollectionsLocalDataSource import com.michaldrabik.data_local.sources.MovieImagesLocalDataSource import com.michaldrabik.data_local.sources.MovieRatingsLocalDataSource import com.michaldrabik.data_local.sources.MovieStreamingsLocalDataSource import com.michaldrabik.data_local.sources.MovieTranslationsLocalDataSource import com.michaldrabik.data_local.sources.MoviesLocalDataSource import com.michaldrabik.data_local.sources.MoviesSyncLogLocalDataSource import com.michaldrabik.data_local.sources.MyMoviesLocalDataSource import com.michaldrabik.data_local.sources.MyShowsLocalDataSource import com.michaldrabik.data_local.sources.NewsLocalDataSource import com.michaldrabik.data_local.sources.PeopleCreditsLocalDataSource import com.michaldrabik.data_local.sources.PeopleImagesLocalDataSource import com.michaldrabik.data_local.sources.PeopleLocalDataSource import com.michaldrabik.data_local.sources.PeopleShowsMoviesLocalDataSource import com.michaldrabik.data_local.sources.RatingsLocalDataSource import com.michaldrabik.data_local.sources.RecentSearchLocalDataSource import com.michaldrabik.data_local.sources.RelatedMoviesLocalDataSource import com.michaldrabik.data_local.sources.RelatedShowsLocalDataSource import com.michaldrabik.data_local.sources.SeasonsLocalDataSource import com.michaldrabik.data_local.sources.SettingsLocalDataSource import com.michaldrabik.data_local.sources.ShowImagesLocalDataSource import com.michaldrabik.data_local.sources.ShowRatingsLocalDataSource import com.michaldrabik.data_local.sources.ShowStreamingsLocalDataSource import com.michaldrabik.data_local.sources.ShowTranslationsLocalDataSource import com.michaldrabik.data_local.sources.ShowsLocalDataSource import com.michaldrabik.data_local.sources.TraktSyncLogLocalDataSource import com.michaldrabik.data_local.sources.TraktSyncQueueLocalDataSource import com.michaldrabik.data_local.sources.TranslationsMoviesSyncLogLocalDataSource import com.michaldrabik.data_local.sources.TranslationsShowsSyncLogLocalDataSource import com.michaldrabik.data_local.sources.UserLocalDataSource import com.michaldrabik.data_local.sources.WatchlistMoviesLocalDataSource import com.michaldrabik.data_local.sources.WatchlistShowsLocalDataSource import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) class SourcesModule { @Provides @Singleton internal fun providesShows(database: AppDatabase): ShowsLocalDataSource = database.showsDao() @Provides @Singleton internal fun providesMovies(database: AppDatabase): MoviesLocalDataSource = database.moviesDao() @Provides @Singleton internal fun providesArchivesShows(database: AppDatabase): ArchiveShowsLocalDataSource = database.archiveShowsDao() @Provides @Singleton internal fun providesArchivesMovies(database: AppDatabase): ArchiveMoviesLocalDataSource = database.archiveMoviesDao() @Provides @Singleton internal fun providesCustomListsItems(database: AppDatabase): CustomListsItemsLocalDataSource = database.customListsItemsDao() @Provides @Singleton internal fun providesCustomImages(database: AppDatabase): CustomImagesLocalDataSource = database.customImagesDao() @Provides @Singleton internal fun providesCustomLists(database: AppDatabase): CustomListsLocalDataSource = database.customListsDao() @Provides @Singleton internal fun providesDiscoverShows(database: AppDatabase): DiscoverShowsLocalDataSource = database.discoverShowsDao() @Provides @Singleton internal fun providesDiscoverMovies(database: AppDatabase): DiscoverMoviesLocalDataSource = database.discoverMoviesDao() @Provides @Singleton internal fun providesEpisodes(database: AppDatabase): EpisodesLocalDataSource = database.episodesDao() @Provides @Singleton internal fun providesEpisodesSyncLog(database: AppDatabase): EpisodesSyncLogLocalDataSource = database.episodesSyncLogDao() @Provides @Singleton internal fun providesEpisodesTranslations(database: AppDatabase): EpisodeTranslationsLocalDataSource = database.episodeTranslationsDao() @Provides @Singleton internal fun providesMovieImages(database: AppDatabase): MovieImagesLocalDataSource = database.movieImagesDao() @Provides @Singleton internal fun providesMovieRatings(database: AppDatabase): MovieRatingsLocalDataSource = database.movieRatingsDao() @Provides @Singleton internal fun providesMovieSyncLog(database: AppDatabase): MoviesSyncLogLocalDataSource = database.moviesSyncLogDao() @Provides @Singleton internal fun providesMovieStreaming(database: AppDatabase): MovieStreamingsLocalDataSource = database.movieStreamingsDao() @Provides @Singleton internal fun providesMovieCollections(database: AppDatabase): MovieCollectionsLocalDataSource = database.movieCollectionsDao() @Provides @Singleton internal fun providesMovieCollectionsItems(database: AppDatabase): MovieCollectionsItemsLocalDataSource = database.movieCollectionsItemsDao() @Provides @Singleton internal fun providesMovieTranslation(database: AppDatabase): MovieTranslationsLocalDataSource = database.movieTranslationsDao() @Provides @Singleton internal fun providesMyMovies(database: AppDatabase): MyMoviesLocalDataSource = database.myMoviesDao() @Provides @Singleton internal fun providesMyShows(database: AppDatabase): MyShowsLocalDataSource = database.myShowsDao() @Provides @Singleton internal fun providesNews(database: AppDatabase): NewsLocalDataSource = database.newsDao() @Provides @Singleton internal fun providesPeopleCredits(database: AppDatabase): PeopleCreditsLocalDataSource = database.peopleCreditsDao() @Provides @Singleton internal fun providesPeople(database: AppDatabase): PeopleLocalDataSource = database.peopleDao() @Provides @Singleton internal fun providesPeopleImages(database: AppDatabase): PeopleImagesLocalDataSource = database.peopleImagesDao() @Provides @Singleton internal fun providesPeopleShowsMovies(database: AppDatabase): PeopleShowsMoviesLocalDataSource = database.peopleShowsMoviesDao() @Provides @Singleton internal fun providesRatings(database: AppDatabase): RatingsLocalDataSource = database.ratingsDao() @Provides @Singleton internal fun providesRecentSearch(database: AppDatabase): RecentSearchLocalDataSource = database.recentSearchDao() @Provides @Singleton internal fun providesSeasons(database: AppDatabase): SeasonsLocalDataSource = database.seasonsDao() @Provides @Singleton internal fun providesSettings(database: AppDatabase): SettingsLocalDataSource = database.settingsDao() @Provides @Singleton internal fun providesShowImages(database: AppDatabase): ShowImagesLocalDataSource = database.showImagesDao() @Provides @Singleton internal fun providesShowRatings(database: AppDatabase): ShowRatingsLocalDataSource = database.showRatingsDao() @Provides @Singleton internal fun providesShowStreamings(database: AppDatabase): ShowStreamingsLocalDataSource = database.showStreamingsDao() @Provides @Singleton internal fun providesShowTranslations(database: AppDatabase): ShowTranslationsLocalDataSource = database.showTranslationsDao() @Provides @Singleton internal fun providesTraktSyncLog(database: AppDatabase): TraktSyncLogLocalDataSource = database.traktSyncLogDao() @Provides @Singleton internal fun providesTraktSyncQueue(database: AppDatabase): TraktSyncQueueLocalDataSource = database.traktSyncQueueDao() @Provides @Singleton internal fun providesTranslationsMovies(database: AppDatabase): TranslationsMoviesSyncLogLocalDataSource = database.translationsMoviesSyncLogDao() @Provides @Singleton internal fun providesTranslationsShows(database: AppDatabase): TranslationsShowsSyncLogLocalDataSource = database.translationsSyncLogDao() @Provides @Singleton internal fun providesUser(database: AppDatabase): UserLocalDataSource = database.userDao() @Provides @Singleton internal fun providesWatchlistMovies(database: AppDatabase): WatchlistMoviesLocalDataSource = database.watchlistMoviesDao() @Provides @Singleton internal fun providesWatchlistShows(database: AppDatabase): WatchlistShowsLocalDataSource = database.watchlistShowsDao() @Provides @Singleton internal fun providesRelatedMovies(database: AppDatabase): RelatedMoviesLocalDataSource = database.relatedMoviesDao() @Provides @Singleton internal fun providesRelatedShows(database: AppDatabase): RelatedShowsLocalDataSource = database.relatedShowsDao() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/di/StorageModule.kt ================================================ package com.michaldrabik.data_local.di import android.content.Context import androidx.room.Room import com.michaldrabik.data_local.database.AppDatabase import com.michaldrabik.data_local.database.migrations.DATABASE_NAME import com.michaldrabik.data_local.database.migrations.Migrations import com.michaldrabik.data_local.utilities.TransactionsProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import timber.log.Timber import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) class StorageModule { @Provides @Singleton internal fun providesDatabase( @ApplicationContext context: Context, migrations: Migrations ): AppDatabase { Timber.d("Creating database...") return Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, DATABASE_NAME ).apply { migrations.getAll().forEach { addMigrations(it) } }.build() } @Provides @Singleton internal fun providesMigrations(@ApplicationContext context: Context): Migrations = Migrations(context) @Provides @Singleton internal fun providesTransactions(database: AppDatabase): TransactionsProvider = TransactionsProvider(database) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/ArchiveMoviesLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.ArchiveMovie import com.michaldrabik.data_local.database.model.Movie interface ArchiveMoviesLocalDataSource { suspend fun getAll(): List suspend fun getAll(ids: List): List suspend fun getAllTraktIds(): List suspend fun getById(traktId: Long): Movie? suspend fun insert(movie: ArchiveMovie) suspend fun deleteById(traktId: Long) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/ArchiveShowsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.ArchiveShow import com.michaldrabik.data_local.database.model.Show interface ArchiveShowsLocalDataSource { suspend fun getAll(): List suspend fun getAll(ids: List): List suspend fun getAllTraktIds(): List suspend fun getById(traktId: Long): Show? suspend fun insert(show: ArchiveShow) suspend fun deleteById(traktId: Long) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/CustomImagesLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.CustomImage interface CustomImagesLocalDataSource { suspend fun getById(traktId: Long, family: String, type: String): CustomImage? suspend fun deleteById(traktId: Long, family: String, type: String) suspend fun insertImage(image: CustomImage) suspend fun upsert(image: CustomImage) suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/CustomListsItemsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.CustomListItem interface CustomListsItemsLocalDataSource { suspend fun update(items: List) suspend fun getListsForItem(idTrakt: Long, type: String): List suspend fun getByIdTrakt(idList: Long, idTrakt: Long, type: String): CustomListItem? suspend fun getItemsById(idList: Long): List suspend fun getItemsForListImages(idList: Long, limit: Int): List suspend fun getRankForList(idList: Long): Long? suspend fun insertItem(item: CustomListItem) suspend fun deleteItem(idList: Long, idTrakt: Long, type: String) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/CustomListsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.CustomList interface CustomListsLocalDataSource { suspend fun insert(items: List): List suspend fun update(items: List) suspend fun getAll(): List suspend fun getById(id: Long): CustomList? suspend fun updateTraktId(id: Long, idTrakt: Long, idSlug: String, timestamp: Long) suspend fun updateTimestamp(id: Long, timestamp: Long) suspend fun updateSortByLocal(id: Long, sortBy: String, sortHow: String, timestamp: Long) suspend fun updateFilterTypeLocal(id: Long, filterType: String, timestamp: Long) suspend fun deleteById(id: Long) suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/DiscoverMoviesLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.DiscoverMovie interface DiscoverMoviesLocalDataSource { suspend fun getAll(): List suspend fun getMostRecent(): DiscoverMovie? suspend fun upsert(movies: List) suspend fun deleteAll() suspend fun replace(movies: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/DiscoverShowsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.DiscoverShow interface DiscoverShowsLocalDataSource { suspend fun getAll(): List suspend fun getMostRecent(): DiscoverShow? suspend fun upsert(shows: List) suspend fun deleteAll() suspend fun replace(shows: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/EpisodeTranslationsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.EpisodeTranslation interface EpisodeTranslationsLocalDataSource { suspend fun getById(traktEpisodeId: Long, traktShowId: Long, language: String): EpisodeTranslation? suspend fun getByIds(traktEpisodeIds: List, traktShowId: Long, language: String): List suspend fun insertSingle(translation: EpisodeTranslation) suspend fun deleteByLanguage(languages: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/EpisodesLocalDataSource.kt ================================================ // ktlint-disable max-line-length package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.Episode interface EpisodesLocalDataSource { suspend fun upsert(episodes: List) suspend fun delete(items: List) suspend fun upsertChunked(items: List) suspend fun isEpisodeWatched(showTraktId: Long, episodeTraktId: Long): Boolean suspend fun getAllForSeason(seasonTraktId: Long): List suspend fun getAllByShowId(showTraktId: Long): List suspend fun getAllByShowId(showTraktId: Long, seasonNumber: Int): List suspend fun getAllByShowsIds(showTraktIds: List): List suspend fun getAllByShowsIdsChunk(showTraktIds: List): List suspend fun getFirstUnwatched( showTraktId: Long, toTime: Long ): Episode? suspend fun getFirstUnwatched( showTraktId: Long, fromTime: Long, toTime: Long ): Episode? suspend fun getFirstUnwatchedAfterEpisode( showTraktId: Long, seasonNumber: Int, episodeNumber: Int, toTime: Long ): Episode? suspend fun getLastWatched(showTraktId: Long): Episode? suspend fun getTotalCount(showTraktId: Long, toTime: Long): Int suspend fun getTotalCount(showTraktId: Long): Int suspend fun getWatchedCount(showTraktId: Long, toTime: Long): Int suspend fun getWatchedCount(showTraktId: Long): Int suspend fun getAllWatchedForShows(showsIds: List): List suspend fun getAllWatchedIdsForShows(showsIds: List): List suspend fun deleteAllUnwatchedForShow(showTraktId: Long) suspend fun deleteAllForShow(showTraktId: Long) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/EpisodesSyncLogLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.EpisodesSyncLog interface EpisodesSyncLogLocalDataSource { suspend fun getAll(): List suspend fun upsert(log: EpisodesSyncLog) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/MovieCollectionsItemsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.Movie import com.michaldrabik.data_local.database.model.MovieCollectionItem interface MovieCollectionsItemsLocalDataSource { suspend fun getById(collectionId: Long): List suspend fun deleteById(collectionId: Long) suspend fun replace( collectionId: Long, items: List, ) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/MovieCollectionsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.MovieCollection interface MovieCollectionsLocalDataSource { suspend fun getById(traktId: Long): MovieCollection? suspend fun getByMovieId(movieTraktId: Long): List suspend fun replaceByMovieId(movieTraktId: Long, entities: List) suspend fun insertAll(items: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/MovieImagesLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.MovieImage interface MovieImagesLocalDataSource { suspend fun getByMovieId(tmdbId: Long, type: String): MovieImage? suspend fun insertMovieImage(image: MovieImage) suspend fun upsert(image: MovieImage) suspend fun deleteByMovieId(id: Long, type: String) suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/MovieRatingsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.MovieRatings interface MovieRatingsLocalDataSource { suspend fun upsert(entity: MovieRatings) suspend fun getById(traktId: Long): MovieRatings? } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/MovieStreamingsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.MovieStreaming interface MovieStreamingsLocalDataSource { suspend fun replace(traktId: Long, entities: List) suspend fun getById(traktId: Long): List suspend fun deleteById(traktId: Long) suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/MovieTranslationsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.MovieTranslation interface MovieTranslationsLocalDataSource { suspend fun getById(traktId: Long, language: String): MovieTranslation? suspend fun getAll(language: String): List suspend fun insertSingle(translation: MovieTranslation) suspend fun deleteByLanguage(languages: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/MoviesLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.Movie interface MoviesLocalDataSource { suspend fun getAll(): List suspend fun getAll(ids: List): List suspend fun getAllChunked(ids: List): List suspend fun getById(traktId: Long): Movie? suspend fun getByTmdbId(tmdbId: Long): Movie? suspend fun getBySlug(slug: String): Movie? suspend fun getById(imdbId: String): Movie? suspend fun deleteById(traktId: Long) suspend fun upsert(movies: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/MoviesSyncLogLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.MoviesSyncLog interface MoviesSyncLogLocalDataSource { suspend fun getAll(): List suspend fun upsert(log: MoviesSyncLog) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/MyMoviesLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.Movie import com.michaldrabik.data_local.database.model.MyMovie interface MyMoviesLocalDataSource { suspend fun getAll(): List suspend fun getAll(ids: List): List suspend fun getAllRecent(limit: Int): List suspend fun getAllTraktIds(): List suspend fun getById(traktId: Long): Movie? suspend fun insert(movies: List) suspend fun deleteById(traktId: Long) suspend fun checkExists(traktId: Long): Boolean } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/MyShowsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.MyShow import com.michaldrabik.data_local.database.model.Show interface MyShowsLocalDataSource { suspend fun getAll(): List suspend fun getAll(ids: List): List suspend fun getAllRecent(limit: Int): List suspend fun getAllTraktIds(): List suspend fun getById(traktId: Long): Show? suspend fun updateWatchedAt(traktId: Long, watchedAt: Long) suspend fun insert(shows: List) suspend fun deleteById(traktId: Long) suspend fun checkExists(traktId: Long): Boolean } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/NewsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.News interface NewsLocalDataSource { suspend fun getAllByType(type: String): List suspend fun replaceForType(items: List, type: String) suspend fun deleteAllByType(type: String): Int } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/PeopleCreditsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.Movie import com.michaldrabik.data_local.database.model.PersonCredits import com.michaldrabik.data_local.database.model.Show interface PeopleCreditsLocalDataSource { suspend fun getAllShowsForPerson(personTraktId: Long): List suspend fun getAllMoviesForPerson(personTraktId: Long): List suspend fun getTimestampForPerson(personTraktId: Long): Long? suspend fun deleteAllForPerson(personTraktId: Long) suspend fun insertSingle(personTraktId: Long, credits: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/PeopleImagesLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.PersonImage interface PeopleImagesLocalDataSource { suspend fun getTimestampForPerson(personTmdbId: Long): Long? suspend fun getAll(personTmdbId: Long): List suspend fun deleteAllForPerson(personTmdbId: Long) suspend fun insertSingle(personTmdbId: Long, images: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/PeopleLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.Person interface PeopleLocalDataSource { suspend fun upsert(people: List) suspend fun getById(tmdbId: Long): Person? suspend fun getAllForShow(showTraktId: Long): List suspend fun getAllForMovie(movieTraktId: Long): List suspend fun getAll(): List suspend fun updateTraktId(idTrakt: Long, idTmdb: Long) suspend fun deleteTranslations() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/PeopleShowsMoviesLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.PersonShowMovie interface PeopleShowsMoviesLocalDataSource { suspend fun getTimestampForShow(showTraktId: Long): Long? suspend fun getTimestampForMovie(movieTraktId: Long): Long? suspend fun deleteAllForShow(showTraktId: Long) suspend fun deleteAllForMovie(movieTraktId: Long) suspend fun insertForShow(people: List, showTraktId: Long) suspend fun insertForMovie(people: List, movieTraktId: Long) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/RatingsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.Rating interface RatingsLocalDataSource { suspend fun getAll(): List suspend fun getAllByType(type: String): List suspend fun getAllByType(idsTrakt: List, type: String): List suspend fun deleteAllByType(type: String) suspend fun deleteByType(traktId: Long, type: String) suspend fun replaceAll(ratings: List, type: String) suspend fun replace(rating: Rating) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/RecentSearchLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.RecentSearch interface RecentSearchLocalDataSource { suspend fun getAll(limit: Int): List suspend fun upsert(searches: List) suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/RelatedMoviesLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.RelatedMovie interface RelatedMoviesLocalDataSource { suspend fun insert(items: List): List suspend fun getAllById(traktId: Long): List suspend fun getAll(): List suspend fun deleteById(traktId: Long) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/RelatedShowsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.RelatedShow interface RelatedShowsLocalDataSource { suspend fun insert(items: List): List suspend fun getAllById(traktId: Long): List suspend fun getAll(): List suspend fun deleteById(traktId: Long) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/SeasonsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.Season interface SeasonsLocalDataSource { suspend fun getAllByShowsIds(traktIds: List): List suspend fun getAllByShowsIdsChunk(traktIds: List): List suspend fun getAllWatchedForShows(traktIds: List): List suspend fun getAllWatchedIdsForShows(traktIds: List): List suspend fun getAllByShowId(traktId: Long): List suspend fun getById(traktId: Long): Season? suspend fun update(items: List) suspend fun upsert(items: List) suspend fun delete(items: List) suspend fun deleteAllForShow(showTraktId: Long) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/SettingsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.Settings interface SettingsLocalDataSource { suspend fun getAll(): Settings suspend fun getCount(): Int suspend fun upsert(settings: Settings) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/ShowImagesLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.ShowImage interface ShowImagesLocalDataSource { suspend fun getByShowId(tmdbId: Long, type: String): ShowImage? suspend fun getByEpisodeId(tmdbId: Long, type: String): ShowImage? suspend fun insertShowImage(image: ShowImage) suspend fun insertEpisodeImage(image: ShowImage) suspend fun upsert(image: ShowImage) suspend fun deleteByShowId(id: Long, type: String) suspend fun deleteByEpisodeId(id: Long, type: String) suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/ShowRatingsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.ShowRatings interface ShowRatingsLocalDataSource { suspend fun upsert(entity: ShowRatings) suspend fun getById(traktId: Long): ShowRatings? } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/ShowStreamingsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.ShowStreaming interface ShowStreamingsLocalDataSource { suspend fun replace(traktId: Long, entities: List) suspend fun getById(traktId: Long): List suspend fun deleteById(traktId: Long) suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/ShowTranslationsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.ShowTranslation interface ShowTranslationsLocalDataSource { suspend fun getById(traktId: Long, language: String): ShowTranslation? suspend fun getAll(language: String): List suspend fun insertSingle(translation: ShowTranslation) suspend fun deleteByLanguage(languages: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/ShowsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.Show interface ShowsLocalDataSource { suspend fun getAll(): List suspend fun getAll(ids: List): List suspend fun getAllChunked(ids: List): List suspend fun getById(traktId: Long): Show? suspend fun getByTmdbId(tmdbId: Long): Show? suspend fun getBySlug(slug: String): Show? suspend fun getById(imdbId: String): Show? suspend fun deleteById(traktId: Long) suspend fun upsert(shows: List) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/TraktSyncLogLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.TraktSyncLog interface TraktSyncLogLocalDataSource { suspend fun getAllShows(): List suspend fun insert(log: TraktSyncLog) suspend fun update(idTrakt: Long, type: String, syncedAt: Long): Int suspend fun deleteAll() suspend fun upsertShow(idTrakt: Long, syncedAt: Long) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/TraktSyncQueueLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.TraktSyncQueue interface TraktSyncQueueLocalDataSource { suspend fun insert(items: List): List suspend fun getAll(): List suspend fun getAll(types: List): List suspend fun deleteAll(idsTrakt: List, type: String): Int suspend fun deleteAll(type: String): Int suspend fun deleteAll() suspend fun deleteAllForList(idList: Long): Int suspend fun delete(items: List) suspend fun delete(idTrakt: Long, idList: Long, type: String, operation: String): Int } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/TranslationsMoviesSyncLogLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.TranslationsMoviesSyncLog interface TranslationsMoviesSyncLogLocalDataSource { suspend fun getAll(): List suspend fun getById(idTrakt: Long): TranslationsMoviesSyncLog? suspend fun upsert(log: TranslationsMoviesSyncLog) suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/TranslationsShowsSyncLogLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.TranslationsSyncLog interface TranslationsShowsSyncLogLocalDataSource { suspend fun getAll(): List suspend fun getById(idTrakt: Long): TranslationsSyncLog? suspend fun upsert(log: TranslationsSyncLog) suspend fun deleteAll() } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/UserLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.User interface UserLocalDataSource { suspend fun get(): User? suspend fun upsert(user: User) } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/WatchlistMoviesLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.Movie import com.michaldrabik.data_local.database.model.WatchlistMovie interface WatchlistMoviesLocalDataSource { suspend fun getAll(): List suspend fun getAllTraktIds(): List suspend fun getById(traktId: Long): Movie? suspend fun insert(movie: WatchlistMovie) suspend fun deleteById(traktId: Long) suspend fun checkExists(traktId: Long): Boolean } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/sources/WatchlistShowsLocalDataSource.kt ================================================ package com.michaldrabik.data_local.sources import com.michaldrabik.data_local.database.model.Show import com.michaldrabik.data_local.database.model.WatchlistShow interface WatchlistShowsLocalDataSource { suspend fun getAll(): List suspend fun getAllTraktIds(): List suspend fun getById(traktId: Long): Show? suspend fun insert(show: WatchlistShow) suspend fun deleteById(traktId: Long) suspend fun checkExists(traktId: Long): Boolean } ================================================ FILE: data-local/src/main/java/com/michaldrabik/data_local/utilities/TransactionsProvider.kt ================================================ package com.michaldrabik.data_local.utilities import androidx.room.RoomDatabase import androidx.room.withTransaction class TransactionsProvider( private val database: RoomDatabase ) { suspend fun withTransaction(block: suspend () -> R): R { return database.withTransaction(block) } } ================================================ FILE: data-remote/.gitignore ================================================ /build ================================================ FILE: data-remote/build.gradle ================================================ apply plugin: "com.android.library" apply plugin: "kotlin-android" apply plugin: 'dagger.hilt.android.plugin' apply from: '../versions.gradle' android { kotlinOptions { jvmTarget = "17" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } compileSdkVersion versions.compileSdk buildFeatures { buildConfig = true } defaultConfig { minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk compileSdkVersion versions.compileSdk testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } buildTypes.all { Properties properties = new Properties() properties.load(project.rootProject.file('local.properties').newDataInputStream()) buildConfigField("String", "TRAKT_CLIENT_ID", properties.getProperty("traktClientId")) buildConfigField("String", "TRAKT_CLIENT_SECRET", properties.getProperty("traktClientSecret")) buildConfigField("String", "TMDB_API_KEY", properties.getProperty("tmdbApiKey")) buildConfigField("String", "OMDB_API_KEY", properties.getProperty("omdbApiKey")) buildConfigField("String", "REDDIT_CLIENT_ID", properties.getProperty("redditClientId")) buildConfigField 'String', 'VER_NAME', "\"${versions.versionName}\"" } buildTypes { debug { minifyEnabled false } release { minifyEnabled false proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" } } namespace 'com.michaldrabik.data_remote' } dependencies { api libs.retrofit api libs.retrofit.moshi api libs.loggingInterceptor implementation libs.coroutines implementation libs.timber implementation libs.hilt.android ksp libs.hilt.compiler testImplementation libs.junit coreLibraryDesugaring libs.android.desugar } ================================================ FILE: data-remote/consumer-rules.pro ================================================ ### Moshi # JSR 305 annotations are for embedding nullability information. -dontwarn javax.annotation.** -keepclasseswithmembers class * { @com.squareup.moshi.* ; } -keep @com.squareup.moshi.JsonQualifier interface * # Enum field names are used by the integrated EnumJsonAdapter. # Annotate enums with @JsonClass(generateAdapter = false) to use them with Moshi. -keepclassmembers @com.squareup.moshi.JsonClass class * extends java.lang.Enum { ; } # The name of @JsonClass types is used to look up the generated adapter. -keepnames @com.squareup.moshi.JsonClass class * # Retain generated target class's synthetic defaults constructor and keep DefaultConstructorMarker's # name. We will look this up reflectively to invoke the type's constructor. # # We can't _just_ keep the defaults constructor because Proguard/R8's spec doesn't allow wildcard # matching preceding parameters. -keepnames class kotlin.jvm.internal.DefaultConstructorMarker -keepclassmembers @com.squareup.moshi.JsonClass @kotlin.Metadata class * { synthetic (...); } # Retain generated JsonAdapters if annotated type is retained. -if @com.squareup.moshi.JsonClass class * -keep class <1>JsonAdapter { (...); ; } -if @com.squareup.moshi.JsonClass class **$* -keep class <1>_<2>JsonAdapter { (...); ; } -if @com.squareup.moshi.JsonClass class **$*$* -keep class <1>_<2>_<3>JsonAdapter { (...); ; } -if @com.squareup.moshi.JsonClass class **$*$*$* -keep class <1>_<2>_<3>_<4>JsonAdapter { (...); ; } -if @com.squareup.moshi.JsonClass class **$*$*$*$* -keep class <1>_<2>_<3>_<4>_<5>JsonAdapter { (...); ; } -if @com.squareup.moshi.JsonClass class **$*$*$*$*$* -keep class <1>_<2>_<3>_<4>_<5>_<6>JsonAdapter { (...); ; } -keep class kotlin.reflect.jvm.internal.impl.builtins.BuiltInsLoaderImpl -keepclassmembers class kotlin.Metadata { public ; } ### Retrofit # Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and # EnclosingMethod is required to use InnerClasses. -keepattributes Signature, InnerClasses, EnclosingMethod # Retrofit does reflection on method and parameter annotations. -keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations # Retain service method parameters when optimizing. -keepclassmembers,allowshrinking,allowobfuscation interface * { @retrofit2.http.* ; } # Ignore annotation used for build tooling. -dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement # Ignore JSR 305 annotations for embedding nullability information. -dontwarn javax.annotation.** # Guarded by a NoClassDefFoundError try/catch and only used when on the classpath. -dontwarn kotlin.Unit # Top-level functions that can only be used by Kotlin. -dontwarn retrofit2.KotlinExtensions -dontwarn retrofit2.KotlinExtensions$* # With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy # and replaces all potential values with null. Explicitly keeping the interfaces prevents this. -if interface * { @retrofit2.http.* ; } -keep,allowobfuscation interface <1> -keep class com.michaldrabik.data_remote.tmdb.model.** { *; } -keep class com.michaldrabik.data_remote.omdb.model.** { *; } -keep class com.michaldrabik.data_remote.trakt.model.** { *; } -keep class com.michaldrabik.data_remote.aws.model.** { *; } -keep class com.michaldrabik.data_remote.reddit.model.** { *; } ### OkHttp # JSR 305 annotations are for embedding nullability information. -dontwarn javax.annotation.** # A resource is loaded with a relative path so the package of this class must be preserved. -keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase # Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java. -dontwarn org.codehaus.mojo.animal_sniffer.* # OkHttp platform used only on JVM and when Conscrypt dependency is available. -dontwarn okhttp3.internal.platform.ConscryptPlatform # A resource is loaded with a relative path so the package of this class must be preserved. -adaptresourcefilenames okhttp3/internal/publicsuffix/PublicSuffixDatabase.gz # OkHttp platform used only on JVM and when Conscrypt and other security providers are available. -dontwarn okhttp3.internal.platform.** -dontwarn org.conscrypt.** -dontwarn org.bouncycastle.** -dontwarn org.openjsse.** # With R8 full mode generic signatures are stripped for classes that are not # kept. Suspend functions are wrapped in continuations where the type argument # is used. -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation -keep,allowobfuscation,allowshrinking class com.squareup.moshi.JsonAdapter # With R8 full mode generic signatures are stripped for classes that are not # kept. Suspend functions are wrapped in continuations where the type argument # is used. -keep,allowobfuscation,allowshrinking interface retrofit2.Call -keep,allowobfuscation,allowshrinking class retrofit2.Response # R8 full mode strips generic signatures from return types if not kept. -if interface * { @retrofit2.http.* public *** *(...); } -keep,allowoptimization,allowshrinking,allowobfuscation class <3> ================================================ FILE: data-remote/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: data-remote/src/main/AndroidManifest.xml ================================================ ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/Config.kt ================================================ package com.michaldrabik.data_remote import java.time.Duration object Config { const val TRAKT_VERSION = "2" const val TRAKT_BASE_URL = "https://api.trakt.tv/" const val TRAKT_CLIENT_ID = BuildConfig.TRAKT_CLIENT_ID const val TRAKT_CLIENT_SECRET = BuildConfig.TRAKT_CLIENT_SECRET const val TRAKT_REDIRECT_URL = "showlyoss://trakt" const val TRAKT_AUTHORIZE_URL = "https://trakt.tv/oauth/authorize?response_type=code&client_id=$TRAKT_CLIENT_ID&redirect_uri=$TRAKT_REDIRECT_URL" val TRAKT_TOKEN_REFRESH_DURATION: Duration = Duration.ofDays(30) const val TRAKT_POPULAR_SHOWS_LIMIT = 100 const val TRAKT_POPULAR_MOVIES_LIMIT = 50 const val TRAKT_TRENDING_SHOWS_LIMIT = 298 const val TRAKT_TRENDING_MOVIES_LIMIT = 252 const val TRAKT_ANTICIPATED_SHOWS_LIMIT = 40 const val TRAKT_ANTICIPATED_MOVIES_LIMIT = 30 const val TRAKT_RELATED_SHOWS_LIMIT = 20 const val TRAKT_RELATED_MOVIES_LIMIT = 20 const val TRAKT_SEARCH_LIMIT = 50 const val TRAKT_SYNC_PAGE_LIMIT = 100 const val TMDB_BASE_URL = "https://api.themoviedb.org/3/" const val TMDB_API_KEY = BuildConfig.TMDB_API_KEY const val OMDB_BASE_URL = "https://www.omdbapi.com/" const val OMDB_API_KEY = BuildConfig.OMDB_API_KEY const val REDDIT_BASE_URL = "https://www.reddit.com/api/v1/" const val REDDIT_OAUTH_BASE_URL = "https://oauth.reddit.com/" const val REDDIT_CLIENT_ID = BuildConfig.REDDIT_CLIENT_ID const val REDDIT_GRANT_TYPE = "https://oauth.reddit.com/grants/installed_client" const val REDDIT_DEVICE_ID = "DO_NOT_TRACK_THIS_DEVICE" const val REDDIT_LIST_LIMIT = 75 const val REDDIT_LIST_PAGES = 2 const val AWS_BASE_URL = "https://showly2.s3.eu-west-2.amazonaws.com/" } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/RemoteDataSource.kt ================================================ package com.michaldrabik.data_remote import com.michaldrabik.data_remote.aws.AwsRemoteDataSource import com.michaldrabik.data_remote.omdb.OmdbRemoteDataSource import com.michaldrabik.data_remote.reddit.RedditRemoteDataSource import com.michaldrabik.data_remote.tmdb.TmdbRemoteDataSource import com.michaldrabik.data_remote.trakt.TraktRemoteDataSource import javax.inject.Inject import javax.inject.Singleton /** * Provides external data sources access points. */ interface RemoteDataSource { val trakt: TraktRemoteDataSource val aws: AwsRemoteDataSource val tmdb: TmdbRemoteDataSource val omdb: OmdbRemoteDataSource val reddit: RedditRemoteDataSource } @Singleton internal class MainRemoteDataSource @Inject constructor( override val trakt: TraktRemoteDataSource, override val tmdb: TmdbRemoteDataSource, override val aws: AwsRemoteDataSource, override val reddit: RedditRemoteDataSource, override val omdb: OmdbRemoteDataSource, ) : RemoteDataSource ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/aws/AwsRemoteDataSource.kt ================================================ package com.michaldrabik.data_remote.aws import com.michaldrabik.data_remote.aws.model.AwsImages /** * Fetch/post remote resources via private AWS API */ interface AwsRemoteDataSource { suspend fun fetchImagesList(): AwsImages } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/aws/api/AwsApi.kt ================================================ package com.michaldrabik.data_remote.aws.api import com.michaldrabik.data_remote.aws.AwsRemoteDataSource import com.michaldrabik.data_remote.aws.model.AwsImages internal class AwsApi(private val service: AwsService) : AwsRemoteDataSource { override suspend fun fetchImagesList() = try { service.fetchImagesList().shows } catch (error: Throwable) { AwsImages(emptyList(), emptyList()) } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/aws/api/AwsService.kt ================================================ package com.michaldrabik.data_remote.aws.api import com.michaldrabik.data_remote.aws.model.AwsImagesList import retrofit2.http.GET interface AwsService { @GET("images/images.json") suspend fun fetchImagesList(): AwsImagesList } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/aws/model/AwsImage.kt ================================================ package com.michaldrabik.data_remote.aws.model data class AwsImage( val idTvdb: Long, val idTmdb: Long, val fileType: String ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/aws/model/AwsImagesList.kt ================================================ package com.michaldrabik.data_remote.aws.model data class AwsImagesList( val shows: AwsImages ) data class AwsImages( val posters: List, val fanarts: List ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/di/module/AwsModule.kt ================================================ package com.michaldrabik.data_remote.di.module import com.michaldrabik.data_remote.aws.AwsRemoteDataSource import com.michaldrabik.data_remote.aws.api.AwsApi import com.michaldrabik.data_remote.aws.api.AwsService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import retrofit2.Retrofit import javax.inject.Named import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object AwsModule { @Provides @Singleton fun providesAwsApi(@Named("retrofitAws") retrofit: Retrofit): AwsRemoteDataSource = AwsApi(retrofit.create(AwsService::class.java)) } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/di/module/OkHttpModule.kt ================================================ package com.michaldrabik.data_remote.di.module import com.michaldrabik.data_remote.BuildConfig import com.michaldrabik.data_remote.omdb.OmdbInterceptor import com.michaldrabik.data_remote.tmdb.TmdbInterceptor import com.michaldrabik.data_remote.trakt.interceptors.TraktAuthenticator import com.michaldrabik.data_remote.trakt.interceptors.TraktAuthorizationInterceptor import com.michaldrabik.data_remote.trakt.interceptors.TraktHeadersInterceptor import com.michaldrabik.data_remote.trakt.interceptors.TraktRefreshTokenInterceptor import com.michaldrabik.data_remote.trakt.interceptors.TraktRetryInterceptor import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import java.time.Duration import javax.inject.Named import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object OkHttpModule { private val TIMEOUT_DURATION = Duration.ofSeconds(60) @Provides @Singleton @Named("okHttpBase") fun providesBaseOkHttp(): OkHttpClient { return createBaseOkHttpClient().build() } @Provides @Singleton @Named("okHttpTrakt") fun providesTraktOkHttp( httpLoggingInterceptor: HttpLoggingInterceptor, traktAuthorizationInterceptor: TraktAuthorizationInterceptor, traktHeadersInterceptor: TraktHeadersInterceptor, traktRefreshTokenInterceptor: TraktRefreshTokenInterceptor, traktRetryInterceptor: TraktRetryInterceptor, traktAuthenticator: TraktAuthenticator, ): OkHttpClient { return createBaseOkHttpClient() .addInterceptor(traktHeadersInterceptor) .addInterceptor(traktRefreshTokenInterceptor) .addInterceptor(traktAuthorizationInterceptor) .addInterceptor(traktRetryInterceptor) .addInterceptor(httpLoggingInterceptor) .authenticator(traktAuthenticator) .build() } @Provides @Singleton @Named("okHttpTmdb") fun providesTmdbOkHttp( httpLoggingInterceptor: HttpLoggingInterceptor, tmdbInterceptor: TmdbInterceptor, ) = createBaseOkHttpClient() .addInterceptor(httpLoggingInterceptor) .addInterceptor(tmdbInterceptor) .build() @Provides @Singleton @Named("okHttpOmdb") fun providesOmdbOkHttp( httpLoggingInterceptor: HttpLoggingInterceptor, omdbInterceptor: OmdbInterceptor, ) = createBaseOkHttpClient() .addInterceptor(httpLoggingInterceptor) .addInterceptor(omdbInterceptor) .build() @Provides @Singleton @Named("okHttpAws") fun providesAwsOkHttp( httpLoggingInterceptor: HttpLoggingInterceptor, ) = createBaseOkHttpClient() .addInterceptor(httpLoggingInterceptor) .build() @Provides @Singleton @Named("okHttpReddit") fun providesRedditOkHttp( httpLoggingInterceptor: HttpLoggingInterceptor, ) = createBaseOkHttpClient() .addInterceptor(httpLoggingInterceptor) .build() @Provides @Singleton fun providesHttpLoggingInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor().apply { level = when { BuildConfig.DEBUG -> HttpLoggingInterceptor.Level.BODY else -> HttpLoggingInterceptor.Level.NONE } } private fun createBaseOkHttpClient() = OkHttpClient.Builder() .writeTimeout(TIMEOUT_DURATION) .readTimeout(TIMEOUT_DURATION) .callTimeout(TIMEOUT_DURATION) } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/di/module/OmdbModule.kt ================================================ package com.michaldrabik.data_remote.di.module import com.michaldrabik.data_remote.omdb.OmdbInterceptor import com.michaldrabik.data_remote.omdb.OmdbRemoteDataSource import com.michaldrabik.data_remote.omdb.api.OmdbApi import com.michaldrabik.data_remote.omdb.api.OmdbService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import retrofit2.Retrofit import javax.inject.Named import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object OmdbModule { @Provides @Singleton fun providesOmdbApi(@Named("retrofitOmdb") retrofit: Retrofit): OmdbRemoteDataSource = OmdbApi(retrofit.create(OmdbService::class.java)) @Provides @Singleton fun providesOmdbInterceptor() = OmdbInterceptor() } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/di/module/PreferencesModule.kt ================================================ package com.michaldrabik.data_remote.di.module import android.content.Context import android.content.SharedPreferences import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Named import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object PreferencesModule { @Provides @Singleton @Named("networkPreferences") fun providesNetworkPreferences(@ApplicationContext context: Context): SharedPreferences = context.applicationContext.getSharedPreferences( "PREFERENCES_NETWORK", Context.MODE_PRIVATE ) } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/di/module/RedditModule.kt ================================================ package com.michaldrabik.data_remote.di.module import com.michaldrabik.data_remote.reddit.RedditRemoteDataSource import com.michaldrabik.data_remote.reddit.api.RedditApi import com.michaldrabik.data_remote.reddit.api.RedditAuthApi import com.michaldrabik.data_remote.reddit.api.RedditListingApi import com.michaldrabik.data_remote.reddit.api.RedditService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import retrofit2.Retrofit import javax.inject.Named import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object RedditModule { @Provides @Singleton internal fun providesRedditApi( authApi: RedditAuthApi, listingApi: RedditListingApi ): RedditRemoteDataSource = RedditApi(authApi, listingApi) @Provides @Singleton internal fun providesRedditAuthApi(@Named("retrofitRedditAuth") retrofit: Retrofit): RedditAuthApi = RedditAuthApi(retrofit.create(RedditService::class.java)) @Provides @Singleton internal fun providesRedditListingApi(@Named("retrofitRedditListing") retrofit: Retrofit): RedditListingApi = RedditListingApi(retrofit.create(RedditService::class.java)) } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/di/module/RemoteDataModule.kt ================================================ package com.michaldrabik.data_remote.di.module import com.michaldrabik.data_remote.MainRemoteDataSource import com.michaldrabik.data_remote.RemoteDataSource import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class RemoteDataModule { @Binds @Singleton internal abstract fun providesRemoteDataSource(source: MainRemoteDataSource): RemoteDataSource } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/di/module/RetrofitModule.kt ================================================ package com.michaldrabik.data_remote.di.module import com.michaldrabik.data_remote.Config.AWS_BASE_URL import com.michaldrabik.data_remote.Config.OMDB_BASE_URL import com.michaldrabik.data_remote.Config.REDDIT_BASE_URL import com.michaldrabik.data_remote.Config.REDDIT_OAUTH_BASE_URL import com.michaldrabik.data_remote.Config.TMDB_BASE_URL import com.michaldrabik.data_remote.Config.TRAKT_BASE_URL import com.squareup.moshi.Moshi import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory import javax.inject.Named import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object RetrofitModule { @Provides @Singleton @Named("retrofitTrakt") fun providesTraktRetrofit( @Named("okHttpTrakt") okHttpClient: OkHttpClient, moshi: Moshi, ): Retrofit = Retrofit.Builder() .client(okHttpClient) .addConverterFactory(MoshiConverterFactory.create(moshi)) .baseUrl(TRAKT_BASE_URL) .build() @Provides @Singleton @Named("retrofitTmdb") fun providesTmdbRetrofit( @Named("okHttpTmdb") okHttpClient: OkHttpClient, moshi: Moshi, ): Retrofit = Retrofit.Builder() .client(okHttpClient) .addConverterFactory(MoshiConverterFactory.create(moshi)) .baseUrl(TMDB_BASE_URL) .build() @Provides @Singleton @Named("retrofitOmdb") fun providesOmdbRetrofit( @Named("okHttpOmdb") okHttpClient: OkHttpClient, moshi: Moshi, ): Retrofit = Retrofit.Builder() .client(okHttpClient) .addConverterFactory(MoshiConverterFactory.create(moshi)) .baseUrl(OMDB_BASE_URL) .build() @Provides @Singleton @Named("retrofitAws") fun providesAwsRetrofit( @Named("okHttpAws") okHttpClient: OkHttpClient, moshi: Moshi, ): Retrofit = Retrofit.Builder() .client(okHttpClient) .addConverterFactory(MoshiConverterFactory.create(moshi)) .baseUrl(AWS_BASE_URL) .build() @Provides @Singleton @Named("retrofitRedditAuth") fun providesRedditRetrofit( @Named("okHttpReddit") okHttpClient: OkHttpClient, moshi: Moshi, ): Retrofit = Retrofit.Builder() .client(okHttpClient) .addConverterFactory(MoshiConverterFactory.create(moshi)) .baseUrl(REDDIT_BASE_URL) .build() @Provides @Singleton @Named("retrofitRedditListing") fun providesRedditRetrofitOAuth( @Named("okHttpReddit") okHttpClient: OkHttpClient, moshiConverter: MoshiConverterFactory, ): Retrofit = Retrofit.Builder() .client(okHttpClient) .addConverterFactory(moshiConverter) .baseUrl(REDDIT_OAUTH_BASE_URL) .build() @Provides @Singleton fun providesMoshiFactory(moshi: Moshi): MoshiConverterFactory = MoshiConverterFactory.create(moshi) @Provides @Singleton fun providesMoshi(): Moshi = Moshi.Builder().build() } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/di/module/TmdbModule.kt ================================================ package com.michaldrabik.data_remote.di.module import com.michaldrabik.data_remote.tmdb.TmdbInterceptor import com.michaldrabik.data_remote.tmdb.TmdbRemoteDataSource import com.michaldrabik.data_remote.tmdb.api.TmdbApi import com.michaldrabik.data_remote.tmdb.api.TmdbService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import retrofit2.Retrofit import javax.inject.Named import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object TmdbModule { @Provides @Singleton fun providesTmdbApi(@Named("retrofitTmdb") retrofit: Retrofit): TmdbRemoteDataSource = TmdbApi(retrofit.create(TmdbService::class.java)) @Provides @Singleton fun providesTmdbInterceptor() = TmdbInterceptor() } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/di/module/TraktModule.kt ================================================ package com.michaldrabik.data_remote.di.module import android.content.SharedPreferences import com.michaldrabik.data_remote.token.TokenProvider import com.michaldrabik.data_remote.token.TraktTokenProvider import com.michaldrabik.data_remote.trakt.TraktRemoteDataSource import com.michaldrabik.data_remote.trakt.api.TraktApi import com.michaldrabik.data_remote.trakt.api.service.TraktAuthService import com.michaldrabik.data_remote.trakt.api.service.TraktCommentsService import com.michaldrabik.data_remote.trakt.api.service.TraktMoviesService import com.michaldrabik.data_remote.trakt.api.service.TraktPeopleService import com.michaldrabik.data_remote.trakt.api.service.TraktSearchService import com.michaldrabik.data_remote.trakt.api.service.TraktShowsService import com.michaldrabik.data_remote.trakt.api.service.TraktSyncService import com.michaldrabik.data_remote.trakt.api.service.TraktUsersService import com.squareup.moshi.Moshi import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient import retrofit2.Retrofit import javax.inject.Named import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object TraktModule { @Provides @Singleton fun providesTraktApi(@Named("retrofitTrakt") retrofit: Retrofit): TraktRemoteDataSource = TraktApi( showsService = retrofit.create(TraktShowsService::class.java), moviesService = retrofit.create(TraktMoviesService::class.java), usersService = retrofit.create(TraktUsersService::class.java), authService = retrofit.create(TraktAuthService::class.java), commentsService = retrofit.create(TraktCommentsService::class.java), searchService = retrofit.create(TraktSearchService::class.java), peopleService = retrofit.create(TraktPeopleService::class.java), syncService = retrofit.create(TraktSyncService::class.java) ) @Provides @Singleton fun providesTraktTokenProvider( @Named("networkPreferences") sharedPreferences: SharedPreferences, @Named("okHttpBase") okHttpClient: OkHttpClient, moshi: Moshi, ): TokenProvider = TraktTokenProvider( sharedPreferences = sharedPreferences, moshi = moshi, okHttpClient = okHttpClient ) } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/omdb/OmdbInterceptor.kt ================================================ package com.michaldrabik.data_remote.omdb import com.michaldrabik.data_remote.Config import okhttp3.Interceptor import okhttp3.Response class OmdbInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val url = chain.request().url.newBuilder() .addQueryParameter("apikey", Config.OMDB_API_KEY) .addQueryParameter("tomatoes", "true") .build() val request = chain.request().newBuilder() .url(url) .build() return chain.proceed(request) } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/omdb/OmdbRemoteDataSource.kt ================================================ package com.michaldrabik.data_remote.omdb import com.michaldrabik.data_remote.omdb.model.OmdbResult /** * Fetch/post remote resources via OMDB API */ interface OmdbRemoteDataSource { suspend fun fetchOmdbData(imdbId: String): OmdbResult } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/omdb/api/OmdbApi.kt ================================================ package com.michaldrabik.data_remote.omdb.api import com.michaldrabik.data_remote.omdb.OmdbRemoteDataSource internal class OmdbApi(private val service: OmdbService) : OmdbRemoteDataSource { override suspend fun fetchOmdbData(imdbId: String) = service.fetchData(imdbId) } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/omdb/api/OmdbService.kt ================================================ package com.michaldrabik.data_remote.omdb.api import com.michaldrabik.data_remote.omdb.model.OmdbResult import retrofit2.http.GET import retrofit2.http.Query interface OmdbService { @GET("/") suspend fun fetchData(@Query("i") imdbId: String): OmdbResult } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/omdb/model/OmdbResult.kt ================================================ package com.michaldrabik.data_remote.omdb.model data class OmdbResult( val Ratings: List?, val imdbRating: String?, val imdbVotes: String?, val Metascore: String?, val tomatoURL: String?, ) data class OmdbRating( val Source: String?, val Value: String?, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/reddit/RedditRemoteDataSource.kt ================================================ package com.michaldrabik.data_remote.reddit import com.michaldrabik.data_remote.Config import com.michaldrabik.data_remote.reddit.model.RedditAuthResponse import com.michaldrabik.data_remote.reddit.model.RedditItem /** * Fetch/post remote resources via Reddit API */ interface RedditRemoteDataSource { suspend fun fetchAuthToken(): RedditAuthResponse suspend fun fetchTelevisionItems( token: String, limit: Int = Config.REDDIT_LIST_LIMIT, pages: Int = Config.REDDIT_LIST_PAGES, ): List suspend fun fetchMoviesItems( token: String, limit: Int = Config.REDDIT_LIST_LIMIT, pages: Int = Config.REDDIT_LIST_PAGES, ): List } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/reddit/api/RedditApi.kt ================================================ package com.michaldrabik.data_remote.reddit.api import com.michaldrabik.data_remote.reddit.RedditRemoteDataSource import com.michaldrabik.data_remote.reddit.model.RedditItem internal class RedditApi( private val authApi: RedditAuthApi, private val listingApi: RedditListingApi, ) : RedditRemoteDataSource { override suspend fun fetchAuthToken() = authApi.fetchAuthToken() override suspend fun fetchTelevisionItems( token: String, limit: Int, pages: Int, ): List { return listingApi.fetchTelevision(token, limit, pages).filterNot { it.is_self } } override suspend fun fetchMoviesItems( token: String, limit: Int, pages: Int, ): List { return listingApi.fetchMovies(token, limit, pages).filterNot { it.is_self } } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/reddit/api/RedditAuthApi.kt ================================================ package com.michaldrabik.data_remote.reddit.api import com.michaldrabik.data_remote.Config import com.michaldrabik.data_remote.reddit.model.RedditAuthResponse import okhttp3.Credentials internal class RedditAuthApi(private val service: RedditService) { suspend fun fetchAuthToken(): RedditAuthResponse { val credentials = Credentials.basic(Config.REDDIT_CLIENT_ID, "") return service.fetchAccessToken( credentials, Config.REDDIT_GRANT_TYPE, Config.REDDIT_DEVICE_ID ) } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/reddit/api/RedditListingApi.kt ================================================ package com.michaldrabik.data_remote.reddit.api import com.michaldrabik.data_remote.reddit.model.RedditItem internal class RedditListingApi(private val service: RedditService) { suspend fun fetchTelevision(token: String, limit: Int, pages: Int): List { val result = mutableListOf() var after: String? = null (0 until pages).forEach { _ -> val response = service.fetchTelevision("Bearer $token", limit, after) result.addAll(response.data.children.map { it.data }) after = response.data.after } return result } suspend fun fetchMovies(token: String, limit: Int, pages: Int): List { val result = mutableListOf() var after: String? = null (0 until pages).forEach { _ -> val response = service.fetchMovies("Bearer $token", limit, after) result.addAll(response.data.children.map { it.data }) after = response.data.after } return result } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/reddit/api/RedditService.kt ================================================ package com.michaldrabik.data_remote.reddit.api import com.michaldrabik.data_remote.reddit.model.RedditAuthResponse import com.michaldrabik.data_remote.reddit.model.RedditResponse import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Query interface RedditService { @POST("access_token") suspend fun fetchAccessToken( @Header("Authorization") credentials: String, @Query("grant_type") grantType: String, @Query("device_id") deviceId: String, ): RedditAuthResponse @GET("r/television/hot/.json") suspend fun fetchTelevision( @Header("Authorization") token: String, @Query("limit") limit: Int, @Query("after") after: String? = null, ): RedditResponse @GET("r/movies/hot/.json") suspend fun fetchMovies( @Header("Authorization") token: String, @Query("limit") limit: Int, @Query("after") after: String? = null, ): RedditResponse } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/reddit/model/RedditAuthResponse.kt ================================================ package com.michaldrabik.data_remote.reddit.model data class RedditAuthResponse( val access_token: String, val token_type: String, val device_id: String, val expires_in: Long, val scope: String, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/reddit/model/RedditData.kt ================================================ package com.michaldrabik.data_remote.reddit.model data class RedditData( val children: List, val after: String?, val before: String?, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/reddit/model/RedditDataItem.kt ================================================ package com.michaldrabik.data_remote.reddit.model data class RedditDataItem( val kind: String, val data: RedditItem, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/reddit/model/RedditItem.kt ================================================ package com.michaldrabik.data_remote.reddit.model data class RedditItem( val id: String, val is_self: Boolean, val title: String, val url: String, val score: Long, val preview: Preview?, val created_utc: Long, ) { data class Preview( val images: List?, ) data class Image( val resolutions: List?, ) { data class Resolution( val url: String, val width: Int, val height: Int, ) } fun findImageUrl(): String? { val resolutions = preview?.images?.firstOrNull()?.resolutions return resolutions?.firstOrNull { it.width > 600 }?.url ?: resolutions?.lastOrNull()?.url } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/reddit/model/RedditResponse.kt ================================================ package com.michaldrabik.data_remote.reddit.model data class RedditResponse( val kind: String, val data: RedditData, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/tmdb/TmdbInterceptor.kt ================================================ package com.michaldrabik.data_remote.tmdb import com.michaldrabik.data_remote.Config import okhttp3.Interceptor import okhttp3.Response class TmdbInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request().newBuilder() .header("Content-Type", "application/json") .header("Authorization", "Bearer ${Config.TMDB_API_KEY}") .build() return chain.proceed(request) } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/tmdb/TmdbRemoteDataSource.kt ================================================ package com.michaldrabik.data_remote.tmdb import com.michaldrabik.data_remote.tmdb.model.TmdbImage import com.michaldrabik.data_remote.tmdb.model.TmdbImages import com.michaldrabik.data_remote.tmdb.model.TmdbPerson import com.michaldrabik.data_remote.tmdb.model.TmdbStreamingCountry import com.michaldrabik.data_remote.tmdb.model.TmdbTranslation /** * Fetch/post remote resources via TMDB API */ interface TmdbRemoteDataSource { suspend fun fetchShowImages(tmdbId: Long): TmdbImages suspend fun fetchEpisodeImage(showTmdbId: Long?, season: Int?, episode: Int?): TmdbImage? suspend fun fetchMovieImages(tmdbId: Long): TmdbImages suspend fun fetchMoviePeople(tmdbId: Long): Map> suspend fun fetchShowPeople(tmdbId: Long): Map> suspend fun fetchShowWatchProviders(tmdbId: Long, countryCode: String): TmdbStreamingCountry? suspend fun fetchMovieWatchProviders(tmdbId: Long, countryCode: String): TmdbStreamingCountry? suspend fun fetchPersonDetails(id: Long): TmdbPerson suspend fun fetchPersonTranslations(id: Long): Map suspend fun fetchPersonImages(tmdbId: Long): TmdbImages } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/tmdb/api/TmdbApi.kt ================================================ package com.michaldrabik.data_remote.tmdb.api import com.michaldrabik.data_remote.tmdb.TmdbRemoteDataSource import com.michaldrabik.data_remote.tmdb.model.TmdbImages import com.michaldrabik.data_remote.tmdb.model.TmdbPerson import com.michaldrabik.data_remote.tmdb.model.TmdbStreamingCountry import com.michaldrabik.data_remote.tmdb.model.TmdbTranslation internal class TmdbApi(private val service: TmdbService) : TmdbRemoteDataSource { override suspend fun fetchShowImages(tmdbId: Long) = try { if (tmdbId <= 0) TmdbImages.EMPTY service.fetchShowImages(tmdbId) } catch (error: Throwable) { TmdbImages.EMPTY } override suspend fun fetchEpisodeImage(showTmdbId: Long?, season: Int?, episode: Int?) = try { if (showTmdbId == null || showTmdbId <= 0) TmdbImages.EMPTY if (season == null || season <= 0) TmdbImages.EMPTY if (episode == null || episode <= 0) TmdbImages.EMPTY val images = service.fetchEpisodeImages(showTmdbId, season, episode) images.stills?.firstOrNull() } catch (error: Throwable) { null } override suspend fun fetchMovieImages(tmdbId: Long) = try { if (tmdbId <= 0) TmdbImages.EMPTY service.fetchMovieImages(tmdbId) } catch (error: Throwable) { TmdbImages.EMPTY } override suspend fun fetchMoviePeople(tmdbId: Long): Map> { val result = service.fetchMoviePeople(tmdbId) val cast = result.cast?.toList() ?: emptyList() val crew = result.crew?.toList() ?: emptyList() return mapOf( TmdbPerson.Type.CAST to cast, TmdbPerson.Type.CREW to crew ) } override suspend fun fetchShowPeople(tmdbId: Long): Map> { val result = service.fetchShowPeople(tmdbId) val cast = result.cast?.toList() ?: emptyList() val crew = result.crew?.toList() ?: emptyList() return mapOf( TmdbPerson.Type.CAST to cast, TmdbPerson.Type.CREW to crew ) } override suspend fun fetchShowWatchProviders(tmdbId: Long, countryCode: String): TmdbStreamingCountry? { val result = service.fetchShowWatchProviders(tmdbId) val code = when (countryCode.uppercase()) { "UK" -> "GB" else -> countryCode.uppercase() } return result.results[code] } override suspend fun fetchMovieWatchProviders(tmdbId: Long, countryCode: String): TmdbStreamingCountry? { val result = service.fetchMovieWatchProviders(tmdbId) val code = when (countryCode.uppercase()) { "UK" -> "GB" else -> countryCode.uppercase() } return result.results[code] } override suspend fun fetchPersonDetails(id: Long): TmdbPerson { return service.fetchPersonDetails(id) } override suspend fun fetchPersonTranslations(id: Long): Map { val result = service.fetchPersonTranslation(id).translations ?: emptyList() return result .filter { if (it.iso_639_1.lowercase() != "zh") true else it.iso_3166_1.lowercase() == "cn" } // Chinese Simplified filter .associateBy( keySelector = { it.iso_639_1.lowercase() }, valueTransform = { it.data ?: TmdbTranslation.Data(null) } ) } override suspend fun fetchPersonImages(tmdbId: Long) = try { if (tmdbId <= 0) TmdbImages.EMPTY service.fetchPersonImages(tmdbId) } catch (error: Throwable) { TmdbImages.EMPTY } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/tmdb/api/TmdbService.kt ================================================ package com.michaldrabik.data_remote.tmdb.api import com.michaldrabik.data_remote.tmdb.model.TmdbImages import com.michaldrabik.data_remote.tmdb.model.TmdbPeople import com.michaldrabik.data_remote.tmdb.model.TmdbPerson import com.michaldrabik.data_remote.tmdb.model.TmdbStreamings import com.michaldrabik.data_remote.tmdb.model.TmdbTranslationResponse import retrofit2.http.GET import retrofit2.http.Path interface TmdbService { @GET("tv/{tmdbId}/images") suspend fun fetchShowImages(@Path("tmdbId") tmdbId: Long): TmdbImages @GET("tv/{tmdbId}/season/{season}/episode/{episode}/images") suspend fun fetchEpisodeImages( @Path("tmdbId") tmdbId: Long?, @Path("season") seasonNumber: Int?, @Path("episode") episodeNumber: Int? ): TmdbImages @GET("movie/{tmdbId}/images") suspend fun fetchMovieImages(@Path("tmdbId") tmdbId: Long): TmdbImages @GET("person/{tmdbId}/images") suspend fun fetchPersonImages(@Path("tmdbId") tmdbId: Long): TmdbImages @GET("person/{tmdbId}") suspend fun fetchPersonDetails(@Path("tmdbId") tmdbId: Long): TmdbPerson @GET("person/{tmdbId}/translations") suspend fun fetchPersonTranslation(@Path("tmdbId") tmdbId: Long): TmdbTranslationResponse @GET("movie/{tmdbId}/credits") suspend fun fetchMoviePeople(@Path("tmdbId") tmdbId: Long): TmdbPeople @GET("tv/{tmdbId}/aggregate_credits") suspend fun fetchShowPeople(@Path("tmdbId") tmdbId: Long): TmdbPeople @GET("movie/{tmdbId}/watch/providers") suspend fun fetchMovieWatchProviders(@Path("tmdbId") tmdbId: Long): TmdbStreamings @GET("tv/{tmdbId}/watch/providers") suspend fun fetchShowWatchProviders(@Path("tmdbId") tmdbId: Long): TmdbStreamings } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/tmdb/model/TmdbImage.kt ================================================ package com.michaldrabik.data_remote.tmdb.model data class TmdbImage( val file_path: String, val vote_average: Float, val vote_count: Long, val iso_639_1: String?, ) { fun isPlain() = iso_639_1 == null fun isEnglish() = iso_639_1 == "en" } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/tmdb/model/TmdbImages.kt ================================================ package com.michaldrabik.data_remote.tmdb.model data class TmdbImages( val backdrops: List?, val posters: List?, val stills: List?, val profiles: List?, ) { companion object { val EMPTY = TmdbImages(emptyList(), emptyList(), emptyList(), emptyList()) } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/tmdb/model/TmdbPeople.kt ================================================ package com.michaldrabik.data_remote.tmdb.model data class TmdbPeople( val id: Long, val cast: List?, val crew: List?, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/tmdb/model/TmdbPerson.kt ================================================ package com.michaldrabik.data_remote.tmdb.model data class TmdbPerson( val id: Long, val name: String?, val place_of_birth: String?, val homepage: String?, val character: String?, val department: String?, val roles: List?, val jobs: List?, val job: String?, val deathday: String?, val birthday: String?, val biography: String?, val imdb_id: String?, val known_for_department: String?, val profile_path: String?, val total_episode_count: Int? ) { data class Role( val character: String? ) data class Job( val job: String? ) enum class Type { CAST, CREW } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/tmdb/model/TmdbStreamingCountry.kt ================================================ package com.michaldrabik.data_remote.tmdb.model data class TmdbStreamingCountry( val link: String, val flatrate: List?, val free: List?, val buy: List?, val rent: List?, val ads: List?, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/tmdb/model/TmdbStreamingService.kt ================================================ package com.michaldrabik.data_remote.tmdb.model data class TmdbStreamingService( val display_priority: Long, val logo_path: String, val provider_id: Long, val provider_name: String, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/tmdb/model/TmdbStreamings.kt ================================================ package com.michaldrabik.data_remote.tmdb.model data class TmdbStreamings( val id: Long, val results: Map, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/tmdb/model/TmdbTranslation.kt ================================================ package com.michaldrabik.data_remote.tmdb.model data class TmdbTranslation( val iso_639_1: String, // ex: zh val iso_3166_1: String, // ex: CN val data: Data? ) { data class Data( val biography: String? ) } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/tmdb/model/TmdbTranslationResponse.kt ================================================ package com.michaldrabik.data_remote.tmdb.model data class TmdbTranslationResponse( val id: Long?, val translations: List? ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/token/TokenProvider.kt ================================================ package com.michaldrabik.data_remote.token import com.michaldrabik.data_remote.trakt.model.OAuthResponse interface TokenProvider { /** * Returns access token if available or null. */ fun getToken(): String? /** * Save access and refresh tokens. */ fun saveTokens(accessToken: String, refreshToken: String) /** * Revokes and deletes access and refresh tokens. */ fun revokeToken() /** * Tries to refresh current access token or throws if failure. */ suspend fun refreshToken(): OAuthResponse suspend fun shouldRefresh(): Boolean } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/token/TraktTokenProvider.kt ================================================ package com.michaldrabik.data_remote.token import android.annotation.SuppressLint import android.content.SharedPreferences import com.michaldrabik.data_remote.Config import com.michaldrabik.data_remote.trakt.model.OAuthResponse import com.squareup.moshi.Moshi import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call import okhttp3.Callback import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.internal.closeQuietly import org.json.JSONObject import timber.log.Timber import java.io.IOException import java.time.Duration import javax.inject.Named import javax.inject.Singleton import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @SuppressLint("ApplySharedPref") @Singleton internal class TraktTokenProvider( private val sharedPreferences: SharedPreferences, private val moshi: Moshi, @Named("okHttpBase") private val okHttpClient: OkHttpClient, ) : TokenProvider { companion object { private const val KEY_ACCESS_TOKEN = "TRAKT_ACCESS_TOKEN" private const val KEY_REFRESH_TOKEN = "TRAKT_REFRESH_TOKEN" private const val KEY_TIMESTAMP = "TRAKT_ACCESS_TOKEN_TIMESTAMP" private val TRAKT_TOKEN_REFRESH_COOLDOWN: Duration = Duration.ofDays(1) } private var token: String? = null private var lastRefreshCheck: Long = 0 override fun getToken(): String? { if (token == null) { token = sharedPreferences.getString(KEY_ACCESS_TOKEN, null) } return token } override fun saveTokens(accessToken: String, refreshToken: String) { sharedPreferences.edit() .putString(KEY_ACCESS_TOKEN, accessToken) .putString(KEY_REFRESH_TOKEN, refreshToken) .putLong(KEY_TIMESTAMP, System.currentTimeMillis()) .commit() token = null } override fun revokeToken() { sharedPreferences.edit() .clear() .remove(KEY_ACCESS_TOKEN) .remove(KEY_REFRESH_TOKEN) .remove(KEY_TIMESTAMP) .commit() token = null } override suspend fun shouldRefresh(): Boolean { val now = System.currentTimeMillis() if (lastRefreshCheck > 0L && now - lastRefreshCheck < TRAKT_TOKEN_REFRESH_COOLDOWN.toMillis()) { return false } lastRefreshCheck = now val timestamp = sharedPreferences.getLong(KEY_TIMESTAMP, 0L) if (timestamp == 0L) { return true } if (now - timestamp > Config.TRAKT_TOKEN_REFRESH_DURATION.toMillis()) { return true } return false } override suspend fun refreshToken(): OAuthResponse { val refreshToken = sharedPreferences.getString(KEY_REFRESH_TOKEN, null) ?: throw Error("Refresh token is not available") val body = JSONObject() .put("refresh_token", refreshToken) .put("client_id", Config.TRAKT_CLIENT_ID) .put("client_secret", Config.TRAKT_CLIENT_SECRET) .put("redirect_uri", Config.TRAKT_REDIRECT_URL) .put("grant_type", "refresh_token") .toString() val request = Request.Builder() .url("${Config.TRAKT_BASE_URL}oauth/token") .addHeader("Content-Type", "application/json") .post(body.toRequestBody("application/json".toMediaType())) .build() Timber.d("Making refresh token call...") return suspendCancellableCoroutine { val callback = object : Callback { override fun onFailure(call: Call, e: IOException) { Timber.d("Refresh token call failed. $e") it.resumeWithException(Error("Refresh token call failed. $e")) } override fun onResponse(call: Call, response: Response) { if (response.isSuccessful) { Timber.d("Refresh token success!") val responseSource = response.body!!.source() val result = moshi.adapter(OAuthResponse::class.java).fromJson(responseSource)!! it.resume(result) } else { it.resumeWithException(Error("Refresh token call failed. ${response.code}")) } response.closeQuietly() } } val call = okHttpClient.newCall(request) it.invokeOnCancellation { call.cancel() } call.enqueue(callback) } } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/TraktRemoteDataSource.kt ================================================ package com.michaldrabik.data_remote.trakt import com.michaldrabik.data_remote.tmdb.model.TmdbPerson import com.michaldrabik.data_remote.trakt.model.Comment import com.michaldrabik.data_remote.trakt.model.CustomList import com.michaldrabik.data_remote.trakt.model.Episode import com.michaldrabik.data_remote.trakt.model.HiddenItem import com.michaldrabik.data_remote.trakt.model.Ids import com.michaldrabik.data_remote.trakt.model.Movie import com.michaldrabik.data_remote.trakt.model.MovieCollection import com.michaldrabik.data_remote.trakt.model.OAuthResponse import com.michaldrabik.data_remote.trakt.model.PersonCredit import com.michaldrabik.data_remote.trakt.model.RatingResultEpisode import com.michaldrabik.data_remote.trakt.model.RatingResultMovie import com.michaldrabik.data_remote.trakt.model.RatingResultSeason import com.michaldrabik.data_remote.trakt.model.RatingResultShow import com.michaldrabik.data_remote.trakt.model.SearchResult import com.michaldrabik.data_remote.trakt.model.Season import com.michaldrabik.data_remote.trakt.model.SeasonTranslation import com.michaldrabik.data_remote.trakt.model.Show import com.michaldrabik.data_remote.trakt.model.SyncExportItem import com.michaldrabik.data_remote.trakt.model.SyncExportRequest import com.michaldrabik.data_remote.trakt.model.SyncExportResult import com.michaldrabik.data_remote.trakt.model.SyncItem import com.michaldrabik.data_remote.trakt.model.Translation import com.michaldrabik.data_remote.trakt.model.User import com.michaldrabik.data_remote.trakt.model.request.CommentRequest import retrofit2.Response /** * Fetch/post remote resources via Trakt API */ interface TraktRemoteDataSource { suspend fun fetchShow(traktId: Long): Show suspend fun fetchShow(traktSlug: String): Show suspend fun fetchMovie(traktId: Long): Movie suspend fun fetchMovie(traktSlug: String): Movie suspend fun fetchPopularShows(genres: String, networks: String): List suspend fun fetchPopularMovies(genres: String): List suspend fun fetchTrendingShows(genres: String, networks: String, limit: Int): List suspend fun fetchTrendingMovies(genres: String, limit: Int): List suspend fun fetchAnticipatedShows(genres: String, networks: String): List suspend fun fetchAnticipatedMovies(genres: String): List suspend fun fetchRelatedShows(traktId: Long, addToLimit: Int): List suspend fun fetchRelatedMovies(traktId: Long, addToLimit: Int): List suspend fun fetchNextEpisode(traktId: Long): Episode? suspend fun fetchSearch(query: String, withMovies: Boolean): List suspend fun fetchPersonIds(idType: String, id: String): Ids? suspend fun fetchPersonShowsCredits(traktId: Long, type: TmdbPerson.Type): List suspend fun fetchPersonMoviesCredits(traktId: Long, type: TmdbPerson.Type): List suspend fun fetchSearchId(idType: String, id: String): List suspend fun fetchSeasons(traktId: Long): List suspend fun fetchShowComments(traktId: Long, limit: Int): List suspend fun fetchMovieComments(traktId: Long, limit: Int): List suspend fun fetchCommentReplies(commentId: Long): List suspend fun postComment(commentRequest: CommentRequest): Comment suspend fun postCommentReply(commentId: Long, commentRequest: CommentRequest): Comment suspend fun deleteComment(commentId: Long): Response suspend fun fetchShowTranslations(traktId: Long, code: String): List suspend fun fetchMovieTranslations(traktId: Long, code: String): List suspend fun fetchSeasonTranslations(showTraktId: Long, seasonNumber: Int, code: String): List suspend fun fetchEpisodeComments( traktId: Long, seasonNumber: Int, episodeNumber: Int, ): List suspend fun fetchAuthTokens(code: String): OAuthResponse suspend fun refreshAuthTokens(refreshToken: String): OAuthResponse suspend fun revokeAuthTokens(token: String) suspend fun fetchMyProfile(): User suspend fun fetchHiddenShows(): List suspend fun postHiddenShows(shows: List = emptyList()) suspend fun postHiddenMovies(movies: List = emptyList()) suspend fun fetchHiddenMovies(): List suspend fun fetchSyncWatchedShows(extended: String? = null): List suspend fun fetchSyncWatchedMovies(extended: String? = null): List suspend fun fetchSyncShowsWatchlist(): List suspend fun fetchSyncMoviesWatchlist(): List suspend fun fetchSyncWatchlist(type: String): List suspend fun fetchSyncLists(): List suspend fun fetchSyncList(listId: Long): CustomList suspend fun fetchSyncListItems(listId: Long, withMovies: Boolean): List suspend fun postCreateList(name: String, description: String?): CustomList suspend fun postUpdateList(customList: CustomList): CustomList suspend fun deleteList(listId: Long) suspend fun postAddListItems( listTraktId: Long, showsIds: List, moviesIds: List, ): SyncExportResult suspend fun postRemoveListItems( listTraktId: Long, showsIds: List, moviesIds: List, ): SyncExportResult suspend fun postSyncWatchlist(request: SyncExportRequest): SyncExportResult suspend fun postSyncWatched(request: SyncExportRequest): SyncExportResult suspend fun postDeleteProgress(request: SyncExportRequest): SyncExportResult suspend fun postDeleteWatchlist(request: SyncExportRequest): SyncExportResult suspend fun deleteHiddenShow(request: SyncExportRequest): SyncExportResult suspend fun deleteHiddenMovie(request: SyncExportRequest): SyncExportResult suspend fun deleteRating(show: Show) suspend fun deleteRating(movie: Movie) suspend fun deleteRating(episode: Episode) suspend fun deleteRating(season: Season) suspend fun postRating(movie: Movie, rating: Int) suspend fun postRating(show: Show, rating: Int) suspend fun postRating(episode: Episode, rating: Int) suspend fun postRating(season: Season, rating: Int) suspend fun fetchShowsRatings(): List suspend fun fetchMoviesRatings(): List suspend fun fetchEpisodesRatings(): List suspend fun fetchSeasonsRatings(): List suspend fun fetchMovieCollections(traktId: Long): List suspend fun fetchMovieCollectionItems(collectionId: Long): List } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/api/TraktApi.kt ================================================ package com.michaldrabik.data_remote.trakt.api import com.michaldrabik.data_remote.Config import com.michaldrabik.data_remote.Config.TRAKT_ANTICIPATED_SHOWS_LIMIT import com.michaldrabik.data_remote.Config.TRAKT_CLIENT_ID import com.michaldrabik.data_remote.Config.TRAKT_CLIENT_SECRET import com.michaldrabik.data_remote.Config.TRAKT_REDIRECT_URL import com.michaldrabik.data_remote.Config.TRAKT_SYNC_PAGE_LIMIT import com.michaldrabik.data_remote.tmdb.model.TmdbPerson import com.michaldrabik.data_remote.trakt.TraktRemoteDataSource import com.michaldrabik.data_remote.trakt.api.service.TraktAuthService import com.michaldrabik.data_remote.trakt.api.service.TraktCommentsService import com.michaldrabik.data_remote.trakt.api.service.TraktMoviesService import com.michaldrabik.data_remote.trakt.api.service.TraktPeopleService import com.michaldrabik.data_remote.trakt.api.service.TraktSearchService import com.michaldrabik.data_remote.trakt.api.service.TraktShowsService import com.michaldrabik.data_remote.trakt.api.service.TraktSyncService import com.michaldrabik.data_remote.trakt.api.service.TraktUsersService import com.michaldrabik.data_remote.trakt.model.Comment import com.michaldrabik.data_remote.trakt.model.CustomList import com.michaldrabik.data_remote.trakt.model.Episode import com.michaldrabik.data_remote.trakt.model.Ids import com.michaldrabik.data_remote.trakt.model.Movie import com.michaldrabik.data_remote.trakt.model.MovieCollection import com.michaldrabik.data_remote.trakt.model.OAuthResponse import com.michaldrabik.data_remote.trakt.model.PersonCredit import com.michaldrabik.data_remote.trakt.model.Season import com.michaldrabik.data_remote.trakt.model.Show import com.michaldrabik.data_remote.trakt.model.SyncExportItem import com.michaldrabik.data_remote.trakt.model.SyncExportRequest import com.michaldrabik.data_remote.trakt.model.SyncExportResult import com.michaldrabik.data_remote.trakt.model.SyncItem import com.michaldrabik.data_remote.trakt.model.request.CommentRequest import com.michaldrabik.data_remote.trakt.model.request.CreateListRequest import com.michaldrabik.data_remote.trakt.model.request.OAuthRefreshRequest import com.michaldrabik.data_remote.trakt.model.request.OAuthRequest import com.michaldrabik.data_remote.trakt.model.request.OAuthRevokeRequest import com.michaldrabik.data_remote.trakt.model.request.RatingRequest import com.michaldrabik.data_remote.trakt.model.request.RatingRequestValue import java.lang.System.currentTimeMillis internal class TraktApi( private val showsService: TraktShowsService, private val moviesService: TraktMoviesService, private val usersService: TraktUsersService, private val syncService: TraktSyncService, private val authService: TraktAuthService, private val commentsService: TraktCommentsService, private val searchService: TraktSearchService, private val peopleService: TraktPeopleService, ) : TraktRemoteDataSource { override suspend fun fetchShow(traktId: Long) = showsService.fetchShow(traktId) override suspend fun fetchShow(traktSlug: String) = showsService.fetchShow(traktSlug) override suspend fun fetchMovie(traktId: Long) = moviesService.fetchMovie(traktId) override suspend fun fetchMovie(traktSlug: String) = moviesService.fetchMovie(traktSlug) override suspend fun fetchPopularShows(genres: String, networks: String) = showsService.fetchPopularShows(genres, networks, Config.TRAKT_POPULAR_SHOWS_LIMIT) override suspend fun fetchPopularMovies(genres: String) = moviesService.fetchPopularMovies(genres) override suspend fun fetchTrendingShows(genres: String, networks: String, limit: Int): List = showsService.fetchTrendingShows(genres, networks, limit).map { it.show!! } override suspend fun fetchTrendingMovies(genres: String, limit: Int) = moviesService.fetchTrendingMovies(genres, limit).map { it.movie!! } override suspend fun fetchAnticipatedShows(genres: String, networks: String): List = showsService.fetchAnticipatedShows(genres, networks, TRAKT_ANTICIPATED_SHOWS_LIMIT).map { it.show!! } override suspend fun fetchAnticipatedMovies(genres: String) = moviesService.fetchAnticipatedMovies(genres).map { it.movie!! } override suspend fun fetchRelatedShows(traktId: Long, addToLimit: Int) = showsService.fetchRelatedShows(traktId, Config.TRAKT_RELATED_SHOWS_LIMIT + addToLimit) override suspend fun fetchRelatedMovies(traktId: Long, addToLimit: Int) = moviesService.fetchRelatedMovies(traktId, Config.TRAKT_RELATED_MOVIES_LIMIT + addToLimit) override suspend fun fetchNextEpisode(traktId: Long): Episode? { val response = showsService.fetchNextEpisode(traktId) if (response.isSuccessful && response.code() == 204) return null return response.body() } override suspend fun fetchSearch(query: String, withMovies: Boolean) = if (withMovies) searchService.fetchSearchResultsMovies(query) else searchService.fetchSearchResults(query) override suspend fun fetchPersonIds(idType: String, id: String): Ids? { val result = searchService.fetchPersonIds(idType, id) if (result.isNotEmpty()) { return result.first().person?.ids } return null } override suspend fun fetchPersonShowsCredits(traktId: Long, type: TmdbPerson.Type): List { val result = peopleService.fetchPersonCredits(traktId = traktId, "shows") val cast = result.cast ?: emptyList() val crew = result.crew?.values?.flatten()?.distinctBy { it.show?.ids?.trakt } ?: emptyList() return if (type == TmdbPerson.Type.CAST) cast else crew } override suspend fun fetchPersonMoviesCredits(traktId: Long, type: TmdbPerson.Type): List { val result = peopleService.fetchPersonCredits(traktId = traktId, "movies") val cast = result.cast ?: emptyList() val crew = result.crew?.values?.flatten()?.distinctBy { it.movie?.ids?.trakt } ?: emptyList() return if (type == TmdbPerson.Type.CAST) cast else crew } override suspend fun fetchSearchId(idType: String, id: String) = searchService.fetchSearchId(idType, id) override suspend fun fetchSeasons(traktId: Long) = showsService.fetchSeasons(traktId) .sortedByDescending { it.number } override suspend fun fetchShowComments(traktId: Long, limit: Int) = showsService.fetchShowComments(traktId, limit, currentTimeMillis()) override suspend fun fetchMovieComments(traktId: Long, limit: Int) = moviesService.fetchMovieComments(traktId, limit, currentTimeMillis()) override suspend fun fetchCommentReplies(commentId: Long) = commentsService.fetchCommentReplies(commentId, currentTimeMillis()) override suspend fun postComment(commentRequest: CommentRequest) = commentsService.postComment(commentRequest) override suspend fun postCommentReply(commentId: Long, commentRequest: CommentRequest) = commentsService.postCommentReply(commentId, commentRequest) override suspend fun deleteComment(commentId: Long) = commentsService.deleteComment(commentId) override suspend fun fetchShowTranslations(traktId: Long, code: String) = showsService.fetchShowTranslations(traktId, code) override suspend fun fetchMovieTranslations(traktId: Long, code: String) = moviesService.fetchMovieTranslations(traktId, code) override suspend fun fetchSeasonTranslations(showTraktId: Long, seasonNumber: Int, code: String) = showsService.fetchSeasonTranslations(showTraktId, seasonNumber, code) override suspend fun fetchEpisodeComments( traktId: Long, seasonNumber: Int, episodeNumber: Int, ): List = try { showsService.fetchEpisodeComments(traktId, seasonNumber, episodeNumber, currentTimeMillis()) } catch (t: Throwable) { emptyList() } override suspend fun fetchAuthTokens(code: String): OAuthResponse { val request = OAuthRequest( code, TRAKT_CLIENT_ID, TRAKT_CLIENT_SECRET, TRAKT_REDIRECT_URL ) return authService.fetchOAuthToken(request) } override suspend fun refreshAuthTokens(refreshToken: String): OAuthResponse { val request = OAuthRefreshRequest( refreshToken, TRAKT_CLIENT_ID, TRAKT_CLIENT_SECRET, TRAKT_REDIRECT_URL ) return authService.refreshOAuthToken(request) } override suspend fun revokeAuthTokens(token: String) { val request = OAuthRevokeRequest( token, TRAKT_CLIENT_ID, TRAKT_CLIENT_SECRET ) authService.revokeOAuthToken(request) } override suspend fun fetchMyProfile() = usersService.fetchMyProfile() override suspend fun fetchHiddenShows() = usersService.fetchHiddenShows(pageLimit = 250) override suspend fun postHiddenShows(shows: List) { usersService.postHiddenShows(SyncExportRequest(shows = shows)) } override suspend fun postHiddenMovies(movies: List) { usersService.postHiddenMovies(SyncExportRequest(movies = movies)) } override suspend fun fetchHiddenMovies() = usersService.fetchHiddenMovies(pageLimit = 250) override suspend fun fetchSyncWatchedShows(extended: String?) = syncService.fetchSyncWatched("shows", extended).filter { it.show != null } override suspend fun fetchSyncWatchedMovies(extended: String?) = syncService.fetchSyncWatched("movies", extended).filter { it.movie != null } override suspend fun fetchSyncShowsWatchlist() = fetchSyncWatchlist("shows") override suspend fun fetchSyncMoviesWatchlist() = fetchSyncWatchlist("movies") override suspend fun fetchSyncWatchlist(type: String): List { var page = 1 val results = mutableListOf() do { val items = syncService.fetchSyncWatchlist(type, page, TRAKT_SYNC_PAGE_LIMIT) results.addAll(items) page += 1 } while (items.size >= TRAKT_SYNC_PAGE_LIMIT) return results } override suspend fun fetchSyncLists() = usersService.fetchSyncLists() override suspend fun fetchSyncList(listId: Long) = usersService.fetchSyncList(listId) override suspend fun fetchSyncListItems(listId: Long, withMovies: Boolean): List { var page = 1 val results = mutableListOf() val types = arrayListOf("show") .apply { if (withMovies) add("movie") } .joinToString(",") do { val items = usersService.fetchSyncListItems(listId, types, page, TRAKT_SYNC_PAGE_LIMIT) results.addAll(items) page += 1 } while (items.size >= TRAKT_SYNC_PAGE_LIMIT) return results } override suspend fun postCreateList(name: String, description: String?): CustomList { val body = CreateListRequest(name, description) return usersService.postCreateList(body) } override suspend fun postUpdateList(customList: CustomList): CustomList { val body = CreateListRequest(customList.name, customList.description) return usersService.postUpdateList(customList.ids.trakt, body) } override suspend fun deleteList(listId: Long) { usersService.deleteList(listId) } override suspend fun postAddListItems( listTraktId: Long, showsIds: List, moviesIds: List, ): SyncExportResult { val body = SyncExportRequest( shows = showsIds.map { SyncExportItem.create(it, null) }, movies = moviesIds.map { SyncExportItem.create(it, null) } ) return usersService.postAddListItems(listTraktId, body) } override suspend fun postRemoveListItems( listTraktId: Long, showsIds: List, moviesIds: List, ): SyncExportResult { val body = SyncExportRequest( shows = showsIds.map { SyncExportItem.create(it, null) }, movies = moviesIds.map { SyncExportItem.create(it, null) } ) return usersService.postRemoveListItems(listTraktId, body) } override suspend fun postSyncWatchlist(request: SyncExportRequest) = syncService.postSyncWatchlist(request) override suspend fun postSyncWatched(request: SyncExportRequest) = syncService.postSyncWatched(request) override suspend fun postDeleteProgress(request: SyncExportRequest) = syncService.deleteHistory(request) override suspend fun postDeleteWatchlist(request: SyncExportRequest) = syncService.deleteWatchlist(request) override suspend fun deleteHiddenShow(request: SyncExportRequest) = usersService.deleteHidden("progress_watched", request) override suspend fun deleteHiddenMovie(request: SyncExportRequest) = usersService.deleteHidden("calendar", request) override suspend fun deleteRating(show: Show) { val requestValue = RatingRequestValue(0, show.ids) val body = RatingRequest(shows = listOf(requestValue)) syncService.postRemoveRating(body) } override suspend fun deleteRating(movie: Movie) { val requestValue = RatingRequestValue(0, movie.ids) val body = RatingRequest(movies = listOf(requestValue)) syncService.postRemoveRating(body) } override suspend fun deleteRating(episode: Episode) { val requestValue = RatingRequestValue(0, episode.ids) val body = RatingRequest(episodes = listOf(requestValue)) syncService.postRemoveRating(body) } override suspend fun deleteRating(season: Season) { val requestValue = RatingRequestValue(0, season.ids) val body = RatingRequest(seasons = listOf(requestValue)) syncService.postRemoveRating(body) } override suspend fun postRating(movie: Movie, rating: Int) { val requestValue = RatingRequestValue(rating, movie.ids) val body = RatingRequest(movies = listOf(requestValue)) syncService.postRating(body) } override suspend fun postRating(show: Show, rating: Int) { val requestValue = RatingRequestValue(rating, show.ids) val body = RatingRequest(shows = listOf(requestValue)) syncService.postRating(body) } override suspend fun postRating(episode: Episode, rating: Int) { val requestValue = RatingRequestValue(rating, episode.ids) val body = RatingRequest(episodes = listOf(requestValue)) syncService.postRating(body) } override suspend fun postRating(season: Season, rating: Int) { val requestValue = RatingRequestValue(rating, season.ids) val body = RatingRequest(seasons = listOf(requestValue)) syncService.postRating(body) } override suspend fun fetchShowsRatings() = syncService.fetchShowsRatings() override suspend fun fetchMoviesRatings() = syncService.fetchMoviesRatings() override suspend fun fetchEpisodesRatings() = syncService.fetchEpisodesRatings() override suspend fun fetchSeasonsRatings() = syncService.fetchSeasonsRatings() override suspend fun fetchMovieCollections(traktId: Long): List { val lists = moviesService.fetchMovieCollections(traktId) return lists.filter { it.privacy == "public" } } override suspend fun fetchMovieCollectionItems(collectionId: Long): List { return moviesService.fetchMovieCollectionItems(collectionId) .sortedBy { it.rank } .map { it.movie } } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/api/service/TraktAuthService.kt ================================================ package com.michaldrabik.data_remote.trakt.api.service import com.michaldrabik.data_remote.trakt.model.OAuthResponse import com.michaldrabik.data_remote.trakt.model.request.OAuthRefreshRequest import com.michaldrabik.data_remote.trakt.model.request.OAuthRequest import com.michaldrabik.data_remote.trakt.model.request.OAuthRevokeRequest import retrofit2.Response import retrofit2.http.Body import retrofit2.http.POST interface TraktAuthService { @POST("oauth/token") suspend fun fetchOAuthToken(@Body request: OAuthRequest): OAuthResponse @POST("oauth/token") suspend fun refreshOAuthToken(@Body request: OAuthRefreshRequest): OAuthResponse @POST("oauth/revoke") suspend fun revokeOAuthToken(@Body request: OAuthRevokeRequest): Response } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/api/service/TraktCommentsService.kt ================================================ package com.michaldrabik.data_remote.trakt.api.service import com.michaldrabik.data_remote.trakt.model.Comment import com.michaldrabik.data_remote.trakt.model.request.CommentRequest import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query interface TraktCommentsService { @GET("comments/{id}/replies") suspend fun fetchCommentReplies( @Path("id") commentId: Long, @Query("timestamp") timestamp: Long ): List @POST("comments") suspend fun postComment( @Body commentBody: CommentRequest ): Comment @POST("comments/{id}/replies") suspend fun postCommentReply( @Path("id") commentId: Long, @Body commentBody: CommentRequest ): Comment @DELETE("comments/{id}") suspend fun deleteComment( @Path("id") commentIt: Long ): Response } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/api/service/TraktMoviesService.kt ================================================ package com.michaldrabik.data_remote.trakt.api.service import com.michaldrabik.data_remote.Config import com.michaldrabik.data_remote.trakt.model.Comment import com.michaldrabik.data_remote.trakt.model.Movie import com.michaldrabik.data_remote.trakt.model.MovieCollection import com.michaldrabik.data_remote.trakt.model.MovieCollectionItem import com.michaldrabik.data_remote.trakt.model.MovieResult import com.michaldrabik.data_remote.trakt.model.Translation import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query interface TraktMoviesService { @GET("movies/{traktId}?extended=full") suspend fun fetchMovie(@Path("traktId") traktId: Long): Movie @GET("movies/{traktSlug}?extended=full") suspend fun fetchMovie(@Path("traktSlug") traktSlug: String): Movie @GET("movies/popular?extended=full&limit=${Config.TRAKT_POPULAR_MOVIES_LIMIT}") suspend fun fetchPopularMovies( @Query("genres") genres: String, ): List @GET("movies/trending?extended=full") suspend fun fetchTrendingMovies( @Query("genres") genres: String, @Query("limit") limit: Int, ): List @GET("movies/anticipated?extended=full&limit=${Config.TRAKT_ANTICIPATED_MOVIES_LIMIT}") suspend fun fetchAnticipatedMovies( @Query("genres") genres: String, ): List @GET("movies/{traktId}/related?extended=full") suspend fun fetchRelatedMovies(@Path("traktId") traktId: Long, @Query("limit") limit: Int): List @GET("movies/{traktId}/comments/newest?extended=full") suspend fun fetchMovieComments( @Path("traktId") traktId: Long, @Query("limit") limit: Int, @Query("timestamp") timestamp: Long, ): List @GET("movies/{traktId}/translations/{code}") suspend fun fetchMovieTranslations( @Path("traktId") traktId: Long, @Path("code") countryCode: String, ): List @GET("movies/{traktId}/lists/official/popular") suspend fun fetchMovieCollections( @Path("traktId") traktId: Long, ): List @GET("lists/{collectionId}/items/movie?extended=full") suspend fun fetchMovieCollectionItems( @Path("collectionId") collectionId: Long, ): List } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/api/service/TraktPeopleService.kt ================================================ package com.michaldrabik.data_remote.trakt.api.service import com.michaldrabik.data_remote.trakt.model.PersonCreditsResult import retrofit2.http.GET import retrofit2.http.Path interface TraktPeopleService { @GET("people/{traktId}/{type}?extended=full") suspend fun fetchPersonCredits(@Path("traktId") traktId: Long, @Path("type") type: String): PersonCreditsResult } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/api/service/TraktSearchService.kt ================================================ package com.michaldrabik.data_remote.trakt.api.service import com.michaldrabik.data_remote.Config import com.michaldrabik.data_remote.trakt.model.SearchResult import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query interface TraktSearchService { @GET("search/{idType}/{id}?type=person") suspend fun fetchPersonIds(@Path("idType") idType: String, @Path("id") id: String): List @GET("search/{idType}/{id}?extended=full") suspend fun fetchSearchId(@Path("idType") idType: String, @Path("id") id: String): List @GET("search/show?extended=full&limit=${Config.TRAKT_SEARCH_LIMIT}") suspend fun fetchSearchResults(@Query("query") queryText: String): List @GET("search/show,movie?extended=full&limit=${Config.TRAKT_SEARCH_LIMIT}") suspend fun fetchSearchResultsMovies(@Query("query") queryText: String): List } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/api/service/TraktShowsService.kt ================================================ package com.michaldrabik.data_remote.trakt.api.service import com.michaldrabik.data_remote.trakt.model.Comment import com.michaldrabik.data_remote.trakt.model.Episode import com.michaldrabik.data_remote.trakt.model.Season import com.michaldrabik.data_remote.trakt.model.SeasonTranslation import com.michaldrabik.data_remote.trakt.model.Show import com.michaldrabik.data_remote.trakt.model.ShowResult import com.michaldrabik.data_remote.trakt.model.Translation import retrofit2.Response import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query interface TraktShowsService { @GET("shows/{traktId}?extended=full") suspend fun fetchShow(@Path("traktId") traktId: Long): Show @GET("shows/{traktSlug}?extended=full") suspend fun fetchShow(@Path("traktSlug") traktSlug: String): Show @GET("shows/popular?extended=full") suspend fun fetchPopularShows( @Query("genres") genres: String, @Query("networks") networks: String, @Query("limit") limit: Int ): List @GET("shows/trending?extended=full") suspend fun fetchTrendingShows( @Query("genres") genres: String, @Query("networks") networks: String, @Query("limit") limit: Int ): List @GET("shows/anticipated?extended=full") suspend fun fetchAnticipatedShows( @Query("genres") genres: String, @Query("networks") networks: String, @Query("limit") limit: Int ): List @GET("shows/{traktId}/related?extended=full") suspend fun fetchRelatedShows(@Path("traktId") traktId: Long, @Query("limit") limit: Int): List @GET("shows/{traktId}/next_episode?extended=full") suspend fun fetchNextEpisode(@Path("traktId") traktId: Long): Response @GET("shows/{traktId}/seasons?extended=full,episodes") suspend fun fetchSeasons(@Path("traktId") traktId: Long): List @GET("shows/{traktId}/comments/newest?extended=full") suspend fun fetchShowComments( @Path("traktId") traktId: Long, @Query("limit") limit: Int, @Query("timestamp") timestamp: Long ): List @GET("shows/{traktId}/translations/{code}") suspend fun fetchShowTranslations( @Path("traktId") traktId: Long, @Path("code") countryCode: String ): List @GET("shows/{showId}/seasons/{seasonNumber}") suspend fun fetchSeasonTranslations( @Path("showId") showTraktId: Long, @Path("seasonNumber") seasonNumber: Int, @Query("translations") countryCode: String ): List @GET("shows/{traktId}/seasons/{seasonNumber}/episodes/{episodeNumber}/comments/newest?limit=50&extended=full") suspend fun fetchEpisodeComments( @Path("traktId") traktId: Long, @Path("seasonNumber") seasonNumber: Int, @Path("episodeNumber") episodeNumber: Int, @Query("timestamp") timestamp: Long ): List } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/api/service/TraktSyncService.kt ================================================ package com.michaldrabik.data_remote.trakt.api.service import com.michaldrabik.data_remote.trakt.model.RatingResultEpisode import com.michaldrabik.data_remote.trakt.model.RatingResultMovie import com.michaldrabik.data_remote.trakt.model.RatingResultSeason import com.michaldrabik.data_remote.trakt.model.RatingResultShow import com.michaldrabik.data_remote.trakt.model.SyncExportRequest import com.michaldrabik.data_remote.trakt.model.SyncExportResult import com.michaldrabik.data_remote.trakt.model.SyncItem import com.michaldrabik.data_remote.trakt.model.request.RatingRequest import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query interface TraktSyncService { @GET("sync/watched/{type}") suspend fun fetchSyncWatched( @Path("type") type: String, @Query("extended") extended: String? ): List @GET("sync/watchlist/{type}?extended=full") suspend fun fetchSyncWatchlist( @Path("type") type: String, @Query("page") page: Int? = null, @Query("limit") limit: Int? = null ): List @POST("sync/watchlist") suspend fun postSyncWatchlist(@Body request: SyncExportRequest): SyncExportResult @POST("sync/history") suspend fun postSyncWatched(@Body request: SyncExportRequest): SyncExportResult @POST("sync/watchlist/remove") suspend fun deleteWatchlist(@Body request: SyncExportRequest): SyncExportResult @POST("sync/history/remove") suspend fun deleteHistory(@Body request: SyncExportRequest): SyncExportResult @POST("sync/ratings") suspend fun postRating(@Body request: RatingRequest) @POST("sync/ratings/remove") suspend fun postRemoveRating(@Body request: RatingRequest) @GET("sync/ratings/shows") suspend fun fetchShowsRatings(): List @GET("sync/ratings/movies") suspend fun fetchMoviesRatings(): List @GET("sync/ratings/episodes") suspend fun fetchEpisodesRatings(): List @GET("sync/ratings/seasons") suspend fun fetchSeasonsRatings(): List } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/api/service/TraktUsersService.kt ================================================ package com.michaldrabik.data_remote.trakt.api.service import com.michaldrabik.data_remote.trakt.model.CustomList import com.michaldrabik.data_remote.trakt.model.HiddenItem import com.michaldrabik.data_remote.trakt.model.SyncExportRequest import com.michaldrabik.data_remote.trakt.model.SyncExportResult import com.michaldrabik.data_remote.trakt.model.SyncItem import com.michaldrabik.data_remote.trakt.model.User import com.michaldrabik.data_remote.trakt.model.request.CreateListRequest import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Path import retrofit2.http.Query interface TraktUsersService { @GET("users/me") suspend fun fetchMyProfile(): User @GET("users/hidden/progress_watched?type=show&extended=full") suspend fun fetchHiddenShows( @Query("limit") pageLimit: Int ): List @POST("users/hidden/progress_watched") suspend fun postHiddenShows( @Body request: SyncExportRequest ): SyncExportResult @POST("users/hidden/calendar") suspend fun postHiddenMovies( @Body request: SyncExportRequest ): SyncExportResult @GET("users/hidden/calendar?type=movie&extended=full") suspend fun fetchHiddenMovies( @Query("limit") pageLimit: Int ): List @GET("users/me/lists") suspend fun fetchSyncLists(): List @GET("users/me/lists/{id}") suspend fun fetchSyncList( @Path("id") listId: Long ): CustomList @GET("users/me/lists/{id}/items/{types}?extended=full") suspend fun fetchSyncListItems( @Path("id") listId: Long, @Path("types") types: String, @Query("page") page: Int? = null, @Query("limit") limit: Int? = null ): List @POST("users/me/lists") suspend fun postCreateList( @Body request: CreateListRequest ): CustomList @PUT("users/me/lists/{id}") suspend fun postUpdateList( @Path("id") listId: Long, @Body request: CreateListRequest ): CustomList @DELETE("users/me/lists/{id}") suspend fun deleteList( @Path("id") listId: Long ): Response @POST("users/me/lists/{id}/items") suspend fun postAddListItems( @Path("id") listId: Long, @Body request: SyncExportRequest ): SyncExportResult @POST("users/me/lists/{id}/items/remove") suspend fun postRemoveListItems( @Path("id") listId: Long, @Body request: SyncExportRequest ): SyncExportResult @POST("users/hidden/{section}/remove") suspend fun deleteHidden( @Path("section") section: String, @Body request: SyncExportRequest ): SyncExportResult } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/interceptors/TraktAuthenticator.kt ================================================ package com.michaldrabik.data_remote.trakt.interceptors import com.michaldrabik.data_remote.token.TokenProvider import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response import okhttp3.Route import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class TraktAuthenticator @Inject constructor( private val tokenProvider: TokenProvider, ) : Authenticator { override fun authenticate(route: Route?, response: Response): Request? = runBlocking { val accessToken = tokenProvider.getToken() if (!isRequestAuthorized(response) || accessToken == null) { return@runBlocking null } val newAccessToken = tokenProvider.getToken() if (newAccessToken != accessToken) { response.request.newBuilder() .header("Authorization", "Bearer $newAccessToken") .build() } try { Timber.d("Refreshing access token...") val refreshedTokens = tokenProvider.refreshToken() tokenProvider.saveTokens( accessToken = refreshedTokens.access_token, refreshToken = refreshedTokens.refresh_token ) response.request .newBuilder() .header("Authorization", "Bearer ${refreshedTokens.access_token}") .build() } catch (error: Throwable) { Timber.d(error) null } } private fun isRequestAuthorized(response: Response): Boolean { val header = response.request.header("Authorization") return header != null && header.startsWith("Bearer") } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/interceptors/TraktAuthorizationInterceptor.kt ================================================ package com.michaldrabik.data_remote.trakt.interceptors import com.michaldrabik.data_remote.token.TokenProvider import okhttp3.Interceptor import okhttp3.Response import javax.inject.Inject import javax.inject.Singleton @Singleton class TraktAuthorizationInterceptor @Inject constructor( private val tokenProvider: TokenProvider, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request().newBuilder() .also { request -> tokenProvider.getToken()?.let { request.header("Authorization", "Bearer $it") } } .build() return chain.proceed(request) } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/interceptors/TraktHeadersInterceptor.kt ================================================ package com.michaldrabik.data_remote.trakt.interceptors import com.michaldrabik.data_remote.Config import okhttp3.Interceptor import okhttp3.Response import javax.inject.Inject import javax.inject.Singleton @Singleton class TraktHeadersInterceptor @Inject constructor() : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request().newBuilder() .header("Content-Type", "application/json") .header("trakt-api-key", Config.TRAKT_CLIENT_ID) .header("trakt-api-version", Config.TRAKT_VERSION) .build() return chain.proceed(request) } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/interceptors/TraktRefreshTokenInterceptor.kt ================================================ package com.michaldrabik.data_remote.trakt.interceptors import com.michaldrabik.data_remote.token.TokenProvider import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class TraktRefreshTokenInterceptor @Inject constructor( private val tokenProvider: TokenProvider, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response = runBlocking { val request = chain.request() if (tokenProvider.shouldRefresh()) { try { val refreshedTokens = tokenProvider.refreshToken() tokenProvider.saveTokens( accessToken = refreshedTokens.access_token, refreshToken = refreshedTokens.refresh_token ) } catch (error: Throwable) { Timber.e(error) } } chain.proceed(request) } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/interceptors/TraktRetryInterceptor.kt ================================================ package com.michaldrabik.data_remote.trakt.interceptors import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Interceptor import okhttp3.Response import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class TraktRetryInterceptor @Inject constructor() : Interceptor { private val mutex = Mutex() private var tryCount = 0 override fun intercept(chain: Interceptor.Chain): Response = runBlocking { mutex.withLock { tryCount = 0 val request = chain.request() var response = chain.proceed(request) while (response.code == 429 && tryCount < 3) { Timber.w("429 Too Many Requests. Retrying...") delay(3000) tryCount += 1 response.close() response = chain.proceed(request) } if (response.code == 429) { val error = Throwable("429 Too Many Requests") Timber.e(error) } response } } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/AirTime.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class AirTime( val day: String?, val time: String?, val timezone: String? ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/Comment.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class Comment( val id: Long?, val parent_id: Long?, val comment: String?, val user_rating: Int?, val spoiler: Boolean?, val review: Boolean?, val likes: Long?, val replies: Long?, val created_at: String?, val updated_at: String?, val user: TraktUser? ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/CustomList.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class CustomList( val ids: Ids, val name: String, val description: String?, val privacy: String, val display_numbers: Boolean, val allow_comments: Boolean, val sort_by: String, val sort_how: String, val item_count: Long, val comment_count: Long, val likes: Long, val created_at: String, val updated_at: String ) { data class Ids( val trakt: Long, val slug: String ) } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/Episode.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class Episode( val season: Int?, val number: Int?, val title: String?, val ids: Ids?, val overview: String?, val rating: Float?, val votes: Int?, val comment_count: Int?, val first_aired: String?, val runtime: Int?, val number_abs: Int?, val last_watched_at: String? ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/HiddenItem.kt ================================================ package com.michaldrabik.data_remote.trakt.model import java.time.ZoneOffset import java.time.ZonedDateTime data class HiddenItem( val show: Show?, val movie: Movie?, val hidden_at: String? ) { fun hiddenAtMillis() = (hidden_at?.let { ZonedDateTime.parse(hidden_at) } ?: ZonedDateTime.now(ZoneOffset.UTC)).toInstant().toEpochMilli() } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/Ids.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class Ids( val trakt: Long?, val slug: String?, val tvdb: Long?, val imdb: String?, val tmdb: Long?, val tvrage: Long?, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/Movie.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class Movie( val ids: Ids?, val title: String?, val year: Int?, val overview: String?, val released: String?, val runtime: Int?, val country: String?, val trailer: String?, val homepage: String?, val status: String?, val rating: Float?, val votes: Long?, val comment_count: Long?, val genres: List?, val language: String? ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/MovieCollection.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class MovieCollection( val ids: Ids, val name: String, val description: String, val privacy: String, val item_count: Int, val likes: Int, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/MovieCollectionItem.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class MovieCollectionItem( val id: Long, val rank: Int, val movie: Movie, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/MovieResult.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class MovieResult( val movie: Movie? ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/OAuthResponse.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class OAuthResponse( val access_token: String, val refresh_token: String ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/Person.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class Person( val ids: Ids?, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/PersonCredit.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class PersonCredit( val characters: List?, val episode_count: Int?, val series_regular: Boolean?, val show: Show?, val movie: Movie?, ) { val isShow = show != null val isMovie = movie != null val year = if (isShow) show!!.year else movie!!.year } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/PersonCreditsResult.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class PersonCreditsResult( val cast: List?, val crew: Map>?, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/RatingResultMovie.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class RatingResultMovie( val rated_at: String?, val rating: Int, val movie: RatingResultValue ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/RatingResultShow.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class RatingResultShow( val rated_at: String?, val rating: Int, val show: RatingResultValue ) data class RatingResultEpisode( val rating: Int, val rated_at: String?, val episode: RatingResultValue, val show: RatingResultValue ) data class RatingResultSeason( val rating: Int, val rated_at: String?, val season: RatingResultValue, val show: RatingResultValue ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/RatingResultValue.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class RatingResultValue( val ids: Ids, val title: String, val season: Int?, val number: Int?, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/SearchResult.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class SearchResult( val score: Float?, val show: Show?, val movie: Movie?, val person: Person? ) { fun getVotes() = when { show != null -> show.votes ?: 0 movie != null -> movie.votes ?: 0 else -> 0 } } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/Season.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class Season( val ids: Ids?, val number: Int?, val episode_count: Int?, val aired_episodes: Int?, val title: String?, val first_aired: String?, val overview: String?, val rating: Float?, val episodes: List? ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/SeasonTranslation.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class SeasonTranslation( val season: Int, val number: Int, val ids: Ids, val translations: List? ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/Show.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class Show( val ids: Ids?, val title: String?, val year: Int?, val overview: String?, val first_aired: String?, val runtime: Int?, val airs: AirTime?, val certification: String?, val network: String?, val country: String?, val trailer: String?, val homepage: String?, val status: String?, val rating: Float?, val votes: Long?, val comment_count: Long?, val genres: List?, val aired_episodes: Int? ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/ShowResult.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class ShowResult( val show: Show? ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/SyncExportItem.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class SyncExportItem( val ids: Ids, val watched_at: String?, val hidden_at: String?, ) { companion object { fun create( traktId: Long, watchedAt: String? = "released", hiddenAt: String? = null, ) = SyncExportItem(Ids(traktId), watchedAt, hiddenAt) } data class Ids( val trakt: Long ) } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/SyncExportRequest.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class SyncExportRequest( val shows: List = emptyList(), val movies: List = emptyList(), val seasons: List = emptyList(), val episodes: List = emptyList() ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/SyncExportResult.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class SyncExportResult( val added: SyncExportResultItem, val deleted: SyncExportResultItem, val existing: SyncExportResultItem ) data class SyncExportResultItem( val shows: Long, val seasons: Long, val episodes: Long ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/SyncItem.kt ================================================ package com.michaldrabik.data_remote.trakt.model import java.time.ZoneOffset.UTC import java.time.ZonedDateTime data class SyncItem( val show: Show?, val movie: Movie?, val seasons: List?, val last_watched_at: String?, val last_updated_at: String?, val listed_at: String? ) { fun getTraktId(): Long? { if (show != null) return show.ids?.trakt if (movie != null) return movie.ids?.trakt return null } fun getType(): String? { if (show != null) return "show" if (movie != null) return "movie" return null } fun lastWatchedMillis() = (last_watched_at?.let { ZonedDateTime.parse(last_watched_at) } ?: ZonedDateTime.now(UTC)).toInstant().toEpochMilli() fun lastUpdateMillis() = (last_updated_at?.let { ZonedDateTime.parse(last_updated_at) } ?: ZonedDateTime.now(UTC)).toInstant().toEpochMilli() fun lastListedMillis() = (listed_at?.let { ZonedDateTime.parse(listed_at) } ?: ZonedDateTime.now(UTC)).toInstant().toEpochMilli() } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/TraktUser.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class TraktUser( val username: String, val images: Image? ) { data class Image( val avatar: ImageDetails? ) data class ImageDetails( val full: String? ) } ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/Translation.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class Translation( val title: String?, val overview: String?, val language: String?, val country: String? ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/User.kt ================================================ package com.michaldrabik.data_remote.trakt.model data class User( val username: String ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/request/CommentRequest.kt ================================================ package com.michaldrabik.data_remote.trakt.model.request import com.michaldrabik.data_remote.trakt.model.Episode import com.michaldrabik.data_remote.trakt.model.Movie import com.michaldrabik.data_remote.trakt.model.Show data class CommentRequest( val show: Show? = null, val movie: Movie? = null, val episode: Episode? = null, val comment: String, val spoiler: Boolean ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/request/CreateListRequest.kt ================================================ package com.michaldrabik.data_remote.trakt.model.request data class CreateListRequest( val name: String, val description: String?, ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/request/OAuthRefreshRequest.kt ================================================ package com.michaldrabik.data_remote.trakt.model.request data class OAuthRefreshRequest( val refresh_token: String, val client_id: String, val client_secret: String, val redirect_uri: String, val grant_type: String = "refresh_token" ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/request/OAuthRequest.kt ================================================ package com.michaldrabik.data_remote.trakt.model.request data class OAuthRequest( val code: String, val client_id: String, val client_secret: String, val redirect_uri: String, val grant_type: String = "authorization_code" ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/request/OAuthRevokeRequest.kt ================================================ package com.michaldrabik.data_remote.trakt.model.request data class OAuthRevokeRequest( val token: String, val client_id: String, val client_secret: String ) ================================================ FILE: data-remote/src/main/java/com/michaldrabik/data_remote/trakt/model/request/RatingRequest.kt ================================================ package com.michaldrabik.data_remote.trakt.model.request import com.michaldrabik.data_remote.trakt.model.Ids data class RatingRequest( val shows: List? = null, val movies: List? = null, val episodes: List? = null, val seasons: List? = null, ) data class RatingRequestValue( val rating: Int, val ids: Ids? ) ================================================ FILE: gradle/libs.versions.toml ================================================ [versions] androidNavigation = "2.6.0" androidRoom = "2.5.2" androidLifecycle = "2.6.1" androidTestRunner = "1.5.2" androidTestTruth = "1.5.0" glide = "4.15.1" retrofit = "2.9.0" hilt = "2.47" hiltWork = "1.0.0" [libraries] gradle = { group = "com.android.tools.build", name = "gradle", version = "8.2.0" } gradle-kotlin-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version = "1.9.0" } gradle-ktlint = { group = "org.jlleitschuh.gradle", name = "ktlint-gradle", version = "10.2.1" } android-core = { group = "androidx.core", name = "core-ktx", version = "1.10.1" } android-appcompat = { group = "androidx.appcompat", name = "appcompat", version = "1.6.1" } android-gridlayout = { group = "androidx.gridlayout", name = "gridlayout", version = "1.0.0" } android-browser = { group = "androidx.browser", name = "browser", version = "1.5.0" } android-desugar = { group = "com.android.tools", name = "desugar_jdk_libs", version = "2.0.3" } android-material = { group = "com.google.android.material", name = "material", version = "1.9.0" } android-dynamicanimation = { group = "androidx.dynamicanimation", name = "dynamicanimation", version = "1.0.0" } android-work = { group = "androidx.work", name = "work-runtime-ktx", version = "2.8.1" } android-swiperefresh = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version = "1.1.0" } android-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version = "2.1.4" } android-recycler = { group = "androidx.recyclerview", name = "recyclerview", version = "1.3.1" } android-fragment = { group = "androidx.fragment", name = "fragment-ktx", version = "1.6.1" } android-navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "androidNavigation" } android-navigation-ui = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "androidNavigation" } android-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "androidRoom" } android-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidRoom" } android-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidRoom" } android-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "androidLifecycle" } android-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidLifecycle" } android-lifecycle-common = { group = "androidx.lifecycle", name = "lifecycle-common-java8", version.ref = "androidLifecycle" } glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } glide-compiler = { group = "com.github.bumptech.glide", name = "compiler", version.ref = "glide" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" } hilt-plugin = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hilt" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltWork" } hilt-work-compiler = { group = "androidx.hilt", name = "hilt-compiler", version = "1.1.0-alpha01" } coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version = "1.7.3" } phoenix = { group = "com.jakewharton", name = "process-phoenix", version = "2.1.2" } timber = { group = "com.jakewharton.timber", name = "timber", version = "5.0.1" } circleIndicator = { group = "me.relex", name = "circleindicator", version = "2.1.6" } loggingInterceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version = "4.11.0" } overscrollDecor = { group = "io.github.everythingme", name = "overscroll-decor-android", version = "1.1.1" } # Testing junit = { group = "junit", name = "junit", version = "4.13.2" } mockk = { group = "io.mockk", name = "mockk", version = "1.13.7" } truth = { group = "com.google.truth", name = "truth", version = "1.1.5" } coroutinesTest = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version = "1.7.3" } android-test-core = { group = "androidx.arch.core", name = "core-testing", version = "2.2.0" } # Android Testing android-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidTestRunner" } android-test-truth = { group = "androidx.test.ext", name = "truth", version.ref = "androidTestTruth" } [bundles] android-navigation = ["android-navigation-fragment", "android-navigation-ui"] android-lifecycle = ["android-lifecycle-common", "android-lifecycle-runtime", "android-lifecycle-viewmodel"] testing = ["junit", "mockk", "truth", "coroutinesTest", "android-test-core"] ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ #Tue Apr 11 21:13:54 CEST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ ## For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx1024m -XX:MaxPermSize=256m # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 # # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true #Tue Aug 27 20:07:15 CEST 2019 #org.gradle.java.home kotlin.code.style=official android.enableJetifier=false org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" android.useAndroidX=true # Stop generating the BuildConfig file by default on all your android modules android.defaults.buildfeatures.buildconfig = false android.nonTransitiveRClass=false android.nonFinalResIds=false ================================================ FILE: gradlew ================================================ #!/usr/bin/env sh # # Copyright 2015 the original author or authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## ## ## Gradle start up script for UN*X ## ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" # Need this for relative symlinks. while [ -h "$PRG" ] ; do ls=`ls -ld "$PRG"` link=`expr "$ls" : '.*-> \(.*\)$'` if expr "$link" : '/.*' > /dev/null; then PRG="$link" else PRG=`dirname "$PRG"`"/$link" fi done SAVED="`pwd`" cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" warn () { echo "$*" } die () { echo echo "$*" echo exit 1 } # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "`uname`" in CYGWIN* ) cygwin=true ;; Darwin* ) darwin=true ;; MINGW* ) msys=true ;; NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD="java" which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD="$MAX_FD_LIMIT" fi ulimit -n $MAX_FD if [ $? -ne 0 ] ; then warn "Could not set maximum file descriptor limit: $MAX_FD" fi else warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" fi fi # For Darwin, add options to specify how the application appears in the dock if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi # For Cygwin or MSYS, switch paths to Windows format before running java if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" for dir in $ROOTDIRSRAW ; do ROOTDIRS="$ROOTDIRS$SEP$dir" SEP="|" done OURCYGPATTERN="(^($ROOTDIRS))" # Add a user-defined pattern to the cygpath arguments if [ "$GRADLE_CYGPATTERN" != "" ] ; then OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" fi # Now convert the arguments - kludge to limit ourselves to /bin/sh i=0 for arg in "$@" ; do CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` else eval `echo args$i`="\"$arg\"" fi i=`expr $i + 1` done case $i in 0) set -- ;; 1) set -- "$args0" ;; 2) set -- "$args0" "$args1" ;; 3) set -- "$args0" "$args1" "$args2" ;; 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } APP_ARGS=`save "$@"` # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto init echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto init echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :init @rem Get command-line arguments, handling Windows variants if not "%OS%" == "Windows_NT" goto win9xME_args :win9xME_args @rem Slurp the command line arguments. set CMD_LINE_ARGS= set _SKIP=2 :win9xME_args_slurp if "x%~1" == "x" goto execute set CMD_LINE_ARGS=%* :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: repository/.gitignore ================================================ /build ================================================ FILE: repository/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'dagger.hilt.android.plugin' apply from: '../versions.gradle' android { kotlinOptions { jvmTarget = "17" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } compileSdkVersion versions.compileSdk defaultConfig { minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk compileSdkVersion versions.compileSdk testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false } } namespace 'com.michaldrabik.repository' } dependencies { implementation project(':common') implementation project(':data-remote') implementation project(':data-local') implementation project(':ui-model') implementation libs.android.core implementation libs.android.appcompat implementation libs.timber implementation libs.hilt.android ksp libs.hilt.compiler testImplementation project(':common-test') testImplementation libs.bundles.testing androidTestImplementation libs.android.test.runner coreLibraryDesugaring libs.android.desugar } ================================================ FILE: repository/src/main/AndroidManifest.xml ================================================ ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/CommentsRepository.kt ================================================ package com.michaldrabik.repository import com.michaldrabik.common.Mode import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.data_remote.trakt.model.request.CommentRequest import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.Comment import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Show import javax.inject.Inject import javax.inject.Singleton @Singleton class CommentsRepository @Inject constructor( private val remoteSource: RemoteDataSource, private val mappers: Mappers ) { suspend fun loadComments(id: IdTrakt, mode: Mode, limit: Int = 100): List { val comments = when (mode) { Mode.SHOWS -> remoteSource.trakt.fetchShowComments(id.id, limit) Mode.MOVIES -> remoteSource.trakt.fetchMovieComments(id.id, limit) } return comments .map { mappers.comment.fromNetwork(it) } .filter { it.parentId <= 0 } } suspend fun loadEpisodeComments(idTrakt: IdTrakt, season: Int, episode: Int) = remoteSource.trakt.fetchEpisodeComments(idTrakt.id, season, episode) .map { mappers.comment.fromNetwork(it) } .filter { it.parentId <= 0 } suspend fun loadReplies(commentId: Long) = remoteSource.trakt.fetchCommentReplies(commentId) .map { mappers.comment.fromNetwork(it).copy(replies = 0) } .sortedBy { it.createdAt?.toEpochSecond() } suspend fun postComment(show: Show, commentText: String, isSpoiler: Boolean): Comment { val showBody = mappers.show.toNetwork(Show.EMPTY.copy(ids = show.ids)) val request = CommentRequest(show = showBody, comment = commentText, spoiler = isSpoiler) val comment = remoteSource.trakt.postComment(request) return mappers.comment.fromNetwork(comment) } suspend fun postComment(movie: Movie, commentText: String, isSpoiler: Boolean): Comment { val movieBody = mappers.movie.toNetwork(Movie.EMPTY.copy(ids = movie.ids)) val request = CommentRequest(movie = movieBody, comment = commentText, spoiler = isSpoiler) val comment = remoteSource.trakt.postComment(request) return mappers.comment.fromNetwork(comment) } suspend fun postComment(episode: Episode, commentText: String, isSpoiler: Boolean): Comment { val episodeBody = mappers.episode.toNetwork(Episode.EMPTY.copy(ids = episode.ids)) val request = CommentRequest(episode = episodeBody, comment = commentText, spoiler = isSpoiler) val comment = remoteSource.trakt.postComment(request) return mappers.comment.fromNetwork(comment) } suspend fun postReply(commentId: Long, commentText: String, isSpoiler: Boolean): Comment { val request = CommentRequest(comment = commentText, spoiler = isSpoiler) val comment = remoteSource.trakt.postCommentReply(commentId, request) return mappers.comment.fromNetwork(comment) } suspend fun deleteComment(commentId: Long) { remoteSource.trakt.deleteComment(commentId) } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/EpisodesManager.kt ================================================ package com.michaldrabik.repository import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.data_local.database.model.EpisodesSyncLog import com.michaldrabik.data_local.sources.EpisodesLocalDataSource import com.michaldrabik.data_local.sources.EpisodesSyncLogLocalDataSource import com.michaldrabik.data_local.sources.SeasonsLocalDataSource import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.shows.ShowsRepository import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.EpisodeBundle import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Season import com.michaldrabik.ui_model.SeasonBundle import com.michaldrabik.ui_model.Show import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton import com.michaldrabik.data_local.database.model.Episode as EpisodeDb import com.michaldrabik.data_local.database.model.Season as SeasonDb @Singleton class EpisodesManager @Inject constructor( private val showsRepository: ShowsRepository, private val episodesLocalSource: EpisodesLocalDataSource, private val seasonsLocalSource: SeasonsLocalDataSource, private val syncLogLocalSource: EpisodesSyncLogLocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers, ) { suspend fun getWatchedSeasonsIds(show: Show) = seasonsLocalSource.getAllWatchedIdsForShows(listOf(show.traktId)) suspend fun getWatchedEpisodesIds(show: Show) = episodesLocalSource.getAllWatchedIdsForShows(listOf(show.traktId)) suspend fun setSeasonWatched(seasonBundle: SeasonBundle): List { val nowUtc = nowUtc() val toAdd = mutableListOf() transactions.withTransaction { val (season, show) = seasonBundle val dbSeason = mappers.season.toDatabase(season, show.ids.trakt, true) val localSeason = seasonsLocalSource.getById(season.ids.trakt.id) if (localSeason == null) { seasonsLocalSource.upsert(listOf(dbSeason)) } val episodes = episodesLocalSource.getAllForSeason(season.ids.trakt.id).filter { it.isWatched } season.episodes.forEach { ep -> if (episodes.none { it.idTrakt == ep.ids.trakt.id }) { val dbEpisode = mappers.episode.toDatabase(ep, season, show.ids.trakt, true, nowUtc) toAdd.add(dbEpisode) } } episodesLocalSource.upsert(toAdd) seasonsLocalSource.update(listOf(dbSeason)) showsRepository.myShows.updateWatchedAt(show.traktId, nowUtc.toMillis()) } return toAdd.map { mappers.episode.fromDatabase(it) } } suspend fun setSeasonUnwatched(seasonBundle: SeasonBundle) { transactions.withTransaction { val (season, show) = seasonBundle val dbSeason = mappers.season.toDatabase(season, show.ids.trakt, false) val watchedEpisodes = episodesLocalSource.getAllForSeason(season.ids.trakt.id).filter { it.isWatched } val toSet = watchedEpisodes.map { it.copy(isWatched = false, lastWatchedAt = null) } val isShowFollowed = showsRepository.myShows.load(show.ids.trakt) != null when { isShowFollowed -> { episodesLocalSource.upsert(toSet) seasonsLocalSource.update(listOf(dbSeason)) } else -> { episodesLocalSource.delete(toSet) seasonsLocalSource.delete(listOf(dbSeason)) } } } } suspend fun setEpisodeWatched(episodeId: Long, seasonId: Long, showId: IdTrakt) { val episodeDb = episodesLocalSource.getAllForSeason(seasonId).find { it.idTrakt == episodeId }!! val seasonDb = seasonsLocalSource.getById(seasonId)!! val show = showsRepository.myShows.load(showId)!! setEpisodeWatched( EpisodeBundle( mappers.episode.fromDatabase(episodeDb), mappers.season.fromDatabase(seasonDb), show ) ) } suspend fun setEpisodeWatched(episodeBundle: EpisodeBundle) { transactions.withTransaction { val (episode, season, show) = episodeBundle val nowUtc = nowUtc() val dbEpisode = mappers.episode.toDatabase(episode, season, show.ids.trakt, true, nowUtc) val dbSeason = mappers.season.toDatabase(season, show.ids.trakt, false) val localSeason = seasonsLocalSource.getById(season.ids.trakt.id) if (localSeason == null) { seasonsLocalSource.upsert(listOf(dbSeason)) } episodesLocalSource.upsert(listOf(dbEpisode)) showsRepository.myShows.updateWatchedAt(show.traktId, nowUtc.toMillis()) onEpisodeSet(season, show) } } suspend fun setEpisodeUnwatched(episodeBundle: EpisodeBundle) { transactions.withTransaction { val (episode, season, show) = episodeBundle val isShowFollowed = showsRepository.myShows.load(show.ids.trakt) != null val dbEpisode = mappers.episode.toDatabase(episode, season, show.ids.trakt, true, episode.lastWatchedAt) when { isShowFollowed -> episodesLocalSource.upsert(listOf(dbEpisode.copy(isWatched = false, lastWatchedAt = null))) else -> episodesLocalSource.delete(listOf(dbEpisode)) } onEpisodeSet(season, show) } } suspend fun setAllUnwatched(showId: IdTrakt, skipSpecials: Boolean = false) { transactions.withTransaction { val watchedEpisodes = episodesLocalSource.getAllByShowId(showId.id) val watchedSeasons = seasonsLocalSource.getAllByShowId(showId.id) val updateEpisodes = watchedEpisodes .filter { if (skipSpecials) it.seasonNumber > 0 else true } .map { it.copy(isWatched = false, lastWatchedAt = null) } val updateSeasons = watchedSeasons .filter { if (skipSpecials) it.seasonNumber > 0 else true } .map { it.copy(isWatched = false) } episodesLocalSource.upsert(updateEpisodes) seasonsLocalSource.update(updateSeasons) } } @Suppress("UNCHECKED_CAST") suspend fun invalidateSeasons(show: Show, newSeasons: List) { if (newSeasons.isEmpty()) { return } coroutineScope { val (localSeasons, localEpisodes) = awaitAll( async { seasonsLocalSource.getAllByShowId(show.traktId) }, async { episodesLocalSource.getAllByShowId(show.traktId) } ) localSeasons as List localEpisodes as List val seasonsToAdd = mutableListOf() val episodesToAdd = mutableListOf() newSeasons.forEach { season -> var isAnyEpisodeUnwatched = false season.episodes.forEach { newEpisode -> var localEpisode = localEpisodes.find { it.episodeNumber == newEpisode.number && it.seasonNumber == newEpisode.season } if (localEpisode == null) { // Double check by Trakt ID as season/episode combination might be old. localEpisode = localEpisodes.find { it.idTrakt == newEpisode.ids.trakt.id } } val lastWatchedAt = localEpisode?.lastWatchedAt val isWatched = localEpisode?.isWatched ?: false if (!isWatched) { isAnyEpisodeUnwatched = true } val episodeDb = mappers.episode.toDatabase( episode = newEpisode, season = season, showId = show.ids.trakt, isWatched = isWatched, lastWatchedAt = lastWatchedAt ) episodesToAdd.add(episodeDb) } val seasonDb = mappers.season.toDatabase( season = season, showId = show.ids.trakt, isWatched = !isAnyEpisodeUnwatched ) seasonsToAdd.add(seasonDb) } transactions.withTransaction { episodesLocalSource.deleteAllForShow(show.traktId) seasonsLocalSource.deleteAllForShow(show.traktId) seasonsLocalSource.upsert(seasonsToAdd) episodesLocalSource.upsertChunked(episodesToAdd) syncLogLocalSource.upsert(EpisodesSyncLog(show.traktId, nowUtcMillis())) } Timber.d("Episodes updated: ${episodesToAdd.size} Seasons updated: ${seasonsToAdd.size}") } } private suspend fun onEpisodeSet(season: Season, show: Show) { val localEpisodes = episodesLocalSource.getAllForSeason(season.ids.trakt.id) val isWatched = localEpisodes.count { it.isWatched } == season.episodeCount val dbSeason = mappers.season.toDatabase(season, show.ids.trakt, isWatched) seasonsLocalSource.update(listOf(dbSeason)) } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/ListsRepository.kt ================================================ package com.michaldrabik.repository import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.CustomListItem import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.CustomList import com.michaldrabik.ui_model.IdTrakt import javax.inject.Inject import javax.inject.Singleton @Singleton class ListsRepository @Inject constructor( private val localSource: LocalDataSource, private val mappers: Mappers, private val transactions: TransactionsProvider ) { suspend fun createList( name: String, description: String?, idTrakt: Long?, idSlug: String? ): CustomList { val list = CustomList.create().copy( idTrakt = idTrakt, idSlug = idSlug ?: "", name = name.trim(), description = description?.trim() ) val listDb = mappers.customList.toDatabase(list) localSource.customLists.insert(listOf(listDb)) return list } suspend fun updateList( id: Long, idTrakt: Long?, idSlug: String?, name: String, description: String? ): CustomList { val listDb = localSource.customLists.getById(id)!! val updated = listDb.copy( name = name, idTrakt = idTrakt ?: listDb.idTrakt, idSlug = idSlug ?: listDb.idSlug, description = description, updatedAt = nowUtcMillis() ) localSource.customLists.update(listOf(updated)) return mappers.customList.fromDatabase(updated) } suspend fun deleteList(listId: Long) = localSource.customLists.deleteById(listId) suspend fun addToList(listId: Long, itemTraktId: IdTrakt, itemType: String) { val timestamp = nowUtcMillis() val itemDb = CustomListItem( rank = 0, idList = listId, idTrakt = itemTraktId.id, type = itemType, listedAt = timestamp, createdAt = timestamp, updatedAt = timestamp ) transactions.withTransaction { localSource.customListsItems.insertItem(itemDb) localSource.customLists.updateTimestamp(listId, nowUtcMillis()) } } suspend fun removeFromList(listId: Long, itemTraktId: IdTrakt, itemType: String) { transactions.withTransaction { localSource.customListsItems.deleteItem(listId, itemTraktId.id, itemType) localSource.customLists.updateTimestamp(listId, nowUtcMillis()) } } suspend fun loadListIdsForItem(itemTraktId: IdTrakt, itemType: String) = localSource.customListsItems.getListsForItem(itemTraktId.id, itemType) suspend fun loadListItemsForId(listId: Long) = localSource.customListsItems.getItemsById(listId) suspend fun loadById(listId: Long): CustomList { val listDb = localSource.customLists.getById(listId)!! return mappers.customList.fromDatabase(listDb) } suspend fun loadItemsById(listId: Long) = localSource.customListsItems.getItemsById(listId) suspend fun loadAll(): List { val listsDb = localSource.customLists.getAll() return listsDb.map { mappers.customList.fromDatabase(it) } } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/NewsRepository.kt ================================================ package com.michaldrabik.repository import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.NewsItem import com.michaldrabik.ui_model.NewsItem.Type.MOVIE import com.michaldrabik.ui_model.NewsItem.Type.SHOW import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @Singleton class NewsRepository @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers, ) { companion object { const val VALID_CACHE_MINUTES = 360L } suspend fun getCachedNews(type: NewsItem.Type) = localSource.news.getAllByType(type.slug) .map { mappers.news.fromDatabase(it) } suspend fun loadShowsNews(token: RedditAuthToken, forceRefresh: Boolean): List { if (!forceRefresh) { val cachedNews = getCachedNews(SHOW) val cacheTimestamp = cachedNews.firstOrNull()?.createdAt?.toMillis() ?: 0 val isCacheValid = nowUtcMillis() - cacheTimestamp <= TimeUnit.MINUTES.toMillis(VALID_CACHE_MINUTES) if (isCacheValid && getCachedNews(SHOW).isNotEmpty()) { return cachedNews.toList() } } val remoteItems = remoteSource.reddit.fetchTelevisionItems(token.token) .map { mappers.news.fromNetwork(it, SHOW) } val dbItems = remoteItems.map { mappers.news.toDatabase(it) } localSource.news.replaceForType(dbItems, SHOW.slug) return remoteItems.toList() } suspend fun loadMoviesNews(token: RedditAuthToken, forceRefresh: Boolean): List { if (!forceRefresh) { val cachedNews = getCachedNews(MOVIE) val cacheTimestamp = cachedNews.firstOrNull()?.createdAt?.toMillis() ?: 0 val isCacheValid = nowUtcMillis() - cacheTimestamp <= TimeUnit.MINUTES.toMillis(VALID_CACHE_MINUTES) if (isCacheValid && getCachedNews(MOVIE).isNotEmpty()) { return cachedNews.toList() } } val remoteItems = remoteSource.reddit.fetchMoviesItems(token.token) .map { mappers.news.fromNetwork(it, MOVIE) } val dbItems = remoteItems.map { mappers.news.toDatabase(it) } localSource.news.replaceForType(dbItems, MOVIE.slug) return remoteItems.toList() } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/OnHoldItemsRepository.kt ================================================ package com.michaldrabik.repository import android.content.SharedPreferences import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Show import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @Singleton class OnHoldItemsRepository @Inject constructor( @Named("progressOnHoldPreferences") private val sharedPreferences: SharedPreferences ) { fun getAll(): List = sharedPreferences.all.keys.map { IdTrakt(it.toLong()) } fun addItem(show: Show) = sharedPreferences.edit().putLong(show.traktId.toString(), show.traktId).apply() fun removeItem(show: Show) = sharedPreferences.edit().remove(show.traktId.toString()).apply() fun isOnHold(show: Show) = sharedPreferences.contains(show.traktId.toString()) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/PeopleRepository.kt ================================================ package com.michaldrabik.repository import com.michaldrabik.common.Config import com.michaldrabik.common.Mode import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.PersonCredits import com.michaldrabik.data_local.database.model.PersonShowMovie import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.data_remote.tmdb.model.TmdbPerson import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.Person import com.michaldrabik.ui_model.Person.Department import com.michaldrabik.ui_model.PersonCredit import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import timber.log.Timber import javax.inject.Inject class PeopleRepository @Inject constructor( private val settingsRepository: SettingsRepository, private val localSource: LocalDataSource, private val remoteSource: RemoteDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers ) { companion object { const val ACTORS_DISPLAY_LIMIT = 30 const val CREW_DISPLAY_LIMIT = 20 } suspend fun loadDetails(person: Person): Person { val local = localSource.people.getById(person.ids.tmdb.id) if (local?.detailsUpdatedAt != null) { return mappers.person.fromDatabase(local, person.characters) } val language = settingsRepository.language val remotePerson = remoteSource.tmdb.fetchPersonDetails(person.ids.tmdb.id) var bioTranslation: String? = null if (language != Config.DEFAULT_LANGUAGE) { val translations = remoteSource.tmdb.fetchPersonTranslations(person.ids.tmdb.id) bioTranslation = translations[language]?.biography } val personUi = mappers.person.fromNetwork(remotePerson).copy( bioTranslation = bioTranslation, imagePath = person.imagePath ?: remotePerson.profile_path ) val dbPerson = mappers.person.toDatabase(personUi, nowUtc()) localSource.people.upsert(listOf(dbPerson)) return personUi } suspend fun loadCredits(person: Person) = coroutineScope { val idTmdb = person.ids.tmdb.id var idTrakt: Long? val localPerson = localSource.people.getById(idTmdb) idTrakt = localPerson?.idTrakt if (idTrakt == null) { val ids = remoteSource.trakt.fetchPersonIds("tmdb", idTmdb.toString()) ids?.trakt?.let { idTrakt = it localSource.people.updateTraktId(it, idTmdb) } } if (idTrakt == null) return@coroutineScope emptyList() // Return locally cached data if available val timestamp = localSource.peopleCredits.getTimestampForPerson(idTrakt!!) if (timestamp != null && nowUtcMillis() - timestamp < Config.PEOPLE_CREDITS_CACHE_DURATION) { val localCredits = mutableListOf() val showsCreditsAsync = async { localSource.peopleCredits.getAllShowsForPerson(idTrakt!!) } val moviesCreditsAsync = async { localSource.peopleCredits.getAllMoviesForPerson(idTrakt!!) } val shows = showsCreditsAsync.await() val movies = moviesCreditsAsync.await() shows.mapTo(localCredits) { PersonCredit( show = mappers.show.fromDatabase(it), movie = null, image = Image.createUnknown(ImageType.POSTER), translation = null ) } movies.mapTo(localCredits) { PersonCredit( movie = mappers.movie.fromDatabase(it), show = null, image = Image.createUnknown(ImageType.POSTER), translation = null ) } return@coroutineScope localCredits } // Return remote fetched data if available and cache it locally val type = if (person.department == Department.ACTING) TmdbPerson.Type.CAST else TmdbPerson.Type.CREW val showsCreditsAsync = async { remoteSource.trakt.fetchPersonShowsCredits(idTrakt!!, type) } val moviesCreditsAsync = async { remoteSource.trakt.fetchPersonMoviesCredits(idTrakt!!, type) } val remoteCredits = awaitAll(showsCreditsAsync, moviesCreditsAsync) .flatten() .map { PersonCredit( show = it.show?.let { show -> mappers.show.fromNetwork(show) }, movie = it.movie?.let { movie -> mappers.movie.fromNetwork(movie) }, image = Image.createUnknown(ImageType.POSTER), translation = null ) } val localCredits = remoteCredits.map { PersonCredits( id = 0, idTraktPerson = idTrakt!!, idTraktShow = it.show?.traktId, idTraktMovie = it.movie?.traktId, type = if (it.show != null) Mode.SHOWS.type else Mode.MOVIES.type, createdAt = nowUtc(), updatedAt = nowUtc() ) } with(localSource) { val remoteShows = remoteCredits.filter { it.show != null }.map { it.show!! } val remoteMovies = remoteCredits.filter { it.movie != null }.map { it.movie!! } transactions.withTransaction { shows.upsert(remoteShows.map { mappers.show.toDatabase(it) }) movies.upsert(remoteMovies.map { mappers.movie.toDatabase(it) }) peopleCredits.insertSingle(idTrakt!!, localCredits) } } return@coroutineScope remoteCredits } suspend fun loadAllForShow(showIds: Ids): Map> { val timestamp = nowUtc() val localTimestamp = localSource.peopleShowsMovies.getTimestampForShow(showIds.trakt.id) ?: 0 val local = localSource.people.getAllForShow(showIds.trakt.id) if (local.isNotEmpty() && localTimestamp + Config.ACTORS_CACHE_DURATION > timestamp.toMillis()) { Timber.d("Returning cached result. Cache still valid for ${(localTimestamp + Config.ACTORS_CACHE_DURATION) - timestamp.toMillis()} ms") return local .map { mappers.person.fromDatabase(it) } .groupBy { it.department } .mapValues { v -> v.value.sortedWith(compareBy { it.imagePath.isNullOrBlank() }) } } val remoteTmdbPeople = remoteSource.tmdb.fetchShowPeople(showIds.tmdb.id) val remoteTmdbActors = remoteTmdbPeople .getOrDefault(TmdbPerson.Type.CAST, emptyList()) .sortedWith(compareBy { it.profile_path.isNullOrBlank() }) .map { mappers.person.fromNetwork(it) } .take(ACTORS_DISPLAY_LIMIT) val crewFilter = arrayOf(Department.DIRECTING, Department.WRITING, Department.SOUND).map { it.slug } val jobsFilter = Person.Job.values().map { it.slug } val remoteTmdbCrew = remoteTmdbPeople .getOrDefault(TmdbPerson.Type.CREW, emptyList()) .asSequence() .filter { it.department in crewFilter } .filter { it.jobs?.any { job -> job.job ?: "" in jobsFilter } == true } .sortedWith(compareBy { it.profile_path.isNullOrBlank() }) .map { mappers.person.fromNetwork(it) } .groupBy { it.department } val directors = remoteTmdbCrew[Department.DIRECTING]?.take(CREW_DISPLAY_LIMIT)?.distinctBy { it.ids.tmdb } ?: emptyList() val writers = remoteTmdbCrew[Department.WRITING]?.take(CREW_DISPLAY_LIMIT)?.distinctBy { it.ids.tmdb } ?: emptyList() val sound = remoteTmdbCrew[Department.SOUND]?.take(CREW_DISPLAY_LIMIT)?.distinctBy { it.ids.tmdb } ?: emptyList() val filteredTmdbPeople = remoteTmdbActors + directors + writers + sound val dbTmdbPeople = filteredTmdbPeople.map { mappers.person.toDatabase(it, null) } val dbTmdbPeopleShows = filteredTmdbPeople.map { PersonShowMovie( id = 0, idTmdbPerson = it.ids.tmdb.id, mode = Mode.SHOWS.type, department = it.department.slug, character = it.characters.joinToString(","), job = it.jobs.joinToString(",") { job -> job.slug }, episodesCount = it.episodesCount, idTraktShow = showIds.trakt.id, idTraktMovie = null, createdAt = timestamp, updatedAt = timestamp ) } with(localSource) { transactions.withTransaction { people.upsert(dbTmdbPeople) peopleShowsMovies.insertForShow(dbTmdbPeopleShows, showIds.trakt.id) } } Timber.d("Returning remote result.") return filteredTmdbPeople.groupBy { it.department } } suspend fun loadAllForMovie(movieIds: Ids): Map> { val timestamp = nowUtc() val localTimestamp = localSource.peopleShowsMovies.getTimestampForMovie(movieIds.trakt.id) ?: 0 val local = localSource.people.getAllForMovie(movieIds.trakt.id) if (local.isNotEmpty() && localTimestamp + Config.ACTORS_CACHE_DURATION > timestamp.toMillis()) { Timber.d("Returning cached result. Cache still valid for ${(localTimestamp + Config.ACTORS_CACHE_DURATION) - timestamp.toMillis()} ms") return local .map { mappers.person.fromDatabase(it) } .groupBy { it.department } .mapValues { v -> v.value.sortedWith(compareBy { it.imagePath.isNullOrBlank() }) } } val remoteTmdbPeople = remoteSource.tmdb.fetchMoviePeople(movieIds.tmdb.id) val remoteTmdbActors = remoteTmdbPeople .getOrDefault(TmdbPerson.Type.CAST, emptyList()) .sortedWith(compareBy { it.profile_path.isNullOrBlank() }) .map { mappers.person.fromNetwork(it) } .take(ACTORS_DISPLAY_LIMIT) val crewFilter = arrayOf(Department.DIRECTING, Department.WRITING, Department.SOUND).map { it.slug } val jobsFilter = Person.Job.values().map { it.slug } val remoteTmdbCrew = remoteTmdbPeople .getOrDefault(TmdbPerson.Type.CREW, emptyList()) .asSequence() .filter { it.department in crewFilter } .filter { it.job in jobsFilter } .sortedWith(compareBy { it.profile_path.isNullOrBlank() }) .map { mappers.person.fromNetwork(it) } .groupBy { it.department } val directors = remoteTmdbCrew[Department.DIRECTING]?.take(CREW_DISPLAY_LIMIT)?.distinctBy { it.ids.tmdb } ?: emptyList() val writers = remoteTmdbCrew[Department.WRITING]?.take(CREW_DISPLAY_LIMIT)?.distinctBy { it.ids.tmdb } ?: emptyList() val sound = remoteTmdbCrew[Department.SOUND]?.take(CREW_DISPLAY_LIMIT)?.distinctBy { it.ids.tmdb } ?: emptyList() val filteredTmdbPeople = remoteTmdbActors + directors + writers + sound val dbTmdbPeople = filteredTmdbPeople.map { mappers.person.toDatabase(it, null) } val dbTmdbPeopleMovies = filteredTmdbPeople.map { PersonShowMovie( id = 0, idTmdbPerson = it.ids.tmdb.id, mode = Mode.MOVIES.type, department = it.department.slug, character = it.characters.joinToString(","), job = it.jobs.joinToString(",") { job -> job.slug }, episodesCount = it.episodesCount, idTraktShow = null, idTraktMovie = movieIds.trakt.id, createdAt = timestamp, updatedAt = timestamp ) } with(localSource) { transactions.withTransaction { people.upsert(dbTmdbPeople) peopleShowsMovies.insertForMovie(dbTmdbPeopleMovies, movieIds.trakt.id) } } Timber.d("Returning remote result.") return filteredTmdbPeople.groupBy { it.department } } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/PinnedItemsRepository.kt ================================================ package com.michaldrabik.repository import android.content.SharedPreferences import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Show import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @Singleton class PinnedItemsRepository @Inject constructor( @Named("watchlistPreferences") private val sharedPreferences: SharedPreferences, @Named("progressMoviesPreferences") private val sharedPreferencesMovies: SharedPreferences ) { fun addPinnedItem(show: Show) = sharedPreferences.edit().putLong(show.traktId.toString(), show.traktId).apply() fun addPinnedItem(movie: Movie) = sharedPreferencesMovies.edit().putLong(movie.traktId.toString(), movie.traktId).apply() fun removePinnedItem(show: Show) = sharedPreferences.edit().remove(show.traktId.toString()).apply() fun removePinnedItem(movie: Movie) = sharedPreferencesMovies.edit().remove(movie.traktId.toString()).apply() fun isItemPinned(show: Show) = sharedPreferences.contains(show.traktId.toString()) fun isItemPinned(movie: Movie) = sharedPreferencesMovies.contains(movie.traktId.toString()) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/RatingsRepository.kt ================================================ package com.michaldrabik.repository import com.michaldrabik.repository.movies.ratings.MoviesRatingsRepository import com.michaldrabik.repository.shows.ratings.ShowsRatingsRepository import javax.inject.Inject import javax.inject.Singleton @Singleton class RatingsRepository @Inject constructor( val shows: ShowsRatingsRepository, val movies: MoviesRatingsRepository, ) { suspend fun clear() { shows.clear() movies.clear() } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/StreamingsRepository.kt ================================================ package com.michaldrabik.repository import com.michaldrabik.data_remote.tmdb.model.TmdbStreamingCountry import com.michaldrabik.data_remote.tmdb.model.TmdbStreamingService import com.michaldrabik.ui_model.StreamingService import com.michaldrabik.ui_model.StreamingService.Option.ADS import com.michaldrabik.ui_model.StreamingService.Option.BUY import com.michaldrabik.ui_model.StreamingService.Option.FLATRATE import com.michaldrabik.ui_model.StreamingService.Option.FREE import com.michaldrabik.ui_model.StreamingService.Option.RENT abstract class StreamingsRepository { protected fun processItems( remoteItems: List, countryCode: String, ) = remoteItems .groupBy { it.name } .filter { it.value.isNotEmpty() } .map { entry -> val entryValue = entry.value.first() StreamingService( name = entry.key, imagePath = entryValue.imagePath, options = entry.value.flatMap { it.options }, link = entryValue.link, mediaName = entryValue.mediaName, countryCode = countryCode ) } protected fun processItems( remoteItems: TmdbStreamingCountry, mediaName: String, countryCode: String, ): List { val items = mutableListOf() items.addAll(remoteItems.flatrate?.map { createStreamingService(mediaName, countryCode, remoteItems, it, FLATRATE) } ?: emptyList()) items.addAll(remoteItems.free?.map { createStreamingService(mediaName, countryCode, remoteItems, it, FREE) } ?: emptyList()) items.addAll(remoteItems.buy?.map { createStreamingService(mediaName, countryCode, remoteItems, it, BUY) } ?: emptyList()) items.addAll(remoteItems.rent?.map { createStreamingService(mediaName, countryCode, remoteItems, it, RENT) } ?: emptyList()) items.addAll(remoteItems.ads?.map { createStreamingService(mediaName, countryCode, remoteItems, it, ADS) } ?: emptyList()) return items .groupBy { it.name } .filter { it.value.isNotEmpty() } .map { entry -> val entryValue = entry.value.first() StreamingService( name = entry.key, imagePath = entryValue.imagePath, options = entry.value.flatMap { it.options }, link = entryValue.link, mediaName = entryValue.mediaName, countryCode = countryCode ) } } private fun createStreamingService( mediaName: String, countryCode: String, country: TmdbStreamingCountry, service: TmdbStreamingService, option: StreamingService.Option, ) = StreamingService( imagePath = service.logo_path, name = service.provider_name, options = listOf(option), link = country.link, mediaName = mediaName, countryCode = countryCode ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/TranslationsRepository.kt ================================================ package com.michaldrabik.repository import android.content.SharedPreferences import com.michaldrabik.common.Config.DEFAULT_LANGUAGE import com.michaldrabik.common.ConfigVariant import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.EpisodeTranslation import com.michaldrabik.data_local.database.model.MovieTranslation import com.michaldrabik.data_local.database.model.ShowTranslation import com.michaldrabik.data_local.database.model.TranslationsMoviesSyncLog import com.michaldrabik.data_local.database.model.TranslationsSyncLog import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.settings.SettingsRepository.Key.LANGUAGE import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Season import com.michaldrabik.ui_model.SeasonTranslation import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_model.Translation import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton import com.michaldrabik.data_remote.trakt.model.Translation as TranslationRemote @Singleton class TranslationsRepository @Inject constructor( @Named("miscPreferences") private var miscPreferences: SharedPreferences, private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers, ) { fun getLanguage() = miscPreferences.getString(LANGUAGE, DEFAULT_LANGUAGE) ?: DEFAULT_LANGUAGE suspend fun loadAllShowsLocal(language: String = DEFAULT_LANGUAGE): Map { val local = localSource.showTranslations.getAll(language) return local.associate { Pair(it.idTrakt, mappers.translation.fromDatabase(it)) } } suspend fun loadAllMoviesLocal(language: String = DEFAULT_LANGUAGE): Map { val local = localSource.movieTranslations.getAll(language) return local.associate { Pair(it.idTrakt, mappers.translation.fromDatabase(it)) } } suspend fun loadTranslation( show: Show, language: String = DEFAULT_LANGUAGE, onlyLocal: Boolean = false, ): Translation? { val local = localSource.showTranslations.getById(show.traktId, language) local?.let { return mappers.translation.fromDatabase(it) } if (onlyLocal) return null val timestamp = localSource.translationsShowsSyncLog.getById(show.traktId)?.syncedAt ?: 0 if (nowUtcMillis() - timestamp < ConfigVariant.TRANSLATION_SYNC_SHOW_MOVIE_COOLDOWN) { return Translation.EMPTY } val remoteTranslation = try { remoteSource.trakt.fetchShowTranslations(show.traktId, language) .firstOrNull { chineseLanguagePredicate(it) && frenchLanguagePredicate(it) } } catch (error: Throwable) { null } val translation = mappers.translation.fromNetwork(remoteTranslation) val translationDb = ShowTranslation.fromTraktId( show.traktId, translation.title, language, translation.overview, nowUtcMillis() ) if (translationDb.overview.isNotBlank() || translationDb.title.isNotBlank()) { localSource.showTranslations.insertSingle(translationDb) } localSource.translationsShowsSyncLog.upsert(TranslationsSyncLog(show.traktId, nowUtcMillis())) return translation } suspend fun loadTranslation( movie: Movie, language: String = DEFAULT_LANGUAGE, onlyLocal: Boolean = false, ): Translation? { val local = localSource.movieTranslations.getById(movie.traktId, language) local?.let { return mappers.translation.fromDatabase(it) } if (onlyLocal) return null val timestamp = localSource.translationsMoviesSyncLog.getById(movie.traktId)?.syncedAt ?: 0 if (nowUtcMillis() - timestamp < ConfigVariant.TRANSLATION_SYNC_SHOW_MOVIE_COOLDOWN) { return Translation.EMPTY } val remoteTranslation = try { remoteSource.trakt.fetchMovieTranslations(movie.traktId, language) .firstOrNull { chineseLanguagePredicate(it) && frenchLanguagePredicate(it) } } catch (error: Throwable) { null } val translation = mappers.translation.fromNetwork(remoteTranslation) val translationDb = MovieTranslation.fromTraktId( movie.traktId, translation.title, language, translation.overview, nowUtcMillis() ) if (translationDb.overview.isNotBlank() || translationDb.title.isNotBlank()) { localSource.movieTranslations.insertSingle(translationDb) } localSource.translationsMoviesSyncLog.upsert(TranslationsMoviesSyncLog(movie.traktId, nowUtcMillis())) return translation } suspend fun loadTranslation( episode: Episode, showId: IdTrakt, language: String = DEFAULT_LANGUAGE, onlyLocal: Boolean = false, ): Translation? { val nowMillis = nowUtcMillis() val local = localSource.episodesTranslations.getById(episode.ids.trakt.id, showId.id, language) local?.let { val isCacheValid = nowMillis - it.updatedAt < ConfigVariant.TRANSLATION_SYNC_EPISODE_COOLDOWN if (it.title.isNotBlank() && it.overview.isNotBlank()) { return mappers.translation.fromDatabase(it) } if ((it.title.isNotBlank() || it.overview.isNotBlank()) && (isCacheValid || onlyLocal)) { return mappers.translation.fromDatabase(it) } } if (onlyLocal) return null val remoteTranslations = remoteSource.trakt.fetchSeasonTranslations(showId.id, episode.season, language) .map { mappers.translation.fromNetwork(it) } remoteTranslations .forEach { item -> val dbItem = EpisodeTranslation.fromTraktId( traktEpisodeId = item.ids.trakt.id, traktShowId = showId.id, title = item.title, overview = item.overview, language = language, createdAt = nowMillis ) localSource.episodesTranslations.insertSingle(dbItem) } remoteTranslations .find { it.ids.trakt == episode.ids.trakt } ?.let { return Translation(it.title, it.overview, it.language) } return null } suspend fun loadTranslations( season: Season, showId: IdTrakt, language: String = DEFAULT_LANGUAGE ): List { val episodes = season.episodes.toList() val episodesIds = season.episodes.map { it.ids.trakt.id } val local = localSource.episodesTranslations.getByIds(episodesIds, showId.id, language) val hasAllTranslated = local.isNotEmpty() && local.all { it.title.isNotBlank() && it.overview.isNotBlank() } val isCacheValid = local.isNotEmpty() && nowUtcMillis() - local.first().updatedAt < ConfigVariant.TRANSLATION_SYNC_EPISODE_COOLDOWN if (hasAllTranslated || (!hasAllTranslated && isCacheValid)) { return episodes.map { episode -> val translation = local.find { it.idTrakt == episode.ids.trakt.id } SeasonTranslation( ids = episode.ids.copy(), title = translation?.title ?: "", overview = translation?.overview ?: "", seasonNumber = season.number, episodeNumber = episode.number, language = language, isLocal = true ) } } val remoteTranslation = remoteSource.trakt.fetchSeasonTranslations(showId.id, season.number, language) .map { mappers.translation.fromNetwork(it) } remoteTranslation .forEach { item -> val dbItem = EpisodeTranslation.fromTraktId( item.ids.trakt.id, showId.id, item.title, language, item.overview, nowUtcMillis() ) localSource.episodesTranslations.insertSingle(dbItem) } return episodes.map { episode -> val translation = remoteTranslation.find { it.ids.trakt.id == episode.ids.trakt.id } SeasonTranslation( ids = episode.ids.copy(), title = translation?.title ?: "", overview = translation?.overview ?: "", seasonNumber = season.number, episodeNumber = episode.number, language = language, isLocal = true ) } } private fun chineseLanguagePredicate(translation: TranslationRemote) = if (translation.language?.lowercase() != "zh") { true } else { translation.country?.equals("cn", ignoreCase = true) == true } private fun frenchLanguagePredicate(translation: TranslationRemote) = if (translation.language?.lowercase() != "fr") { true } else { translation.country?.equals("fr", ignoreCase = true) == true } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/UserRedditManager.kt ================================================ @file:Suppress("EXPERIMENTAL_FEATURE_WARNING") package com.michaldrabik.repository import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.User import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.RemoteDataSource import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @Singleton class UserRedditManager @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, ) { companion object { private const val TOKEN_EXPIRE_BUFFER_SECONDS = 60 } private var redditToken: RedditAuthToken? = null private var redditTokenTimestamp: Long = 0 suspend fun checkAuthorization(): RedditAuthToken { if (redditToken != null && nowUtcMillis() < redditTokenTimestamp) { return redditToken!! } val user = localSource.user.get() user?.let { if (nowUtcMillis() < it.redditTokenTimestamp) { val authToken = RedditAuthToken(it.redditToken) redditToken = authToken redditTokenTimestamp = it.redditTokenTimestamp return authToken } } val authResponse = remoteSource.reddit.fetchAuthToken() val resultToken = RedditAuthToken(authResponse.access_token) val resultTokenTimestamp = nowUtcMillis() + TimeUnit.SECONDS.toMillis(authResponse.expires_in - TOKEN_EXPIRE_BUFFER_SECONDS) transactions.withTransaction { val userDb = localSource.user.get() localSource.user.upsert( userDb?.copy( redditToken = resultToken.token, redditTokenTimestamp = resultTokenTimestamp, ) ?: User( redditToken = resultToken.token, redditTokenTimestamp = resultTokenTimestamp, traktToken = "", traktRefreshToken = "", traktTokenTimestamp = 0, traktUsername = "", tvdbToken = "", tvdbTokenTimestamp = 0, ) ) } redditToken = resultToken redditTokenTimestamp = resultTokenTimestamp return resultToken } } @JvmInline value class RedditAuthToken(val token: String = "") ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/UserTraktManager.kt ================================================ package com.michaldrabik.repository import com.michaldrabik.common.errors.ShowlyError import com.michaldrabik.data_local.database.model.User import com.michaldrabik.data_local.sources.UserLocalDataSource import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.token.TokenProvider import com.michaldrabik.data_remote.trakt.TraktRemoteDataSource import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton import com.michaldrabik.data_remote.trakt.model.User as UserModel @Singleton class UserTraktManager @Inject constructor( private val remoteSource: TraktRemoteDataSource, private val userLocalSource: UserLocalDataSource, private val transactions: TransactionsProvider, private val tokenProvider: TokenProvider ) { fun isAuthorized() = tokenProvider.getToken() != null fun checkAuthorization() { if (tokenProvider.getToken() == null) { throw ShowlyError.UnauthorizedError("Authorization needed.") } } suspend fun authorize(authCode: String) { val tokens = remoteSource.fetchAuthTokens(authCode) tokenProvider.saveTokens(tokens.access_token, tokens.refresh_token) val user = remoteSource.fetchMyProfile() saveUser(user) } suspend fun revokeToken() { val token = tokenProvider.getToken() tokenProvider.revokeToken() try { if (!token.isNullOrBlank()) { remoteSource.revokeAuthTokens(token) } } catch (error: Throwable) { // Just log error as revoke token call is fully optional. Timber.w("Error while revoking token: $error") } } suspend fun getUsername() = userLocalSource.get()?.traktUsername ?: "" private suspend fun saveUser(userModel: UserModel) { transactions.withTransaction { val user = userLocalSource.get() userLocalSource.upsert( user?.copy( traktUsername = userModel.username ) ?: User( traktToken = "", traktRefreshToken = "", traktTokenTimestamp = 0, traktUsername = userModel.username, tvdbToken = "", tvdbTokenTimestamp = 0, redditToken = "", redditTokenTimestamp = 0 ) ) } } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/images/EpisodeImagesProvider.kt ================================================ package com.michaldrabik.repository.images import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.IdTmdb import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageFamily.EPISODE import com.michaldrabik.ui_model.ImageSource import com.michaldrabik.ui_model.ImageStatus.AVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNAVAILABLE import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.ImageType.FANART import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class EpisodeImagesProvider @Inject constructor( private val dispatchers: CoroutineDispatchers, private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers ) { suspend fun loadRemoteImage(showId: IdTmdb, episode: Episode): Image = withContext(dispatchers.IO) { val tvdbId = episode.ids.tvdb val tmdbId = episode.ids.tmdb val cachedImage = findCachedImage(episode, FANART) if (cachedImage.status == AVAILABLE) { return@withContext cachedImage } var image = Image.createUnavailable(FANART) try { var remoteImage = remoteSource.tmdb.fetchEpisodeImage(showId.id, episode.season, episode.number) if (remoteImage == null && (episode.numberAbs ?: 0) > 0) { // Try absolute episode number if present (may happen with certain Anime series) remoteImage = remoteSource.tmdb.fetchEpisodeImage(showId.id, episode.season, episode.numberAbs) } image = when (remoteImage) { null -> Image.createUnavailable(FANART) else -> Image( id = -1, idTvdb = tvdbId, idTmdb = tmdbId, type = FANART, family = EPISODE, fileUrl = remoteImage.file_path, thumbnailUrl = "", status = AVAILABLE, source = ImageSource.TMDB ) } } catch (error: Throwable) { Timber.w(error) } when (image.status) { UNAVAILABLE -> localSource.showImages.deleteByEpisodeId(tmdbId.id, image.type.key) else -> localSource.showImages.insertEpisodeImage(mappers.image.toDatabaseShow(image)) } return@withContext image } private suspend fun findCachedImage(episode: Episode, type: ImageType): Image { val cachedImage = localSource.showImages.getByEpisodeId(episode.ids.tmdb.id, type.key) return when (cachedImage) { null -> Image.createUnknown(type, EPISODE) else -> mappers.image.fromDatabase(cachedImage).copy(type = type) } } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/images/MovieImagesProvider.kt ================================================ package com.michaldrabik.repository.images import com.michaldrabik.common.Mode import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.CustomImage import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.data_remote.tmdb.model.TmdbImage import com.michaldrabik.data_remote.tmdb.model.TmdbImages import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_model.IdTmdb import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.IdTvdb import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageFamily import com.michaldrabik.ui_model.ImageFamily.MOVIE import com.michaldrabik.ui_model.ImageSource.CUSTOM import com.michaldrabik.ui_model.ImageSource.TMDB import com.michaldrabik.ui_model.ImageStatus.AVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNAVAILABLE import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.ImageType.FANART import com.michaldrabik.ui_model.ImageType.FANART_WIDE import com.michaldrabik.ui_model.ImageType.POSTER import com.michaldrabik.ui_model.Movie import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @Singleton class MovieImagesProvider @Inject constructor( private val dispatchers: CoroutineDispatchers, private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers, private val settingsRepository: SettingsRepository ) { private val unavailableCache = mutableSetOf() suspend fun findCustomImage(traktId: Long, type: ImageType): Image? = withContext(dispatchers.IO) { if (!settingsRepository.isPremium) { return@withContext null } val custom = localSource.customImages.getById(traktId, Mode.MOVIES.type, type.key) custom?.let { mappers.image.fromDatabase(it, type) } } suspend fun findCachedImage(movie: Movie, type: ImageType): Image = withContext(dispatchers.IO) { val custom = findCustomImage(movie.traktId, type) if (custom != null) { return@withContext custom } val image = localSource.movieImages.getByMovieId(movie.ids.tmdb.id, type.key) when (image) { null -> if (unavailableCache.contains(movie.ids.trakt)) { Image.createUnavailable(type, MOVIE, TMDB) } else { Image.createUnknown(type, MOVIE, TMDB) } else -> mappers.image.fromDatabase(image).copy(type = type) } } suspend fun loadRemoteImage(movie: Movie, type: ImageType, force: Boolean = false): Image = withContext(dispatchers.IO) { val tmdbId = movie.ids.tmdb val tvdbId = movie.ids.tvdb val cachedImage = findCachedImage(movie, type) if (cachedImage.status in arrayOf(AVAILABLE, UNAVAILABLE)) { if (!force) return@withContext cachedImage if (force && cachedImage.source == CUSTOM) return@withContext cachedImage } val images = remoteSource.tmdb.fetchMovieImages(tmdbId.id) val typeImages = when (type) { POSTER -> images.posters ?: emptyList() FANART, FANART_WIDE -> images.backdrops ?: emptyList() else -> throw Error("Invalid type") } val remoteImage = findBestImage(typeImages, type) val image = when (remoteImage) { null -> Image.createUnavailable(type, MOVIE, TMDB) else -> Image.createAvailable(movie.ids, type, MOVIE, remoteImage.file_path, TMDB) } when (image.status) { UNAVAILABLE -> { unavailableCache.add(movie.ids.trakt) localSource.movieImages.deleteByMovieId(tmdbId.id, image.type.key) } else -> { localSource.movieImages.insertMovieImage(mappers.image.toDatabaseMovie(image)) storeExtraImage(tmdbId, tvdbId, images, type) } } image } private suspend fun storeExtraImage( tmdbId: IdTmdb, tvdbId: IdTvdb, images: TmdbImages, targetType: ImageType ) { val extraType = if (targetType == POSTER) FANART else POSTER val typeImages = when (extraType) { POSTER -> images.posters ?: emptyList() FANART, FANART_WIDE -> images.backdrops ?: emptyList() else -> throw Error("Invalid type") } findBestImage(typeImages, extraType)?.let { val extraImage = Image(-1, tvdbId, tmdbId, extraType, MOVIE, it.file_path, "", AVAILABLE, TMDB) localSource.movieImages.insertMovieImage(mappers.image.toDatabaseMovie(extraImage)) } } suspend fun loadRemoteImages(movie: Movie, type: ImageType): List = withContext(dispatchers.IO) { val tmdbId = movie.ids.tmdb val remoteImages = remoteSource.tmdb.fetchMovieImages(tmdbId.id) val typeImages = when (type) { POSTER -> remoteImages.posters ?: emptyList() FANART, FANART_WIDE -> remoteImages.backdrops ?: emptyList() else -> throw Error("Invalid type") } typeImages.map { Image.createAvailable(movie.ids, type, MOVIE, it.file_path, TMDB) } } private fun findBestImage(images: List, type: ImageType) = images .filter { if (type == POSTER) it.isEnglish() else it.isPlain() } .sortedWith(compareBy({ it.vote_count }, { it.vote_average })) .lastOrNull() ?: images.firstOrNull { if (type == POSTER) it.isEnglish() else it.isPlain() } ?: images.firstOrNull() suspend fun saveCustomImage(traktId: IdTrakt, image: Image, imageFamily: ImageFamily, imageType: ImageType) { withContext(dispatchers.IO) { val imageDb = CustomImage(0, traktId.id, imageFamily.key, imageType.key, image.fullFileUrl) localSource.customImages.insertImage(imageDb) } } suspend fun deleteCustomImage(traktId: IdTrakt, imageFamily: ImageFamily, imageType: ImageType) { withContext(dispatchers.IO) { localSource.customImages.deleteById(traktId.id, imageFamily.key, imageType.key) } } suspend fun deleteLocalCache() = withContext(dispatchers.IO) { localSource.movieImages.deleteAll() } fun clear() = unavailableCache.clear() } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/images/PeopleImagesProvider.kt ================================================ package com.michaldrabik.repository.images import com.michaldrabik.common.Config import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.PersonImage import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.ui_model.IdTmdb import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageFamily import com.michaldrabik.ui_model.ImageSource import com.michaldrabik.ui_model.ImageType import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class PeopleImagesProvider @Inject constructor( private val dispatchers: CoroutineDispatchers, private val localSource: LocalDataSource, private val remoteSource: RemoteDataSource, ) { suspend fun loadCachedImage(personTmdbId: IdTmdb): Image? = withContext(dispatchers.IO) { val localPerson = localSource.people.getById(personTmdbId.id) return@withContext localPerson?.image?.let { Image.createAvailable( ids = Ids.EMPTY, type = ImageType.PROFILE, family = ImageFamily.PROFILE, path = it, source = ImageSource.TMDB ) } } suspend fun loadImages(personTmdbId: IdTmdb): List = withContext(dispatchers.IO) { val localTimestamp = localSource.peopleImages.getTimestampForPerson(personTmdbId.id) ?: 0 if (localTimestamp + Config.PEOPLE_IMAGES_CACHE_DURATION > nowUtcMillis()) { Timber.d("Returning cached result. Cache still valid for ${(localTimestamp + Config.PEOPLE_IMAGES_CACHE_DURATION) - nowUtcMillis()} ms") val local = localSource.peopleImages.getAll(personTmdbId.id) return@withContext local.map { Image.createAvailable( ids = Ids.EMPTY, type = ImageType.PROFILE, family = ImageFamily.PROFILE, path = it.filePath, source = ImageSource.TMDB ) } } val images = (remoteSource.tmdb.fetchPersonImages(personTmdbId.id).profiles ?: emptyList()) .filter { it.file_path.isNotBlank() } val dbImages = images.map { PersonImage( id = 0, idTmdb = personTmdbId.id, filePath = it.file_path, createdAt = nowUtc(), updatedAt = nowUtc() ) } localSource.peopleImages.insertSingle(personTmdbId.id, dbImages) return@withContext images.map { Image.createAvailable( ids = Ids.EMPTY, type = ImageType.PROFILE, family = ImageFamily.PROFILE, path = it.file_path, source = ImageSource.TMDB ) } } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/images/ShowImagesProvider.kt ================================================ package com.michaldrabik.repository.images import com.michaldrabik.common.Mode import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.CustomImage import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.data_remote.aws.model.AwsImages import com.michaldrabik.data_remote.tmdb.model.TmdbImage import com.michaldrabik.data_remote.tmdb.model.TmdbImages import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_model.IdTmdb import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.IdTvdb import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageFamily import com.michaldrabik.ui_model.ImageFamily.SHOW import com.michaldrabik.ui_model.ImageSource.AWS import com.michaldrabik.ui_model.ImageSource.CUSTOM import com.michaldrabik.ui_model.ImageSource.TMDB import com.michaldrabik.ui_model.ImageStatus.AVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNAVAILABLE import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.ImageType.FANART import com.michaldrabik.ui_model.ImageType.FANART_WIDE import com.michaldrabik.ui_model.ImageType.POSTER import com.michaldrabik.ui_model.Show import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @Singleton class ShowImagesProvider @Inject constructor( private val dispatchers: CoroutineDispatchers, private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers, private val settingsRepository: SettingsRepository, ) { private val unavailableCache = mutableSetOf() private var awsImagesCache: AwsImages? = null suspend fun findCustomImage(traktId: Long, type: ImageType): Image? = withContext(dispatchers.IO) { if (!settingsRepository.isPremium) { return@withContext null } val custom = localSource.customImages.getById(traktId, Mode.SHOWS.type, type.key) custom?.let { mappers.image.fromDatabase(it, type) } } suspend fun findCachedImage(show: Show, type: ImageType): Image = withContext(dispatchers.IO) { val custom = findCustomImage(show.traktId, type) if (custom != null) { return@withContext custom } val image = localSource.showImages.getByShowId(show.ids.tmdb.id, type.key) when (image) { null -> if (unavailableCache.contains(show.ids.trakt)) { Image.createUnavailable(type, SHOW) } else { Image.createUnknown(type, SHOW) } else -> mappers.image.fromDatabase(image).copy(type = type) } } suspend fun loadRemoteImage(show: Show, type: ImageType, force: Boolean = false): Image = withContext(dispatchers.IO) { val tvdbId = show.ids.tvdb val tmdbId = show.ids.tmdb val cachedImage = findCachedImage(show, type) if (cachedImage.status in arrayOf(AVAILABLE, UNAVAILABLE)) { if (!force || cachedImage.source == CUSTOM) { return@withContext cachedImage } } var source = TMDB val images = remoteSource.tmdb.fetchShowImages(tmdbId.id) var typeImages = when (type) { POSTER -> images.posters ?: emptyList() FANART, FANART_WIDE -> images.backdrops ?: emptyList() else -> throw Error("Invalid type") } // If requested poster is unavailable try backing up to a fanart if (typeImages.isEmpty() && type == POSTER) { typeImages = images.backdrops ?: emptyList() if (typeImages.isEmpty()) { // Use custom uploaded S3 image as a final backup loadAwsImagesCache() awsImagesCache?.posters?.find { poster -> poster.idTmdb == tmdbId.id }?.let { val path = "posters/${it.idTmdb}.${it.fileType}" typeImages = listOf(TmdbImage(path, 0F, 0, "en")) source = AWS } } } // Use custom uploaded S3 image as a first backup for fanart. if (typeImages.isEmpty() && type in arrayOf(FANART, FANART_WIDE)) { loadAwsImagesCache() val awsImage = awsImagesCache?.fanarts?.find { fanart -> fanart.idTmdb == tmdbId.id } if (awsImage != null) { val path = "fanarts/${awsImage.idTmdb}.${awsImage.fileType}" typeImages = listOf(TmdbImage(path, 0F, 0, "en")) source = AWS } else { // If requested fanart is unavailable try backing up to an episode image val seasons = remoteSource.trakt.fetchSeasons(show.traktId) if (seasons.isNotEmpty()) { val episode = seasons[0].episodes?.firstOrNull() episode?.let { ep -> runCatching { val backupImage = remoteSource.tmdb.fetchEpisodeImage(tmdbId.id, ep.season, ep.number) backupImage?.let { typeImages = listOf(TmdbImage(it.file_path, 0F, 0, "en")) } } } } } } val remoteImage = findBestImage(typeImages, type) val image = when (remoteImage) { null -> Image.createUnavailable(type) else -> Image.createAvailable(show.ids, type, SHOW, remoteImage.file_path, source) } when (image.status) { UNAVAILABLE -> { unavailableCache.add(show.ids.trakt) localSource.showImages.deleteByShowId(tmdbId.id, image.type.key) } else -> { localSource.showImages.insertShowImage(mappers.image.toDatabaseShow(image)) saveExtraImage(tmdbId, tvdbId, images, type) } } image } private suspend fun saveExtraImage( tmdbId: IdTmdb, tvdbId: IdTvdb, images: TmdbImages, targetType: ImageType, ) { val extraType = if (targetType == POSTER) FANART else POSTER val typeImages = when (extraType) { POSTER -> images.posters ?: emptyList() FANART, FANART_WIDE -> images.backdrops ?: emptyList() else -> throw Error("Invalid type") } findBestImage(typeImages, extraType)?.let { val extraImage = Image(-1, tvdbId, tmdbId, extraType, SHOW, it.file_path, "", AVAILABLE, TMDB) localSource.showImages.insertShowImage(mappers.image.toDatabaseShow(extraImage)) } } suspend fun loadRemoteImages(show: Show, type: ImageType): List = withContext(dispatchers.IO) { val tmdbId = show.ids.tmdb val remoteImages = remoteSource.tmdb.fetchShowImages(tmdbId.id) val typeImages = when (type) { POSTER -> remoteImages.posters ?: emptyList() FANART, FANART_WIDE -> remoteImages.backdrops ?: emptyList() else -> throw Error("Invalid type") } typeImages .map { Image.createAvailable(show.ids, type, SHOW, it.file_path, TMDB) } } private suspend fun loadAwsImagesCache() { if (awsImagesCache == null) { val awsImages = remoteSource.aws.fetchImagesList() awsImagesCache = awsImages.copy() } } private fun findBestImage(images: List, type: ImageType) = images .filter { if (type == POSTER) it.isEnglish() else it.isPlain() } .sortedWith(compareBy({ it.vote_count }, { it.vote_average })) .lastOrNull() ?: images.firstOrNull { if (type == POSTER) it.isEnglish() else it.isPlain() } ?: images.firstOrNull() suspend fun saveCustomImage(traktId: IdTrakt, image: Image, imageFamily: ImageFamily, imageType: ImageType) { val imageDb = CustomImage(0, traktId.id, imageFamily.key, imageType.key, image.fullFileUrl) withContext(dispatchers.IO) { localSource.customImages.insertImage(imageDb) } } suspend fun deleteCustomImage(traktId: IdTrakt, imageFamily: ImageFamily, imageType: ImageType) { withContext(dispatchers.IO) { localSource.customImages.deleteById(traktId.id, imageFamily.key, imageType.key) } } suspend fun deleteLocalCache() = withContext(dispatchers.IO) { localSource.showImages.deleteAll() } fun clear() = unavailableCache.clear() } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/CollectionMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.MovieCollection import java.time.ZonedDateTime import javax.inject.Inject import com.michaldrabik.data_local.database.model.MovieCollection as MovieCollectionEntity import com.michaldrabik.data_remote.trakt.model.MovieCollection as MovieCollectionNetwork class CollectionMapper @Inject constructor() { fun fromNetwork(input: MovieCollectionNetwork): MovieCollection { return MovieCollection( id = IdTrakt(input.ids.trakt!!), name = input.name, description = input.description, itemCount = input.item_count ) } fun fromEntity(input: MovieCollectionEntity): MovieCollection { return MovieCollection( id = IdTrakt(input.idTrakt), name = input.name, description = input.description, itemCount = input.itemCount ) } fun toEntity( movieId: Long, input: MovieCollection, updatedAt: ZonedDateTime = nowUtc(), createdAt: ZonedDateTime = nowUtc(), ): MovieCollectionEntity { return MovieCollectionEntity( idTrakt = input.id.id, idTraktMovie = movieId, name = input.name, description = input.description, itemCount = input.itemCount, updatedAt = updatedAt, createdAt = createdAt ) } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/CommentMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.ui_model.Comment import com.michaldrabik.ui_model.User import java.time.ZonedDateTime import javax.inject.Inject import com.michaldrabik.data_remote.trakt.model.Comment as CommentNetwork class CommentMapper @Inject constructor() { fun fromNetwork(comment: CommentNetwork?) = Comment( id = comment?.id ?: -1, parentId = comment?.parent_id ?: -1, comment = comment?.comment ?: "", userRating = comment?.user_rating ?: -1, spoiler = comment?.spoiler ?: false, review = comment?.review ?: false, likes = comment?.likes ?: 0, replies = comment?.replies ?: 0, createdAt = if (comment?.created_at.isNullOrBlank()) null else ZonedDateTime.parse(comment?.created_at), updatedAt = if (comment?.updated_at.isNullOrBlank()) null else ZonedDateTime.parse(comment?.updated_at), user = User( username = comment?.user?.username ?: "", avatarUrl = comment?.user?.images?.avatar?.full ?: "" ), isMe = false, isSignedIn = false, isLoading = false, hasRepliesLoaded = false ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/CustomListMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.common.Mode import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.ui_model.CustomList import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortType import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import javax.inject.Inject import com.michaldrabik.data_local.database.model.CustomList as CustomListDb import com.michaldrabik.data_remote.trakt.model.CustomList as CustomListNetwork import com.michaldrabik.data_remote.trakt.model.CustomList.Ids as IdsList class CustomListMapper @Inject constructor() { fun fromNetwork(list: CustomListNetwork) = CustomList( id = 0, idTrakt = list.ids.trakt, idSlug = list.ids.slug, name = list.name, description = list.description, privacy = list.privacy, displayNumbers = list.display_numbers, allowComments = list.allow_comments, sortBy = SortOrder.fromSlug(list.sort_by) ?: SortOrder.RANK, sortHow = SortType.fromSlug(list.sort_how), sortByLocal = SortOrder.RANK, sortHowLocal = SortType.ASCENDING, filterTypeLocal = Mode.getAll(), itemCount = list.item_count, commentCount = list.comment_count, likes = list.likes, createdAt = ZonedDateTime.parse(list.created_at), updatedAt = ZonedDateTime.parse(list.updated_at) ) fun fromDatabase(list: CustomListDb) = CustomList( id = list.id, idTrakt = list.idTrakt, idSlug = list.idSlug, name = list.name, description = list.description, privacy = list.privacy, displayNumbers = list.displayNumbers, allowComments = list.allowComments, sortBy = SortOrder.fromSlug(list.sortBy) ?: SortOrder.RANK, sortHow = SortType.fromSlug(list.sortHow), sortByLocal = SortOrder.fromSlug(list.sortByLocal) ?: SortOrder.RANK, sortHowLocal = SortType.fromSlug(list.sortHowLocal), filterTypeLocal = when { list.filterTypeLocal.isEmpty() -> emptyList() else -> list.filterTypeLocal.split(",").map { Mode.fromType(it) } }, itemCount = list.itemCount, commentCount = list.commentCount, likes = list.likes, createdAt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(list.createdAt), ZoneId.of("UTC")), updatedAt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(list.updatedAt), ZoneId.of("UTC")) ) fun toDatabase(list: CustomList) = CustomListDb( id = list.id, idTrakt = list.idTrakt, idSlug = list.idSlug, name = list.name, description = list.description, privacy = list.privacy, displayNumbers = list.displayNumbers, allowComments = list.allowComments, sortBy = list.sortBy.slug, sortHow = list.sortHow.slug, sortByLocal = list.sortByLocal.slug, sortHowLocal = list.sortHowLocal.slug, filterTypeLocal = list.filterTypeLocal.joinToString(",") { it.type }, itemCount = list.itemCount, commentCount = list.commentCount, likes = list.likes, createdAt = list.createdAt.toMillis(), updatedAt = list.updatedAt.toMillis() ) fun toNetwork(list: CustomList) = CustomListNetwork( ids = IdsList( trakt = list.idTrakt ?: -1, slug = list.idSlug ), name = list.name, description = list.description, privacy = list.privacy, display_numbers = list.displayNumbers, allow_comments = list.allowComments, sort_by = list.sortBy.slug, sort_how = list.sortHow.slug, item_count = list.itemCount, comment_count = list.commentCount, likes = list.likes, created_at = list.createdAt.format(DateTimeFormatter.ISO_INSTANT), updated_at = list.updatedAt.format(DateTimeFormatter.ISO_INSTANT) ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/EpisodeMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.common.extensions.toZonedDateTime import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.IdImdb import com.michaldrabik.ui_model.IdTmdb import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.IdTvdb import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Season import java.time.ZonedDateTime import javax.inject.Inject import com.michaldrabik.data_local.database.model.Episode as EpisodeDb import com.michaldrabik.data_remote.trakt.model.Episode as EpisodeNetwork class EpisodeMapper @Inject constructor( private val idsMapper: IdsMapper ) { fun fromNetwork(episode: EpisodeNetwork) = Episode( season = episode.season ?: -1, number = episode.number ?: -1, title = episode.title ?: "", ids = idsMapper.fromNetwork(episode.ids), overview = episode.overview ?: "", rating = episode.rating ?: 0F, votes = episode.votes ?: 0, commentCount = episode.comment_count ?: 0, firstAired = episode.first_aired.toZonedDateTime(), runtime = episode.runtime ?: -1, numberAbs = episode.number_abs, lastWatchedAt = episode.last_watched_at.toZonedDateTime() ) fun toNetwork(episode: Episode) = EpisodeNetwork( ids = idsMapper.toNetwork(episode.ids), season = episode.season, number = episode.number, number_abs = episode.numberAbs, title = episode.title, overview = episode.overview, rating = episode.rating, votes = episode.votes, comment_count = episode.commentCount, first_aired = episode.firstAired.toString(), runtime = episode.runtime, last_watched_at = episode.lastWatchedAt.toString() ) fun toDatabase( episode: Episode, season: Season, showId: IdTrakt, isWatched: Boolean, lastWatchedAt: ZonedDateTime? ): EpisodeDb = EpisodeDb( idTrakt = episode.ids.trakt.id, idSeason = season.ids.trakt.id, idShowTrakt = showId.id, idShowTvdb = episode.ids.tvdb.id, idShowImdb = episode.ids.imdb.id, idShowTmdb = episode.ids.tmdb.id, seasonNumber = season.number, episodeNumber = episode.number, episodeNumberAbs = episode.numberAbs, episodeOverview = episode.overview, title = episode.title, firstAired = episode.firstAired, commentsCount = episode.commentCount, rating = episode.rating, runtime = episode.runtime, votesCount = episode.votes, isWatched = isWatched, lastWatchedAt = lastWatchedAt ) fun fromDatabase(episodeDb: EpisodeDb) = Episode( ids = Ids.EMPTY.copy( trakt = IdTrakt(episodeDb.idTrakt), tvdb = IdTvdb(episodeDb.idShowTvdb), imdb = IdImdb(episodeDb.idShowImdb), tmdb = IdTmdb(episodeDb.idShowTmdb) ), title = episodeDb.title, number = episodeDb.episodeNumber, numberAbs = episodeDb.episodeNumberAbs, season = episodeDb.seasonNumber, overview = episodeDb.episodeOverview, commentCount = episodeDb.commentsCount, firstAired = episodeDb.firstAired, rating = episodeDb.rating, runtime = episodeDb.runtime, votes = episodeDb.votesCount, lastWatchedAt = episodeDb.lastWatchedAt ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/IdsMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.data_local.database.model.Movie import com.michaldrabik.ui_model.IdImdb import com.michaldrabik.ui_model.IdSlug import com.michaldrabik.ui_model.IdTmdb import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.IdTvRage import com.michaldrabik.ui_model.IdTvdb import com.michaldrabik.ui_model.Ids import javax.inject.Inject import com.michaldrabik.data_local.database.model.Show as ShowDb import com.michaldrabik.data_remote.trakt.model.Ids as IdsNetwork class IdsMapper @Inject constructor() { fun fromNetwork(ids: IdsNetwork?) = Ids( IdTrakt(ids?.trakt ?: -1), IdSlug(ids?.slug ?: ""), IdTvdb(ids?.tvdb ?: -1), IdImdb(ids?.imdb ?: ""), IdTmdb(ids?.tmdb ?: -1), IdTvRage(ids?.tvrage ?: -1) ) fun toNetwork(ids: Ids?) = IdsNetwork( trakt = ids?.trakt?.id, slug = ids?.slug?.id, tvdb = ids?.tvdb?.id, imdb = ids?.imdb?.id, tmdb = ids?.tmdb?.id, tvrage = ids?.tvrage?.id ) fun fromDatabase(show: ShowDb?) = Ids( IdTrakt(show?.idTrakt ?: -1), IdSlug(show?.idSlug ?: ""), IdTvdb(show?.idTvdb ?: -1), IdImdb(show?.idImdb ?: ""), IdTmdb(show?.idTmdb ?: -1), IdTvRage(show?.idTvrage ?: -1) ) fun fromDatabase(movie: Movie?) = Ids( IdTrakt(movie?.idTrakt ?: -1), IdSlug(movie?.idSlug ?: ""), IdTvdb(-1), IdImdb(movie?.idImdb ?: ""), IdTmdb(movie?.idTmdb ?: -1), IdTvRage(-1) ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/ImageMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.data_local.database.model.CustomImage import com.michaldrabik.ui_model.IdTmdb import com.michaldrabik.ui_model.IdTvdb import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageFamily import com.michaldrabik.ui_model.ImageSource import com.michaldrabik.ui_model.ImageStatus.AVAILABLE import com.michaldrabik.ui_model.ImageType import java.util.Locale.ROOT import javax.inject.Inject import com.michaldrabik.data_local.database.model.MovieImage as MovieImageDb import com.michaldrabik.data_local.database.model.ShowImage as ShowImageDb class ImageMapper @Inject constructor() { fun fromDatabase(imageDb: ShowImageDb): Image { return Image( imageDb.id, IdTvdb(imageDb.idTvdb), IdTmdb(imageDb.idTmdb), enumValueOf(imageDb.type.uppercase(ROOT)), enumValueOf(imageDb.family.uppercase(ROOT)), imageDb.fileUrl, imageDb.thumbnailUrl, AVAILABLE, ImageSource.fromKey(imageDb.source) ) } fun fromDatabase(imageDb: MovieImageDb): Image { return Image( imageDb.id, IdTvdb(), IdTmdb(imageDb.idTmdb), enumValueOf(imageDb.type.uppercase(ROOT)), ImageFamily.MOVIE, imageDb.fileUrl, "", AVAILABLE, ImageSource.fromKey(imageDb.source) ) } fun fromDatabase(imageDb: CustomImage, type: ImageType?): Image { return Image( imageDb.id, IdTvdb(), IdTmdb(), type ?: enumValueOf(imageDb.type.uppercase(ROOT)), enumValueOf(imageDb.family.uppercase(ROOT)), imageDb.fileUrl, imageDb.fileUrl, AVAILABLE, ImageSource.CUSTOM ) } fun toDatabaseShow(image: Image): ShowImageDb = ShowImageDb( idTvdb = image.idTvdb.id, idTmdb = image.idTmdb.id, type = image.type.key, family = image.family.key, fileUrl = image.fileUrl, thumbnailUrl = image.thumbnailUrl, source = image.source.key ) fun toDatabaseMovie(image: Image): MovieImageDb = MovieImageDb( idTmdb = image.idTmdb.id, type = image.type.key, fileUrl = image.fileUrl, source = image.source.key ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/Mappers.kt ================================================ package com.michaldrabik.repository.mappers import javax.inject.Inject import javax.inject.Singleton @Singleton class Mappers @Inject constructor( val ids: IdsMapper, val image: ImageMapper, val show: ShowMapper, val movie: MovieMapper, val episode: EpisodeMapper, val season: SeasonMapper, val person: PersonMapper, val comment: CommentMapper, val news: NewsMapper, val settings: SettingsMapper, val translation: TranslationMapper, val customList: CustomListMapper, val ratings: RatingsMapper, val userRatings: UserRatingsMapper, val streamings: StreamingsMapper, ) ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/MovieMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.MovieStatus import java.time.LocalDate import javax.inject.Inject import com.michaldrabik.data_local.database.model.Movie as MovieDb import com.michaldrabik.data_remote.trakt.model.Movie as MovieNetwork class MovieMapper @Inject constructor( private val idsMapper: IdsMapper ) { fun fromNetwork(movie: MovieNetwork) = Movie( idsMapper.fromNetwork(movie.ids), movie.title ?: "", movie.year ?: -1, movie.overview ?: "", movie.released?.let { if (it.isNotBlank()) LocalDate.parse(it) else null }, movie.runtime ?: -1, movie.country ?: "", movie.trailer ?: "", movie.homepage ?: "", movie.language ?: "", MovieStatus.fromKey(movie.status), movie.rating ?: -1F, movie.votes ?: -1, movie.comment_count ?: -1, movie.genres ?: emptyList(), nowUtcMillis(), nowUtcMillis() ) fun toNetwork(movie: Movie) = MovieNetwork( idsMapper.toNetwork(movie.ids), movie.title, movie.year, movie.overview, movie.released?.toString(), movie.runtime, movie.country, movie.trailer, movie.homepage, movie.status.key, movie.rating, movie.votes, movie.commentCount, movie.genres, movie.language ) fun fromDatabase(movie: MovieDb) = Movie( idsMapper.fromDatabase(movie), movie.title, movie.year, movie.overview, if (movie.released.isBlank()) null else LocalDate.parse(movie.released), movie.runtime, movie.country, movie.trailer, movie.homepage, movie.language, MovieStatus.fromKey(movie.status), movie.rating, movie.votes, movie.commentCount, movie.genres.split(","), movie.updatedAt, movie.createdAt ) fun toDatabase(movie: Movie) = MovieDb( movie.ids.trakt.id, movie.ids.tmdb.id, movie.ids.imdb.id, movie.ids.slug.id, movie.title, movie.year, movie.overview, movie.released?.toString() ?: "", movie.runtime, movie.country, movie.trailer, movie.language, movie.homepage, movie.status.key, movie.rating, movie.votes, movie.commentCount, movie.genres.joinToString(","), nowUtcMillis(), movie.createdAt ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/NewsMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.common.extensions.dateFromMillis import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.data_remote.reddit.model.RedditItem import com.michaldrabik.ui_model.NewsItem import javax.inject.Inject import com.michaldrabik.data_local.database.model.News as NewsDb class NewsMapper @Inject constructor() { fun fromNetwork(input: RedditItem, type: NewsItem.Type) = NewsItem( id = input.id, title = input.title, url = input.url, type = type, score = input.score, image = input.findImageUrl()?.replace("&", "&"), datedAt = dateFromMillis(input.created_utc * 1000), createdAt = nowUtc(), updatedAt = nowUtc(), ) fun fromDatabase(input: NewsDb) = NewsItem( id = input.idNews, title = input.title, url = input.url, type = NewsItem.Type.fromSlug(input.type), score = input.score, image = input.image, datedAt = dateFromMillis(input.datedAt), createdAt = dateFromMillis(input.createdAt), updatedAt = dateFromMillis(input.updatedAt), ) fun toDatabase(input: NewsItem) = NewsDb( id = 0, idNews = input.id, title = input.title, url = input.url, type = input.type.slug, image = input.image, score = input.score, datedAt = input.datedAt.toMillis(), createdAt = nowUtcMillis(), updatedAt = nowUtcMillis() ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/PersonMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.data_remote.tmdb.model.TmdbPerson import com.michaldrabik.ui_model.IdImdb import com.michaldrabik.ui_model.IdTmdb import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Person import java.time.LocalDate import java.time.ZonedDateTime import java.time.format.DateTimeFormatter.ISO_LOCAL_DATE import javax.inject.Inject import com.michaldrabik.data_local.database.model.Person as PersonDb class PersonMapper @Inject constructor() { fun fromNetwork(person: TmdbPerson) = Person( ids = Ids.EMPTY.copy( tmdb = IdTmdb(person.id), imdb = IdImdb(person.imdb_id ?: "") ), name = person.name ?: "", department = typeToEnum(person.department ?: person.known_for_department), bio = person.biography, bioTranslation = null, birthplace = person.place_of_birth, imagePath = person.profile_path, homepage = person.homepage, characters = extractCharacters(person), jobs = extractJobs(person), episodesCount = person.total_episode_count ?: 0, birthday = person.birthday?.let { if (it.isNotBlank()) LocalDate.parse(it) else null }, deathday = person.deathday?.let { if (it.isNotBlank()) LocalDate.parse(it) else null } ) fun fromDatabase(personDb: PersonDb, characters: List = emptyList()) = Person( ids = Ids.EMPTY.copy( trakt = IdTrakt(personDb.idTrakt ?: -1), tmdb = IdTmdb(personDb.idTmdb), imdb = IdImdb(personDb.idImdb ?: "") ), name = personDb.name, department = typeToEnum(personDb.department), bio = personDb.biography, bioTranslation = personDb.biographyTranslation, characters = if (characters.isNotEmpty()) characters else personDb.character?.split(",") ?: emptyList(), jobs = personDb.job?.split(",")?.map { Person.Job.fromSlug(it) } ?: emptyList(), episodesCount = personDb.episodesCount ?: 0, birthplace = personDb.birthplace, imagePath = personDb.image, homepage = personDb.homepage, birthday = personDb.birthday?.let { if (it.isNotBlank()) LocalDate.parse(it) else null }, deathday = personDb.deathday?.let { if (it.isNotBlank()) LocalDate.parse(it) else null } ) fun toDatabase(person: Person, detailsTimestamp: ZonedDateTime?): PersonDb { val idTrakt = if (person.ids.trakt.id != -1L) person.ids.trakt.id else null val idImdb = if (person.ids.imdb.id.isNotBlank()) person.ids.imdb.id else null return PersonDb( idTmdb = person.ids.tmdb.id, idTrakt = idTrakt, idImdb = idImdb, name = person.name, department = person.department.slug, biography = person.bio, biographyTranslation = person.bioTranslation, character = person.characters.joinToString(","), job = person.jobs.joinToString(",") { it.slug }, episodesCount = person.episodesCount, birthday = person.birthday?.format(ISO_LOCAL_DATE), birthplace = person.birthplace, deathday = person.deathday?.format(ISO_LOCAL_DATE), image = person.imagePath, homepage = person.homepage, createdAt = nowUtc(), updatedAt = nowUtc(), detailsUpdatedAt = detailsTimestamp ) } private fun extractCharacters(person: TmdbPerson) = when { person.roles != null -> person.roles?.mapNotNull { it.character } ?: emptyList() !person.character.isNullOrBlank() -> listOf(person.character!!) else -> emptyList() } private fun extractJobs(person: TmdbPerson) = when { person.jobs != null -> person.jobs?.map { Person.Job.fromSlug(it.job) } ?: emptyList() !person.job.isNullOrBlank() -> listOf(Person.Job.fromSlug(person.job)) else -> emptyList() } private fun typeToEnum(type: String?) = when (type) { "Acting", "Actors" -> Person.Department.ACTING "Directing" -> Person.Department.DIRECTING "Writing" -> Person.Department.WRITING "Sound" -> Person.Department.SOUND else -> Person.Department.UNKNOWN } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/RatingsMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.database.model.MovieRatings import com.michaldrabik.data_local.database.model.ShowRatings import com.michaldrabik.data_remote.omdb.model.OmdbResult import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ratings import javax.inject.Inject class RatingsMapper @Inject constructor() { fun fromNetwork(omdbResult: OmdbResult) = Ratings( imdb = if (omdbResult.imdbRating == "N/A") null else Ratings.Value(omdbResult.imdbRating, false), metascore = if (omdbResult.Metascore == "N/A") null else Ratings.Value(omdbResult.Metascore, false), rottenTomatoes = Ratings.Value(omdbResult.Ratings?.find { it.Source == "Rotten Tomatoes" }?.Value, false), rottenTomatoesUrl = if (omdbResult.tomatoURL == "N/A") null else omdbResult.tomatoURL ) fun fromDatabase(entity: MovieRatings) = Ratings( trakt = Ratings.Value(entity.trakt, false), imdb = Ratings.Value(entity.imdb, false), rottenTomatoes = Ratings.Value(entity.rottenTomatoes, false), rottenTomatoesUrl = entity.rottenTomatoesUrl, metascore = Ratings.Value(entity.metascore, false) ) fun fromDatabase(entity: ShowRatings) = Ratings( trakt = Ratings.Value(entity.trakt, false), imdb = Ratings.Value(entity.imdb, false), rottenTomatoes = Ratings.Value(entity.rottenTomatoes, false), rottenTomatoesUrl = entity.rottenTomatoesUrl, metascore = Ratings.Value(entity.metascore, false) ) fun toMovieDatabase( idTrakt: IdTrakt, ratings: Ratings, ) = MovieRatings( id = 0, idTrakt = idTrakt.id, trakt = ratings.trakt?.value, imdb = ratings.imdb?.value, metascore = ratings.metascore?.value, rottenTomatoes = ratings.rottenTomatoes?.value, rottenTomatoesUrl = ratings.rottenTomatoesUrl, createdAt = nowUtcMillis(), updatedAt = nowUtcMillis(), ) fun toShowDatabase( idTrakt: IdTrakt, ratings: Ratings, ) = ShowRatings( id = 0, idTrakt = idTrakt.id, trakt = ratings.trakt?.value, imdb = ratings.imdb?.value, metascore = ratings.metascore?.value, rottenTomatoes = ratings.rottenTomatoes?.value, rottenTomatoesUrl = ratings.rottenTomatoesUrl, createdAt = nowUtcMillis(), updatedAt = nowUtcMillis(), ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/SeasonMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.data_local.database.model.Episode import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Season import java.time.ZonedDateTime import javax.inject.Inject import com.michaldrabik.data_local.database.model.Season as SeasonDb import com.michaldrabik.data_remote.trakt.model.Season as SeasonNetwork class SeasonMapper @Inject constructor( private val idsMapper: IdsMapper, private val episodeMapper: EpisodeMapper ) { fun fromNetwork(season: SeasonNetwork) = Season( idsMapper.fromNetwork(season.ids), season.number ?: -1, season.episode_count ?: -1, season.aired_episodes ?: -1, season.title ?: "", if (season.first_aired.isNullOrBlank()) null else ZonedDateTime.parse(season.first_aired), season.overview ?: "", season.rating ?: -1F, season.episodes?.map { episodeMapper.fromNetwork(it) } ?: emptyList() ) fun toNetwork(season: Season) = SeasonNetwork( ids = idsMapper.toNetwork(season.ids), number = season.number, episode_count = season.episodeCount, aired_episodes = season.airedEpisodes, title = season.title, first_aired = season.firstAired.toString(), overview = season.overview, rating = season.rating, episodes = season.episodes.map { episodeMapper.toNetwork(it) } ) fun fromDatabase(seasonDb: SeasonDb, episodes: List = emptyList()) = Season( Ids.EMPTY.copy(trakt = IdTrakt(seasonDb.idTrakt)), seasonDb.seasonNumber, seasonDb.episodesCount, seasonDb.episodesAiredCount, seasonDb.seasonTitle, seasonDb.seasonFirstAired, seasonDb.seasonOverview, seasonDb.rating ?: -1F, episodes.map { episodeMapper.fromDatabase(it) } ) fun toDatabase( season: Season, showId: IdTrakt, isWatched: Boolean ): SeasonDb { return SeasonDb( season.ids.trakt.id, showId.id, season.number, season.title, season.overview, season.firstAired, season.episodeCount, season.airedEpisodes, season.rating, isWatched ) } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/SettingsMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.ui_model.Genre import com.michaldrabik.ui_model.Network import com.michaldrabik.ui_model.NotificationDelay import com.michaldrabik.ui_model.Settings import javax.inject.Inject import com.michaldrabik.data_local.database.model.Settings as SettingsDb class SettingsMapper @Inject constructor() { fun fromDatabase(settings: SettingsDb) = Settings( isInitialRun = settings.isInitialRun, episodesNotificationsEnabled = settings.episodesNotificationsEnabled, episodesNotificationsDelay = NotificationDelay.fromDelay(settings.episodesNotificationsDelay), myShowsWatchingSortBy = enumValueOf(settings.myShowsRunningSortBy), myShowsUpcomingSortBy = enumValueOf(settings.myShowsIncomingSortBy), myShowsFinishedSortBy = enumValueOf(settings.myShowsEndedSortBy), myShowsAllSortBy = enumValueOf(settings.myShowsAllSortBy), myShowsRunningIsCollapsed = settings.myShowsRunningIsCollapsed, myShowsIncomingIsCollapsed = settings.myShowsIncomingIsCollapsed, myShowsEndedIsCollapsed = settings.myShowsEndedIsCollapsed, myRecentsAmount = settings.myShowsRecentsAmount, watchlistShowsSortBy = enumValueOf(settings.seeLaterShowsSortBy), archiveShowsSortBy = enumValueOf(settings.archiveShowsSortBy), showAnticipatedShows = settings.showAnticipatedShows, discoverFilterFeed = enumValueOf(settings.discoverFilterFeed), discoverFilterGenres = settings.discoverFilterGenres.split(",").filter { it.isNotBlank() }.map { Genre.valueOf(it) }, discoverFilterNetworks = settings.discoverFilterNetworks.split(",").filter { it.isNotBlank() }.map { Network.valueOf(it) }, traktSyncSchedule = enumValueOf(settings.traktSyncSchedule), traktQuickSyncEnabled = settings.traktQuickSyncEnabled, traktQuickRemoveEnabled = settings.traktQuickRemoveEnabled, progressSortOrder = enumValueOf(settings.watchlistSortBy), archiveIncludeStatistics = settings.archiveShowsIncludeStatistics, specialSeasonsEnabled = settings.specialSeasonsEnabled, showAnticipatedMovies = settings.showAnticipatedMovies, discoverMoviesFilterGenres = settings.discoverMoviesFilterGenres.split(",").filter { it.isNotBlank() }.map { Genre.valueOf(it) }, discoverMoviesFilterFeed = enumValueOf(settings.discoverMoviesFilterFeed), myMoviesAllSortBy = enumValueOf(settings.myMoviesAllSortBy), watchlistMoviesSortBy = enumValueOf(settings.seeLaterMoviesSortBy), progressMoviesSortBy = enumValueOf(settings.progressMoviesSortBy), showCollectionShows = settings.showCollectionShows, showCollectionMovies = settings.showCollectionMovies, widgetsShowLabel = settings.widgetsShowLabel, traktQuickRateEnabled = settings.quickRateEnabled, listsSortBy = enumValueOf(settings.listsSortBy), progressUpcomingEnabled = settings.progressUpcomingEnabled ) fun toDatabase(settings: Settings) = SettingsDb( isInitialRun = settings.isInitialRun, pushNotificationsEnabled = false, episodesNotificationsEnabled = settings.episodesNotificationsEnabled, episodesNotificationsDelay = settings.episodesNotificationsDelay.delayMs, myShowsRunningSortBy = settings.myShowsWatchingSortBy.name, myShowsIncomingSortBy = settings.myShowsUpcomingSortBy.name, myShowsEndedSortBy = settings.myShowsFinishedSortBy.name, myShowsAllSortBy = settings.myShowsAllSortBy.name, myShowsRunningIsCollapsed = settings.myShowsRunningIsCollapsed, myShowsIncomingIsCollapsed = settings.myShowsIncomingIsCollapsed, myShowsEndedIsCollapsed = settings.myShowsEndedIsCollapsed, myShowsRunningIsEnabled = false, myShowsIncomingIsEnabled = false, myShowsEndedIsEnabled = false, myShowsRecentIsEnabled = false, myMoviesRecentIsEnabled = false, myShowsRecentsAmount = settings.myRecentsAmount, seeLaterShowsSortBy = settings.watchlistShowsSortBy.name, archiveShowsSortBy = settings.archiveShowsSortBy.name, showAnticipatedShows = settings.showAnticipatedShows, discoverFilterFeed = settings.discoverFilterFeed.name, discoverFilterGenres = settings.discoverFilterGenres.joinToString(",") { it.name }, discoverFilterNetworks = settings.discoverFilterNetworks.joinToString(",") { it.name }, traktSyncSchedule = settings.traktSyncSchedule.name, traktQuickSyncEnabled = settings.traktQuickSyncEnabled, traktQuickRemoveEnabled = settings.traktQuickRemoveEnabled, watchlistSortBy = settings.progressSortOrder.name, archiveShowsIncludeStatistics = settings.archiveIncludeStatistics, specialSeasonsEnabled = settings.specialSeasonsEnabled, showAnticipatedMovies = settings.showAnticipatedMovies, discoverMoviesFilterFeed = settings.discoverMoviesFilterFeed.name, discoverMoviesFilterGenres = settings.discoverMoviesFilterGenres.joinToString(",") { it.name }, myMoviesAllSortBy = settings.myMoviesAllSortBy.name, seeLaterMoviesSortBy = settings.watchlistMoviesSortBy.name, progressMoviesSortBy = settings.progressMoviesSortBy.name, showCollectionShows = settings.showCollectionShows, showCollectionMovies = settings.showCollectionMovies, widgetsShowLabel = settings.widgetsShowLabel, quickRateEnabled = settings.traktQuickRateEnabled, listsSortBy = settings.listsSortBy.name, progressUpcomingEnabled = settings.progressUpcomingEnabled ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/ShowMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.ui_model.AirTime import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_model.ShowStatus import javax.inject.Inject import com.michaldrabik.data_local.database.model.Show as ShowDb import com.michaldrabik.data_remote.trakt.model.AirTime as AirTimeNetwork import com.michaldrabik.data_remote.trakt.model.Show as ShowNetwork class ShowMapper @Inject constructor( private val idsMapper: IdsMapper ) { fun fromNetwork(show: ShowNetwork) = Show( idsMapper.fromNetwork(show.ids), show.title ?: "", show.year ?: -1, show.overview ?: "", show.first_aired ?: "", show.runtime ?: -1, AirTime( show.airs?.day ?: "", show.airs?.time ?: "", show.airs?.timezone ?: "" ), show.certification ?: "", show.network ?: "", show.country ?: "", show.trailer ?: "", show.homepage ?: "", ShowStatus.fromKey(show.status), show.rating ?: -1F, show.votes ?: -1, show.comment_count ?: -1, show.genres ?: emptyList(), show.aired_episodes ?: -1, nowUtcMillis(), nowUtcMillis() ) fun toNetwork(show: Show) = ShowNetwork( idsMapper.toNetwork(show.ids), show.title, show.year, show.overview, show.firstAired, show.runtime, AirTimeNetwork( show.airTime.day, show.airTime.time, show.airTime.timezone ), show.certification, show.network, show.country, show.trailer, show.homepage, show.status.key, show.rating, show.votes, show.commentCount, show.genres, show.airedEpisodes ) fun fromDatabase(show: ShowDb) = Show( idsMapper.fromDatabase(show), show.title, show.year, show.overview, show.firstAired, show.runtime, AirTime(show.airtimeDay, show.airtimeTime, show.airtimeTimezone), show.certification, show.network, show.country, show.trailer, show.homepage, ShowStatus.fromKey(show.status), show.rating, show.votes, show.commentCount, show.genres.split(","), show.airedEpisodes, show.createdAt, show.updatedAt ) fun toDatabase(show: Show) = ShowDb( show.traktId, show.ids.tvdb.id, show.ids.tmdb.id, show.ids.imdb.id, show.ids.slug.id, show.ids.tvrage.id, show.title, show.year, show.overview, show.firstAired, show.runtime, show.airTime.day, show.airTime.time, show.airTime.timezone, show.certification, show.network, show.country, show.trailer, show.homepage, show.status.key, show.rating, show.votes, show.commentCount, show.genres.joinToString(","), show.airedEpisodes, show.createdAt, nowUtcMillis() ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/StreamingsMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.data_local.database.model.MovieStreaming import com.michaldrabik.data_local.database.model.ShowStreaming import com.michaldrabik.data_remote.tmdb.model.TmdbStreamingCountry import com.michaldrabik.data_remote.tmdb.model.TmdbStreamingService import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.StreamingService import com.michaldrabik.ui_model.StreamingService.Option.ADS import com.michaldrabik.ui_model.StreamingService.Option.BUY import com.michaldrabik.ui_model.StreamingService.Option.FLATRATE import com.michaldrabik.ui_model.StreamingService.Option.FREE import com.michaldrabik.ui_model.StreamingService.Option.RENT import javax.inject.Inject class StreamingsMapper @Inject constructor() { fun fromDatabaseShow( input: List, mediaName: String, countryCode: String, ): List { return input.map { StreamingService( imagePath = it.logoPath ?: "", name = it.providerName ?: "", options = listOf(StreamingService.Option.valueOf(it.type!!)), mediaName = mediaName, countryCode = countryCode, link = it.link ?: "" ) } } fun toDatabaseShow(ids: Ids, input: TmdbStreamingCountry) = mutableListOf().apply { addAll(input.flatrate?.map { createEntityShow(ids, FLATRATE, input, it) } ?: emptyList()) addAll(input.free?.map { createEntityShow(ids, FREE, input, it) } ?: emptyList()) addAll(input.buy?.map { createEntityShow(ids, BUY, input, it) } ?: emptyList()) addAll(input.rent?.map { createEntityShow(ids, RENT, input, it) } ?: emptyList()) addAll(input.ads?.map { createEntityShow(ids, ADS, input, it) } ?: emptyList()) } fun fromDatabaseMovie( input: List, mediaName: String, countryCode: String, ): List { return input.map { StreamingService( imagePath = it.logoPath ?: "", name = it.providerName ?: "", options = listOf(StreamingService.Option.valueOf(it.type!!)), mediaName = mediaName, countryCode = countryCode, link = it.link ?: "" ) } } fun toDatabaseMovie(ids: Ids, input: TmdbStreamingCountry) = mutableListOf().apply { addAll(input.flatrate?.map { createEntityMovie(ids, FLATRATE, input, it) } ?: emptyList()) addAll(input.free?.map { createEntityMovie(ids, FREE, input, it) } ?: emptyList()) addAll(input.buy?.map { createEntityMovie(ids, BUY, input, it) } ?: emptyList()) addAll(input.rent?.map { createEntityMovie(ids, RENT, input, it) } ?: emptyList()) addAll(input.ads?.map { createEntityMovie(ids, ADS, input, it) } ?: emptyList()) } private fun createEntityMovie( ids: Ids, option: StreamingService.Option, country: TmdbStreamingCountry, input: TmdbStreamingService, ) = MovieStreaming( idTrakt = ids.trakt.id, idTmdb = ids.tmdb.id, type = option.name, providerId = input.provider_id, providerName = input.provider_name, displayPriority = input.display_priority, logoPath = input.logo_path, link = country.link, createdAt = nowUtc(), updatedAt = nowUtc() ) private fun createEntityShow( ids: Ids, option: StreamingService.Option, country: TmdbStreamingCountry, input: TmdbStreamingService, ) = ShowStreaming( idTrakt = ids.trakt.id, idTmdb = ids.tmdb.id, type = option.name, providerId = input.provider_id, providerName = input.provider_name, displayPriority = input.display_priority, logoPath = input.logo_path, link = country.link, createdAt = nowUtc(), updatedAt = nowUtc() ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/TranslationMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.data_local.database.model.EpisodeTranslation import com.michaldrabik.data_local.database.model.MovieTranslation import com.michaldrabik.data_local.database.model.ShowTranslation import com.michaldrabik.ui_model.SeasonTranslation import com.michaldrabik.ui_model.Translation import javax.inject.Inject import com.michaldrabik.data_remote.trakt.model.SeasonTranslation as SeasonTranslationNetwork import com.michaldrabik.data_remote.trakt.model.Translation as TranslationNetwork class TranslationMapper @Inject constructor( private val idsMapper: IdsMapper ) { fun fromNetwork(value: TranslationNetwork?) = Translation( title = value?.title ?: "", overview = value?.overview ?: "", language = value?.language ?: "" ) fun fromNetwork(value: SeasonTranslationNetwork?) = SeasonTranslation( ids = idsMapper.fromNetwork(value?.ids), seasonNumber = value?.season ?: -1, episodeNumber = value?.number ?: -1, title = value?.translations?.firstOrNull()?.title ?: "", overview = value?.translations?.firstOrNull()?.overview ?: "", language = value?.translations?.firstOrNull()?.language ?: "" ) fun fromDatabase(value: ShowTranslation?) = Translation( title = value?.title ?: "", overview = value?.overview ?: "", language = value?.language ?: "" ) fun fromDatabase(value: MovieTranslation?) = Translation( title = value?.title ?: "", overview = value?.overview ?: "", language = value?.language ?: "" ) fun fromDatabase(value: EpisodeTranslation?) = Translation( title = value?.title ?: "", overview = value?.overview ?: "", language = value?.language ?: "" ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/mappers/UserRatingsMapper.kt ================================================ package com.michaldrabik.repository.mappers import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.data_local.database.model.Rating import com.michaldrabik.data_remote.trakt.model.RatingResultEpisode import com.michaldrabik.data_remote.trakt.model.RatingResultMovie import com.michaldrabik.data_remote.trakt.model.RatingResultSeason import com.michaldrabik.data_remote.trakt.model.RatingResultShow import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Season import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_model.TraktRating import java.time.ZonedDateTime import javax.inject.Inject class UserRatingsMapper @Inject constructor() { fun fromDatabase(entity: Rating) = TraktRating( idTrakt = IdTrakt(entity.idTrakt), rating = entity.rating, ratedAt = entity.ratedAt ) fun toDatabaseMovie( rating: RatingResultMovie ) = Rating( idTrakt = rating.movie.ids.trakt!!, type = "movie", rating = rating.rating, seasonNumber = null, episodeNumber = null, ratedAt = ZonedDateTime.parse(rating.rated_at), createdAt = nowUtc(), updatedAt = nowUtc() ) fun toDatabaseMovie( movie: Movie, rating: Int, ratedAt: ZonedDateTime ) = Rating( idTrakt = movie.traktId, type = "movie", rating = rating, seasonNumber = null, episodeNumber = null, ratedAt = ratedAt, createdAt = nowUtc(), updatedAt = nowUtc() ) fun toDatabaseShow( rating: RatingResultShow ) = Rating( idTrakt = rating.show.ids.trakt!!, type = "show", rating = rating.rating, seasonNumber = null, episodeNumber = null, ratedAt = ZonedDateTime.parse(rating.rated_at), createdAt = nowUtc(), updatedAt = nowUtc() ) fun toDatabaseShow( show: Show, rating: Int, ratedAt: ZonedDateTime ) = Rating( idTrakt = show.traktId, type = "show", rating = rating, seasonNumber = null, episodeNumber = null, ratedAt = ratedAt, createdAt = nowUtc(), updatedAt = nowUtc() ) fun toDatabaseEpisode( rating: RatingResultEpisode ) = Rating( idTrakt = rating.episode.ids.trakt!!, type = "episode", rating = rating.rating, seasonNumber = rating.episode.season, episodeNumber = rating.episode.number, ratedAt = ZonedDateTime.parse(rating.rated_at), createdAt = nowUtc(), updatedAt = nowUtc() ) fun toDatabaseEpisode( episode: Episode, rating: Int, ratedAt: ZonedDateTime ) = Rating( idTrakt = episode.ids.trakt.id, type = "episode", rating = rating, seasonNumber = episode.season, episodeNumber = episode.number, ratedAt = ratedAt, createdAt = nowUtc(), updatedAt = nowUtc() ) fun toDatabaseSeason( rating: RatingResultSeason ) = Rating( idTrakt = rating.season.ids.trakt!!, type = "season", rating = rating.rating, seasonNumber = rating.season.season, episodeNumber = rating.season.number, ratedAt = ZonedDateTime.parse(rating.rated_at), createdAt = nowUtc(), updatedAt = nowUtc() ) fun toDatabaseSeason( season: Season, rating: Int, ratedAt: ZonedDateTime ) = Rating( idTrakt = season.ids.trakt.id, type = "season", rating = rating, seasonNumber = season.number, episodeNumber = null, ratedAt = ratedAt, createdAt = nowUtc(), updatedAt = nowUtc() ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/movies/DiscoverMoviesRepository.kt ================================================ package com.michaldrabik.repository.movies import com.michaldrabik.common.Config import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.DiscoverMovie import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.Config.TRAKT_TRENDING_MOVIES_LIMIT import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.Genre import com.michaldrabik.ui_model.Movie import javax.inject.Inject class DiscoverMoviesRepository @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers ) { suspend fun isCacheValid(): Boolean { val stamp = localSource.discoverMovies.getMostRecent()?.createdAt ?: 0 return nowUtcMillis() - stamp < Config.DISCOVER_MOVIES_CACHE_DURATION } suspend fun loadAllCached(): List { val cachedMovies = localSource.discoverMovies.getAll().map { it.idTrakt } val movies = localSource.movies.getAll(cachedMovies) return cachedMovies .map { id -> movies.first { it.idTrakt == id } } .map { mappers.movie.fromDatabase(it) } } // TODO This logic should probably sit in a case and not repository. suspend fun loadAllRemote( showAnticipated: Boolean, showCollection: Boolean, collectionSize: Int, genres: List ): List { val remoteMovies = mutableListOf() val anticipatedMovies = mutableListOf() val popularMovies = mutableListOf() val genresQuery = genres.joinToString(",") { it.slug } val limit = if (showCollection) TRAKT_TRENDING_MOVIES_LIMIT else TRAKT_TRENDING_MOVIES_LIMIT + (collectionSize / 2) val trendingMovies = remoteSource.trakt.fetchTrendingMovies(genresQuery, limit) .map { mappers.movie.fromNetwork(it) } if (genres.isNotEmpty()) { // Wa are adding popular results for genres filtered content to add more results. val popular = remoteSource.trakt.fetchPopularMovies(genresQuery).map { mappers.movie.fromNetwork(it) } popularMovies.addAll(popular) } if (showAnticipated) { val movies = remoteSource.trakt.fetchAnticipatedMovies(genresQuery).map { mappers.movie.fromNetwork(it) }.toMutableList() anticipatedMovies.addAll(movies) } trendingMovies.forEachIndexed { index, movie -> addIfMissing(remoteMovies, movie) if (index % 4 == 0 && anticipatedMovies.isNotEmpty()) { val element = anticipatedMovies.removeAt(0) addIfMissing(remoteMovies, element) } } popularMovies.forEach { show -> addIfMissing(remoteMovies, show) } if (!showAnticipated) { return remoteMovies.filter { !it.status.isAnticipated() } } return remoteMovies } suspend fun cacheDiscoverMovies(movies: List) { transactions.withTransaction { val timestamp = nowUtcMillis() localSource.movies.upsert(movies.map { mappers.movie.toDatabase(it) }) localSource.discoverMovies.replace( movies.map { DiscoverMovie( idTrakt = it.ids.trakt.id, createdAt = timestamp, updatedAt = timestamp ) } ) } } private fun addIfMissing(movies: MutableList, movie: Movie) { if (movies.any { it.ids.trakt == movie.ids.trakt }) return movies.add(movie) } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/movies/HiddenMoviesRepository.kt ================================================ package com.michaldrabik.repository.movies import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.ArchiveMovie import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.IdTrakt import javax.inject.Inject class HiddenMoviesRepository @Inject constructor( private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers ) { suspend fun loadAll() = localSource.archiveMovies.getAll() .map { mappers.movie.fromDatabase(it) } suspend fun loadAll(ids: List) = localSource.archiveMovies.getAll(ids.map { it.id }) .map { mappers.movie.fromDatabase(it) } suspend fun load(id: IdTrakt) = localSource.archiveMovies.getById(id.id)?.let { mappers.movie.fromDatabase(it) } suspend fun loadAllIds() = localSource.archiveMovies.getAllTraktIds() suspend fun insert(id: IdTrakt) { val dbMovie = ArchiveMovie.fromTraktId(id.id, nowUtcMillis()) transactions.withTransaction { with(localSource) { archiveMovies.insert(dbMovie) myMovies.deleteById(id.id) watchlistMovies.deleteById(id.id) } } } suspend fun delete(id: IdTrakt) = localSource.archiveMovies.deleteById(id.id) suspend fun exists(id: IdTrakt) = localSource.archiveMovies.getById(id.id) != null } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/movies/MovieCollectionsRepository.kt ================================================ package com.michaldrabik.repository.movies import com.michaldrabik.common.ConfigVariant.COLLECTIONS_CACHE_DURATION import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.data_local.database.model.MovieCollectionItem import com.michaldrabik.data_local.sources.MovieCollectionsItemsLocalDataSource import com.michaldrabik.data_local.sources.MovieCollectionsLocalDataSource import com.michaldrabik.data_local.sources.MoviesLocalDataSource import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.trakt.TraktRemoteDataSource import com.michaldrabik.repository.mappers.CollectionMapper import com.michaldrabik.repository.mappers.MovieMapper import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.MovieCollection import kotlinx.coroutines.withContext import java.time.ZonedDateTime import javax.inject.Inject import javax.inject.Singleton @Singleton class MovieCollectionsRepository @Inject constructor( private val dispatchers: CoroutineDispatchers, private val remoteSource: TraktRemoteDataSource, private val moviesLocalSource: MoviesLocalDataSource, private val movieCollectionsLocalSource: MovieCollectionsLocalDataSource, private val movieCollectionsItemsLocalSource: MovieCollectionsItemsLocalDataSource, private val collectionMapper: CollectionMapper, private val movieMapper: MovieMapper, private val transactions: TransactionsProvider, ) { suspend fun loadCollection(collectionId: IdTrakt) = withContext(dispatchers.IO) { movieCollectionsLocalSource.getById(collectionId.id) } suspend fun loadCollections(movieId: IdTrakt): Pair, Source> = withContext(dispatchers.IO) { val now = nowUtc() val localCollections = movieCollectionsLocalSource.getByMovieId(movieId.id) val localTimestamp = localCollections.firstOrNull()?.updatedAt localTimestamp?.let { timestamp -> if (now.toMillis() - timestamp.toMillis() < COLLECTIONS_CACHE_DURATION) { return@withContext Pair( localCollections.map { collectionMapper.fromEntity(it) }, Source.LOCAL ) } } val remoteCollections = remoteSource.fetchMovieCollections(movieId.id) val collections = remoteCollections.map { collectionMapper.fromNetwork(it) } updateLocalCollections(collections, movieId, now) return@withContext Pair( collections, Source.REMOTE ) } suspend fun loadCollectionItems(collectionId: IdTrakt): List = withContext(dispatchers.IO) { val now = nowUtc() val localItems = movieCollectionsItemsLocalSource.getById(collectionId.id) val localTimestamp = localItems.firstOrNull()?.updatedAt localTimestamp?.let { timestamp -> if (now.toMillis() - timestamp < COLLECTIONS_CACHE_DURATION) { return@withContext localItems.map { movieMapper.fromDatabase(it) } } } val remoteItems = remoteSource.fetchMovieCollectionItems(collectionId.id) val items = remoteItems.map { movieMapper.fromNetwork(it) } transactions.withTransaction { val entities = items.mapIndexed { index, movie -> MovieCollectionItem( rank = index, idTrakt = movie.traktId, idTraktCollection = collectionId.id, createdAt = now, updatedAt = now ) } moviesLocalSource.upsert(items.map { movieMapper.toDatabase(it) }) movieCollectionsItemsLocalSource.replace(collectionId.id, entities) // Fill up collection with other movies that belong in it. val collection = movieCollectionsLocalSource.getById(collectionId.id) collection?.let { coll -> val insertEntities = entities .filter { it.idTrakt != coll.idTraktMovie } .map { coll.copy(id = 0, idTraktMovie = it.idTrakt) } movieCollectionsLocalSource.insertAll(insertEntities) } } return@withContext items } private suspend fun updateLocalCollections( collections: List, movieId: IdTrakt, now: ZonedDateTime, ) { var entities = collections.map { collectionMapper.toEntity( movieId = movieId.id, input = it, updatedAt = now, createdAt = now ) } if (entities.isEmpty()) { entities = listOf( collectionMapper.toEntity( movieId.id, MovieCollection.EMPTY ) ) } movieCollectionsLocalSource.replaceByMovieId(movieId.id, entities) } enum class Source { LOCAL, REMOTE } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/movies/MovieDetailsRepository.kt ================================================ package com.michaldrabik.repository.movies import com.michaldrabik.common.Config import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.MoviesSyncLog import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.IdImdb import com.michaldrabik.ui_model.IdSlug import com.michaldrabik.ui_model.IdTmdb import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Movie import javax.inject.Inject class MovieDetailsRepository @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers, ) { suspend fun load(idTrakt: IdTrakt, force: Boolean = false): Movie { val local = localSource.movies.getById(idTrakt.id) if (force || local == null || nowUtcMillis() - local.updatedAt > Config.MOVIE_DETAILS_CACHE_DURATION) { val remote = remoteSource.trakt.fetchMovie(idTrakt.id) val movie = mappers.movie.fromNetwork(remote) localSource.movies.upsert(listOf(mappers.movie.toDatabase(movie))) localSource.moviesSyncLog.upsert(MoviesSyncLog(movie.traktId, nowUtcMillis())) return movie } return mappers.movie.fromDatabase(local) } suspend fun find(idImdb: IdImdb): Movie? { val localMovie = localSource.movies.getById(idImdb.id) if (localMovie != null) { return mappers.movie.fromDatabase(localMovie) } return null } suspend fun find(idTmdb: IdTmdb): Movie? { val localMovie = localSource.movies.getByTmdbId(idTmdb.id) if (localMovie != null) { return mappers.movie.fromDatabase(localMovie) } return null } suspend fun find(idSlug: IdSlug): Movie? { val localMovie = localSource.movies.getBySlug(idSlug.id) if (localMovie != null) { return mappers.movie.fromDatabase(localMovie) } return null } suspend fun delete(idTrakt: IdTrakt) = localSource.movies.deleteById(idTrakt.id) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/movies/MovieStreamingsRepository.kt ================================================ package com.michaldrabik.repository.movies import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.StreamingsRepository import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.StreamingService import java.time.ZonedDateTime import javax.inject.Inject import javax.inject.Singleton @Singleton class MovieStreamingsRepository @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers, ) : StreamingsRepository() { suspend fun getLocalStreamings(movie: Movie, countryCode: String): Pair, ZonedDateTime?> { val localItems = localSource.movieStreamings.getById(movie.traktId) val mappedItems = mappers.streamings.fromDatabaseMovie(localItems, movie.title, countryCode) val processedItems = processItems(mappedItems, countryCode) val date = localItems.firstOrNull()?.createdAt return Pair(processedItems, date) } suspend fun loadRemoteStreamings(movie: Movie, countryCode: String): List { val remoteItems = remoteSource.tmdb.fetchMovieWatchProviders(movie.ids.tmdb.id, countryCode) ?: return emptyList() val entities = mappers.streamings.toDatabaseMovie(movie.ids, remoteItems) localSource.movieStreamings.replace(movie.traktId, entities) return processItems(remoteItems, movie.title, countryCode) } suspend fun deleteCache() = localSource.movieStreamings.deleteAll() } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/movies/MoviesRepository.kt ================================================ package com.michaldrabik.repository.movies import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import javax.inject.Inject import javax.inject.Singleton @Singleton class MoviesRepository @Inject constructor( val discoverMovies: DiscoverMoviesRepository, val relatedMovies: RelatedMoviesRepository, val movieDetails: MovieDetailsRepository, val myMovies: MyMoviesRepository, val watchlistMovies: WatchlistMoviesRepository, val hiddenMovies: HiddenMoviesRepository, ) { suspend fun loadCollection(skipHidden: Boolean = false) = coroutineScope { val async1 = async { myMovies.loadAll() } val async2 = async { watchlistMovies.loadAll() } val async3 = async { if (skipHidden) emptyList() else hiddenMovies.loadAll() } val (my, watchlist, hidden) = awaitAll(async1, async2, async3) (my + watchlist + hidden).distinctBy { it.traktId } } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/movies/MyMoviesRepository.kt ================================================ package com.michaldrabik.repository.movies import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.MyMovie import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.IdTrakt import javax.inject.Inject class MyMoviesRepository @Inject constructor( private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers, ) { suspend fun load(id: IdTrakt) = localSource.myMovies.getById(id.id)?.let { mappers.movie.fromDatabase(it) } suspend fun loadAll() = localSource.myMovies.getAll() .map { mappers.movie.fromDatabase(it) } suspend fun loadAll(ids: List) = localSource.myMovies.getAll(ids.map { it.id }) .map { mappers.movie.fromDatabase(it) } suspend fun loadAllRecent(amount: Int) = localSource.myMovies.getAllRecent(amount) .map { mappers.movie.fromDatabase(it) } suspend fun loadAllIds() = localSource.myMovies.getAllTraktIds() suspend fun insert(id: IdTrakt) { val movie = MyMovie.fromTraktId(id.id, nowUtcMillis()) transactions.withTransaction { with(localSource) { myMovies.insert(listOf(movie)) watchlistMovies.deleteById(id.id) archiveMovies.deleteById(id.id) } } } suspend fun delete(id: IdTrakt) = localSource.myMovies.deleteById(id.id) suspend fun exists(id: IdTrakt) = localSource.myMovies.checkExists(id.id) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/movies/RelatedMoviesRepository.kt ================================================ package com.michaldrabik.repository.movies import com.michaldrabik.common.Config import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.RelatedMovie import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Movie import javax.inject.Inject import kotlin.math.min class RelatedMoviesRepository @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers ) { suspend fun loadAll(movie: Movie): List { val related = localSource.relatedMovies.getAllById(movie.ids.trakt.id) val latest = related.maxByOrNull { it.updatedAt } if (latest != null && nowUtcMillis() - latest.updatedAt < Config.RELATED_CACHE_DURATION) { val relatedIds = related.map { it.idTrakt } return localSource.movies.getAll(relatedIds) .map { mappers.movie.fromDatabase(it) } } val remote = remoteSource.trakt.fetchRelatedMovies(movie.ids.trakt.id, min(0, 15)) .map { mappers.movie.fromNetwork(it) } cacheRelated(remote, movie.ids.trakt) return remote } private suspend fun cacheRelated(movies: List, movieId: IdTrakt) { transactions.withTransaction { val timestamp = nowUtcMillis() localSource.movies.upsert(movies.map { mappers.movie.toDatabase(it) }) localSource.relatedMovies.deleteById(movieId.id) localSource.relatedMovies.insert( movies.map { RelatedMovie.fromTraktId(it.ids.trakt.id, movieId.id, timestamp) } ) } } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/movies/WatchlistMoviesRepository.kt ================================================ package com.michaldrabik.repository.movies import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.WatchlistMovie import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.IdTrakt import javax.inject.Inject class WatchlistMoviesRepository @Inject constructor( private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers, ) { suspend fun loadAll() = localSource.watchlistMovies.getAll() .map { mappers.movie.fromDatabase(it) } suspend fun loadAllIds() = localSource.watchlistMovies.getAllTraktIds() suspend fun load(id: IdTrakt) = localSource.watchlistMovies.getById(id.id)?.let { mappers.movie.fromDatabase(it) } suspend fun insert(id: IdTrakt) { val movie = WatchlistMovie.fromTraktId(id.id, nowUtcMillis()) transactions.withTransaction { with(localSource) { watchlistMovies.insert(movie) myMovies.deleteById(movie.idTrakt) archiveMovies.deleteById(movie.idTrakt) } } } suspend fun delete(id: IdTrakt) = localSource.watchlistMovies.deleteById(id.id) suspend fun exists(id: IdTrakt) = localSource.watchlistMovies.checkExists(id.id) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/movies/ratings/MoviesExternalRatingsRepository.kt ================================================ package com.michaldrabik.repository.movies.ratings import com.michaldrabik.common.ConfigVariant import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Ratings import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @Singleton class MoviesExternalRatingsRepository @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers, ) { suspend fun loadRatings(movie: Movie): Ratings { val localRatings = localSource.movieRatings.getById(movie.traktId) localRatings?.let { if (nowUtcMillis() - it.updatedAt < ConfigVariant.RATINGS_CACHE_DURATION) { return mappers.ratings.fromDatabase(it) } } val remoteRatings = remoteSource.omdb.fetchOmdbData(movie.ids.imdb.id) .let { mappers.ratings.fromNetwork(it) } .copy(trakt = Ratings.Value(String.format(Locale.ENGLISH, "%.1f", movie.rating), false)) val dbRatings = mappers.ratings.toMovieDatabase(movie.ids.trakt, remoteRatings) localSource.movieRatings.upsert(dbRatings) return remoteRatings } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/movies/ratings/MoviesRatingsRepository.kt ================================================ package com.michaldrabik.repository.movies.ratings import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.Rating import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.TraktRating import javax.inject.Inject import javax.inject.Singleton @Singleton class MoviesRatingsRepository @Inject constructor( val external: MoviesExternalRatingsRepository, private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers, ) { companion object { private const val TYPE_MOVIE = "movie" } suspend fun preloadRatings() { val ratings = remoteSource.trakt.fetchMoviesRatings() val entities = ratings .filter { it.rated_at != null && it.movie.ids.trakt != null } .map { mappers.userRatings.toDatabaseMovie(it) } localSource.ratings.replaceAll(entities, TYPE_MOVIE) } suspend fun loadMoviesRatings(): List { val ratings = localSource.ratings.getAllByType(TYPE_MOVIE) return ratings.map { mappers.userRatings.fromDatabase(it) } } suspend fun loadRatings(movies: List): List { val ratings = mutableListOf() movies.chunked(250).forEach { chunk -> val items = localSource.ratings.getAllByType(chunk.map { it.traktId }, TYPE_MOVIE) ratings.addAll(items) } return ratings.map { mappers.userRatings.fromDatabase(it) } } suspend fun addRating(movie: Movie, rating: Int) { remoteSource.trakt.postRating( mappers.movie.toNetwork(movie), rating ) val entity = mappers.userRatings.toDatabaseMovie(movie, rating, nowUtc()) localSource.ratings.replace(entity) } suspend fun deleteRating(movie: Movie) { remoteSource.trakt.deleteRating( mappers.movie.toNetwork(movie) ) localSource.ratings.deleteByType(movie.traktId, TYPE_MOVIE) } suspend fun clear() { localSource.ratings.deleteAllByType(TYPE_MOVIE) } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/settings/SettingsFiltersRepository.kt ================================================ package com.michaldrabik.repository.settings import android.content.SharedPreferences import androidx.core.content.edit import com.michaldrabik.repository.utilities.BooleanPreference import com.michaldrabik.repository.utilities.EnumPreference import com.michaldrabik.ui_model.Genre import com.michaldrabik.ui_model.MyShowsSection import com.michaldrabik.ui_model.Network import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @Singleton class SettingsFiltersRepository @Inject constructor( @Named("miscPreferences") private var preferences: SharedPreferences, ) { companion object Key { private const val PROGRESS_SHOWS_UPCOMING = "PROGRESS_SHOWS_UPCOMING" private const val PROGRESS_SHOWS_ON_HOLD = "PROGRESS_SHOWS_ON_HOLD" private const val MY_SHOWS_TYPE = "MY_SHOWS_TYPE" private const val MY_SHOWS_NETWORKS = "MY_SHOWS_NETWORKS" private const val MY_SHOWS_GENRES = "MY_SHOWS_GENRES" private const val WATCHLIST_SHOWS_UPCOMING = "WATCHLIST_SHOWS_UPCOMING" private const val WATCHLIST_SHOWS_NETWORKS = "WATCHLIST_SHOWS_NETWORKS" private const val WATCHLIST_SHOWS_GENRES = "WATCHLIST_SHOWS_GENRES" private const val HIDDEN_SHOWS_NETWORKS = "HIDDEN_SHOWS_NETWORKS" private const val HIDDEN_SHOWS_GENRES = "HIDDEN_SHOWS_GENRES" private const val MY_MOVIES_GENRES = "MY_MOVIES_GENRES" private const val WATCHLIST_MOVIES_UPCOMING = "WATCHLIST_MOVIES_UPCOMING" private const val WATCHLIST_MOVIES_GENRES = "WATCHLIST_MOVIES_GENRES" private const val HIDDEN_MOVIES_GENRES = "HIDDEN_MOVIES_GENRES" } // Shows var progressShowsUpcoming by BooleanPreference(preferences, PROGRESS_SHOWS_UPCOMING, false) var progressShowsOnHold by BooleanPreference(preferences, PROGRESS_SHOWS_ON_HOLD, false) var myShowsType by EnumPreference(preferences, MY_SHOWS_TYPE, MyShowsSection.ALL, MyShowsSection::class.java) var myShowsNetworks: List get() { val filters = preferences.getStringSet(MY_SHOWS_NETWORKS, emptySet()) ?: emptySet() return filters.map { Network.valueOf(it) } } set(value) { preferences.edit { putStringSet(MY_SHOWS_NETWORKS, value.map { it.name }.toSet()) } } var myShowsGenres: List get() { val filters = preferences.getStringSet(MY_SHOWS_GENRES, emptySet()) ?: emptySet() return filters.map { Genre.valueOf(it) } } set(value) { preferences.edit { putStringSet(MY_SHOWS_GENRES, value.map { it.name }.toSet()) } } var watchlistShowsUpcoming by BooleanPreference(preferences, WATCHLIST_SHOWS_UPCOMING, false) var watchlistShowsNetworks: List get() { val filters = preferences.getStringSet(WATCHLIST_SHOWS_NETWORKS, emptySet()) ?: emptySet() return filters.map { Network.valueOf(it) } } set(value) { preferences.edit { putStringSet(WATCHLIST_SHOWS_NETWORKS, value.map { it.name }.toSet()) } } var watchlistShowsGenres: List get() { val filters = preferences.getStringSet(WATCHLIST_SHOWS_GENRES, emptySet()) ?: emptySet() return filters.map { Genre.valueOf(it) } } set(value) { preferences.edit { putStringSet(WATCHLIST_SHOWS_GENRES, value.map { it.name }.toSet()) } } var hiddenShowsNetworks: List get() { val filters = preferences.getStringSet(HIDDEN_SHOWS_NETWORKS, emptySet()) ?: emptySet() return filters.map { Network.valueOf(it) } } set(value) { preferences.edit { putStringSet(HIDDEN_SHOWS_NETWORKS, value.map { it.name }.toSet()) } } var hiddenShowsGenres: List get() { val filters = preferences.getStringSet(HIDDEN_SHOWS_GENRES, emptySet()) ?: emptySet() return filters.map { Genre.valueOf(it) } } set(value) { preferences.edit { putStringSet(HIDDEN_SHOWS_GENRES, value.map { it.name }.toSet()) } } // Movies var myMoviesGenres: List get() { val filters = preferences.getStringSet(MY_MOVIES_GENRES, emptySet()) ?: emptySet() return filters.map { Genre.valueOf(it) } } set(value) { preferences.edit { putStringSet(MY_MOVIES_GENRES, value.map { it.name }.toSet()) } } var watchlistMoviesUpcoming by BooleanPreference(preferences, WATCHLIST_MOVIES_UPCOMING, false) var watchlistMoviesGenres: List get() { val filters = preferences.getStringSet(WATCHLIST_MOVIES_GENRES, emptySet()) ?: emptySet() return filters.map { Genre.valueOf(it) } } set(value) { preferences.edit { putStringSet(WATCHLIST_MOVIES_GENRES, value.map { it.name }.toSet()) } } var hiddenMoviesGenres: List get() { val filters = preferences.getStringSet(HIDDEN_MOVIES_GENRES, emptySet()) ?: emptySet() return filters.map { Genre.valueOf(it) } } set(value) { preferences.edit { putStringSet(HIDDEN_MOVIES_GENRES, value.map { it.name }.toSet()) } } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/settings/SettingsRepository.kt ================================================ package com.michaldrabik.repository.settings import android.app.UiModeManager.MODE_NIGHT_YES import android.content.SharedPreferences import androidx.core.content.edit import com.michaldrabik.common.Config.DEFAULT_COUNTRY import com.michaldrabik.common.Config.DEFAULT_DATE_FORMAT import com.michaldrabik.common.Config.DEFAULT_LANGUAGE import com.michaldrabik.common.Config.DEFAULT_NEWS_VIEW_TYPE import com.michaldrabik.common.Mode import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.utilities.BooleanPreference import com.michaldrabik.repository.utilities.EnumPreference import com.michaldrabik.repository.utilities.LongPreference import com.michaldrabik.repository.utilities.StringPreference import com.michaldrabik.ui_model.NewsItem import com.michaldrabik.ui_model.ProgressNextEpisodeType import com.michaldrabik.ui_model.ProgressNextEpisodeType.LAST_WATCHED import com.michaldrabik.ui_model.ProgressType import com.michaldrabik.ui_model.Settings import kotlinx.coroutines.withContext import java.util.UUID import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @Singleton class SettingsRepository @Inject constructor( val sorting: SettingsSortRepository, val filters: SettingsFiltersRepository, val widgets: SettingsWidgetsRepository, val viewMode: SettingsViewModeRepository, val spoilers: SettingsSpoilersRepository, private val dispatchers: CoroutineDispatchers, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers, @Named("miscPreferences") private var preferences: SharedPreferences, ) { companion object Key { const val LANGUAGE = "KEY_LANGUAGE" internal const val PREMIUM = "KEY_PREMIUM" private const val COUNTRY = "KEY_COUNTRY" private const val DATE_FORMAT = "KEY_DATE_FORMAT" private const val MODE = "KEY_MOVIES_MODE" private const val MOVIES_ENABLED = "KEY_MOVIES_ENABLED" private const val NEWS_ENABLED = "KEY_NEWS_ENABLED" private const val TWITTER_AD_ENABLED = "TWITTER_AD_ENABLED" private const val PROGRESS_PERCENT = "KEY_PROGRESS_PERCENT" private const val STREAMINGS_ENABLED = "KEY_STREAMINGS_ENABLED" private const val THEME = "KEY_THEME" private const val USER_ID = "KEY_USER_ID" private const val INSTALL_TIMESTAMP = "INSTALL_TIMESTAMP" private const val PROGRESS_UPCOMING_COLLAPSED = "PROGRESS_UPCOMING_COLLAPSED" private const val PROGRESS_UPCOMING_DAYS = "PROGRESS_UPCOMING_DAYS" private const val PROGRESS_ON_HOLD_COLLAPSED = "PROGRESS_ON_HOLD_COLLAPSED" private const val PROGRESS_NEXT_EPISODE_TYPE = "PROGRESS_NEXT_EPISODE_TYPE" private const val NEWS_FILTERS = "NEWS_FILTERS" private const val NEWS_VIEW_TYPE = "NEWS_VIEW_TYPE" private const val LOCALE_INITIALISED = "LOCALE_INITIALISED" } suspend fun isInitialized() = withContext(dispatchers.IO) { localSource.settings.getCount() > 0 } suspend fun load(): Settings { val settingsDb = withContext(dispatchers.IO) { localSource.settings.getAll() } return mappers.settings.fromDatabase(settingsDb) } suspend fun update(settings: Settings) { withContext(dispatchers.IO) { transactions.withTransaction { val settingsDb = mappers.settings.toDatabase(settings) localSource.settings.upsert(settingsDb) } } } var installTimestamp by LongPreference(preferences, INSTALL_TIMESTAMP, 0L) var isPremium by BooleanPreference(preferences, PREMIUM) var streamingsEnabled by BooleanPreference(preferences, STREAMINGS_ENABLED, true) var isMoviesEnabled by BooleanPreference(preferences, MOVIES_ENABLED, true) var isNewsEnabled by BooleanPreference(preferences, NEWS_ENABLED) var isTwitterAdEnabled by BooleanPreference(preferences, TWITTER_AD_ENABLED, true) var language by StringPreference(preferences, LANGUAGE, DEFAULT_LANGUAGE) var country by StringPreference(preferences, COUNTRY, DEFAULT_COUNTRY) var dateFormat by StringPreference(preferences, DATE_FORMAT, DEFAULT_DATE_FORMAT) var progressUpcomingDays by LongPreference(preferences, PROGRESS_UPCOMING_DAYS, 90) var isProgressUpcomingCollapsed by BooleanPreference(preferences, PROGRESS_UPCOMING_COLLAPSED) var isProgressOnHoldCollapsed by BooleanPreference(preferences, PROGRESS_ON_HOLD_COLLAPSED) var progressNextEpisodeType by EnumPreference(preferences, PROGRESS_NEXT_EPISODE_TYPE, LAST_WATCHED, ProgressNextEpisodeType::class.java) var newsViewType by StringPreference(preferences, NEWS_VIEW_TYPE, DEFAULT_NEWS_VIEW_TYPE) var isLocaleInitialised by BooleanPreference(preferences, LOCALE_INITIALISED, false) var mode: Mode get() { val default = Mode.SHOWS.name return Mode.valueOf(preferences.getString(MODE, default) ?: default) } set(value) = preferences.edit(true) { putString(MODE, value.name) } var theme: Int get() { if (!isPremium) return MODE_NIGHT_YES return preferences.getInt(THEME, MODE_NIGHT_YES) } set(value) = preferences.edit(true) { putInt(THEME, value) } var progressPercentType: ProgressType get() { val setting = preferences.getString(PROGRESS_PERCENT, ProgressType.AIRED.name) ?: ProgressType.AIRED.name return ProgressType.valueOf(setting) } set(value) = preferences.edit(true) { putString(PROGRESS_PERCENT, value.name) } val userId get() = when (val id = preferences.getString(USER_ID, null)) { null -> { val uuid = UUID.randomUUID().toString().take(13) preferences.edit().putString(USER_ID, uuid).apply() uuid } else -> id } var newsFilters: List get() { val filters = preferences.getString(NEWS_FILTERS, null) return when { filters.isNullOrBlank() -> emptyList() else -> filters.split(",").map { NewsItem.Type.fromSlug(it) } } } set(value) { preferences.edit { putString(NEWS_FILTERS, value.joinToString(",") { it.slug }) } } suspend fun revokePremium() { val settings = load() update(settings.copy(traktQuickRateEnabled = false)) isPremium = false theme = MODE_NIGHT_YES isNewsEnabled = false widgets.revokePremium() } suspend fun clearLanguageLogs() { with(localSource) { transactions.withTransaction { translationsShowsSyncLog.deleteAll() translationsMoviesSyncLog.deleteAll() } } } suspend fun clearUnusedTranslations(input: List) { with(localSource) { transactions.withTransaction { showTranslations.deleteByLanguage(input) movieTranslations.deleteByLanguage(input) episodesTranslations.deleteByLanguage(input) people.deleteTranslations() } } } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/settings/SettingsSortRepository.kt ================================================ package com.michaldrabik.repository.settings import android.content.SharedPreferences import com.michaldrabik.repository.utilities.BooleanPreference import com.michaldrabik.repository.utilities.EnumPreference import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortOrder.NAME import com.michaldrabik.ui_model.SortType import com.michaldrabik.ui_model.SortType.ASCENDING import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @Singleton class SettingsSortRepository @Inject constructor( @Named("miscPreferences") private var preferences: SharedPreferences, ) { companion object Key { private const val PROGRESS_SHOWS_SORT_ORDER = "PROGRESS_SHOWS_SORT_ORDER" private const val PROGRESS_SHOWS_SORT_TYPE = "PROGRESS_SHOWS_SORT_TYPE" private const val PROGRESS_SHOWS_NEW_AT_TOP = "PROGRESS_SHOWS_NEW_AT_TOP" private const val WATCHLIST_SHOWS_SORT_ORDER = "WATCHLIST_SHOWS_SORT_ORDER" private const val WATCHLIST_SHOWS_SORT_TYPE = "WATCHLIST_SHOWS_SORT_TYPE" private const val HIDDEN_SHOWS_SORT_ORDER = "HIDDEN_SHOWS_SORT_ORDER" private const val HIDDEN_SHOWS_SORT_TYPE = "HIDDEN_SHOWS_SORT_TYPE" private const val MY_SHOWS_ALL_SORT_ORDER = "MY_SHOWS_ALL_SORT_ORDER" private const val MY_SHOWS_ALL_SORT_TYPE = "MY_SHOWS_ALL_SORT_TYPE" private const val PROGRESS_MOVIES_SORT_ORDER = "PROGRESS_MOVIES_SORT_ORDER" private const val PROGRESS_MOVIES_SORT_TYPE = "PROGRESS_MOVIES_SORT_TYPE" private const val WATCHLIST_MOVIES_SORT_ORDER = "WATCHLIST_MOVIES_SORT_ORDER" private const val WATCHLIST_MOVIES_SORT_TYPE = "WATCHLIST_MOVIES_SORT_TYPE" private const val HIDDEN_MOVIES_SORT_ORDER = "HIDDEN_MOVIES_SORT_ORDER" private const val HIDDEN_MOVIES_SORT_TYPE = "HIDDEN_MOVIES_SORT_TYPE" private const val MY_MOVIES_ALL_SORT_ORDER = "MY_MOVIES_ALL_SORT_ORDER" private const val MY_MOVIES_ALL_SORT_TYPE = "MY_MOVIES_ALL_SORT_TYPE" private const val LISTS_SORT_ORDER = "LISTS_SORT_ORDER" private const val LISTS_SORT_TYPE = "LISTS_SORT_TYPE" } var progressShowsNewAtTop by BooleanPreference(preferences, PROGRESS_SHOWS_NEW_AT_TOP, false) var progressShowsSortOrder by EnumPreference(preferences, PROGRESS_SHOWS_SORT_ORDER, NAME, SortOrder::class.java) var progressShowsSortType by EnumPreference(preferences, PROGRESS_SHOWS_SORT_TYPE, ASCENDING, SortType::class.java) var watchlistShowsSortOrder by EnumPreference(preferences, WATCHLIST_SHOWS_SORT_ORDER, NAME, SortOrder::class.java) var watchlistShowsSortType by EnumPreference(preferences, WATCHLIST_SHOWS_SORT_TYPE, ASCENDING, SortType::class.java) var hiddenShowsSortOrder by EnumPreference(preferences, HIDDEN_SHOWS_SORT_ORDER, NAME, SortOrder::class.java) var hiddenShowsSortType by EnumPreference(preferences, HIDDEN_SHOWS_SORT_TYPE, ASCENDING, SortType::class.java) var myShowsAllSortOrder by EnumPreference(preferences, MY_SHOWS_ALL_SORT_ORDER, NAME, SortOrder::class.java) var myShowsAllSortType by EnumPreference(preferences, MY_SHOWS_ALL_SORT_TYPE, ASCENDING, SortType::class.java) var progressMoviesSortOrder by EnumPreference(preferences, PROGRESS_MOVIES_SORT_ORDER, NAME, SortOrder::class.java) var progressMoviesSortType by EnumPreference(preferences, PROGRESS_MOVIES_SORT_TYPE, ASCENDING, SortType::class.java) var watchlistMoviesSortOrder by EnumPreference(preferences, WATCHLIST_MOVIES_SORT_ORDER, NAME, SortOrder::class.java) var watchlistMoviesSortType by EnumPreference(preferences, WATCHLIST_MOVIES_SORT_TYPE, ASCENDING, SortType::class.java) var hiddenMoviesSortOrder by EnumPreference(preferences, HIDDEN_MOVIES_SORT_ORDER, NAME, SortOrder::class.java) var hiddenMoviesSortType by EnumPreference(preferences, HIDDEN_MOVIES_SORT_TYPE, ASCENDING, SortType::class.java) var myMoviesAllSortOrder by EnumPreference(preferences, MY_MOVIES_ALL_SORT_ORDER, NAME, SortOrder::class.java) var myMoviesAllSortType by EnumPreference(preferences, MY_MOVIES_ALL_SORT_TYPE, ASCENDING, SortType::class.java) var listsAllSortOrder by EnumPreference(preferences, LISTS_SORT_ORDER, NAME, SortOrder::class.java) var listsAllSortType by EnumPreference(preferences, LISTS_SORT_TYPE, ASCENDING, SortType::class.java) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/settings/SettingsSpoilersRepository.kt ================================================ package com.michaldrabik.repository.settings import android.content.SharedPreferences import com.michaldrabik.repository.utilities.BooleanPreference import com.michaldrabik.ui_model.SpoilersSettings import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @Singleton class SettingsSpoilersRepository @Inject constructor( @Named("spoilersPreferences") private var preferences: SharedPreferences, ) { companion object Key { private const val SHOWS_UNCOLLECTED_SHOWS_HIDDEN = "SHOWS_UNCOLLECTED_SHOWS_HIDDEN" private const val SHOWS_UNCOLLECTED_SHOWS_RATINGS_HIDDEN = "SHOWS_UNCOLLECTED_SHOWS_RATINGS_HIDDEN" private const val SHOWS_MY_SHOWS_HIDDEN = "SHOWS_MY_SHOWS_HIDDEN" private const val SHOWS_MY_SHOWS_RATINGS_HIDDEN = "SHOWS_MY_SHOWS_RATINGS_HIDDEN" private const val SHOWS_WATCHLIST_SHOWS_HIDDEN = "SHOWS_WATCHLIST_SHOWS_HIDDEN" private const val SHOWS_WATCHLIST_SHOWS_RATINGS_HIDDEN = "SHOWS_WATCHLIST_SHOWS_RATINGS_HIDDEN" private const val SHOWS_HIDDEN_SHOWS_HIDDEN = "SHOWS_HIDDEN_SHOWS_HIDDEN" private const val SHOWS_HIDDEN_SHOWS_RATINGS_HIDDEN = "SHOWS_HIDDEN_SHOWS_RATINGS_HIDDEN" private const val MOVIES_UNCOLLECTED_MOVIES_HIDDEN = "MOVIES_UNCOLLECTED_MOVIES_HIDDEN" private const val MOVIES_UNCOLLECTED_MOVIES_RATINGS_HIDDEN = "MOVIES_UNCOLLECTED_MOVIES_RATINGS_HIDDEN" private const val MOVIES_MY_MOVIES_HIDDEN = "MOVIES_MY_MOVIES_HIDDEN" private const val MOVIES_MY_MOVIES_RATINGS_HIDDEN = "MOVIES_MY_MOVIES_RATINGS_HIDDEN" private const val MOVIES_WATCHLIST_MOVIES_HIDDEN = "MOVIES_WATCHLIST_MOVIES_HIDDEN" private const val MOVIES_WATCHLIST_MOVIES_RATINGS_HIDDEN = "MOVIES_WATCHLIST_MOVIES_RATINGS_HIDDEN" private const val MOVIES_HIDDEN_MOVIES_HIDDEN = "MOVIES_HIDDEN_MOVIES_HIDDEN" private const val MOVIES_HIDDEN_MOVIES_RATINGS_HIDDEN = "MOVIES_HIDDEN_MOVIES_RATINGS_HIDDEN" private const val EPISODES_TITLE_HIDDEN = "EPISODES_TITLE_HIDDEN" private const val EPISODES_DESCRIPTION_HIDDEN = "EPISODES_DESCRIPTION_HIDDEN" private const val EPISODES_RATING_HIDDEN = "EPISODES_RATING_HIDDEN" private const val EPISODES_IMAGE_HIDDEN = "EPISODES_IMAGE_HIDDEN" private const val TAP_TO_REVEAL = "TAP_TO_REVEAL" } var isMyShowsHidden by BooleanPreference(preferences, SHOWS_MY_SHOWS_HIDDEN, false) var isMyShowsRatingsHidden by BooleanPreference(preferences, SHOWS_MY_SHOWS_RATINGS_HIDDEN, false) var isWatchlistShowsHidden by BooleanPreference(preferences, SHOWS_WATCHLIST_SHOWS_HIDDEN, false) var isWatchlistShowsRatingsHidden by BooleanPreference(preferences, SHOWS_WATCHLIST_SHOWS_RATINGS_HIDDEN, false) var isHiddenShowsHidden by BooleanPreference(preferences, SHOWS_HIDDEN_SHOWS_HIDDEN, false) var isHiddenShowsRatingsHidden by BooleanPreference(preferences, SHOWS_HIDDEN_SHOWS_RATINGS_HIDDEN, false) var isUncollectedShowsHidden by BooleanPreference(preferences, SHOWS_UNCOLLECTED_SHOWS_HIDDEN, false) var isUncollectedShowsRatingsHidden by BooleanPreference(preferences, SHOWS_UNCOLLECTED_SHOWS_RATINGS_HIDDEN, false) var isMyMoviesHidden by BooleanPreference(preferences, MOVIES_MY_MOVIES_HIDDEN, false) var isMyMoviesRatingsHidden by BooleanPreference(preferences, MOVIES_MY_MOVIES_RATINGS_HIDDEN, false) var isWatchlistMoviesHidden by BooleanPreference(preferences, MOVIES_WATCHLIST_MOVIES_HIDDEN, false) var isWatchlistMoviesRatingsHidden by BooleanPreference(preferences, MOVIES_WATCHLIST_MOVIES_RATINGS_HIDDEN, false) var isHiddenMoviesHidden by BooleanPreference(preferences, MOVIES_HIDDEN_MOVIES_HIDDEN, false) var isHiddenMoviesRatingsHidden by BooleanPreference(preferences, MOVIES_HIDDEN_MOVIES_RATINGS_HIDDEN, false) var isUncollectedMoviesHidden by BooleanPreference(preferences, MOVIES_UNCOLLECTED_MOVIES_HIDDEN, false) var isUncollectedMoviesRatingsHidden by BooleanPreference(preferences, MOVIES_UNCOLLECTED_MOVIES_RATINGS_HIDDEN, false) var isEpisodesTitleHidden by BooleanPreference(preferences, EPISODES_TITLE_HIDDEN, false) var isEpisodesDescriptionHidden by BooleanPreference(preferences, EPISODES_DESCRIPTION_HIDDEN, false) var isEpisodesRatingHidden by BooleanPreference(preferences, EPISODES_RATING_HIDDEN, false) var isEpisodesImageHidden by BooleanPreference(preferences, EPISODES_IMAGE_HIDDEN, false) var isTapToReveal by BooleanPreference(preferences, TAP_TO_REVEAL, false) fun getAll(): SpoilersSettings = SpoilersSettings( isMyShowsHidden = isMyShowsHidden, isMyShowsRatingsHidden = isMyShowsRatingsHidden, isMyMoviesHidden = isMyMoviesHidden, isMyMoviesRatingsHidden = isMyMoviesRatingsHidden, isWatchlistShowsHidden = isWatchlistShowsHidden, isWatchlistShowsRatingsHidden = isWatchlistShowsRatingsHidden, isWatchlistMoviesHidden = isWatchlistMoviesHidden, isWatchlistMoviesRatingsHidden = isWatchlistMoviesRatingsHidden, isHiddenShowsHidden = isHiddenShowsHidden, isHiddenShowsRatingsHidden = isHiddenShowsRatingsHidden, isHiddenMoviesHidden = isHiddenMoviesHidden, isHiddenMoviesRatingsHidden = isHiddenMoviesRatingsHidden, isNotCollectedShowsHidden = isUncollectedShowsHidden, isNotCollectedShowsRatingsHidden = isUncollectedShowsRatingsHidden, isNotCollectedMoviesHidden = isUncollectedMoviesHidden, isNotCollectedMoviesRatingsHidden = isUncollectedMoviesRatingsHidden, isEpisodeTitleHidden = isEpisodesTitleHidden, isEpisodeDescriptionHidden = isEpisodesDescriptionHidden, isEpisodeRatingHidden = isEpisodesRatingHidden, isEpisodeImageHidden = isEpisodesImageHidden, isTapToReveal = isTapToReveal ) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/settings/SettingsViewModeRepository.kt ================================================ package com.michaldrabik.repository.settings import android.content.SharedPreferences import com.michaldrabik.common.Config import com.michaldrabik.repository.utilities.IntPreference import com.michaldrabik.repository.utilities.StringPreference import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @Singleton class SettingsViewModeRepository @Inject constructor( @Named("miscPreferences") private var preferences: SharedPreferences, ) { companion object Key { private const val MY_SHOWS_VIEW_MODE = "MY_SHOWS_VIEW_MODE" private const val WATCHLIST_SHOWS_VIEW_MODE = "WATCHLIST_SHOWS_VIEW_MODE" private const val HIDDEN_SHOWS_VIEW_MODE = "HIDDEN_SHOWS_VIEW_MODE" private const val MY_MOVIES_VIEW_MODE = "MY_MOVIES_VIEW_MODE" private const val WATCHLIST_MOVIES_VIEW_MODE = "WATCHLIST_MOVIES_VIEW_MODE" private const val HIDDEN_MOVIES_VIEW_MODE = "HIDDEN_MOVIES_VIEW_MODE" private const val CUSTOM_LIST_VIEW_MODE = "CUSTOM_LIST_VIEW_MODE" private const val TABLET_GRID_SPAN_SIZE = "TABLET_GRID_SPAN_SIZE" } var myShowsViewMode by StringPreference(preferences, MY_SHOWS_VIEW_MODE, Config.DEFAULT_LIST_VIEW_MODE) var watchlistShowsViewMode by StringPreference(preferences, WATCHLIST_SHOWS_VIEW_MODE, Config.DEFAULT_LIST_VIEW_MODE) var hiddenShowsViewMode by StringPreference(preferences, HIDDEN_SHOWS_VIEW_MODE, Config.DEFAULT_LIST_VIEW_MODE) var myMoviesViewMode by StringPreference(preferences, MY_MOVIES_VIEW_MODE, Config.DEFAULT_LIST_VIEW_MODE) var watchlistMoviesViewMode by StringPreference(preferences, WATCHLIST_MOVIES_VIEW_MODE, Config.DEFAULT_LIST_VIEW_MODE) var hiddenMoviesViewMode by StringPreference(preferences, HIDDEN_MOVIES_VIEW_MODE, Config.DEFAULT_LIST_VIEW_MODE) var customListsViewMode by StringPreference(preferences, CUSTOM_LIST_VIEW_MODE, Config.DEFAULT_LIST_VIEW_MODE) var tabletGridSpanSize by IntPreference(preferences, TABLET_GRID_SPAN_SIZE, Config.DEFAULT_LISTS_GRID_SPAN) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/settings/SettingsWidgetsRepository.kt ================================================ package com.michaldrabik.repository.settings import android.app.UiModeManager import android.content.SharedPreferences import androidx.core.content.edit import com.michaldrabik.common.Mode import com.michaldrabik.repository.utilities.BooleanPreference import com.michaldrabik.ui_model.CalendarMode import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @Singleton class SettingsWidgetsRepository @Inject constructor( @Named("miscPreferences") private var preferences: SharedPreferences ) { companion object Key { private const val THEME_WIDGET = "KEY_THEME_WIDGET" private const val THEME_WIDGET_TRANSPARENT = "KEY_THEME_WIDGET_TRANSPARENT" private const val WIDGET_CALENDAR_MODE = "WIDGET_CALENDAR_MODE" private const val WIDGET_CALENDAR_MOVIES_MODE = "WIDGET_CALENDAR_MOVIES_MODE" } val isPremium by BooleanPreference(preferences, SettingsRepository.PREMIUM) var widgetsTheme: Int get() { if (!isPremium) return UiModeManager.MODE_NIGHT_YES return preferences.getInt(THEME_WIDGET, UiModeManager.MODE_NIGHT_YES) } set(value) = preferences.edit(true) { putInt(THEME_WIDGET, value) } var widgetsTransparency: Int get() { if (!isPremium) return 100 return preferences.getInt(THEME_WIDGET_TRANSPARENT, 100) } set(value) = preferences.edit(true) { putInt(THEME_WIDGET_TRANSPARENT, value) } fun getWidgetCalendarMode(mode: Mode, widgetId: Int): CalendarMode { val default = CalendarMode.PRESENT_FUTURE.name val key = when (mode) { Mode.SHOWS -> WIDGET_CALENDAR_MODE Mode.MOVIES -> WIDGET_CALENDAR_MOVIES_MODE } val value = preferences.getString("$key$widgetId", default) ?: default return CalendarMode.valueOf(value) } fun setWidgetCalendarMode(mode: Mode, widgetId: Int, calendarMode: CalendarMode) { val key = when (mode) { Mode.SHOWS -> WIDGET_CALENDAR_MODE Mode.MOVIES -> WIDGET_CALENDAR_MOVIES_MODE } preferences.edit(true) { putString("$key$widgetId", calendarMode.name) } } fun revokePremium() { widgetsTheme = UiModeManager.MODE_NIGHT_YES widgetsTransparency = 100 } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/shows/DiscoverShowsRepository.kt ================================================ package com.michaldrabik.repository.shows import com.michaldrabik.common.Config import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.DiscoverShow import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.Config.TRAKT_TRENDING_SHOWS_LIMIT import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.Genre import com.michaldrabik.ui_model.Network import com.michaldrabik.ui_model.Show import javax.inject.Inject class DiscoverShowsRepository @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers ) { suspend fun isCacheValid(): Boolean { val stamp = localSource.discoverShows.getMostRecent()?.createdAt ?: 0 return nowUtcMillis() - stamp < Config.DISCOVER_SHOWS_CACHE_DURATION } suspend fun loadAllCached(): List { val cachedShows = localSource.discoverShows.getAll().map { it.idTrakt } val shows = localSource.shows.getAll(cachedShows) return cachedShows .map { id -> shows.first { it.idTrakt == id } } .map { mappers.show.fromDatabase(it) } } // TODO This logic should probably sit in a case and not repository. suspend fun loadAllRemote( showAnticipated: Boolean, showCollection: Boolean, collectionSize: Int, genres: List, networks: List ): List { val remoteShows = mutableListOf() val anticipatedShows = mutableListOf() val popularShows = mutableListOf() val genresQuery = genres.joinToString(",") { it.slug } val networksQuery = networks.joinToString(",") { it.channels.joinToString(",") } val limit = if (showCollection) TRAKT_TRENDING_SHOWS_LIMIT else TRAKT_TRENDING_SHOWS_LIMIT + (collectionSize / 2) val trendingShows = remoteSource.trakt.fetchTrendingShows(genresQuery, networksQuery, limit) .map { mappers.show.fromNetwork(it) } if (genres.isNotEmpty() || networks.isNotEmpty()) { // Wa are adding popular results for genres/networks filtered content to add more results. val popular = remoteSource.trakt.fetchPopularShows(genresQuery, networksQuery) .map { mappers.show.fromNetwork(it) } popularShows.addAll(popular) } if (showAnticipated) { val shows = remoteSource.trakt.fetchAnticipatedShows(genresQuery, networksQuery).map { mappers.show.fromNetwork(it) }.toMutableList() anticipatedShows.addAll(shows) } trendingShows.forEachIndexed { index, show -> addIfMissing(remoteShows, show) if (index % 4 == 0 && anticipatedShows.isNotEmpty()) { val element = anticipatedShows.removeAt(0) addIfMissing(remoteShows, element) } } popularShows.forEach { show -> addIfMissing(remoteShows, show) } if (!showAnticipated) { return remoteShows.filter { !it.status.isAnticipated() } } return remoteShows } suspend fun cacheDiscoverShows(shows: List) { transactions.withTransaction { val timestamp = nowUtcMillis() localSource.shows.upsert(shows.map { mappers.show.toDatabase(it) }) localSource.discoverShows.replace( shows.map { DiscoverShow( idTrakt = it.ids.trakt.id, createdAt = timestamp, updatedAt = timestamp ) } ) } } private fun addIfMissing(shows: MutableList, show: Show) { if (shows.any { it.ids.trakt == show.ids.trakt }) return shows.add(show) } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/shows/HiddenShowsRepository.kt ================================================ package com.michaldrabik.repository.shows import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.ArchiveShow import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.IdTrakt import javax.inject.Inject class HiddenShowsRepository @Inject constructor( private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers ) { suspend fun loadAll() = localSource.archiveShows.getAll() .map { mappers.show.fromDatabase(it) } suspend fun loadAll(ids: List) = localSource.archiveShows.getAll(ids.map { it.id }) .map { mappers.show.fromDatabase(it) } suspend fun load(id: IdTrakt) = localSource.archiveShows.getById(id.id)?.let { mappers.show.fromDatabase(it) } suspend fun loadAllIds() = localSource.archiveShows.getAllTraktIds() suspend fun insert(id: IdTrakt) { val dbShow = ArchiveShow.fromTraktId(id.id, nowUtcMillis()) with(localSource) { transactions.withTransaction { archiveShows.insert(dbShow) myShows.deleteById(id.id) watchlistShows.deleteById(id.id) } } } suspend fun delete(id: IdTrakt) = localSource.archiveShows.deleteById(id.id) suspend fun exists(id: IdTrakt) = localSource.archiveShows.getById(id.id) != null } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/shows/MyShowsRepository.kt ================================================ package com.michaldrabik.repository.shows import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.database.model.MyShow import com.michaldrabik.data_local.sources.ArchiveShowsLocalDataSource import com.michaldrabik.data_local.sources.MyShowsLocalDataSource import com.michaldrabik.data_local.sources.WatchlistShowsLocalDataSource import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.IdTrakt import javax.inject.Inject class MyShowsRepository @Inject constructor( private val myShowsLocalSource: MyShowsLocalDataSource, private val watchlistShowsLocalSource: WatchlistShowsLocalDataSource, private val hiddenShowsLocalDataSource: ArchiveShowsLocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers, ) { suspend fun load(id: IdTrakt) = myShowsLocalSource.getById(id.id)?.let { mappers.show.fromDatabase(it) } suspend fun loadAll() = myShowsLocalSource.getAll() .map { mappers.show.fromDatabase(it) } suspend fun loadAll(ids: List) = myShowsLocalSource.getAll(ids.map { it.id }) .map { mappers.show.fromDatabase(it) } suspend fun loadAllRecent(amount: Int) = myShowsLocalSource.getAllRecent(amount) .map { mappers.show.fromDatabase(it) } suspend fun loadAllIds() = myShowsLocalSource.getAllTraktIds() suspend fun insert(id: IdTrakt, lastWatchedAt: Long) { val nowUtc = nowUtcMillis() val dbShow = MyShow.fromTraktId( traktId = id.id, createdAt = nowUtc, updatedAt = nowUtc, watchedAt = lastWatchedAt ) transactions.withTransaction { myShowsLocalSource.insert(listOf(dbShow)) watchlistShowsLocalSource.deleteById(id.id) hiddenShowsLocalDataSource.deleteById(id.id) } } suspend fun delete(id: IdTrakt) { myShowsLocalSource.deleteById(id.id) } suspend fun exists(id: IdTrakt) = myShowsLocalSource.checkExists(id.id) suspend fun updateWatchedAt(idTrakt: Long, watchedAt: Long) { myShowsLocalSource.updateWatchedAt(idTrakt, watchedAt) } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/shows/RelatedShowsRepository.kt ================================================ package com.michaldrabik.repository.shows import com.michaldrabik.common.Config import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.RelatedShow import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Show import javax.inject.Inject import kotlin.math.min class RelatedShowsRepository @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers ) { suspend fun loadAll(show: Show, hiddenCount: Int): List { val relatedShows = localSource.relatedShows.getAllById(show.traktId) val latest = relatedShows.maxByOrNull { it.updatedAt } if (latest != null && nowUtcMillis() - latest.updatedAt < Config.RELATED_CACHE_DURATION) { val relatedShowsIds = relatedShows.map { it.idTrakt } return localSource.shows.getAll(relatedShowsIds) .map { mappers.show.fromDatabase(it) } } val remoteShows = remoteSource.trakt.fetchRelatedShows(show.traktId, min(hiddenCount, 10)) .map { mappers.show.fromNetwork(it) } cacheRelatedShows(remoteShows, show.ids.trakt) return remoteShows } private suspend fun cacheRelatedShows(shows: List, showId: IdTrakt) { transactions.withTransaction { val timestamp = nowUtcMillis() localSource.shows.upsert(shows.map { mappers.show.toDatabase(it) }) localSource.relatedShows.deleteById(showId.id) localSource.relatedShows.insert( shows.map { RelatedShow.fromTraktId(it.ids.trakt.id, showId.id, timestamp) } ) } } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/shows/ShowDetailsRepository.kt ================================================ package com.michaldrabik.repository.shows import com.michaldrabik.common.Config import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.IdImdb import com.michaldrabik.ui_model.IdSlug import com.michaldrabik.ui_model.IdTmdb import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Show import javax.inject.Inject class ShowDetailsRepository @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers, ) { suspend fun load(idTrakt: IdTrakt, force: Boolean = false): Show { val localShow = localSource.shows.getById(idTrakt.id) if (force || localShow == null || nowUtcMillis() - localShow.updatedAt > Config.SHOW_DETAILS_CACHE_DURATION) { val remoteShow = remoteSource.trakt.fetchShow(idTrakt.id) val show = mappers.show.fromNetwork(remoteShow) localSource.shows.upsert(listOf(mappers.show.toDatabase(show))) return show } return mappers.show.fromDatabase(localShow) } suspend fun find(idImdb: IdImdb): Show? { val localShow = localSource.shows.getById(idImdb.id) if (localShow != null) { return mappers.show.fromDatabase(localShow) } return null } suspend fun find(idTmdb: IdTmdb): Show? { val localShow = localSource.shows.getByTmdbId(idTmdb.id) if (localShow != null) { return mappers.show.fromDatabase(localShow) } return null } suspend fun find(idSlug: IdSlug): Show? { val localShow = localSource.shows.getBySlug(idSlug.id) if (localShow != null) { return mappers.show.fromDatabase(localShow) } return null } suspend fun delete(idTrakt: IdTrakt) { with(localSource) { transactions.withTransaction { shows.deleteById(idTrakt.id) seasons.deleteAllForShow(idTrakt.id) episodes.deleteAllForShow(idTrakt.id) } } } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/shows/ShowStreamingsRepository.kt ================================================ package com.michaldrabik.repository.shows import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.StreamingsRepository import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_model.StreamingService import java.time.ZonedDateTime import javax.inject.Inject import javax.inject.Singleton @Singleton class ShowStreamingsRepository @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers, ) : StreamingsRepository() { suspend fun getLocalStreamings(show: Show, countryCode: String): Pair, ZonedDateTime?> { val localItems = localSource.showStreamings.getById(show.traktId) val mappedItems = mappers.streamings.fromDatabaseShow(localItems, show.title, countryCode) val processedItems = processItems(mappedItems, countryCode) val date = localItems.firstOrNull()?.createdAt return Pair(processedItems, date) } suspend fun loadRemoteStreamings(show: Show, countryCode: String): List { val remoteItems = remoteSource.tmdb.fetchShowWatchProviders(show.ids.tmdb.id, countryCode) ?: return emptyList() val entities = mappers.streamings.toDatabaseShow(show.ids, remoteItems) localSource.showStreamings.replace(show.traktId, entities) return processItems(remoteItems, show.title, countryCode) } suspend fun deleteCache() = localSource.showStreamings.deleteAll() } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/shows/ShowsRepository.kt ================================================ package com.michaldrabik.repository.shows import javax.inject.Inject import javax.inject.Singleton @Singleton class ShowsRepository @Inject constructor( val discoverShows: DiscoverShowsRepository, val myShows: MyShowsRepository, val watchlistShows: WatchlistShowsRepository, val hiddenShows: HiddenShowsRepository, val relatedShows: RelatedShowsRepository, val detailsShow: ShowDetailsRepository ) ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/shows/WatchlistShowsRepository.kt ================================================ package com.michaldrabik.repository.shows import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.WatchlistShow import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.IdTrakt import javax.inject.Inject class WatchlistShowsRepository @Inject constructor( private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers, ) { suspend fun loadAll() = localSource.watchlistShows.getAll() .map { mappers.show.fromDatabase(it) } suspend fun loadAllIds() = localSource.watchlistShows.getAllTraktIds() suspend fun load(id: IdTrakt) = localSource.watchlistShows.getById(id.id)?.let { mappers.show.fromDatabase(it) } suspend fun insert(id: IdTrakt) { val dbShow = WatchlistShow.fromTraktId(id.id, nowUtcMillis()) with(localSource) { transactions.withTransaction { watchlistShows.insert(dbShow) myShows.deleteById(id.id) archiveShows.deleteById(id.id) } } } suspend fun delete(id: IdTrakt) = localSource.watchlistShows.deleteById(id.id) suspend fun exists(id: IdTrakt) = localSource.watchlistShows.checkExists(id.id) } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/shows/ratings/ShowsExternalRatingsRepository.kt ================================================ package com.michaldrabik.repository.shows.ratings import com.michaldrabik.common.ConfigVariant import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.Ratings import com.michaldrabik.ui_model.Show import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @Singleton class ShowsExternalRatingsRepository @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers, ) { suspend fun loadRatings(show: Show): Ratings { val localRatings = localSource.showRatings.getById(show.traktId) localRatings?.let { if (nowUtcMillis() - it.updatedAt < ConfigVariant.RATINGS_CACHE_DURATION) { return mappers.ratings.fromDatabase(it) } } val remoteRatings = remoteSource.omdb.fetchOmdbData(show.ids.imdb.id) .let { mappers.ratings.fromNetwork(it) } .copy(trakt = Ratings.Value(String.format(Locale.ENGLISH, "%.1f", show.rating), false)) val dbRatings = mappers.ratings.toShowDatabase(show.ids.trakt, remoteRatings) localSource.showRatings.upsert(dbRatings) return remoteRatings } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/shows/ratings/ShowsRatingsRepository.kt ================================================ package com.michaldrabik.repository.shows.ratings import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.Rating import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.Season import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_model.TraktRating import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class ShowsRatingsRepository @Inject constructor( val external: ShowsExternalRatingsRepository, private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers ) { companion object { private const val TYPE_SHOW = "show" private const val TYPE_EPISODE = "episode" private const val TYPE_SEASON = "season" private const val CHUNK_SIZE = 250 } suspend fun preloadRatings() = supervisorScope { suspend fun preloadShowsRatings() { val ratings = remoteSource.trakt.fetchShowsRatings() val entities = ratings .filter { it.rated_at != null && it.show.ids.trakt != null } .map { mappers.userRatings.toDatabaseShow(it) } localSource.ratings.replaceAll(entities, TYPE_SHOW) } suspend fun preloadEpisodesRatings() { val ratings = remoteSource.trakt.fetchEpisodesRatings() val entities = ratings .filter { it.rated_at != null && it.episode.ids.trakt != null } .map { mappers.userRatings.toDatabaseEpisode(it) } localSource.ratings.replaceAll(entities, TYPE_EPISODE) } suspend fun preloadSeasonsRatings() { val ratings = remoteSource.trakt.fetchSeasonsRatings() val entities = ratings .filter { it.rated_at != null && it.season.ids.trakt != null } .map { mappers.userRatings.toDatabaseSeason(it) } localSource.ratings.replaceAll(entities, TYPE_SEASON) } val errorHandler = CoroutineExceptionHandler { _, _ -> Timber.e("Failed to preload some of ratings.") } launch(errorHandler) { preloadShowsRatings() } launch(errorHandler) { preloadEpisodesRatings() } launch(errorHandler) { preloadSeasonsRatings() } } suspend fun loadShowsRatings(): List { val ratings = localSource.ratings.getAllByType(TYPE_SHOW) return ratings.map { mappers.userRatings.fromDatabase(it) } } suspend fun loadRatings(shows: List): List { val ratings = mutableListOf() shows.chunked(CHUNK_SIZE).forEach { chunk -> val items = localSource.ratings.getAllByType(chunk.map { it.traktId }, TYPE_SHOW) ratings.addAll(items) } return ratings.map { mappers.userRatings.fromDatabase(it) } } suspend fun loadRatingsSeasons(seasons: List): List { val ratings = mutableListOf() seasons.chunked(CHUNK_SIZE).forEach { chunk -> val items = localSource.ratings.getAllByType(chunk.map { it.ids.trakt.id }, TYPE_SEASON) ratings.addAll(items) } return ratings.map { mappers.userRatings.fromDatabase(it) } } suspend fun loadRating(episode: Episode): TraktRating? { val rating = localSource.ratings.getAllByType(listOf(episode.ids.trakt.id), TYPE_EPISODE) return rating.firstOrNull()?.let { mappers.userRatings.fromDatabase(it) } } suspend fun loadRating(season: Season): TraktRating? { val rating = localSource.ratings.getAllByType(listOf(season.ids.trakt.id), TYPE_SEASON) return rating.firstOrNull()?.let { mappers.userRatings.fromDatabase(it) } } suspend fun addRating(show: Show, rating: Int) { remoteSource.trakt.postRating( mappers.show.toNetwork(show), rating ) val entity = mappers.userRatings.toDatabaseShow(show, rating, nowUtc()) localSource.ratings.replace(entity) } suspend fun addRating(episode: Episode, rating: Int) { remoteSource.trakt.postRating( mappers.episode.toNetwork(episode), rating ) val entity = mappers.userRatings.toDatabaseEpisode(episode, rating, nowUtc()) localSource.ratings.replace(entity) } suspend fun addRating(season: Season, rating: Int) { remoteSource.trakt.postRating( mappers.season.toNetwork(season), rating ) val entity = mappers.userRatings.toDatabaseSeason(season, rating, nowUtc()) localSource.ratings.replace(entity) } suspend fun deleteRating(show: Show) { remoteSource.trakt.deleteRating( mappers.show.toNetwork(show) ) localSource.ratings.deleteByType(show.traktId, TYPE_SHOW) } suspend fun deleteRating(episode: Episode) { remoteSource.trakt.deleteRating( mappers.episode.toNetwork(episode) ) localSource.ratings.deleteByType(episode.ids.trakt.id, TYPE_EPISODE) } suspend fun deleteRating(season: Season) { remoteSource.trakt.deleteRating( mappers.season.toNetwork(season) ) localSource.ratings.deleteByType(season.ids.trakt.id, TYPE_SEASON) } suspend fun clear() { with(localSource) { transactions.withTransaction { ratings.deleteAllByType(TYPE_EPISODE) ratings.deleteAllByType(TYPE_SEASON) ratings.deleteAllByType(TYPE_SHOW) } } } } ================================================ FILE: repository/src/main/java/com/michaldrabik/repository/utilities/PreferencesDelegates.kt ================================================ package com.michaldrabik.repository.utilities import android.content.SharedPreferences import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty class StringPreference( private val sharedPreferences: SharedPreferences, private val key: String, private val defaultValue: String, ) : ReadWriteProperty { override fun getValue(thisRef: Any, property: KProperty<*>): String = sharedPreferences.getString(key, defaultValue) ?: defaultValue override fun setValue(thisRef: Any, property: KProperty<*>, value: String) { sharedPreferences.edit() .putString(key, value) .apply() } } class BooleanPreference( private val sharedPreferences: SharedPreferences, private val key: String, private val defaultValue: Boolean = false, ) : ReadWriteProperty { override fun getValue(thisRef: Any, property: KProperty<*>): Boolean = sharedPreferences.getBoolean(key, defaultValue) override fun setValue(thisRef: Any, property: KProperty<*>, value: Boolean) = sharedPreferences.edit() .putBoolean(key, value) .apply() } class IntPreference( private val sharedPreferences: SharedPreferences, private val key: String, private val defaultValue: Int = 0, ) : ReadWriteProperty { override fun getValue(thisRef: Any, property: KProperty<*>): Int = sharedPreferences.getInt(key, defaultValue) override fun setValue(thisRef: Any, property: KProperty<*>, value: Int) = sharedPreferences.edit() .putInt(key, value) .apply() } class LongPreference( private val sharedPreferences: SharedPreferences, private val key: String, private val defaultValue: Long = 0, ) : ReadWriteProperty { override fun getValue(thisRef: Any, property: KProperty<*>): Long = sharedPreferences.getLong(key, defaultValue) override fun setValue(thisRef: Any, property: KProperty<*>, value: Long) = sharedPreferences.edit() .putLong(key, value) .apply() } class EnumPreference>( private val sharedPreferences: SharedPreferences, private val key: String, private val defaultValue: T, private val clazz: Class, ) : ReadWriteProperty { @Suppress("RECEIVER_NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") override fun getValue(thisRef: Any, property: KProperty<*>): T { val enumName = sharedPreferences.getString(key, "") return clazz.enumConstants.find { it.name == enumName } ?: defaultValue } override fun setValue(thisRef: Any, property: KProperty<*>, value: T) { sharedPreferences.edit() .putString(key, value.name) .apply() } } ================================================ FILE: repository/src/test/java/com/michaldrabik/repository/DiscoverShowsRepositoryTest.kt ================================================ package com.michaldrabik.repository import com.google.common.truth.Truth.assertThat import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.database.dao.DiscoverShowsDao import com.michaldrabik.data_local.database.dao.ShowsDao import com.michaldrabik.data_local.database.model.DiscoverShow import com.michaldrabik.repository.common.BaseMockTest import com.michaldrabik.repository.shows.DiscoverShowsRepository import com.michaldrabik.ui_model.Show import io.mockk.coEvery import io.mockk.coVerify import io.mockk.confirmVerified import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Test import java.util.concurrent.TimeUnit import com.michaldrabik.data_local.database.model.Show as ShowDb class DiscoverShowsRepositoryTest : BaseMockTest() { @MockK lateinit var showsDao: ShowsDao @MockK lateinit var discoverShowsDao: DiscoverShowsDao private lateinit var SUT: DiscoverShowsRepository @Before override fun setUp() { super.setUp() SUT = DiscoverShowsRepository(cloud, database, transactions, mappers) coEvery { database.shows } returns showsDao coEvery { database.discoverShows } returns discoverShowsDao } @After fun confirmSutVerified() { confirmVerified(showsDao) confirmVerified(discoverShowsDao) } @Test fun `Should return true if cache is valid`() { runBlocking { val discoverShow = mockk { every { createdAt } returns nowUtcMillis() - TimeUnit.HOURS.toMillis(6) } coEvery { discoverShowsDao.getMostRecent() } returns discoverShow assertThat(SUT.isCacheValid()).isTrue() coVerify(exactly = 1) { discoverShowsDao.getMostRecent() } } } @Test fun `Should return false if cache is not valid`() { runBlocking { val discoverShow = mockk { every { createdAt } returns nowUtcMillis() - TimeUnit.HOURS.toMillis(13) } coEvery { discoverShowsDao.getMostRecent() } returns discoverShow assertThat(SUT.isCacheValid()).isFalse() coVerify(exactly = 1) { discoverShowsDao.getMostRecent() } } } @Test fun `Should load cached shows`() { runBlocking { val discoverShow = mockk { every { idTrakt } returns 10 } val showDb = mockk() { every { idTrakt } returns 10 } coEvery { discoverShowsDao.getAll() } returns listOf(discoverShow) coEvery { showsDao.getAll(any()) } returns listOf(showDb) coEvery { mappers.show.fromDatabase(any()) } returns Show.EMPTY val shows = SUT.loadAllCached() assertThat(shows).hasSize(1) coVerify(exactly = 1) { discoverShowsDao.getAll() } coVerify(exactly = 1) { showsDao.getAll(any()) } } } } ================================================ FILE: repository/src/test/java/com/michaldrabik/repository/MyShowsRepositoryTest.kt ================================================ package com.michaldrabik.repository import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.model.MyShow import com.michaldrabik.data_local.sources.ArchiveShowsLocalDataSource import com.michaldrabik.data_local.sources.MyShowsLocalDataSource import com.michaldrabik.data_local.sources.WatchlistShowsLocalDataSource import com.michaldrabik.repository.common.BaseMockTest import com.michaldrabik.repository.shows.MyShowsRepository import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Show import io.mockk.coEvery import io.mockk.coJustRun import io.mockk.coVerify import io.mockk.confirmVerified import io.mockk.impl.annotations.RelaxedMockK import io.mockk.slot import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Test import com.michaldrabik.data_local.database.model.Show as ShowDb class MyShowsRepositoryTest : BaseMockTest() { @RelaxedMockK lateinit var myShowsLocalSource: MyShowsLocalDataSource @RelaxedMockK lateinit var watchlistShowsLocalSource: WatchlistShowsLocalDataSource @RelaxedMockK lateinit var hiddenShowsLocalDataSource: ArchiveShowsLocalDataSource @RelaxedMockK lateinit var showDb: ShowDb private lateinit var SUT: MyShowsRepository @Before override fun setUp() { super.setUp() SUT = MyShowsRepository( myShowsLocalSource, watchlistShowsLocalSource, hiddenShowsLocalDataSource, transactions, mappers ) coEvery { database.myShows } returns myShowsLocalSource coEvery { database.watchlistShows } returns watchlistShowsLocalSource coEvery { database.archiveShows } returns hiddenShowsLocalDataSource } @After fun confirmSutVerified() { confirmVerified(myShowsLocalSource) confirmVerified(watchlistShowsLocalSource) confirmVerified(hiddenShowsLocalDataSource) } @Test fun `Should load and map single show by Trakt ID`() { runBlocking { val show = Show.EMPTY.copy(title = "Test") coEvery { myShowsLocalSource.getById(any()) } returns showDb coEvery { mappers.show.fromDatabase(any()) } returns show val testShow = SUT.load(IdTrakt(1L)) assertThat(testShow?.title).isEqualTo(show.title) coVerify(exactly = 1) { myShowsLocalSource.getById(any()) } coVerify(exactly = 1) { mappers.show.fromDatabase(showDb) } } } @Test fun `Should load and map all shows`() { runBlocking { coEvery { myShowsLocalSource.getAll() } returns listOf(showDb) coEvery { mappers.show.fromDatabase(any()) } returns Show.EMPTY SUT.loadAll() coVerify(exactly = 1) { myShowsLocalSource.getAll() } coVerify(exactly = 1) { mappers.show.fromDatabase(showDb) } } } @Test fun `Should load all shows ids`() { runBlocking { coEvery { myShowsLocalSource.getAllTraktIds() } returns listOf(1L, 2L) val ids = SUT.loadAllIds() assertThat(ids).containsExactly(1L, 2L) coVerify(exactly = 1) { myShowsLocalSource.getAllTraktIds() } } } @Test fun `Should load and map all shows by Trakt Ids`() { runBlocking { coEvery { myShowsLocalSource.getAll(any()) } returns listOf(showDb, showDb) coEvery { mappers.show.fromDatabase(any()) } returns Show.EMPTY val shows = SUT.loadAll(listOf(IdTrakt(1), IdTrakt(2))) assertThat(shows).hasSize(2) coVerify(exactly = 1) { myShowsLocalSource.getAll(listOf(1, 2)) } coVerify(exactly = 2) { mappers.show.fromDatabase(showDb) } } } @Test fun `Should load and map all recents shows using amount`() { runBlocking { coEvery { myShowsLocalSource.getAllRecent(any()) } returns listOf(showDb, showDb) coEvery { mappers.show.fromDatabase(any()) } returns Show.EMPTY val shows = SUT.loadAllRecent(2) assertThat(shows).hasSize(2) coVerify(exactly = 1) { myShowsLocalSource.getAllRecent(2) } coVerify(exactly = 2) { mappers.show.fromDatabase(showDb) } } } @Test fun `Should insert show into database using Trakt ID`() { runBlocking { val slot = slot>() coJustRun { myShowsLocalSource.insert(capture(slot)) } SUT.insert(IdTrakt(10L), 666) slot.captured[0].run { assertThat(id).isEqualTo(0) assertThat(idTrakt).isEqualTo(10) assertThat(createdAt).isGreaterThan(0L) assertThat(updatedAt).isGreaterThan(0L) assertThat(lastWatchedAt).isEqualTo(666) } coVerify(exactly = 1) { myShowsLocalSource.insert(any()) } coVerify(exactly = 1) { watchlistShowsLocalSource.deleteById(any()) } coVerify(exactly = 1) { hiddenShowsLocalDataSource.deleteById(any()) } } } @Test fun `Should delete show from database using Trakt ID`() { runBlocking { val slot = slot() coJustRun { myShowsLocalSource.deleteById(capture(slot)) } SUT.delete(IdTrakt(10L)) assertThat(slot.captured).isEqualTo(10L) coVerify(exactly = 1) { myShowsLocalSource.deleteById(10L) } } } } ================================================ FILE: repository/src/test/java/com/michaldrabik/repository/PeopleRepositoryTest.kt ================================================ package com.michaldrabik.repository import com.google.common.truth.Truth.assertThat import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.data_local.database.dao.MoviesDao import com.michaldrabik.data_local.database.dao.PeopleCreditsDao import com.michaldrabik.data_local.database.dao.PeopleDao import com.michaldrabik.data_local.database.dao.PeopleShowsMoviesDao import com.michaldrabik.data_local.database.dao.ShowsDao import com.michaldrabik.data_local.database.model.Movie import com.michaldrabik.data_local.database.model.Show import com.michaldrabik.data_remote.tmdb.TmdbRemoteDataSource import com.michaldrabik.data_remote.trakt.TraktRemoteDataSource import com.michaldrabik.data_remote.trakt.model.PersonCredit import com.michaldrabik.repository.common.BaseMockTest import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_model.IdTmdb import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Person import com.michaldrabik.ui_model.Person.Department import io.mockk.coEvery import io.mockk.coVerify import io.mockk.coVerifyOrder import io.mockk.confirmVerified import io.mockk.impl.annotations.RelaxedMockK import io.mockk.mockk import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Test import com.michaldrabik.data_local.database.model.Person as PersonDb import com.michaldrabik.data_remote.trakt.model.Ids as IdsRemote class PeopleRepositoryTest : BaseMockTest() { @RelaxedMockK lateinit var peopleDao: PeopleDao @RelaxedMockK lateinit var showsDao: ShowsDao @RelaxedMockK lateinit var moviesDao: MoviesDao @RelaxedMockK lateinit var peopleShowsMoviesDao: PeopleShowsMoviesDao @RelaxedMockK lateinit var peopleCreditsDao: PeopleCreditsDao @RelaxedMockK lateinit var person: PersonDb @RelaxedMockK lateinit var tmdbApi: TmdbRemoteDataSource @RelaxedMockK lateinit var traktApi: TraktRemoteDataSource @RelaxedMockK lateinit var settingsRepository: SettingsRepository private lateinit var SUT: PeopleRepository @Before override fun setUp() { super.setUp() SUT = PeopleRepository(settingsRepository, database, cloud, transactions, mappers) coEvery { database.people } returns peopleDao coEvery { database.shows } returns showsDao coEvery { database.movies } returns moviesDao coEvery { database.peopleCredits } returns peopleCreditsDao coEvery { database.peopleShowsMovies } returns peopleShowsMoviesDao coEvery { cloud.tmdb } returns tmdbApi coEvery { cloud.trakt } returns traktApi } @After fun confirmSutVerified() { confirmVerified(peopleDao) } @Test fun `Should return local data for shows properly`() = runBlocking { coEvery { peopleShowsMoviesDao.getTimestampForShow(any()) } returns nowUtc().minusHours(10).toMillis() coEvery { peopleDao.getAllForShow(any()) } returns listOf(person) SUT.loadAllForShow(Ids.EMPTY.copy(trakt = IdTrakt(11))) coVerifyOrder { peopleShowsMoviesDao.getTimestampForShow(11) peopleDao.getAllForShow(11) } coVerify(exactly = 0) { tmdbApi.fetchShowPeople(any()) } } @Test fun `Should return remote data for shows properly`() = runBlocking { coEvery { peopleShowsMoviesDao.getTimestampForShow(any()) } returns nowUtc().minusDays(10).toMillis() coEvery { peopleDao.getAllForShow(any()) } returns listOf(person) SUT.loadAllForShow(Ids.EMPTY.copy(trakt = IdTrakt(11), tmdb = IdTmdb(12))) coVerifyOrder { peopleShowsMoviesDao.getTimestampForShow(11) peopleDao.getAllForShow(11) tmdbApi.fetchShowPeople(12) peopleDao.upsert(any()) peopleShowsMoviesDao.insertForShow(any(), 11) } } @Test fun `Should return local data for movies properly`() = runBlocking { coEvery { peopleShowsMoviesDao.getTimestampForMovie(any()) } returns nowUtc().minusHours(10).toMillis() coEvery { peopleDao.getAllForMovie(any()) } returns listOf(person) SUT.loadAllForMovie(Ids.EMPTY.copy(trakt = IdTrakt(11))) coVerifyOrder { peopleShowsMoviesDao.getTimestampForMovie(11) peopleDao.getAllForMovie(11) } coVerify(exactly = 0) { tmdbApi.fetchMoviePeople(any()) } } @Test fun `Should return remote data for movies properly`() = runBlocking { coEvery { peopleShowsMoviesDao.getTimestampForMovie(any()) } returns nowUtc().minusDays(10).toMillis() coEvery { peopleDao.getAllForMovie(any()) } returns listOf(person) SUT.loadAllForMovie(Ids.EMPTY.copy(trakt = IdTrakt(11), tmdb = IdTmdb(12))) coVerifyOrder { peopleShowsMoviesDao.getTimestampForMovie(11) peopleDao.getAllForMovie(11) tmdbApi.fetchMoviePeople(12) peopleDao.upsert(any()) peopleShowsMoviesDao.insertForMovie(any(), 11) } } @Test fun `Should return shows items with image in the first place`() = runBlocking { coEvery { peopleShowsMoviesDao.getTimestampForShow(any()) } returns nowUtc().minusHours(10).toMillis() val person1 = mockk(relaxed = true) { coEvery { image } returns null coEvery { department } returns "Acting" } val person2 = mockk(relaxed = true) { coEvery { image } returns "test" coEvery { department } returns "Acting" } val person3 = mockk(relaxed = true) { coEvery { image } returns "test" coEvery { department } returns "Acting" } coEvery { peopleDao.getAllForShow(any()) } returns listOf(person1, person2, person3) val result = SUT.loadAllForShow(Ids.EMPTY.copy(trakt = IdTrakt(11))) assertThat(result[Department.ACTING]!!.first().imagePath).isNotNull() coVerify { peopleDao.getAllForShow(any()) } } @Test fun `Should return movies items with image in the first place`() = runBlocking { coEvery { peopleShowsMoviesDao.getTimestampForMovie(any()) } returns nowUtc().minusHours(10).toMillis() val person1 = mockk(relaxed = true) { coEvery { image } returns null coEvery { department } returns "Acting" } val person2 = mockk(relaxed = true) { coEvery { image } returns "test" coEvery { department } returns "Acting" } val person3 = mockk(relaxed = true) { coEvery { image } returns "test" coEvery { department } returns "Acting" } coEvery { peopleDao.getAllForMovie(any()) } returns listOf(person1, person2, person3) val result = SUT.loadAllForMovie(Ids.EMPTY.copy(trakt = IdTrakt(11))) assertThat(result[Department.ACTING]!!.first().imagePath).isNotNull() coVerify { peopleDao.getAllForMovie(any()) } } @Test fun `Should return empty credits if Trakt ID is not found for given TMDB ID`() = runBlocking { val person = mockk(relaxed = true) val personDb = mockk(relaxed = true) { coEvery { idTrakt } returns null } val ids = mockk(relaxed = true) { coEvery { trakt } returns null } coEvery { peopleDao.getById(any()) } returns personDb coEvery { traktApi.fetchPersonIds(any(), any()) } returns ids val result = SUT.loadCredits(person) assertThat(result).isEmpty() coVerify { peopleDao.getById(any()) } coVerify(exactly = 0) { peopleDao.updateTraktId(any(), any()) } } @Test fun `Should return locally cached credits if Trakt ID is found and cache is valid`() = runBlocking { val person = mockk(relaxed = true) val personDb = mockk(relaxed = true) { coEvery { idTrakt } returns 1 } val ids = mockk(relaxed = true) { coEvery { trakt } returns 1 } val show = mockk(relaxed = true) val movie = mockk(relaxed = true) coEvery { peopleDao.getById(any()) } returns personDb coEvery { traktApi.fetchPersonIds(any(), any()) } returns ids coEvery { peopleCreditsDao.getTimestampForPerson(any()) } returns nowUtcMillis() - 100 coEvery { peopleCreditsDao.getAllShowsForPerson(any()) } returns listOf(show) coEvery { peopleCreditsDao.getAllMoviesForPerson(any()) } returns listOf(movie) val result = SUT.loadCredits(person) assertThat(result).hasSize(2) assertThat(result[0].show).isNotNull() assertThat(result[1].movie).isNotNull() coVerify { peopleDao.getById(any()) } coVerify(exactly = 0) { peopleDao.updateTraktId(any(), any()) } coVerify(exactly = 0) { traktApi.fetchPersonShowsCredits(any(), any()) } coVerify(exactly = 0) { traktApi.fetchPersonMoviesCredits(any(), any()) } } @Test fun `Should return remote credits if Trakt ID is found and cache is invalid`() = runBlocking { val person = mockk(relaxed = true) val personDb = mockk(relaxed = true) { coEvery { idTrakt } returns 1 } val ids = mockk(relaxed = true) { coEvery { trakt } returns 1 } val creditsShows = mockk(relaxed = true) val creditsMovies = mockk(relaxed = true) coEvery { peopleDao.getById(any()) } returns personDb coEvery { traktApi.fetchPersonIds(any(), any()) } returns ids coEvery { traktApi.fetchPersonShowsCredits(any(), any()) } returns listOf(creditsShows) coEvery { traktApi.fetchPersonMoviesCredits(any(), any()) } returns listOf(creditsMovies) val result = SUT.loadCredits(person) assertThat(result).hasSize(2) assertThat(result[0].show).isNotNull() assertThat(result[1].movie).isNotNull() coVerify { peopleDao.getById(any()) } coVerify(exactly = 1) { showsDao.upsert(any()) } coVerify(exactly = 1) { moviesDao.upsert(any()) } coVerify(exactly = 1) { peopleCreditsDao.insertSingle(any(), any()) } coVerify(exactly = 0) { peopleDao.updateTraktId(any(), any()) } } } ================================================ FILE: repository/src/test/java/com/michaldrabik/repository/RelatedShowsRepositoryTest.kt ================================================ package com.michaldrabik.repository import com.michaldrabik.common.Config import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.database.dao.RelatedShowsDao import com.michaldrabik.data_local.database.dao.ShowsDao import com.michaldrabik.data_local.database.model.RelatedShow import com.michaldrabik.data_remote.trakt.TraktRemoteDataSource import com.michaldrabik.repository.common.BaseMockTest import com.michaldrabik.repository.shows.RelatedShowsRepository import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify import io.mockk.coVerifyOrder import io.mockk.coVerifySequence import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.RelaxedMockK import io.mockk.just import io.mockk.mockk import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test import java.util.concurrent.TimeUnit.HOURS class RelatedShowsRepositoryTest : BaseMockTest() { @MockK lateinit var traktApi: TraktRemoteDataSource @RelaxedMockK lateinit var relatedShowsDao: RelatedShowsDao @MockK lateinit var showsDao: ShowsDao private lateinit var SUT: RelatedShowsRepository @Before override fun setUp() { super.setUp() every { database.shows } returns showsDao every { database.relatedShows } returns relatedShowsDao every { cloud.trakt } returns traktApi SUT = RelatedShowsRepository(cloud, database, transactions, mappers) } @Test fun `Should return cached shows properly`() { runBlocking { val showDb = mockk(relaxed = true) { every { updatedAt } returns nowUtcMillis() - HOURS.toMillis(1) } coEvery { showsDao.getAll(any()) } returns emptyList() coEvery { relatedShowsDao.getAllById(any()) } returns listOf(showDb) SUT.loadAll(mockk(relaxed = true), 0) coVerifySequence { relatedShowsDao.getAllById(any()) showsDao.getAll(any()) } coVerify(exactly = 0) { traktApi.fetchRelatedShows(any(), 0) } } } @Test fun `Should return remote shows if nothing is cached`() { runBlocking { coEvery { showsDao.getAll(any()) } returns emptyList() coEvery { showsDao.upsert(any()) } just Runs coEvery { traktApi.fetchRelatedShows(any(), 0) } returns listOf(mockk(relaxed = true)) coEvery { relatedShowsDao.getAllById(any()) } returns listOf() SUT.loadAll(mockk(relaxed = true), 0) coVerifyOrder { relatedShowsDao.getAllById(any()) traktApi.fetchRelatedShows(any(), 0) } coVerify(exactly = 0) { showsDao.getAll(any()) } } } @Test fun `Should return remote shows if cached values expired`() { runBlocking { val showDb = mockk(relaxed = true) { every { updatedAt } returns nowUtcMillis() - (Config.RELATED_CACHE_DURATION + 1000) } coEvery { showsDao.getAll(any()) } returns emptyList() coEvery { showsDao.upsert(any()) } just Runs coEvery { traktApi.fetchRelatedShows(any(), 0) } returns listOf(mockk(relaxed = true)) coEvery { relatedShowsDao.getAllById(any()) } returns listOf(showDb) SUT.loadAll(mockk(relaxed = true), 0) coVerifyOrder { relatedShowsDao.getAllById(any()) traktApi.fetchRelatedShows(any(), 0) } coVerify(exactly = 0) { showsDao.getAll(any()) } } } } ================================================ FILE: repository/src/test/java/com/michaldrabik/repository/SettingsRepositoryTest.kt ================================================ package com.michaldrabik.repository import android.content.SharedPreferences import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.dao.SettingsDao import com.michaldrabik.repository.common.BaseMockTest import com.michaldrabik.repository.mappers.SettingsMapper import com.michaldrabik.repository.settings.SettingsFiltersRepository import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.repository.settings.SettingsSortRepository import com.michaldrabik.repository.settings.SettingsSpoilersRepository import com.michaldrabik.repository.settings.SettingsViewModeRepository import com.michaldrabik.repository.settings.SettingsWidgetsRepository import com.michaldrabik.ui_model.Settings import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify import io.mockk.confirmVerified import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test class SettingsRepositoryTest : BaseMockTest() { @MockK lateinit var settingsDao: SettingsDao @MockK lateinit var sharedPreferences: SharedPreferences @MockK lateinit var settingsSortRepository: SettingsSortRepository @MockK lateinit var settingsFilterRepository: SettingsFiltersRepository @MockK lateinit var settingsWidgetsRepository: SettingsWidgetsRepository @MockK lateinit var settingsViewModeRepository: SettingsViewModeRepository @MockK lateinit var settingsSpoilerRepositoryTest: SettingsSpoilersRepository private lateinit var SUT: SettingsRepository @Before override fun setUp() { super.setUp() every { database.settings } returns settingsDao SUT = SettingsRepository( sorting = settingsSortRepository, filters = settingsFilterRepository, widgets = settingsWidgetsRepository, viewMode = settingsViewModeRepository, spoilers = settingsSpoilerRepositoryTest, dispatchers = testDispatchers, localSource = database, transactions = transactions, mappers = mappers, preferences = sharedPreferences ) } @Test fun `Should be initialized if there are settings in database`() { runBlocking { coEvery { settingsDao.getCount() } returns 1 assertThat(SUT.isInitialized()).isTrue() } } @Test fun `Should not be initialized if there are no settings in database`() { runBlocking { coEvery { settingsDao.getCount() } returns 0 assertThat(SUT.isInitialized()).isFalse() } } @Test fun `Should load settings properly`() { runBlocking { val mapper = SettingsMapper() val settings = Settings.createInitial() val settingsDb = mapper.toDatabase(settings) coEvery { mappers.settings } returns mapper coEvery { settingsDao.getAll() } returns settingsDb val loaded = SUT.load() assertThat(loaded).isEqualTo(settings) coVerify { settingsDao.getAll() } confirmVerified(settingsDao) } } @Test fun `Should update settings properly`() { runBlocking { val mapper = SettingsMapper() val settings = Settings.createInitial() val settingsDb = mapper.toDatabase(settings) coEvery { mappers.settings } returns mapper coEvery { settingsDao.upsert(settingsDb) } just Runs SUT.update(settings) coVerify { settingsDao.upsert(settingsDb) } confirmVerified(settingsDao) } } } ================================================ FILE: repository/src/test/java/com/michaldrabik/repository/ShowDetailsRepositoryTest.kt ================================================ package com.michaldrabik.repository import com.google.common.truth.Truth.assertThat import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.database.dao.ShowsDao import com.michaldrabik.data_local.database.model.Show import com.michaldrabik.data_remote.trakt.TraktRemoteDataSource import com.michaldrabik.repository.common.BaseMockTest import com.michaldrabik.repository.shows.ShowDetailsRepository import com.michaldrabik.ui_model.IdTrakt import io.mockk.Called import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerifySequence import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just import io.mockk.mockk import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test import java.util.concurrent.TimeUnit import com.michaldrabik.data_remote.trakt.model.Show as ShowRemote class ShowDetailsRepositoryTest : BaseMockTest() { @MockK lateinit var traktApi: TraktRemoteDataSource @MockK lateinit var showsDao: ShowsDao private lateinit var SUT: ShowDetailsRepository @Before override fun setUp() { super.setUp() every { database.shows } returns showsDao every { cloud.trakt } returns traktApi SUT = ShowDetailsRepository(cloud, database, transactions, mappers) } @Test fun `Should load cached show details on given conditions`() { runBlocking { val showDb = mockk(relaxed = true) { every { idTrakt } returns 1 every { updatedAt } returns nowUtcMillis() - 100 } coEvery { showsDao.getById(any()) } returns showDb val show = SUT.load(IdTrakt(1), false) assertThat(show.ids.trakt).isEqualTo(IdTrakt(1)) coVerifySequence { showsDao.getById(any()) traktApi.fetchShow(any()) wasNot Called } } } @Test fun `Should load remote show details if force flag is set`() { runBlocking { val showRemote = mockk(relaxed = true) { every { ids?.trakt } returns 1 } coEvery { showsDao.getById(any()) } returns null coEvery { showsDao.upsert(any()) } just Runs coEvery { traktApi.fetchShow(any()) } returns showRemote val show = SUT.load(IdTrakt(1), true) assertThat(show.ids.trakt).isEqualTo(IdTrakt(1)) coVerifySequence { showsDao.getById(any()) traktApi.fetchShow(any()) showsDao.upsert(any()) } } } @Test fun `Should load remote show details if nothing is cached`() { runBlocking { val showRemote = mockk(relaxed = true) { every { ids?.trakt } returns 1 } coEvery { showsDao.getById(any()) } returns null coEvery { showsDao.upsert(any()) } just Runs coEvery { traktApi.fetchShow(any()) } returns showRemote val show = SUT.load(IdTrakt(1), false) assertThat(show.ids.trakt).isEqualTo(IdTrakt(1)) coVerifySequence { showsDao.getById(any()) traktApi.fetchShow(any()) showsDao.upsert(any()) } } } @Test fun `Should load remote show details if cached show expired`() { runBlocking { val showDb = mockk(relaxed = true) { every { idTrakt } returns 1 every { updatedAt } returns nowUtcMillis() - TimeUnit.DAYS.toMillis(10) } val showRemote = mockk(relaxed = true) { every { ids?.trakt } returns 1 } coEvery { showsDao.getById(any()) } returns showDb coEvery { showsDao.upsert(any()) } just Runs coEvery { traktApi.fetchShow(any()) } returns showRemote val show = SUT.load(IdTrakt(1), false) assertThat(show.ids.trakt).isEqualTo(IdTrakt(1)) coVerifySequence { showsDao.getById(any()) traktApi.fetchShow(any()) showsDao.upsert(any()) } } } } ================================================ FILE: repository/src/test/java/com/michaldrabik/repository/WatchlistShowsRepositoryTest.kt ================================================ package com.michaldrabik.repository import com.google.common.truth.Truth.assertThat import com.michaldrabik.data_local.database.dao.ArchiveShowsDao import com.michaldrabik.data_local.database.dao.MyShowsDao import com.michaldrabik.data_local.database.dao.WatchlistShowsDao import com.michaldrabik.data_local.database.model.WatchlistShow import com.michaldrabik.repository.common.BaseMockTest import com.michaldrabik.repository.shows.WatchlistShowsRepository import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Show import io.mockk.coEvery import io.mockk.coJustRun import io.mockk.coVerify import io.mockk.confirmVerified import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.RelaxedMockK import io.mockk.slot import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Test import com.michaldrabik.data_local.database.model.Show as ShowDb class WatchlistShowsRepositoryTest : BaseMockTest() { @MockK lateinit var seeLaterShowsDao: WatchlistShowsDao @MockK lateinit var myShowsDao: MyShowsDao @MockK lateinit var archivedShowsDao: ArchiveShowsDao @RelaxedMockK lateinit var showDb: ShowDb private lateinit var SUT: WatchlistShowsRepository @Before override fun setUp() { super.setUp() SUT = WatchlistShowsRepository(database, transactions, mappers) coEvery { database.watchlistShows } returns seeLaterShowsDao coEvery { database.myShows } returns myShowsDao coEvery { database.archiveShows } returns archivedShowsDao } @After fun confirmSutVerified() { confirmVerified(seeLaterShowsDao) } @Test fun `Should load and map all SeeLater shows`() { runBlocking { coEvery { seeLaterShowsDao.getAll() } returns listOf(showDb) coEvery { mappers.show.fromDatabase(any()) } returns Show.EMPTY SUT.loadAll() coVerify(exactly = 1) { seeLaterShowsDao.getAll() } coVerify(exactly = 1) { mappers.show.fromDatabase(showDb) } } } @Test fun `Should load and map single SeeLater show by Trakt ID`() { runBlocking { val show = Show.EMPTY.copy(title = "Test") coEvery { seeLaterShowsDao.getById(any()) } returns showDb coEvery { mappers.show.fromDatabase(any()) } returns show val testShow = SUT.load(IdTrakt(1L)) assertThat(testShow?.title).isEqualTo(show.title) coVerify(exactly = 1) { seeLaterShowsDao.getById(any()) } coVerify(exactly = 1) { mappers.show.fromDatabase(showDb) } } } @Test fun `Should insert show into database using Trakt ID`() { runBlocking { coJustRun { myShowsDao.deleteById(any()) } coJustRun { archivedShowsDao.deleteById(any()) } val slot = slot() coJustRun { seeLaterShowsDao.insert(capture(slot)) } SUT.insert(IdTrakt(1L)) assertThat(slot.captured.id).isEqualTo(0) assertThat(slot.captured.idTrakt).isEqualTo(1) coVerify(exactly = 1) { seeLaterShowsDao.insert(any()) } } } @Test fun `Should delete show from archived and my shows when inserting into see later`() { runBlocking { coJustRun { myShowsDao.deleteById(any()) } coJustRun { archivedShowsDao.deleteById(any()) } val slot = slot() coJustRun { seeLaterShowsDao.insert(capture(slot)) } SUT.insert(IdTrakt(1L)) assertThat(slot.captured.id).isEqualTo(0) assertThat(slot.captured.idTrakt).isEqualTo(1) coVerify(exactly = 1) { seeLaterShowsDao.insert(any()) } coVerify(exactly = 1) { myShowsDao.deleteById(1L) } coVerify(exactly = 1) { archivedShowsDao.deleteById(1L) } } } @Test fun `Should delete show from database using Trakt ID`() { runBlocking { val slot = slot() coJustRun { seeLaterShowsDao.deleteById(capture(slot)) } SUT.delete(IdTrakt(10L)) assertThat(slot.captured).isEqualTo(10L) coVerify(exactly = 1) { seeLaterShowsDao.deleteById(10L) } } } @Test fun `Should load all SeeLater shows ids`() { runBlocking { coEvery { seeLaterShowsDao.getAllTraktIds() } returns listOf(1L, 2L) val ids = SUT.loadAllIds() assertThat(ids).containsExactly(1L, 2L) coVerify(exactly = 1) { seeLaterShowsDao.getAllTraktIds() } } } } ================================================ FILE: repository/src/test/java/com/michaldrabik/repository/common/BaseMockTest.kt ================================================ package com.michaldrabik.repository.common import com.michaldrabik.common_test.UnconfinedCoroutineDispatchers import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.R import com.michaldrabik.repository.mappers.CommentMapper import com.michaldrabik.repository.mappers.CustomListMapper import com.michaldrabik.repository.mappers.EpisodeMapper import com.michaldrabik.repository.mappers.IdsMapper import com.michaldrabik.repository.mappers.ImageMapper import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.mappers.MovieMapper import com.michaldrabik.repository.mappers.NewsMapper import com.michaldrabik.repository.mappers.PersonMapper import com.michaldrabik.repository.mappers.RatingsMapper import com.michaldrabik.repository.mappers.SeasonMapper import com.michaldrabik.repository.mappers.SettingsMapper import com.michaldrabik.repository.mappers.ShowMapper import com.michaldrabik.repository.mappers.StreamingsMapper import com.michaldrabik.repository.mappers.TranslationMapper import com.michaldrabik.repository.mappers.UserRatingsMapper import io.mockk.MockKAnnotations import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.SpyK import io.mockk.mockkStatic import io.mockk.slot import org.junit.Before @Suppress("EXPERIMENTAL_API_USAGE") abstract class BaseMockTest { @MockK lateinit var database: LocalDataSource @MockK lateinit var transactions: TransactionsProvider @MockK lateinit var cloud: RemoteDataSource protected val testDispatchers = UnconfinedCoroutineDispatchers() private val idsMapper = IdsMapper() private val episodeMappers = EpisodeMapper(idsMapper) @SpyK var mappers = Mappers( idsMapper, ImageMapper(), ShowMapper(idsMapper), MovieMapper(idsMapper), episodeMappers, SeasonMapper(idsMapper, episodeMappers), PersonMapper(), CommentMapper(), NewsMapper(), SettingsMapper(), TranslationMapper(idsMapper), CustomListMapper(), RatingsMapper(), UserRatingsMapper(), StreamingsMapper() ) @Before open fun setUp() { MockKAnnotations.init(this) clearAllMocks() mockkStatic("androidx.room.RoomDatabaseKt") val lambda = slot R>() coEvery { transactions.withTransaction(capture(lambda)) } coAnswers { lambda.captured.invoke() } } } ================================================ FILE: settings.gradle ================================================ rootProject.name = 'Showly OSS' include ':app' include ':data-remote' include ':data-local' include ':common' include ':common-test' include ':repository' include ':ui-news' include ':ui-lists' include ':ui-premium' include ':ui-statistics-movies' include ':ui-progress-movies' include ':ui-my-movies' include ':ui-comments' include ':ui-gallery' include ':ui-movie' include ':ui-discover-movies' include ':ui-episodes' include ':ui-widgets' include ':ui-progress' include ':ui-discover' include ':ui-my-shows' include ':ui-show' include ':ui-search' include ':ui-statistics' include ':ui-trakt-sync' include ':ui-navigation' include ':ui-settings' include ':ui-base' include ':ui-model' include ':ui-streamings' include ':ui-people' ================================================ FILE: ui-base/.gitignore ================================================ /build ================================================ FILE: ui-base/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'kotlin-parcelize' apply plugin: 'dagger.hilt.android.plugin' apply from: '../versions.gradle' android { kotlinOptions { jvmTarget = "17" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } buildFeatures { viewBinding true } compileSdkVersion versions.compileSdk defaultConfig { minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk compileSdkVersion versions.compileSdk testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false } } namespace 'com.michaldrabik.ui_base' } dependencies { implementation project(':common') implementation project(':data-remote') implementation project(':data-local') implementation project(':ui-model') implementation project(':ui-navigation') implementation project(':repository') api libs.android.appcompat api libs.android.core api libs.bundles.android.lifecycle api libs.bundles.android.navigation api libs.android.fragment api libs.android.recycler api libs.android.constraintlayout api libs.android.swiperefresh api libs.android.work api libs.android.material api libs.android.dynamicanimation api libs.overscrollDecor api libs.timber api libs.glide ksp libs.glide.compiler implementation libs.hilt.android ksp libs.hilt.compiler implementation libs.hilt.work ksp libs.hilt.work.compiler coreLibraryDesugaring libs.android.desugar } ================================================ FILE: ui-base/src/main/AndroidManifest.xml ================================================ ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/Analytics.kt ================================================ package com.michaldrabik.ui_base import com.michaldrabik.ui_model.DiscoverFilters import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Show // Analytics removed, do nothing with log events object Analytics { fun logShowDetailsDisplay(show: Show) {} fun logMovieDetailsDisplay(movie: Movie) {} fun logShowAddToMyShows(show: Show) {} fun logMovieAddToMyMovies(movie: Movie) {} fun logShowAddToWatchlistShows(show: Show) {} fun logMovieAddToWatchlistMovies(movie: Movie) {} fun logShowAddToArchive(show: Show) {} fun logMovieAddToArchive(movie: Movie) {} fun logShowTrailerClick(show: Show) {} fun logMovieTrailerClick(movie: Movie) {} fun logShowCommentsClick(show: Show) {} fun logMovieCommentsClick(movie: Movie) {} fun logShowShareClick(show: Show) {} fun logMovieShareClick(movie: Movie) {} fun logShowGalleryClick(idTrakt: Long) {} fun logMovieGalleryClick(idTrakt: Long) {} fun logShowQuickProgress(show: Show) {} fun logMovieRated(movie: Movie, rating: Int) {} fun logEpisodeRated(idTrakt: Long, episode: Episode, rating: Int) {} fun logDiscoverFiltersApply(filters: DiscoverFilters) {} fun logDiscoverMoviesFiltersApply(filters: DiscoverFilters) {} // In App Rate fun logInAppRateDisplayed() {} fun logInAppRateDecision(isYes: Boolean) {} // Trakt fun logTraktLogin() {} fun logTraktLogout() {} fun logTraktFullSyncSuccess(import: Boolean, export: Boolean) {} fun logTraktQuickSyncSuccess(count: Int) {} // Settings fun logSettingsTraktQuickSync(enabled: Boolean) {} fun logSettingsTraktQuickRemove(enabled: Boolean) {} fun logSettingsTraktQuickRate(enabled: Boolean) {} fun logSettingsRecentlyAddedAmount(amount: Long) {} fun logSettingsAnnouncements(enabled: Boolean) {} fun logSettingsSpecialSeasons(enabled: Boolean) {} fun logSettingsProgressUpcoming(enabled: Boolean) {} fun logSettingsMoviesEnabled(enabled: Boolean) {} fun logSettingsNewsEnabled(enabled: Boolean) {} fun logSettingsStreamingsEnabled(enabled: Boolean) {} fun logSettingsWidgetsTitlesEnabled(enabled: Boolean) {} fun logSettingsWhenToNotify(value: String) {} fun logSettingsLanguage(value: String) {} fun logSettingsTheme(value: Int) {} fun logSettingsPremium(value: Boolean) {} fun logSettingsWidgetsTheme(value: Int) {} fun logSettingsCountry(value: String) {} fun logSettingsProgressType(value: String) {} fun logInAppUpdate(versionName: String, versionCode: Long) {} fun logUnsupportedSubscriptions() {} fun logExportHistory(episodesCount: Int, moviesCount: Int, retryCount: Int) {} fun logQuickExportHistory(episodesCount: Int, moviesCount: Int, retryCount: Int) {} } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/BaseAdapter.kt ================================================ package com.michaldrabik.ui_base import android.view.View import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView import com.michaldrabik.ui_base.common.ListItem abstract class BaseAdapter( val listChangeListener: (() -> Unit)? = null ) : RecyclerView.Adapter(), AsyncListDiffer.ListListener { abstract val asyncDiffer: AsyncListDiffer private var notifyChange = false open fun setItems(newItems: List, notifyChange: Boolean = false) { this.notifyChange = notifyChange with(asyncDiffer) { removeListListener(this@BaseAdapter) addListListener(this@BaseAdapter) submitList(newItems) } } override fun getItemCount() = asyncDiffer.currentList.size fun getItems(): List = asyncDiffer.currentList fun indexOf(item: Item) = asyncDiffer.currentList.indexOfFirst { it.isSameAs(item) } override fun onCurrentListChanged( previousList: MutableList, currentList: MutableList, ) { if (notifyChange) { listChangeListener?.invoke() } } class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/BaseBottomSheetFragment.kt ================================================ package com.michaldrabik.ui_base import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.annotation.IdRes import androidx.annotation.LayoutRes import androidx.appcompat.view.ContextThemeWrapper import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.michaldrabik.ui_base.utilities.NavigationHost abstract class BaseBottomSheetFragment(@LayoutRes val layoutResId: Int) : BottomSheetDialogFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { val contextThemeWrapper = ContextThemeWrapper(requireActivity(), R.style.AppTheme) return inflater.cloneInContext(contextThemeWrapper).inflate(layoutResId, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) expandSheet() } protected fun navigateTo(@IdRes destination: Int, bundle: Bundle? = null) = (requireActivity() as NavigationHost).findNavControl()?.navigate(destination, bundle) protected fun isSheetExpanded(): Boolean { val behavior: BottomSheetBehavior<*> = (dialog as BottomSheetDialog).behavior return behavior.state == BottomSheetBehavior.STATE_EXPANDED } protected fun expandSheet() { val behavior: BottomSheetBehavior<*> = (dialog as BottomSheetDialog).behavior behavior.state = BottomSheetBehavior.STATE_EXPANDED behavior.skipCollapsed = true } protected fun closeSheet() = (requireActivity() as NavigationHost).findNavControl()?.navigateUp() } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/BaseFragment.kt ================================================ package com.michaldrabik.ui_base import android.animation.Animator import android.content.Context import android.os.Bundle import android.view.ViewPropertyAnimator import androidx.activity.addCallback import androidx.annotation.IdRes import androidx.annotation.LayoutRes import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModel import com.google.android.material.snackbar.Snackbar import com.michaldrabik.common.Mode import com.michaldrabik.ui_base.utilities.ModeHost import com.michaldrabik.ui_base.utilities.MoviesStatusHost import com.michaldrabik.ui_base.utilities.NavigationHost import com.michaldrabik.ui_base.utilities.SnackbarHost import com.michaldrabik.ui_base.utilities.TipsHost import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.isTablet import com.michaldrabik.ui_base.utilities.extensions.showErrorSnackbar import com.michaldrabik.ui_base.utilities.extensions.showInfoSnackbar import com.michaldrabik.ui_model.Tip abstract class BaseFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId), TipsHost { protected abstract val viewModel: T open val navigationId: Int = 0 protected var isInitialized = false protected val animations = mutableListOf() protected val animators = mutableListOf() protected val snackbars = mutableListOf() protected var mode: Mode get() = (requireActivity() as ModeHost).getMode() set(value) = (requireActivity() as ModeHost).setMode(value) protected val moviesEnabled: Boolean get() = (requireActivity() as MoviesStatusHost).hasMoviesEnabled() protected val isTablet by lazy { requireContext().isTablet() } override fun onResume() { super.onResume() setupBackPressed() } protected fun findNavControl() = (requireActivity() as NavigationHost).findNavControl() protected fun hideNavigation(animate: Boolean = true) = (requireActivity() as NavigationHost).hideNavigation(animate) protected fun showNavigation(animate: Boolean = true) = (requireActivity() as NavigationHost).showNavigation(animate) protected fun showSnack(message: MessageEvent) { val host = (requireActivity() as SnackbarHost).provideSnackbarLayout() when (message) { is MessageEvent.Info -> { val length = if (message.isIndefinite) Snackbar.LENGTH_INDEFINITE else Snackbar.LENGTH_SHORT val action = if (message.isIndefinite) ({}) else null host.showInfoSnackbar(getString(message.textRestId), length = length, action = action) } is MessageEvent.Error -> host.showErrorSnackbar(getString(message.textRestId)) } } protected open fun setupBackPressed() { val dispatcher = requireActivity().onBackPressedDispatcher dispatcher.addCallback(viewLifecycleOwner) { isEnabled = false findNavControl()?.popBackStack() } } protected fun navigateTo(@IdRes destination: Int, bundle: Bundle? = null) { findNavControl()?.navigate(destination, bundle) } override fun isTipShown(tip: Tip) = (requireActivity() as TipsHost).isTipShown(tip) override fun showTip(tip: Tip) = (requireActivity() as TipsHost).showTip(tip) override fun setTipShow(tip: Tip) = (requireActivity() as TipsHost).showTip(tip) private fun clearAnimations() { animations.forEach { it?.cancel() } animators.forEach { it?.cancel() } animations.clear() animators.clear() } override fun onDestroyView() { snackbars.forEach { it?.dismiss() } clearAnimations() super.onDestroyView() } fun Fragment.requireAppContext(): Context = requireContext().applicationContext } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/BaseMovieAdapter.kt ================================================ package com.michaldrabik.ui_base import android.view.View import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView import com.michaldrabik.ui_base.common.MovieListItem abstract class BaseMovieAdapter( val listChangeListener: (() -> Unit)? = null ) : RecyclerView.Adapter(), AsyncListDiffer.ListListener { abstract val asyncDiffer: AsyncListDiffer private var notifyChange = false open fun setItems(newItems: List, notifyChange: Boolean = false) { this.notifyChange = notifyChange asyncDiffer.removeListListener(this) asyncDiffer.addListListener(this) asyncDiffer.submitList(newItems) } override fun getItemCount() = asyncDiffer.currentList.size fun getItems(): List = asyncDiffer.currentList fun indexOf(item: Item) = asyncDiffer.currentList.indexOfFirst { it isSameAs item } override fun onCurrentListChanged( previousList: MutableList, currentList: MutableList ) { if (notifyChange) { listChangeListener?.invoke() } } class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/Logger.kt ================================================ package com.michaldrabik.ui_base import okhttp3.RequestBody import okhttp3.internal.closeQuietly import okio.Buffer import retrofit2.HttpException import java.io.IOException import kotlin.coroutines.cancellation.CancellationException object Logger { fun record(error: Throwable, source: String) { if (error is CancellationException) { return } if (error is IOException && error.message == "Canceled") { return } if (error is HttpException) { return } } private fun RequestBody.asString(): String? { val buffer = Buffer() return try { this.writeTo(buffer) buffer.readUtf8() } catch (error: Throwable) { null } finally { buffer.closeQuietly() } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/AppCountry.kt ================================================ package com.michaldrabik.ui_base.common enum class AppCountry( val code: String, val displayName: String, val justWatchQuery: String = "search" ) { ARGENTINA("ar", "Argentina", "buscar"), AUSTRALIA("au", "Australia"), AUSTRIA("at", "Austria", "Suche"), BELGIUM("be", "Belgium", "recherche"), BRAZIL("br", "Brazil", "busca"), BULGARIA("bg", "Bulgaria"), CANADA("ca", "Canada"), CHILE("cl", "Chile", "buscar"), COLOMBIA("co", "Colombia", "buscar"), CZECH_REP("cz", "Czech Republic", "vyhledání"), DENMARK("dk", "Denmark"), ECUADOR("ec", "Ecuador", "buscar"), ESTONIA("ee", "Estonia", "otsing"), FINLAND("fi", "Finland", "etsi"), FRANCE("fr", "France", "recherche"), GERMANY("de", "Germany", "Suche"), GREECE("gr", "Greece"), HUNGARY("hu", "Hungary"), INDIA("in", "India"), INDONESIA("id", "Indonesia"), IRELAND("ie", "Ireland"), ITALY("it", "Italy", "cerca"), JAPAN("jp", "Japan", "検索"), LATVIA("lv", "Latvia"), LITHUANIA("lt", "Lithuania"), MALAYSIA("my", "Malaysia"), MEXICO("mx", "Mexico", "buscar"), NETHERLANDS("nl", "Netherlands"), NEW_ZEALAND("nz", "New Zealand"), NORWAY("no", "Norway"), PERU("pe", "Peru", "buscar"), PHILIPPINES("ph", "Philippines"), POLAND("pl", "Poland"), PORTUGAL("pt", "Portugal", "busca"), ROMANIA("ro", "Romania"), RUSSIA("ru", "Russia", "поиск"), SINGAPORE("sg", "Singapore"), SOUTH_AFRICA("za", "South Africa"), SOUTH_KOREA("kr", "South Korea", "검색"), SPAIN("es", "Spain", "buscar"), SWEDEN("se", "Sweden"), SWITZERLAND("ch", "Switzerland", "Suche"), THAILAND("th", "Thailand"), TURKEY("tr", "Turkey", "arama"), UNITED_KINGDOM("uk", "United Kingdom"), UNITED_STATES("us", "United States"), VENEZUELA("ve", "Venezuela", "buscar"); companion object { fun fromCode(code: String) = values().first { it.code == code } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/AppScopeProvider.kt ================================================ package com.michaldrabik.ui_base.common import kotlinx.coroutines.CoroutineScope interface AppScopeProvider { val appScope: CoroutineScope } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/FastLinearLayoutManager.kt ================================================ package com.michaldrabik.ui_base.common import android.content.Context import android.util.DisplayMetrics import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView class FastLinearLayoutManager(context: Context?, orientation: Int, reverseLayout: Boolean) : LinearLayoutManager(context, orientation, reverseLayout) { companion object { const val SPEED_RATIO = 8F } override fun smoothScrollToPosition(recyclerView: RecyclerView?, state: RecyclerView.State?, position: Int) { val scroller = object : LinearSmoothScroller(recyclerView?.context) { override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics) = SPEED_RATIO / displayMetrics.densityDpi } scroller.targetPosition = position startSmoothScroll(scroller) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/ListItem.kt ================================================ package com.michaldrabik.ui_base.common import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.Show interface ListItem { val show: Show val image: Image val isLoading: Boolean infix fun isSameAs(other: ListItem) = show.ids.trakt == other.show.ids.trakt } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/ListViewMode.kt ================================================ package com.michaldrabik.ui_base.common enum class ListViewMode { LIST_NORMAL, LIST_COMPACT, GRID, GRID_TITLE } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/MovieListItem.kt ================================================ package com.michaldrabik.ui_base.common import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.Movie interface MovieListItem { val movie: Movie val image: Image val isLoading: Boolean infix fun isSameAs(other: MovieListItem) = movie.ids.trakt == other.movie.ids.trakt } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/OnScrollResetListener.kt ================================================ package com.michaldrabik.ui_base.common interface OnScrollResetListener { fun onScrollReset() } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/OnSearchClickListener.kt ================================================ package com.michaldrabik.ui_base.common interface OnSearchClickListener { fun onEnterSearch() fun onExitSearch() } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/OnShowsMoviesSyncedListener.kt ================================================ package com.michaldrabik.ui_base.common interface OnShowsMoviesSyncedListener { fun onShowsMoviesSyncFinished() } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/OnTabReselectedListener.kt ================================================ package com.michaldrabik.ui_base.common interface OnTabReselectedListener { fun onTabReselected() } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/OnTraktAuthorizeListener.kt ================================================ package com.michaldrabik.ui_base.common import android.net.Uri interface OnTraktAuthorizeListener { fun onAuthorizationResult(authData: Uri?) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/SafeOnClickListener.kt ================================================ package com.michaldrabik.ui_base.common import android.view.View import com.michaldrabik.common.extensions.nowUtcMillis private const val SAFE_INTERVAL = 650 class SafeOnClickListener( private val isSafe: Boolean, private val action: (view: View) -> Unit ) : View.OnClickListener { private var lastClickTimestamp = 0L override fun onClick(clickedView: View) { if (!isSafe) { action(clickedView) return } val currentTimestamp = nowUtcMillis() if (lastClickTimestamp == 0L || currentTimestamp - lastClickTimestamp > SAFE_INTERVAL) { action(clickedView) lastClickTimestamp = currentTimestamp } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/WidgetsProvider.kt ================================================ package com.michaldrabik.ui_base.common interface WidgetsProvider { fun requestShowsWidgetsUpdate() fun requestMoviesWidgetsUpdate() } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/behaviour/ScrollableViewBehaviour.kt ================================================ package com.michaldrabik.ui_base.common.behaviour import android.content.Context import android.util.AttributeSet import android.view.View import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.ViewCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView /** * Note: some extra work is added because of an issue: * https://gist.github.com/erikhuizinga/edf408167b46eb5b1568424563ca4e59?ts=2 */ class ScrollableViewBehaviour : CoordinatorLayout.Behavior { constructor() : super() constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) override fun layoutDependsOn(parent: CoordinatorLayout, child: View, dependency: View) = dependency is RecyclerView override fun onNestedPreScroll( coordinatorLayout: CoordinatorLayout, child: View, target: View, dx: Int, dy: Int, consumed: IntArray, type: Int, ) { super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) stopNestedScrollIfNeeded(dy, target, type) } override fun onStartNestedScroll( coordinatorLayout: CoordinatorLayout, child: View, directTargetChild: View, target: View, axes: Int, type: Int, ) = when (axes) { ViewCompat.SCROLL_AXIS_VERTICAL -> true else -> super.onStartNestedScroll(coordinatorLayout, child, directTargetChild, target, axes, type) } override fun onNestedScroll( coordinatorLayout: CoordinatorLayout, child: View, target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray, ) { super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed) child.translationY = (child.translationY - dyConsumed.toFloat()).coerceAtMost(0F) stopNestedScrollIfNeeded(dyConsumed, target, type) resetAtTop(target, child) } private fun resetAtTop(target: View, child: View) { val lm = (target as? RecyclerView)?.layoutManager as? LinearLayoutManager lm?.let { val isScrolled = lm.findFirstCompletelyVisibleItemPosition() != 0 if (!isScrolled) { child.animate().translationY(0F).setDuration(50).start() } } } private fun stopNestedScrollIfNeeded(dy: Int, target: View, type: Int) { if (type == ViewCompat.TYPE_NON_TOUCH) { if (dy == 0) { ViewCompat.stopNestedScroll(target, ViewCompat.TYPE_NON_TOUCH) } } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/behaviour/SearchViewBehaviour.kt ================================================ package com.michaldrabik.ui_base.common.behaviour import android.view.View import android.view.ViewGroup import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.ViewCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView /** * Note: some extra work is added because of an issue: * https://gist.github.com/erikhuizinga/edf408167b46eb5b1568424563ca4e59?ts=2 */ class SearchViewBehaviour(private val padding: Int) : CoordinatorLayout.Behavior() { override fun layoutDependsOn(parent: CoordinatorLayout, child: ViewGroup, dependency: View) = dependency is RecyclerView override fun onNestedPreScroll( coordinatorLayout: CoordinatorLayout, child: ViewGroup, target: View, dx: Int, dy: Int, consumed: IntArray, type: Int, ) { super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) stopNestedScrollIfNeeded(dy, target, type) } override fun onStartNestedScroll( coordinatorLayout: CoordinatorLayout, child: ViewGroup, directTargetChild: View, target: View, axes: Int, type: Int, ) = when (axes) { ViewCompat.SCROLL_AXIS_VERTICAL -> true else -> super.onStartNestedScroll(coordinatorLayout, child, directTargetChild, target, axes, type) } override fun onNestedScroll( coordinatorLayout: CoordinatorLayout, child: ViewGroup, target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray, ) { super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed) if (dyConsumed > 0) { val limit = -(child.height + 1.5F * padding) child.translationY = (child.translationY - dyConsumed.toFloat()).coerceAtLeast(limit) } else if (dyConsumed <= 0) { child.translationY = (child.translationY - dyConsumed.toFloat()).coerceAtMost(0F) } stopNestedScrollIfNeeded(dyConsumed, target, type) resetAtTop(target, child) } private fun resetAtTop(target: View, child: View) { val lm = (target as? RecyclerView)?.layoutManager as? LinearLayoutManager lm?.let { val isScrolled = lm.findFirstCompletelyVisibleItemPosition() != 0 if (!isScrolled) { child.animate().translationY(0F).setDuration(50).start() } } } private fun stopNestedScrollIfNeeded(dy: Int, target: View, type: Int) { if (type == ViewCompat.TYPE_NON_TOUCH) { if (dy == 0) { ViewCompat.stopNestedScroll(target, ViewCompat.TYPE_NON_TOUCH) } } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/ContextMenuBottomSheet.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu import android.os.Bundle import androidx.annotation.IdRes import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResultListener import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.michaldrabik.common.Config import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.sheets.remove_trakt.RemoveTraktBottomSheet import com.michaldrabik.ui_base.common.sheets.remove_trakt.RemoveTraktBottomSheet.Mode import com.michaldrabik.ui_base.databinding.ViewContextMenuBinding import com.michaldrabik.ui_base.utilities.SnackbarHost import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.requireParcelable import com.michaldrabik.ui_base.utilities.extensions.showErrorSnackbar import com.michaldrabik.ui_base.utilities.extensions.showInfoSnackbar import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.extensions.withFailListener import com.michaldrabik.ui_base.utilities.extensions.withSuccessListener import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.IdTvdb import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageStatus import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_OPTIONS import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_ITEM_MENU import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_REMOVE_TRAKT import com.michaldrabik.ui_navigation.java.NavigationArgs.RESULT abstract class ContextMenuBottomSheet : BaseBottomSheetFragment(R.layout.view_context_menu) { companion object { private const val ARG_SHOW_PIN_BUTTONS = "ARG_SHOW_PIN_BUTTONS" private const val ARG_DETAILS_ENABLED = "ARG_DETAILS_ENABLED" fun createBundle( idTrakt: IdTrakt, showPinButtons: Boolean = false, detailsEnabled: Boolean = true ) = bundleOf( ARG_ID to idTrakt, ARG_OPTIONS to bundleOf( ARG_SHOW_PIN_BUTTONS to showPinButtons, ARG_DETAILS_ENABLED to detailsEnabled ) ) } protected val binding by viewBinding(ViewContextMenuBinding::bind) protected val itemId by lazy { requireParcelable(ARG_ID) } private val showPinButtons by lazy { requireParcelable(ARG_OPTIONS).getBoolean(ARG_SHOW_PIN_BUTTONS) } private val detailsEnabled by lazy { requireParcelable(ARG_OPTIONS).getBoolean(ARG_DETAILS_ENABLED) } private val cornerRadius by lazy { dimenToPx(R.dimen.mediaTileCorner).toFloat() } private val cornerBigRadius by lazy { dimenToPx(R.dimen.collectionItemCorner).toFloat() } private val centerCropTransformation by lazy { CenterCrop() } private val cornersTransformation by lazy { GranularRoundedCorners(cornerBigRadius, cornerRadius, cornerRadius, cornerRadius) } protected val colorAccent by lazy { ContextCompat.getColor(requireContext(), R.color.colorAccent) } protected val colorGray by lazy { ContextCompat.getColor(requireContext(), R.color.colorGrayLight) } protected abstract fun openDetails() override fun getTheme(): Int = R.style.CustomBottomSheetDialog protected open fun setupView() { with(binding) { contextMenuItemDescription.setInitialLines(5) contextMenuItemPinButtonsLayout.visibleIf(showPinButtons) contextMenuItemSeparator2.visibleIf(showPinButtons) contextMenuItemImage.onClick { if (detailsEnabled) openDetails() } contextMenuItemPlaceholder.onClick { if (detailsEnabled) openDetails() } } } protected fun renderImage(image: Image, tvdbId: IdTvdb) { Glide.with(this).clear(binding.contextMenuItemImage) var imageUrl = image.fullFileUrl if (image.status == ImageStatus.UNAVAILABLE) { binding.contextMenuItemPlaceholder.visible() binding.contextMenuItemImage.gone() return } if (image.status == ImageStatus.UNKNOWN) { imageUrl = "${Config.TVDB_IMAGE_BASE_POSTER_URL}${tvdbId.id}-1.jpg" } Glide.with(this) .load(imageUrl) .transform(centerCropTransformation, cornersTransformation) .transition(DrawableTransitionOptions.withCrossFade(Config.IMAGE_FADE_DURATION_MS)) .withSuccessListener { binding.contextMenuItemPlaceholder.gone() binding.contextMenuItemImage.visible() } .withFailListener { binding.contextMenuItemPlaceholder.visible() binding.contextMenuItemImage.gone() } .into(binding.contextMenuItemImage) } protected fun renderSnackbar(message: MessageEvent) { when (message) { is MessageEvent.Info -> binding.contextMenuItemSnackbarHost.showInfoSnackbar(getString(message.textRestId)) is MessageEvent.Error -> binding.contextMenuItemSnackbarHost.showErrorSnackbar(getString(message.textRestId)) } } protected fun openRemoveTraktSheet(@IdRes action: Int, mode: Mode) { setFragmentResultListener(REQUEST_REMOVE_TRAKT) { _, bundle -> if (bundle.getBoolean(RESULT, false)) { val text = resources.getQuantityString(R.plurals.textTraktQuickSyncComplete, 1) (requireActivity() as SnackbarHost).provideSnackbarLayout().showInfoSnackbar(text) } close() } val args = RemoveTraktBottomSheet.createBundle(itemId, mode) navigateTo(action, args) } protected fun close() { setFragmentResult(REQUEST_ITEM_MENU, Bundle.EMPTY) closeSheet() dismiss() } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/events/FinishUiEvent.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.events data class FinishUiEvent(val isSuccess: Boolean) ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/events/RemoveTraktUiEvent.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.events data class RemoveTraktUiEvent( val removeProgress: Boolean = false, val removeWatchlist: Boolean = false, val removeHidden: Boolean = false ) ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/movie/MovieContextMenuBottomSheet.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.movie import android.content.res.ColorStateList import android.os.Bundle import android.view.View import androidx.core.os.bundleOf import androidx.core.widget.ImageViewCompat import androidx.fragment.app.viewModels import com.michaldrabik.common.Config.SPOILERS_HIDE_SYMBOL import com.michaldrabik.common.Config.SPOILERS_RATINGS_HIDE_SYMBOL import com.michaldrabik.common.Config.SPOILERS_REGEX import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.sheets.context_menu.ContextMenuBottomSheet import com.michaldrabik.ui_base.common.sheets.context_menu.events.FinishUiEvent import com.michaldrabik.ui_base.common.sheets.context_menu.events.RemoveTraktUiEvent import com.michaldrabik.ui_base.common.sheets.context_menu.movie.helpers.MovieContextItem import com.michaldrabik.ui_base.common.sheets.remove_trakt.RemoveTraktBottomSheet.Mode import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.extensions.capitalizeWords import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_navigation.java.NavigationArgs import dagger.hilt.android.AndroidEntryPoint import java.util.Locale @AndroidEntryPoint class MovieContextMenuBottomSheet : ContextMenuBottomSheet() { private val viewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() launchAndRepeatStarted( { viewModel.messageFlow.collect { renderSnackbar(it) } }, { viewModel.eventFlow.collect { handleEvent(it) } }, { viewModel.uiState.collect { render(it) } }, doAfterLaunch = { viewModel.loadMovie(itemId) } ) } override fun setupView() { super.setupView() with(binding) { contextMenuItemMoveToMyButton.text = getString(R.string.textMoveToMyMovies) contextMenuItemRemoveFromMyButton.text = getString(R.string.textRemoveFromMyMovies) contextMenuItemMoveToMyButton.onClick { viewModel.moveToMyMovies() } contextMenuItemRemoveFromMyButton.onClick { viewModel.removeFromMyMovies() } contextMenuItemMoveToWatchlistButton.onClick { viewModel.moveToWatchlist() } contextMenuItemRemoveFromWatchlistButton.onClick { viewModel.removeFromWatchlist() } contextMenuItemMoveToHiddenButton.onClick { viewModel.moveToHidden() } contextMenuItemRemoveFromHiddenButton.onClick { viewModel.removeFromHidden() } contextMenuItemPinButton.onClick { viewModel.addToTopPinned() } contextMenuItemUnpinButton.onClick { viewModel.removeFromTopPinned() } } } private fun render(uiState: MovieContextMenuUiState) { uiState.run { isLoading?.let { isLoading -> when { isLoading -> binding.contextMenuItemProgress.show() else -> binding.contextMenuItemProgress.hide() } binding.contextMenuItemButtonsLayout.visibleIf(!isLoading, gone = false) } item?.let { renderItem(it) renderImage(it.image, it.movie.ids.tvdb) } } } private fun renderItem(item: MovieContextItem) { with(binding) { contextMenuItemTitle.text = if (item.translation?.title.isNullOrBlank()) item.movie.title else item.translation?.title renderItemDescription(item) renderItemRating(item) contextMenuItemNetwork.text = when { item.movie.released != null -> item.dateFormat?.format(item.movie.released)?.capitalizeWords() else -> String.format(Locale.ENGLISH, "%d", item.movie.year) } contextMenuRatingStar.visibleIf(item.movie.rating > 0) contextMenuUserRating.text = String.format(Locale.ENGLISH, "%d", item.userRating) contextMenuUserRating.visibleIf(item.userRating != null) contextMenuUserRatingStar.visibleIf(item.userRating != null) contextMenuItemDescription.visibleIf(item.movie.overview.isNotBlank()) contextMenuItemNetwork.visibleIf(item.movie.released != null || item.movie.year > 0) contextMenuItemPinButton.visibleIf(!item.isPinnedTop) contextMenuItemUnpinButton.visibleIf(item.isPinnedTop) contextMenuItemMoveToMyButton.visibleIf(!item.isMyMovie) contextMenuItemMoveToWatchlistButton.visibleIf(!item.isWatchlist) contextMenuItemMoveToHiddenButton.visibleIf(!item.isHidden) contextMenuItemRemoveFromMyButton.visibleIf(item.isMyMovie) contextMenuItemRemoveFromWatchlistButton.visibleIf(item.isWatchlist) contextMenuItemRemoveFromHiddenButton.visibleIf(item.isHidden) contextMenuItemBadge.visibleIf(item.isMyMovie || item.isWatchlist) val color = if (item.isMyMovie) colorAccent else colorGray ImageViewCompat.setImageTintList(contextMenuItemBadge, ColorStateList.valueOf(color)) if (!item.isInCollection()) { contextMenuItemMoveToMyButton.text = getString(R.string.textAddToMyMovies) contextMenuItemMoveToWatchlistButton.text = getString(R.string.textAddToWatchlist) contextMenuItemMoveToHiddenButton.text = getString(R.string.textHide) } } } private fun renderItemDescription(item: MovieContextItem) { with(binding) { contextMenuItemDescription.text = if (item.translation?.overview.isNullOrBlank()) item.movie.overview else item.translation?.overview val isMyMovieHidden = item.spoilers.isMyMoviesHidden && item.isMyMovie val isWatchlistHidden = item.spoilers.isWatchlistMoviesHidden && item.isWatchlist val isHiddenMovieHidden = item.spoilers.isHiddenMoviesHidden && item.isHidden val isNotCollectedHidden = item.spoilers.isNotCollectedMoviesHidden && (!item.isInCollection()) if (isMyMovieHidden || isWatchlistHidden || isHiddenMovieHidden || isNotCollectedHidden) { val spoilerDescription = contextMenuItemDescription.text.toString() val hiddenDescription = SPOILERS_REGEX.replace(spoilerDescription, SPOILERS_HIDE_SYMBOL) contextMenuItemDescription.tag = spoilerDescription contextMenuItemDescription.text = hiddenDescription } if (item.spoilers.isTapToReveal) { with(contextMenuItemDescription) { onClick { tag?.let { text = it.toString() } enableFoldOnClick() } } } } } private fun renderItemRating(item: MovieContextItem) { with(binding) { var rating = String.format(Locale.ENGLISH, "%.1f", item.movie.rating) val isMyHidden = item.spoilers.isMyMoviesRatingsHidden && item.isMyMovie val isWatchlistHidden = item.spoilers.isWatchlistMoviesRatingsHidden && item.isWatchlist val isHiddenShowHidden = item.spoilers.isHiddenMoviesRatingsHidden && item.isHidden val isNotCollectedHidden = item.spoilers.isNotCollectedMoviesRatingsHidden && (!item.isInCollection()) if (isMyHidden || isWatchlistHidden || isHiddenShowHidden || isNotCollectedHidden) { contextMenuRating.tag = rating rating = SPOILERS_RATINGS_HIDE_SYMBOL } contextMenuRating.visibleIf(item.movie.rating > 0) contextMenuRatingStar.visibleIf(item.movie.rating > 0) contextMenuRating.text = rating if (item.spoilers.isTapToReveal) { with(contextMenuRating) { onClick { tag?.let { text = it.toString() } } } } } } private fun handleEvent(event: Event<*>) { when (val result = event.peek()) { is RemoveTraktUiEvent -> when { result.removeProgress -> openRemoveTraktSheet(R.id.actionMovieItemContextDialogToRemoveTraktProgress, Mode.MOVIE) result.removeWatchlist -> openRemoveTraktSheet(R.id.actionMovieItemContextDialogToRemoveTraktWatchlist, Mode.MOVIE) result.removeHidden -> openRemoveTraktSheet(R.id.actionMovieItemContextDialogToRemoveTraktHidden, Mode.MOVIE) else -> close() } is FinishUiEvent -> if (result.isSuccess) close() else -> throw IllegalStateException() } } override fun openDetails() { val bundle = bundleOf(NavigationArgs.ARG_MOVIE_ID to itemId.id) navigateTo(R.id.actionMovieItemContextDialogToMovieDetails, bundle) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/movie/MovieContextMenuUiState.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.movie import com.michaldrabik.ui_base.common.sheets.context_menu.movie.helpers.MovieContextItem data class MovieContextMenuUiState( val isLoading: Boolean? = null, val item: MovieContextItem? = null, ) ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/movie/MovieContextMenuViewModel.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.movie import android.annotation.SuppressLint import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.sheets.context_menu.events.FinishUiEvent import com.michaldrabik.ui_base.common.sheets.context_menu.events.RemoveTraktUiEvent import com.michaldrabik.ui_base.common.sheets.context_menu.movie.cases.MovieContextMenuHiddenCase import com.michaldrabik.ui_base.common.sheets.context_menu.movie.cases.MovieContextMenuLoadItemCase import com.michaldrabik.ui_base.common.sheets.context_menu.movie.cases.MovieContextMenuMyMoviesCase import com.michaldrabik.ui_base.common.sheets.context_menu.movie.cases.MovieContextMenuPinnedCase import com.michaldrabik.ui_base.common.sheets.context_menu.movie.cases.MovieContextMenuWatchlistCase import com.michaldrabik.ui_base.common.sheets.context_menu.movie.helpers.MovieContextItem import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.utilities.extensions.rethrowCancellation import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_model.IdTrakt import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.properties.Delegates.notNull @SuppressLint("StaticFieldLeak") @HiltViewModel class MovieContextMenuViewModel @Inject constructor( private val loadItemCase: MovieContextMenuLoadItemCase, private val myMoviesCase: MovieContextMenuMyMoviesCase, private val watchlistCase: MovieContextMenuWatchlistCase, private val hiddenCase: MovieContextMenuHiddenCase, private val pinnedCase: MovieContextMenuPinnedCase, private val settingsRepository: SettingsRepository ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private var movieId by notNull() private var isQuickRemoveEnabled by notNull() private val loadingState = MutableStateFlow(false) private val itemState = MutableStateFlow(null) fun loadMovie(idTrakt: IdTrakt) { viewModelScope.launch { movieId = idTrakt isQuickRemoveEnabled = settingsRepository.load().traktQuickRemoveEnabled try { loadingState.value = true val item = loadItemCase.loadItem(idTrakt) itemState.value = item } catch (error: Throwable) { messageChannel.send(MessageEvent.Error(R.string.errorGeneral)) } finally { loadingState.value = false } } } fun moveToMyMovies() { viewModelScope.launch { try { val result = myMoviesCase.moveToMyMovies(movieId) checkQuickRemove(result) } catch (error: Throwable) { onError(error) } } } fun removeFromMyMovies() { viewModelScope.launch { try { myMoviesCase.removeFromMyMovies(movieId) checkQuickRemove(RemoveTraktUiEvent(removeProgress = true)) } catch (error: Throwable) { onError(error) } } } fun moveToWatchlist() { viewModelScope.launch { try { val result = watchlistCase.moveToWatchlist(movieId) checkQuickRemove(result) } catch (error: Throwable) { onError(error) } } } fun removeFromWatchlist() { viewModelScope.launch { try { watchlistCase.removeFromWatchlist(movieId) checkQuickRemove(RemoveTraktUiEvent(removeWatchlist = true)) } catch (error: Throwable) { onError(error) } } } fun moveToHidden() { viewModelScope.launch { try { val result = hiddenCase.moveToHidden(movieId) checkQuickRemove(result) } catch (error: Throwable) { onError(error) } } } fun removeFromHidden() { viewModelScope.launch { try { hiddenCase.removeFromHidden(movieId) checkQuickRemove(RemoveTraktUiEvent(removeHidden = true)) } catch (error: Throwable) { onError(error) } } } fun addToTopPinned() { viewModelScope.launch { pinnedCase.addToTopPinned(movieId) eventChannel.send(Event(FinishUiEvent(true))) } } fun removeFromTopPinned() { viewModelScope.launch { pinnedCase.removeFromTopPinned(movieId) eventChannel.send(Event(FinishUiEvent(true))) } } private suspend fun checkQuickRemove(event: RemoveTraktUiEvent) { if (isQuickRemoveEnabled) { loadingState.value = false eventChannel.send(Event(event)) } else { eventChannel.send(Event(FinishUiEvent(true))) } } private suspend fun onError(error: Throwable) { loadingState.value = false messageChannel.send(MessageEvent.Error(R.string.errorGeneral)) rethrowCancellation(error) } val uiState = combine( loadingState, itemState ) { s1, s2 -> MovieContextMenuUiState( isLoading = s1, item = s2 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = MovieContextMenuUiState() ) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/movie/cases/MovieContextMenuHiddenCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.movie.cases import com.michaldrabik.common.Mode import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.data_local.database.model.TraktSyncQueue import com.michaldrabik.repository.PinnedItemsRepository import com.michaldrabik.repository.movies.MoviesRepository import com.michaldrabik.ui_base.common.sheets.context_menu.events.RemoveTraktUiEvent import com.michaldrabik.ui_base.trakt.quicksync.QuickSyncManager import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Movie import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class MovieContextMenuHiddenCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val moviesRepository: MoviesRepository, private val pinnedItemsRepository: PinnedItemsRepository, private val quickSyncManager: QuickSyncManager, ) { suspend fun moveToHidden(traktId: IdTrakt) = withContext(dispatchers.IO) { val movie = Movie.EMPTY.copy(ids = Ids.EMPTY.copy(traktId)) val (isMyMovie, isWatchlist) = awaitAll( async { moviesRepository.myMovies.exists(traktId) }, async { moviesRepository.watchlistMovies.exists(traktId) } ) moviesRepository.hiddenMovies.insert(movie.ids.trakt) pinnedItemsRepository.removePinnedItem(movie) with(quickSyncManager) { clearMovies(listOf(traktId.id)) clearWatchlistMovies(listOf(traktId.id)) scheduleHidden(traktId.id, Mode.MOVIES, TraktSyncQueue.Operation.ADD) } RemoveTraktUiEvent(removeProgress = isMyMovie, removeWatchlist = isWatchlist) } suspend fun removeFromHidden(traktId: IdTrakt) = withContext(dispatchers.IO) { moviesRepository.hiddenMovies.delete(traktId) quickSyncManager.clearHiddenMovies(listOf(traktId.id)) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/movie/cases/MovieContextMenuLoadItemCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.movie.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.repository.PinnedItemsRepository import com.michaldrabik.repository.RatingsRepository import com.michaldrabik.repository.TranslationsRepository import com.michaldrabik.repository.images.MovieImagesProvider import com.michaldrabik.repository.movies.MoviesRepository import com.michaldrabik.repository.settings.SettingsSpoilersRepository import com.michaldrabik.ui_base.common.sheets.context_menu.movie.helpers.MovieContextItem import com.michaldrabik.ui_base.dates.DateFormatProvider import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.ImageType import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.async import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class MovieContextMenuLoadItemCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val moviesRepository: MoviesRepository, private val pinnedItemsRepository: PinnedItemsRepository, private val imagesProvider: MovieImagesProvider, private val translationsRepository: TranslationsRepository, private val ratingsRepository: RatingsRepository, private val settingsSpoilersRepository: SettingsSpoilersRepository, private val dateFormatProvider: DateFormatProvider, ) { suspend fun loadItem(traktId: IdTrakt) = withContext(dispatchers.IO) { val movie = moviesRepository.movieDetails.load(traktId) val dateFormat = dateFormatProvider.loadShortDayFormat() val language = translationsRepository.getLanguage() val spoilers = settingsSpoilersRepository.getAll() val imageAsync = async { imagesProvider.findCachedImage(movie, ImageType.POSTER) } val translationAsync = async { translationsRepository.loadTranslation(movie, language = language, onlyLocal = true) } val ratingAsync = async { ratingsRepository.movies.loadRatings(listOf(movie)) } val isMyMovieAsync = async { moviesRepository.myMovies.exists(traktId) } val isWatchlistAsync = async { moviesRepository.watchlistMovies.exists(traktId) } val isHiddenAsync = async { moviesRepository.hiddenMovies.exists(traktId) } val isPinnedAsync = async { pinnedItemsRepository.isItemPinned(movie) } MovieContextItem( movie = movie, image = imageAsync.await(), translation = translationAsync.await(), userRating = ratingAsync.await().firstOrNull()?.rating, isMyMovie = isMyMovieAsync.await(), isWatchlist = isWatchlistAsync.await(), isHidden = isHiddenAsync.await(), isPinnedTop = isPinnedAsync.await(), dateFormat = dateFormat, spoilers = spoilers ) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/movie/cases/MovieContextMenuMyMoviesCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.movie.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.repository.PinnedItemsRepository import com.michaldrabik.repository.movies.MoviesRepository import com.michaldrabik.ui_base.common.sheets.context_menu.events.RemoveTraktUiEvent import com.michaldrabik.ui_base.notifications.AnnouncementManager import com.michaldrabik.ui_base.trakt.quicksync.QuickSyncManager import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Movie import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class MovieContextMenuMyMoviesCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val moviesRepository: MoviesRepository, private val pinnedItemsRepository: PinnedItemsRepository, private val announcementManager: AnnouncementManager, private val quickSyncManager: QuickSyncManager, ) { suspend fun moveToMyMovies(traktId: IdTrakt) = withContext(dispatchers.IO) { val movie = Movie.EMPTY.copy(ids = Ids.EMPTY.copy(traktId)) val (isWatchlist, isHidden) = awaitAll( async { moviesRepository.watchlistMovies.exists(traktId) }, async { moviesRepository.hiddenMovies.exists(traktId) } ) moviesRepository.myMovies.insert(traktId) pinnedItemsRepository.removePinnedItem(movie) announcementManager.refreshMoviesAnnouncements() with(quickSyncManager) { clearWatchlistMovies(listOf(traktId.id)) clearHiddenMovies(listOf(traktId.id)) scheduleMovies(listOf(traktId.id)) } RemoveTraktUiEvent(removeWatchlist = isWatchlist, removeHidden = isHidden) } suspend fun removeFromMyMovies(traktId: IdTrakt) = withContext(dispatchers.IO) { val movie = Movie.EMPTY.copy(ids = Ids.EMPTY.copy(traktId)) moviesRepository.myMovies.delete(traktId) pinnedItemsRepository.removePinnedItem(movie) quickSyncManager.clearMovies(listOf(traktId.id)) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/movie/cases/MovieContextMenuPinnedCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.movie.cases import com.michaldrabik.repository.PinnedItemsRepository import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Movie import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class MovieContextMenuPinnedCase @Inject constructor( private val pinnedItemsRepository: PinnedItemsRepository, ) { fun addToTopPinned(traktId: IdTrakt) { val movie = Movie.EMPTY.copy(ids = Ids.EMPTY.copy(traktId)) pinnedItemsRepository.addPinnedItem(movie) } fun removeFromTopPinned(traktId: IdTrakt) { val movie = Movie.EMPTY.copy(ids = Ids.EMPTY.copy(traktId)) pinnedItemsRepository.removePinnedItem(movie) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/movie/cases/MovieContextMenuWatchlistCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.movie.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.repository.PinnedItemsRepository import com.michaldrabik.repository.movies.MoviesRepository import com.michaldrabik.ui_base.common.sheets.context_menu.events.RemoveTraktUiEvent import com.michaldrabik.ui_base.notifications.AnnouncementManager import com.michaldrabik.ui_base.trakt.quicksync.QuickSyncManager import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Movie import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class MovieContextMenuWatchlistCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val moviesRepository: MoviesRepository, private val pinnedItemsRepository: PinnedItemsRepository, private val announcementManager: AnnouncementManager, private val quickSyncManager: QuickSyncManager, ) { suspend fun moveToWatchlist(traktId: IdTrakt) = withContext(dispatchers.IO) { val movie = Movie.EMPTY.copy(ids = Ids.EMPTY.copy(traktId)) val (isMyMovie, isHidden) = awaitAll( async { moviesRepository.myMovies.exists(traktId) }, async { moviesRepository.hiddenMovies.exists(traktId) } ) moviesRepository.watchlistMovies.insert(movie.ids.trakt) pinnedItemsRepository.removePinnedItem(movie) announcementManager.refreshMoviesAnnouncements() with(quickSyncManager) { clearMovies(listOf(traktId.id)) clearHiddenMovies(listOf(traktId.id)) scheduleMoviesWatchlist(listOf(traktId.id)) } RemoveTraktUiEvent(removeProgress = isMyMovie, removeHidden = isHidden) } suspend fun removeFromWatchlist(traktId: IdTrakt) = withContext(dispatchers.IO) { moviesRepository.watchlistMovies.delete(traktId) quickSyncManager.clearWatchlistMovies(listOf(traktId.id)) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/movie/helpers/MovieContextItem.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.movie.helpers import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.SpoilersSettings import com.michaldrabik.ui_model.Translation import java.time.format.DateTimeFormatter data class MovieContextItem( val movie: Movie, val image: Image, val translation: Translation?, val dateFormat: DateTimeFormatter?, val userRating: Int?, val isMyMovie: Boolean, val isWatchlist: Boolean, val isHidden: Boolean, val isPinnedTop: Boolean, val spoilers: SpoilersSettings ) { fun isInCollection() = isHidden || isWatchlist || isMyMovie } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/show/ShowContextMenuBottomSheet.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.show import android.content.res.ColorStateList import android.os.Bundle import android.view.View import androidx.core.os.bundleOf import androidx.core.widget.ImageViewCompat import androidx.fragment.app.viewModels import com.michaldrabik.common.Config.SPOILERS_HIDE_SYMBOL import com.michaldrabik.common.Config.SPOILERS_RATINGS_HIDE_SYMBOL import com.michaldrabik.common.Config.SPOILERS_REGEX import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.sheets.context_menu.ContextMenuBottomSheet import com.michaldrabik.ui_base.common.sheets.context_menu.events.FinishUiEvent import com.michaldrabik.ui_base.common.sheets.context_menu.events.RemoveTraktUiEvent import com.michaldrabik.ui_base.common.sheets.context_menu.show.helpers.ShowContextItem import com.michaldrabik.ui_base.common.sheets.remove_trakt.RemoveTraktBottomSheet.Mode import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_SHOW_ID import dagger.hilt.android.AndroidEntryPoint import java.util.Locale @AndroidEntryPoint class ShowContextMenuBottomSheet : ContextMenuBottomSheet() { private val viewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() launchAndRepeatStarted( { viewModel.messageFlow.collect { renderSnackbar(it) } }, { viewModel.eventFlow.collect { handleEvent(it) } }, { viewModel.uiState.collect { render(it) } }, doAfterLaunch = { viewModel.loadShow(itemId) } ) } override fun setupView() { super.setupView() with(binding) { contextMenuItemMoveToMyButton.text = getString(R.string.textMoveToMyShows) contextMenuItemRemoveFromMyButton.text = getString(R.string.textRemoveFromMyShows) contextMenuItemMoveToMyButton.onClick { viewModel.moveToMyShows() } contextMenuItemRemoveFromMyButton.onClick { viewModel.removeFromMyShows() } contextMenuItemMoveToWatchlistButton.onClick { viewModel.moveToWatchlist() } contextMenuItemRemoveFromWatchlistButton.onClick { viewModel.removeFromWatchlist() } contextMenuItemMoveToHiddenButton.onClick { viewModel.moveToHidden() } contextMenuItemRemoveFromHiddenButton.onClick { viewModel.removeFromHidden() } contextMenuItemPinButton.onClick { viewModel.addToTopPinned() } contextMenuItemUnpinButton.onClick { viewModel.removeFromTopPinned() } contextMenuItemAddOnHoldButton.onClick { viewModel.addToOnHoldPinned() } contextMenuItemRemoveOnHoldButton.onClick { viewModel.removeFromOnHoldPinned() } } } private fun render(uiState: ShowContextMenuUiState) { uiState.run { isLoading?.let { when { isLoading -> binding.contextMenuItemProgress.show() else -> binding.contextMenuItemProgress.hide() } binding.contextMenuItemButtonsLayout.visibleIf(!isLoading, gone = false) } isLoadingSecondary?.let { when { isLoadingSecondary -> binding.contextMenuItemProgressSecondary.visible() else -> binding.contextMenuItemProgressSecondary.gone() } binding.contextMenuItemButtonsLayout.visibleIf(!isLoadingSecondary, gone = false) } item?.let { renderItem(it) renderImage(it.image, it.show.ids.tvdb) } } } private fun renderItem(item: ShowContextItem) { with(binding) { contextMenuItemTitle.text = if (item.translation?.title.isNullOrBlank()) item.show.title else item.translation?.title renderItemDescription(item) renderItemRating(item) contextMenuItemNetwork.text = if (item.show.year > 0) getString(R.string.textNetwork, item.show.network, item.show.year.toString()) else String.format("%s", item.show.network) contextMenuUserRating.text = String.format(Locale.ENGLISH, "%d", item.userRating) contextMenuUserRating.visibleIf(item.userRating != null) contextMenuUserRatingStar.visibleIf(item.userRating != null) contextMenuItemDescription.visibleIf(item.show.overview.isNotBlank()) contextMenuItemNetwork.visibleIf(item.show.network.isNotBlank()) contextMenuItemPinButton.visibleIf(!item.isPinnedTop) contextMenuItemUnpinButton.visibleIf(item.isPinnedTop) contextMenuItemAddOnHoldButton.visibleIf(!item.isOnHold) contextMenuItemRemoveOnHoldButton.visibleIf(item.isOnHold) contextMenuItemMoveToMyButton.visibleIf(!item.isMyShow) contextMenuItemMoveToWatchlistButton.visibleIf(!item.isWatchlist) contextMenuItemMoveToHiddenButton.visibleIf(!item.isHidden) contextMenuItemRemoveFromMyButton.visibleIf(item.isMyShow) contextMenuItemRemoveFromWatchlistButton.visibleIf(item.isWatchlist) contextMenuItemRemoveFromHiddenButton.visibleIf(item.isHidden) contextMenuItemBadge.visibleIf(item.isMyShow || item.isWatchlist) val color = if (item.isMyShow) colorAccent else colorGray ImageViewCompat.setImageTintList(contextMenuItemBadge, ColorStateList.valueOf(color)) if (!item.isInCollection()) { contextMenuItemMoveToMyButton.text = getString(R.string.textAddToMyShows) contextMenuItemMoveToWatchlistButton.text = getString(R.string.textAddToWatchlist) contextMenuItemMoveToHiddenButton.text = getString(R.string.textHide) } } } private fun renderItemDescription(item: ShowContextItem) { with(binding) { contextMenuItemDescription.text = if (item.translation?.overview.isNullOrBlank()) item.show.overview else item.translation?.overview val isMyShowHidden = item.spoilers.isMyShowsHidden && item.isMyShow val isWatchlistHidden = item.spoilers.isWatchlistShowsHidden && item.isWatchlist val isHiddenShowHidden = item.spoilers.isHiddenShowsHidden && item.isHidden val isNotCollectedHidden = item.spoilers.isNotCollectedShowsHidden && (!item.isInCollection()) if (isMyShowHidden || isWatchlistHidden || isHiddenShowHidden || isNotCollectedHidden) { val spoilerDescription = contextMenuItemDescription.text.toString() val hiddenDescription = SPOILERS_REGEX.replace(contextMenuItemDescription.text.toString(), SPOILERS_HIDE_SYMBOL) contextMenuItemDescription.tag = spoilerDescription contextMenuItemDescription.text = hiddenDescription } if (item.spoilers.isTapToReveal) { with(contextMenuItemDescription) { onClick { tag?.let { text = it.toString() } enableFoldOnClick() } } } } } private fun renderItemRating(item: ShowContextItem) { with(binding) { var rating = String.format(Locale.ENGLISH, "%.1f", item.show.rating) val isMyShowHidden = item.spoilers.isMyShowsRatingsHidden && item.isMyShow val isWatchlistHidden = item.spoilers.isWatchlistShowsRatingsHidden && item.isWatchlist val isHiddenShowHidden = item.spoilers.isHiddenShowsRatingsHidden && item.isHidden val isNotCollectedHidden = item.spoilers.isNotCollectedShowsRatingsHidden && (!item.isInCollection()) if (isMyShowHidden || isWatchlistHidden || isHiddenShowHidden || isNotCollectedHidden) { contextMenuRating.tag = rating rating = SPOILERS_RATINGS_HIDE_SYMBOL } contextMenuRating.visibleIf(item.show.rating > 0) contextMenuRatingStar.visibleIf(item.show.rating > 0) contextMenuRating.text = rating if (item.spoilers.isTapToReveal) { with(contextMenuRating) { onClick { tag?.let { text = it.toString() } } } } } } private fun handleEvent(event: Event<*>) { when (val result = event.peek()) { is RemoveTraktUiEvent -> when { result.removeProgress -> openRemoveTraktSheet(R.id.actionShowItemContextDialogToRemoveTraktProgress, Mode.SHOW) result.removeWatchlist -> openRemoveTraktSheet(R.id.actionShowItemContextDialogToRemoveTraktWatchlist, Mode.SHOW) result.removeHidden -> openRemoveTraktSheet(R.id.actionShowItemContextDialogToRemoveTraktHidden, Mode.SHOW) else -> close() } is FinishUiEvent -> if (result.isSuccess) close() else -> throw IllegalStateException() } } override fun openDetails() { val bundle = bundleOf(ARG_SHOW_ID to itemId.id) navigateTo(R.id.actionShowItemContextDialogToShowDetails, bundle) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/show/ShowContextMenuUiState.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.show import com.michaldrabik.ui_base.common.sheets.context_menu.show.helpers.ShowContextItem data class ShowContextMenuUiState( val isLoading: Boolean? = null, val isLoadingSecondary: Boolean? = null, val item: ShowContextItem? = null, ) ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/show/ShowContextMenuViewModel.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.show import android.annotation.SuppressLint import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.repository.images.ShowImagesProvider import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.sheets.context_menu.events.FinishUiEvent import com.michaldrabik.ui_base.common.sheets.context_menu.events.RemoveTraktUiEvent import com.michaldrabik.ui_base.common.sheets.context_menu.show.cases.ShowContextMenuHiddenCase import com.michaldrabik.ui_base.common.sheets.context_menu.show.cases.ShowContextMenuLoadItemCase import com.michaldrabik.ui_base.common.sheets.context_menu.show.cases.ShowContextMenuMyShowsCase import com.michaldrabik.ui_base.common.sheets.context_menu.show.cases.ShowContextMenuOnHoldCase import com.michaldrabik.ui_base.common.sheets.context_menu.show.cases.ShowContextMenuPinnedCase import com.michaldrabik.ui_base.common.sheets.context_menu.show.cases.ShowContextMenuWatchlistCase import com.michaldrabik.ui_base.common.sheets.context_menu.show.helpers.ShowContextItem import com.michaldrabik.ui_base.network.NetworkStatusProvider import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.utilities.extensions.launchDelayed import com.michaldrabik.ui_base.utilities.extensions.rethrowCancellation import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.ImageType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject import kotlin.properties.Delegates.notNull @SuppressLint("StaticFieldLeak") @HiltViewModel class ShowContextMenuViewModel @Inject constructor( private val loadItemCase: ShowContextMenuLoadItemCase, private val myShowsCase: ShowContextMenuMyShowsCase, private val watchlistCase: ShowContextMenuWatchlistCase, private val hiddenCase: ShowContextMenuHiddenCase, private val pinnedCase: ShowContextMenuPinnedCase, private val onHoldCase: ShowContextMenuOnHoldCase, private val imagesProvider: ShowImagesProvider, private val networkProvider: NetworkStatusProvider, private val settingsRepository: SettingsRepository ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private var showId by notNull() private var isQuickRemoveEnabled by notNull() private val loadingState = MutableStateFlow(false) private val loadingSecondaryState = MutableStateFlow(false) private val itemState = MutableStateFlow(null) fun loadShow(idTrakt: IdTrakt) { viewModelScope.launch { showId = idTrakt isQuickRemoveEnabled = settingsRepository.load().traktQuickRemoveEnabled try { loadingState.value = true val item = loadItemCase.loadItem(idTrakt) itemState.value = item } catch (error: Throwable) { messageChannel.send(MessageEvent.Error(R.string.errorGeneral)) } finally { loadingState.value = false } } } fun moveToMyShows() { viewModelScope.launch { if (!networkProvider.isOnline()) { messageChannel.send(MessageEvent.Error(R.string.errorNoInternetConnection)) return@launch } val progressJob = launchDelayed(250) { loadingSecondaryState.value = true } try { val result = myShowsCase.moveToMyShows(showId) preloadImage() checkQuickRemove(result) } catch (error: Throwable) { onError(error) } finally { progressJob.cancel() } } } fun removeFromMyShows() { viewModelScope.launch { try { myShowsCase.removeFromMyShows( traktId = showId, removeLocalData = networkProvider.isOnline() ) checkQuickRemove(RemoveTraktUiEvent(removeProgress = true)) } catch (error: Throwable) { onError(error) } } } fun moveToWatchlist() { viewModelScope.launch { try { val result = watchlistCase.moveToWatchlist( traktId = showId, removeLocalData = networkProvider.isOnline() ) checkQuickRemove(result) } catch (error: Throwable) { onError(error) } } } fun removeFromWatchlist() { viewModelScope.launch { try { watchlistCase.removeFromWatchlist(showId) checkQuickRemove(RemoveTraktUiEvent(removeWatchlist = true)) } catch (error: Throwable) { onError(error) } } } fun moveToHidden() { viewModelScope.launch { try { val result = hiddenCase.moveToHidden( traktId = showId, removeLocalData = networkProvider.isOnline() ) checkQuickRemove(result) } catch (error: Throwable) { onError(error) } } } fun removeFromHidden() { viewModelScope.launch { try { hiddenCase.removeFromHidden(showId) checkQuickRemove(RemoveTraktUiEvent(removeHidden = true)) } catch (error: Throwable) { onError(error) } } } fun addToTopPinned() { viewModelScope.launch { pinnedCase.addToTopPinned(showId) eventChannel.send(Event(FinishUiEvent(true))) } } fun removeFromTopPinned() { viewModelScope.launch { pinnedCase.removeFromTopPinned(showId) eventChannel.send(Event(FinishUiEvent(true))) } } fun addToOnHoldPinned() { viewModelScope.launch { onHoldCase.addToOnHold(showId) eventChannel.send(Event(FinishUiEvent(true))) } } fun removeFromOnHoldPinned() { viewModelScope.launch { onHoldCase.removeFromOnHold(showId) eventChannel.send(Event(FinishUiEvent(true))) } } private suspend fun preloadImage() { try { val show = itemState.value?.show show?.let { imagesProvider.loadRemoteImage(it, ImageType.FANART) } } catch (error: Throwable) { Timber.e(error) rethrowCancellation(error) } } private suspend fun checkQuickRemove(event: RemoveTraktUiEvent) { if (isQuickRemoveEnabled) { loadingState.value = false loadingSecondaryState.value = false eventChannel.send(Event(event)) } else { eventChannel.send(Event(FinishUiEvent(true))) } } private suspend fun onError(error: Throwable) { loadingState.value = false loadingSecondaryState.value = false messageChannel.send(MessageEvent.Error(R.string.errorGeneral)) rethrowCancellation(error) } val uiState = combine( loadingState, loadingSecondaryState, itemState ) { s1, s2, s3 -> ShowContextMenuUiState( isLoading = s1, isLoadingSecondary = s2, item = s3 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = ShowContextMenuUiState() ) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/show/cases/ShowContextMenuHiddenCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.show.cases import com.michaldrabik.common.Mode import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.Season import com.michaldrabik.data_local.database.model.TraktSyncQueue import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.repository.PinnedItemsRepository import com.michaldrabik.repository.shows.ShowsRepository import com.michaldrabik.ui_base.common.sheets.context_menu.events.RemoveTraktUiEvent import com.michaldrabik.ui_base.notifications.AnnouncementManager import com.michaldrabik.ui_base.trakt.quicksync.QuickSyncManager import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Show import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class ShowContextMenuHiddenCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val showsRepository: ShowsRepository, private val pinnedItemsRepository: PinnedItemsRepository, private val quickSyncManager: QuickSyncManager, private val announcementManager: AnnouncementManager, ) { suspend fun moveToHidden(traktId: IdTrakt, removeLocalData: Boolean) = withContext(dispatchers.IO) { val show = Show.EMPTY.copy(ids = Ids.EMPTY.copy(traktId)) val (isMyShow, isWatchlist) = awaitAll( async { showsRepository.myShows.exists(traktId) }, async { showsRepository.watchlistShows.exists(traktId) } ) transactions.withTransaction { showsRepository.hiddenShows.insert(show.ids.trakt) if (removeLocalData && isMyShow) { localSource.episodes.deleteAllUnwatchedForShow(traktId.id) val seasons = localSource.seasons.getAllByShowId(traktId.id) val episodes = localSource.episodes.getAllByShowId(traktId.id) val toDelete = mutableListOf() seasons.forEach { season -> if (episodes.none { it.idSeason == season.idTrakt }) { toDelete.add(season) } } localSource.seasons.delete(toDelete) } } pinnedItemsRepository.removePinnedItem(show) announcementManager.refreshShowsAnnouncements() with(quickSyncManager) { clearWatchlistShows(listOf(traktId.id)) scheduleHidden(traktId.id, Mode.SHOWS, TraktSyncQueue.Operation.ADD) } RemoveTraktUiEvent(removeProgress = isMyShow, removeWatchlist = isWatchlist) } suspend fun removeFromHidden(traktId: IdTrakt) = withContext(dispatchers.IO) { showsRepository.hiddenShows.delete(traktId) announcementManager.refreshShowsAnnouncements() quickSyncManager.clearHiddenShows(listOf(traktId.id)) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/show/cases/ShowContextMenuLoadItemCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.show.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.repository.OnHoldItemsRepository import com.michaldrabik.repository.PinnedItemsRepository import com.michaldrabik.repository.RatingsRepository import com.michaldrabik.repository.TranslationsRepository import com.michaldrabik.repository.images.ShowImagesProvider import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.repository.shows.ShowsRepository import com.michaldrabik.ui_base.common.sheets.context_menu.show.helpers.ShowContextItem import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.ImageType import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.async import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class ShowContextMenuLoadItemCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val showsRepository: ShowsRepository, private val pinnedItemsRepository: PinnedItemsRepository, private val onHoldItemsRepository: OnHoldItemsRepository, private val imagesProvider: ShowImagesProvider, private val translationsRepository: TranslationsRepository, private val ratingsRepository: RatingsRepository, private val settingsRepository: SettingsRepository, ) { suspend fun loadItem(traktId: IdTrakt) = withContext(dispatchers.IO) { val show = showsRepository.detailsShow.load(traktId) val language = translationsRepository.getLanguage() val spoilers = settingsRepository.spoilers.getAll() val imageAsync = async { imagesProvider.findCachedImage(show, ImageType.POSTER) } val translationAsync = async { translationsRepository.loadTranslation(show, language = language, onlyLocal = true) } val ratingAsync = async { ratingsRepository.shows.loadRatings(listOf(show)) } val isMyShowAsync = async { showsRepository.myShows.exists(traktId) } val isWatchlistAsync = async { showsRepository.watchlistShows.exists(traktId) } val isHiddenAsync = async { showsRepository.hiddenShows.exists(traktId) } val isPinnedAsync = async { pinnedItemsRepository.isItemPinned(show) } val isOnHoldAsync = async { onHoldItemsRepository.isOnHold(show) } ShowContextItem( show = show, image = imageAsync.await(), translation = translationAsync.await(), userRating = ratingAsync.await().firstOrNull()?.rating, isMyShow = isMyShowAsync.await(), isWatchlist = isWatchlistAsync.await(), isHidden = isHiddenAsync.await(), isPinnedTop = isPinnedAsync.await(), isOnHold = isOnHoldAsync.await(), spoilers = spoilers ) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/show/cases/ShowContextMenuMyShowsCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.show.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.PinnedItemsRepository import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.repository.shows.ShowsRepository import com.michaldrabik.ui_base.common.sheets.context_menu.events.RemoveTraktUiEvent import com.michaldrabik.ui_base.notifications.AnnouncementManager import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Show import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import javax.inject.Inject import com.michaldrabik.data_local.database.model.Episode as EpisodeDb import com.michaldrabik.data_local.database.model.Season as SeasonDb @ViewModelScoped class ShowContextMenuMyShowsCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val remoteSource: RemoteDataSource, private val mappers: Mappers, private val showsRepository: ShowsRepository, private val pinnedItemsRepository: PinnedItemsRepository, private val settingsRepository: SettingsRepository, private val announcementManager: AnnouncementManager, ) { suspend fun moveToMyShows(traktId: IdTrakt) = withContext(dispatchers.IO) { val show = Show.EMPTY.copy(ids = Ids.EMPTY.copy(traktId)) val (isWatchlist, isHidden) = awaitAll( async { showsRepository.watchlistShows.exists(traktId) }, async { showsRepository.hiddenShows.exists(traktId) } ) val seasons = remoteSource.trakt.fetchSeasons(traktId.id) .map { mappers.season.fromNetwork(it) } .filter { it.episodes.isNotEmpty() } .filter { if (!showSpecials()) !it.isSpecial() else true } val episodes = seasons.flatMap { it.episodes } transactions.withTransaction { val localSeasons = localSource.seasons.getAllByShowId(traktId.id) val localEpisodes = localSource.episodes.getAllByShowId(traktId.id) val lastWatchedAt = localEpisodes.maxByOrNull { it.lastWatchedAt != null }?.lastWatchedAt?.toMillis() ?: 0L showsRepository.myShows.insert(traktId, lastWatchedAt) val seasonsToAdd = mutableListOf() val episodesToAdd = mutableListOf() seasons.forEach { season -> if (localSeasons.none { it.idTrakt == season.ids.trakt.id }) { seasonsToAdd.add(mappers.season.toDatabase(season, traktId, false)) } } episodes.forEach { episode -> if (localEpisodes.none { it.idTrakt == episode.ids.trakt.id }) { val season = seasons.find { it.number == episode.season }!! episodesToAdd.add(mappers.episode.toDatabase(episode, season, traktId, false, null)) } } localSource.seasons.upsert(seasonsToAdd) localSource.episodes.upsert(episodesToAdd) } pinnedItemsRepository.removePinnedItem(show) announcementManager.refreshShowsAnnouncements() RemoveTraktUiEvent(removeWatchlist = isWatchlist, removeHidden = isHidden) } suspend fun removeFromMyShows(traktId: IdTrakt, removeLocalData: Boolean) = withContext(dispatchers.IO) { val show = Show.EMPTY.copy(ids = Ids.EMPTY.copy(traktId)) transactions.withTransaction { showsRepository.myShows.delete(show.ids.trakt) if (removeLocalData) { localSource.episodes.deleteAllUnwatchedForShow(show.traktId) val seasons = localSource.seasons.getAllByShowId(show.traktId) val episodes = localSource.episodes.getAllByShowId(show.traktId) val toDelete = mutableListOf() seasons.forEach { season -> if (episodes.none { it.idSeason == season.idTrakt }) { toDelete.add(season) } } localSource.seasons.delete(toDelete) } pinnedItemsRepository.removePinnedItem(show) announcementManager.refreshShowsAnnouncements() } } private suspend fun showSpecials() = withContext(dispatchers.IO) { settingsRepository.load().specialSeasonsEnabled } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/show/cases/ShowContextMenuOnHoldCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.show.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.repository.OnHoldItemsRepository import com.michaldrabik.repository.PinnedItemsRepository import com.michaldrabik.ui_base.notifications.AnnouncementManager import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Show import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class ShowContextMenuOnHoldCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val onHoldItemsRepository: OnHoldItemsRepository, private val pinnedItemsRepository: PinnedItemsRepository, private val announcementManager: AnnouncementManager, ) { suspend fun addToOnHold(traktId: IdTrakt) { val show = Show.EMPTY.copy(ids = Ids.EMPTY.copy(traktId)) pinnedItemsRepository.removePinnedItem(show) onHoldItemsRepository.addItem(show) withContext(dispatchers.IO) { announcementManager.refreshShowsAnnouncements() } } suspend fun removeFromOnHold(traktId: IdTrakt) { val show = Show.EMPTY.copy(ids = Ids.EMPTY.copy(traktId)) onHoldItemsRepository.removeItem(show) withContext(dispatchers.IO) { announcementManager.refreshShowsAnnouncements() } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/show/cases/ShowContextMenuPinnedCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.show.cases import com.michaldrabik.repository.OnHoldItemsRepository import com.michaldrabik.repository.PinnedItemsRepository import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Show import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class ShowContextMenuPinnedCase @Inject constructor( private val pinnedItemsRepository: PinnedItemsRepository, private val onHoldItemsRepository: OnHoldItemsRepository, ) { fun addToTopPinned(traktId: IdTrakt) { val show = Show.EMPTY.copy(ids = Ids.EMPTY.copy(traktId)) onHoldItemsRepository.removeItem(show) pinnedItemsRepository.addPinnedItem(show) } fun removeFromTopPinned(traktId: IdTrakt) { val show = Show.EMPTY.copy(ids = Ids.EMPTY.copy(traktId)) pinnedItemsRepository.removePinnedItem(show) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/show/cases/ShowContextMenuWatchlistCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.show.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.Season import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.repository.PinnedItemsRepository import com.michaldrabik.repository.shows.ShowsRepository import com.michaldrabik.ui_base.common.sheets.context_menu.events.RemoveTraktUiEvent import com.michaldrabik.ui_base.notifications.AnnouncementManager import com.michaldrabik.ui_base.trakt.quicksync.QuickSyncManager import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Show import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class ShowContextMenuWatchlistCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val showsRepository: ShowsRepository, private val pinnedItemsRepository: PinnedItemsRepository, private val quickSyncManager: QuickSyncManager, private val announcementManager: AnnouncementManager, ) { suspend fun moveToWatchlist( traktId: IdTrakt, removeLocalData: Boolean, ) = withContext(dispatchers.IO) { val show = Show.EMPTY.copy(ids = Ids.EMPTY.copy(traktId)) val (isMyShow, isHidden) = awaitAll( async { showsRepository.myShows.exists(traktId) }, async { showsRepository.hiddenShows.exists(traktId) } ) transactions.withTransaction { showsRepository.watchlistShows.insert(show.ids.trakt) if (removeLocalData && isMyShow) { localSource.episodes.deleteAllUnwatchedForShow(traktId.id) val seasons = localSource.seasons.getAllByShowId(traktId.id) val episodes = localSource.episodes.getAllByShowId(traktId.id) val toDelete = mutableListOf() seasons.forEach { season -> if (episodes.none { it.idSeason == season.idTrakt }) { toDelete.add(season) } } localSource.seasons.delete(toDelete) } } pinnedItemsRepository.removePinnedItem(show) announcementManager.refreshShowsAnnouncements() with(quickSyncManager) { clearHiddenShows(listOf(traktId.id)) scheduleShowsWatchlist(listOf(traktId.id)) } RemoveTraktUiEvent(removeProgress = isMyShow, removeHidden = isHidden) } suspend fun removeFromWatchlist(traktId: IdTrakt) = withContext(dispatchers.IO) { showsRepository.watchlistShows.delete(traktId) announcementManager.refreshShowsAnnouncements() quickSyncManager.clearWatchlistShows(listOf(traktId.id)) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/context_menu/show/helpers/ShowContextItem.kt ================================================ package com.michaldrabik.ui_base.common.sheets.context_menu.show.helpers import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_model.SpoilersSettings import com.michaldrabik.ui_model.Translation data class ShowContextItem( val show: Show, val image: Image, val translation: Translation?, val userRating: Int?, val isMyShow: Boolean, val isWatchlist: Boolean, val isHidden: Boolean, val isPinnedTop: Boolean, val isOnHold: Boolean, val spoilers: SpoilersSettings ) { fun isInCollection() = isHidden || isWatchlist || isMyShow } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/links/LinksBottomSheet.kt ================================================ package com.michaldrabik.ui_base.common.sheets.links import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri import android.os.Bundle import android.os.Parcelable import android.view.View import androidx.core.os.bundleOf import androidx.fragment.app.viewModels import com.michaldrabik.common.Mode import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.databinding.ViewLinksBinding import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.openWebUrl import com.michaldrabik.ui_base.utilities.extensions.requireParcelable import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_navigation.java.NavigationArgs import dagger.hilt.android.AndroidEntryPoint import kotlinx.parcelize.Parcelize @AndroidEntryPoint class LinksBottomSheet : BaseBottomSheetFragment(R.layout.view_links) { @Parcelize data class Options( val ids: Ids, val title: String, val website: String, val type: Mode, ) : Parcelable companion object { fun createBundle(movie: Movie): Bundle { val options = Options(movie.ids, movie.title, movie.homepage, Mode.MOVIES) return bundleOf(NavigationArgs.ARG_OPTIONS to options) } fun createBundle(show: Show): Bundle { val options = Options(show.ids, show.title, show.homepage, Mode.SHOWS) return bundleOf(NavigationArgs.ARG_OPTIONS to options) } } private val viewModel by viewModels() private val binding by viewBinding(ViewLinksBinding::bind) private val options by lazy { requireParcelable(NavigationArgs.ARG_OPTIONS) } private val ids by lazy { options.ids } private val title by lazy { options.title } private val website by lazy { options.website } private val type by lazy { options.type } override fun getTheme(): Int = R.style.CustomBottomSheetDialog override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() } private fun setupView() { with(binding) { viewLinksJustWatch.onClick { val country = viewModel.loadCountry() openWebUrl("https://www.justwatch.com/${country.code}/${country.justWatchQuery}?content_type=${type.type}&q=${Uri.encode(title)}") } viewLinksYouTube.onClick { openWebUrl("https://www.youtube.com/results?search_query=${getQuery()}") } viewLinksWiki.onClick { openWebUrl("https://en.wikipedia.org/w/index.php?search=${getQuery()}") } viewLinksGoogle.onClick { openWebUrl("https://www.google.com/search?q=${getQuery()}") } viewLinksDuckDuck.onClick { openWebUrl("https://duckduckgo.com/?q=${getQuery()}") } viewLinksGif.onClick { openWebUrl("https://giphy.com/search/${getQuery()}") } viewLinksTwitter.onClick { openWebUrl("https://twitter.com/search?q=${getQuery()}&src=typed_query") } viewLinksButtonClose.onClick { closeSheet() } } setWebLink() setTraktLink() setTvdbLink() setTmdbLink() setImdbLink() } private fun setWebLink() { binding.viewLinksWebsite.run { if (website.isBlank()) { alpha = 0.5F isEnabled = false } else { onClick { openWebUrl(website) } } } } private fun setTraktLink() { binding.viewLinksTrakt.run { if (ids.trakt.id == -1L) { alpha = 0.5F isEnabled = false } else { onClick { openWebUrl("https://trakt.tv/search/trakt/${ids.trakt.id}?id_type=${type.type}") } } } } private fun setTvdbLink() { binding.viewLinksTvdb.run { if (ids.tvdb.id == -1L) { alpha = 0.5F isEnabled = false } else { onClick { when (type) { Mode.SHOWS -> openWebUrl("https://www.thetvdb.com/?id=${ids.tvdb.id}&tab=series") Mode.MOVIES -> openWebUrl("https://www.thetvdb.com/?id=${ids.tvdb.id}&tab=movies") } } } } } private fun setTmdbLink() { binding.viewLinksTmdb.run { if (ids.tmdb.id == -1L) { alpha = 0.5F isEnabled = false } else { onClick { when (type) { Mode.SHOWS -> openWebUrl("https://www.themoviedb.org/tv/${ids.tmdb.id}") Mode.MOVIES -> openWebUrl("https://www.themoviedb.org/movie/${ids.tmdb.id}") } } } } } private fun setImdbLink() { binding.viewLinksImdb.run { if (ids.imdb.id.isBlank()) { alpha = 0.5F isEnabled = false } else { onClick { val i = Intent(Intent.ACTION_VIEW) i.data = Uri.parse("imdb:///title/${ids.imdb.id}") try { startActivity(i) } catch (e: ActivityNotFoundException) { // IMDb App not installed. Start in web browser openWebUrl("https://www.imdb.com/title/${ids.imdb.id}") } } } } } private fun getQuery() = when (type) { Mode.SHOWS -> "$title TV Series" Mode.MOVIES -> "$title Movie" } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/links/LinksViewModel.kt ================================================ package com.michaldrabik.ui_base.common.sheets.links import androidx.lifecycle.ViewModel import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.common.AppCountry import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class LinksViewModel @Inject constructor( private val settingsRepository: SettingsRepository ) : ViewModel() { fun loadCountry() = AppCountry.fromCode(settingsRepository.country) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/links/views/LinkItemView.kt ================================================ package com.michaldrabik.ui_base.common.sheets.links.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.databinding.ViewLinksItemBinding class LinkItemView : FrameLayout { private val binding = ViewLinksItemBinding.inflate(LayoutInflater.from(context), this) constructor(context: Context) : this(context, null) constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) clipChildren = false clipToPadding = false context.theme.obtainStyledAttributes(attrs, R.styleable.LinkItem, 0, 0).apply { try { with(binding) { viewLinkItemName.text = getString(R.styleable.LinkItem_text) viewLinkItemImage.setImageResource(getResourceId(R.styleable.LinkItem_icon, -1)) } } finally { recycle() } } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/ratings/RatingsBottomSheet.kt ================================================ package com.michaldrabik.ui_base.common.sheets.ratings import android.os.Bundle import android.os.Parcelable import android.view.View import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.views.RateValueView.Direction import com.michaldrabik.ui_base.databinding.ViewRateSheetBinding import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.requireParcelable import com.michaldrabik.ui_base.utilities.extensions.showErrorSnackbar import com.michaldrabik.ui_base.utilities.extensions.showInfoSnackbar import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.TraktRating import com.michaldrabik.ui_navigation.java.NavigationArgs import dagger.hilt.android.AndroidEntryPoint import kotlinx.parcelize.Parcelize @AndroidEntryPoint class RatingsBottomSheet : BaseBottomSheetFragment(R.layout.view_rate_sheet) { companion object { fun createBundle(id: IdTrakt, type: Options.Type): Bundle { val options = Options(id, type) return bundleOf(NavigationArgs.ARG_OPTIONS to options) } private const val INITIAL_RATING = 5 } private val viewModel by viewModels() private val binding by viewBinding(ViewRateSheetBinding::bind) private val options by lazy { requireParcelable(NavigationArgs.ARG_OPTIONS) } private val id by lazy { options.id } private val type by lazy { options.type } private val starsViews by lazy { with(binding) { listOf(star1, star2, star3, star4, star5, star6, star7, star8, star9, star10) } } private var selectedRating = INITIAL_RATING override fun getTheme(): Int = R.style.CustomBottomSheetDialog override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() launchAndRepeatStarted( { viewModel.uiState.collect { render(it) } }, { viewModel.messageFlow.collect { renderSnackbar(it) } }, { viewModel.eventFlow.collect { handleEvent(it) } }, doAfterLaunch = { viewModel.loadRating(id, type) } ) } private fun setupView() { renderRating(INITIAL_RATING) starsViews.forEach { star -> star.onClick { renderRating(it.tag.toString().toInt(), animate = true) } } binding.viewRateSheetSaveButton.onClick { viewModel.saveRating(selectedRating, id, type) } binding.viewRateSheetRemoveButton.onClick { viewModel.removeRating(id, type) } } private fun render(uiState: RatingsUiState) { with(uiState) { with(binding) { isLoading?.let { viewRateSheetProgress.visibleIf(it) viewRateSheetSaveButton.visibleIf(!it, gone = false) viewRateSheetRemoveButton.visibleIf(!it, gone = false) starsViews.forEach { view -> view.isEnabled = !it } } rating?.let { viewRateSheetSaveButton.isEnabled = true if (isLoading != true) { viewRateSheetRemoveButton.visibleIf(it != TraktRating.EMPTY) } viewRateSheetStarsLayout.visible() if (it != TraktRating.EMPTY && isLoading != true) { renderRating(it.rating) } } } } } private fun renderRating(rate: Int, animate: Boolean = false) { val currentRating = selectedRating selectedRating = rate.coerceIn(1..10) starsViews.forEach { it.setImageResource(R.drawable.ic_star_empty) } (1..selectedRating).forEachIndexed { index, _ -> starsViews[index].setImageResource(R.drawable.ic_star) } if (animate && currentRating != selectedRating) { val direction = if (currentRating > selectedRating) Direction.RIGHT else Direction.LEFT binding.viewRateSheetRating.setValueAnimated(selectedRating.toString(), direction) } else { binding.viewRateSheetRating.setValue(selectedRating.toString()) } } private fun renderSnackbar(message: MessageEvent) { when (message) { is MessageEvent.Info -> binding.viewRateSheetSnackHost.showInfoSnackbar(getString(message.textRestId)) is MessageEvent.Error -> binding.viewRateSheetSnackHost.showErrorSnackbar(getString(message.textRestId)) } } private fun handleEvent(event: Event<*>) { when (event) { is FinishUiEvent -> closeWithSuccess(event.operation) } } private fun closeWithSuccess(operation: Options.Operation) { val result = bundleOf(NavigationArgs.RESULT to operation) setFragmentResult(NavigationArgs.REQUEST_RATING, result) closeSheet() } @Parcelize data class Options( val id: IdTrakt, val type: Type, ) : Parcelable { enum class Type { SHOW, MOVIE, EPISODE, SEASON } @Parcelize enum class Operation : Parcelable { SAVE, REMOVE } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/ratings/RatingsSheetViewModel.kt ================================================ package com.michaldrabik.ui_base.common.sheets.ratings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError.CoroutineCancellation import com.michaldrabik.common.errors.ShowlyError.UnauthorizedError import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.sheets.ratings.RatingsBottomSheet.Options.Operation import com.michaldrabik.ui_base.common.sheets.ratings.RatingsBottomSheet.Options.Type import com.michaldrabik.ui_base.common.sheets.ratings.cases.RatingsEpisodeCase import com.michaldrabik.ui_base.common.sheets.ratings.cases.RatingsMovieCase import com.michaldrabik.ui_base.common.sheets.ratings.cases.RatingsSeasonCase import com.michaldrabik.ui_base.common.sheets.ratings.cases.RatingsShowCase import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.TraktRating import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class RatingsSheetViewModel @Inject constructor( private val showRatingsCase: RatingsShowCase, private val movieRatingsCase: RatingsMovieCase, private val episodeRatingsCase: RatingsEpisodeCase, private val seasonRatingsCase: RatingsSeasonCase, ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val loadingState = MutableStateFlow(false) private val ratingState = MutableStateFlow(null) fun loadRating(idTrakt: IdTrakt, type: Type) { viewModelScope.launch { try { val rating = when (type) { Type.SHOW -> showRatingsCase.loadRating(idTrakt) Type.MOVIE -> movieRatingsCase.loadRating(idTrakt) Type.EPISODE -> episodeRatingsCase.loadRating(idTrakt) Type.SEASON -> seasonRatingsCase.loadRating(idTrakt) } ratingState.value = rating } catch (error: Throwable) { handleError(error) } } } fun saveRating(rating: Int, id: IdTrakt, type: Type) { viewModelScope.launch { try { loadingState.value = true when (type) { Type.SHOW -> showRatingsCase.saveRating(id, rating) Type.MOVIE -> movieRatingsCase.saveRating(id, rating) Type.EPISODE -> episodeRatingsCase.saveRating(id, rating) Type.SEASON -> seasonRatingsCase.saveRating(id, rating) } eventChannel.send(FinishUiEvent(operation = Operation.SAVE)) } catch (error: Throwable) { loadingState.value = false handleError(error) } } } fun removeRating(id: IdTrakt, type: Type) { viewModelScope.launch { try { loadingState.value = true when (type) { Type.SHOW -> showRatingsCase.deleteRating(id) Type.MOVIE -> movieRatingsCase.deleteRating(id) Type.EPISODE -> episodeRatingsCase.deleteRating(id) Type.SEASON -> seasonRatingsCase.deleteRating(id) } eventChannel.send(FinishUiEvent(operation = Operation.REMOVE)) } catch (error: Throwable) { loadingState.value = false handleError(error) } } } private suspend fun handleError(error: Throwable) { when (ErrorHelper.parse(error)) { is CoroutineCancellation -> throw error is UnauthorizedError -> messageChannel.send(MessageEvent.Error(R.string.errorTraktAuthorization)) else -> messageChannel.send(MessageEvent.Error(R.string.errorGeneral)) } } val uiState = combine( loadingState, ratingState ) { s1, s2 -> RatingsUiState( isLoading = s1, rating = s2 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = RatingsUiState() ) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/ratings/RatingsUiEvents.kt ================================================ // ktlint-disable filename package com.michaldrabik.ui_base.common.sheets.ratings import com.michaldrabik.ui_base.common.sheets.ratings.RatingsBottomSheet.Options.Operation import com.michaldrabik.ui_base.utilities.events.Event data class FinishUiEvent(val operation: Operation) : Event(operation) ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/ratings/RatingsUiState.kt ================================================ package com.michaldrabik.ui_base.common.sheets.ratings import com.michaldrabik.ui_model.TraktRating data class RatingsUiState( val isLoading: Boolean? = null, val rating: TraktRating? = null, ) ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/ratings/cases/RatingsEpisodeCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.ratings.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError import com.michaldrabik.repository.RatingsRepository import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.TraktRating import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class RatingsEpisodeCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val userTraktManager: UserTraktManager, private val ratingsRepository: RatingsRepository, ) { companion object { private val RATING_VALID_RANGE = 1..10 } suspend fun loadRating(idTrakt: IdTrakt): TraktRating = withContext(dispatchers.IO) { val episode = Episode.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = idTrakt)) try { val rating = ratingsRepository.shows.loadRating(episode) rating ?: TraktRating.EMPTY } catch (error: Throwable) { handleError(error) TraktRating.EMPTY } } suspend fun saveRating(idTrakt: IdTrakt, rating: Int) = withContext(dispatchers.IO) { check(rating in RATING_VALID_RANGE) userTraktManager.checkAuthorization() val episode = Episode.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = idTrakt)) try { ratingsRepository.shows.addRating(episode, rating) } catch (error: Throwable) { handleError(error) } } suspend fun deleteRating(idTrakt: IdTrakt) = withContext(dispatchers.IO) { userTraktManager.checkAuthorization() val episode = Episode.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = idTrakt)) try { ratingsRepository.shows.deleteRating(episode) } catch (error: Throwable) { handleError(error) } } private suspend fun handleError(error: Throwable) { val showlyError = ErrorHelper.parse(error) if (showlyError is ShowlyError.UnauthorizedError) { userTraktManager.revokeToken() } throw error } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/ratings/cases/RatingsMovieCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.ratings.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError import com.michaldrabik.repository.RatingsRepository import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.TraktRating import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class RatingsMovieCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val userTraktManager: UserTraktManager, private val ratingsRepository: RatingsRepository, ) { companion object { private val RATING_VALID_RANGE = 1..10 } suspend fun loadRating(idTrakt: IdTrakt): TraktRating = withContext(dispatchers.IO) { val movie = Movie.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = idTrakt)) try { val rating = ratingsRepository.movies.loadRatings(listOf(movie)) rating.firstOrNull() ?: TraktRating.EMPTY } catch (error: Throwable) { handleError(error) TraktRating.EMPTY } } suspend fun saveRating(idTrakt: IdTrakt, rating: Int) = withContext(dispatchers.IO) { check(rating in RATING_VALID_RANGE) userTraktManager.checkAuthorization() val movie = Movie.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = idTrakt)) try { ratingsRepository.movies.addRating(movie, rating) } catch (error: Throwable) { handleError(error) } } suspend fun deleteRating(idTrakt: IdTrakt) = withContext(dispatchers.IO) { userTraktManager.checkAuthorization() val movie = Movie.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = idTrakt)) try { ratingsRepository.movies.deleteRating(movie) } catch (error: Throwable) { handleError(error) } } private suspend fun handleError(error: Throwable) { val showlyError = ErrorHelper.parse(error) if (showlyError is ShowlyError.UnauthorizedError) { userTraktManager.revokeToken() } throw error } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/ratings/cases/RatingsSeasonCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.ratings.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError import com.michaldrabik.repository.RatingsRepository import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Season import com.michaldrabik.ui_model.TraktRating import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class RatingsSeasonCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val userTraktManager: UserTraktManager, private val ratingsRepository: RatingsRepository, ) { companion object { private val RATING_VALID_RANGE = 1..10 } suspend fun loadRating(idTrakt: IdTrakt): TraktRating = withContext(dispatchers.IO) { val season = Season.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = idTrakt)) try { val rating = ratingsRepository.shows.loadRatingsSeasons(listOf(season)) rating.firstOrNull() ?: TraktRating.EMPTY } catch (error: Throwable) { handleError(error) TraktRating.EMPTY } } suspend fun saveRating(idTrakt: IdTrakt, rating: Int) = withContext(dispatchers.IO) { check(rating in RATING_VALID_RANGE) userTraktManager.checkAuthorization() val season = Season.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = idTrakt)) try { ratingsRepository.shows.addRating(season, rating) } catch (error: Throwable) { handleError(error) } } suspend fun deleteRating(idTrakt: IdTrakt) = withContext(dispatchers.IO) { userTraktManager.checkAuthorization() val season = Season.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = idTrakt)) try { ratingsRepository.shows.deleteRating(season) } catch (error: Throwable) { handleError(error) } } private suspend fun handleError(error: Throwable) { val showlyError = ErrorHelper.parse(error) if (showlyError is ShowlyError.UnauthorizedError) { userTraktManager.revokeToken() } throw error } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/ratings/cases/RatingsShowCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.ratings.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError import com.michaldrabik.repository.RatingsRepository import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_model.TraktRating import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class RatingsShowCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val userTraktManager: UserTraktManager, private val ratingsRepository: RatingsRepository, ) { companion object { private val RATING_VALID_RANGE = 1..10 } suspend fun loadRating(idTrakt: IdTrakt): TraktRating = withContext(dispatchers.IO) { val show = Show.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = idTrakt)) try { val rating = ratingsRepository.shows.loadRatings(listOf(show)) rating.firstOrNull() ?: TraktRating.EMPTY } catch (error: Throwable) { handleError(error) TraktRating.EMPTY } } suspend fun saveRating(idTrakt: IdTrakt, rating: Int) = withContext(dispatchers.IO) { check(rating in RATING_VALID_RANGE) userTraktManager.checkAuthorization() val show = Show.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = idTrakt)) try { ratingsRepository.shows.addRating(show, rating) } catch (error: Throwable) { handleError(error) } } suspend fun deleteRating(idTrakt: IdTrakt) = withContext(dispatchers.IO) { userTraktManager.checkAuthorization() val show = Show.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = idTrakt)) try { ratingsRepository.shows.deleteRating(show) } catch (error: Throwable) { handleError(error) } } private suspend fun handleError(error: Throwable) { val showlyError = ErrorHelper.parse(error) if (showlyError is ShowlyError.UnauthorizedError) { userTraktManager.revokeToken() } throw error } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/remove_trakt/RemoveTraktBottomSheet.kt ================================================ package com.michaldrabik.ui_base.common.sheets.remove_trakt import android.content.DialogInterface import android.os.Parcelable import androidx.annotation.LayoutRes import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import androidx.lifecycle.ViewModel import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.utilities.extensions.requireLongArray import com.michaldrabik.ui_base.utilities.extensions.requireParcelable import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_navigation.java.NavigationArgs import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_TYPE import kotlinx.parcelize.Parcelize abstract class RemoveTraktBottomSheet(@LayoutRes layoutResId: Int) : BaseBottomSheetFragment(layoutResId) { companion object { fun createBundle(itemIds: List, mode: Mode) = bundleOf( ARG_ID to itemIds.map { it.id }.toLongArray(), ARG_TYPE to mode ) fun createBundle(itemId: IdTrakt, mode: Mode) = createBundle(listOf(itemId), mode) } protected val itemIds: List by lazy { requireLongArray(ARG_ID).map { IdTrakt(it) } } protected val itemType by lazy { requireParcelable(ARG_TYPE) } override fun getTheme(): Int = R.style.CustomBottomSheetDialog override fun onCancel(dialog: DialogInterface) { setFragmentResult(NavigationArgs.REQUEST_REMOVE_TRAKT, bundleOf(NavigationArgs.RESULT to false)) super.onCancel(dialog) } @Parcelize enum class Mode : Parcelable { SHOW, EPISODE, MOVIE } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/remove_trakt/remove_trakt_hidden/RemoveTraktHiddenBottomSheet.kt ================================================ package com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_hidden import android.annotation.SuppressLint import android.os.Bundle import android.view.View import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.sheets.remove_trakt.RemoveTraktBottomSheet import com.michaldrabik.ui_base.databinding.ViewRemoveTraktHiddenBinding import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.showErrorSnackbar import com.michaldrabik.ui_base.utilities.extensions.showInfoSnackbar import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_REMOVE_TRAKT import com.michaldrabik.ui_navigation.java.NavigationArgs.RESULT import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class RemoveTraktHiddenBottomSheet : RemoveTraktBottomSheet(R.layout.view_remove_trakt_hidden) { private val viewModel by viewModels() private val binding by viewBinding(ViewRemoveTraktHiddenBinding::bind) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() launchAndRepeatStarted( { viewModel.messageFlow.collect { renderSnackbar(it) } }, { viewModel.uiState.collect { render(it) } } ) } private fun setupView() { with(binding) { viewRemoveTraktHiddenButtonNo.onClick { setFragmentResult(REQUEST_REMOVE_TRAKT, bundleOf(RESULT to false)) closeSheet() } viewRemoveTraktHiddenButtonYes.onClick { viewModel.removeFromTrakt(itemIds, itemType) } } } @SuppressLint("SetTextI18n") private fun render(uiState: RemoveTraktHiddenUiState) { uiState.run { isLoading?.let { with(binding) { viewRemoveTraktHiddenProgress.visibleIf(it) viewRemoveTraktHiddenButtonNo.visibleIf(!it, gone = false) viewRemoveTraktHiddenButtonNo.isClickable = !it viewRemoveTraktHiddenButtonYes.visibleIf(!it, gone = false) viewRemoveTraktHiddenButtonYes.isClickable = !it } } isFinished?.let { if (it) { setFragmentResult(REQUEST_REMOVE_TRAKT, bundleOf(RESULT to true)) closeSheet() } } } } private fun renderSnackbar(message: MessageEvent) { when (message) { is MessageEvent.Info -> binding.viewRemoveTraktHiddenSnackHost.showInfoSnackbar(getString(message.textRestId)) is MessageEvent.Error -> binding.viewRemoveTraktHiddenSnackHost.showErrorSnackbar(getString(message.textRestId)) } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/remove_trakt/remove_trakt_hidden/RemoveTraktHiddenUiState.kt ================================================ package com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_hidden data class RemoveTraktHiddenUiState( val isLoading: Boolean? = null, val isFinished: Boolean? = null, ) ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/remove_trakt/remove_trakt_hidden/RemoveTraktHiddenViewModel.kt ================================================ package com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_hidden import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.sheets.remove_trakt.RemoveTraktBottomSheet.Mode import com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_hidden.cases.RemoveTraktHiddenCase import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_model.IdTrakt import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class RemoveTraktHiddenViewModel @Inject constructor( private val removeTraktHiddenCase: RemoveTraktHiddenCase ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val loadingState = MutableStateFlow(false) private val finishedState = MutableStateFlow(false) fun removeFromTrakt(traktIds: List, mode: Mode) { viewModelScope.launch { try { loadingState.value = true removeTraktHiddenCase.removeTraktHidden(traktIds, mode) finishedState.value = true } catch (error: Throwable) { messageChannel.send(MessageEvent.Error(R.string.errorTraktSyncGeneral)) loadingState.value = false } } } val uiState = combine( loadingState, finishedState ) { s1, s2 -> RemoveTraktHiddenUiState( isLoading = s1, isFinished = s2 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = RemoveTraktHiddenUiState() ) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/remove_trakt/remove_trakt_hidden/cases/RemoveTraktHiddenCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_hidden.cases import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.data_remote.trakt.model.SyncExportItem import com.michaldrabik.data_remote.trakt.model.SyncExportRequest import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.ui_base.common.sheets.remove_trakt.RemoveTraktBottomSheet.Mode import com.michaldrabik.ui_model.IdTrakt import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class RemoveTraktHiddenCase @Inject constructor( private val remoteSource: RemoteDataSource, private val userManager: UserTraktManager, ) { suspend fun removeTraktHidden(traktIds: List, mode: Mode) { userManager.checkAuthorization() val items = traktIds.map { SyncExportItem.create(it.id) } when (mode) { Mode.SHOW -> { val request = SyncExportRequest(shows = items) remoteSource.trakt.deleteHiddenShow(request) } Mode.MOVIE -> { val request = SyncExportRequest(movies = items) remoteSource.trakt.deleteHiddenMovie(request) } else -> throw IllegalStateException() } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/remove_trakt/remove_trakt_progress/RemoveTraktProgressBottomSheet.kt ================================================ package com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_progress import android.annotation.SuppressLint import android.os.Bundle import android.view.View import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.sheets.remove_trakt.RemoveTraktBottomSheet import com.michaldrabik.ui_base.databinding.ViewRemoveTraktProgressBinding import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.showErrorSnackbar import com.michaldrabik.ui_base.utilities.extensions.showInfoSnackbar import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_REMOVE_TRAKT import com.michaldrabik.ui_navigation.java.NavigationArgs.RESULT import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class RemoveTraktProgressBottomSheet : RemoveTraktBottomSheet(R.layout.view_remove_trakt_progress) { private val viewModel by viewModels() private val binding by viewBinding(ViewRemoveTraktProgressBinding::bind) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() launchAndRepeatStarted( { viewModel.messageFlow.collect { renderSnackbar(it) } }, { viewModel.uiState.collect { render(it) } } ) } private fun setupView() { with(binding) { viewRemoveTraktProgressButtonNo.onClick { setFragmentResult(REQUEST_REMOVE_TRAKT, bundleOf(RESULT to false)) closeSheet() } viewRemoveTraktProgressButtonYes.onClick { viewModel.removeFromTrakt(itemIds, itemType) } } } @SuppressLint("SetTextI18n") private fun render(uiState: RemoveTraktProgressUiState) { uiState.run { isLoading?.let { with(binding) { viewRemoveTraktProgressProgress.visibleIf(it) viewRemoveTraktProgressButtonNo.visibleIf(!it, gone = false) viewRemoveTraktProgressButtonNo.isClickable = !it viewRemoveTraktProgressButtonYes.visibleIf(!it, gone = false) viewRemoveTraktProgressButtonYes.isClickable = !it } } isFinished?.let { if (it) { setFragmentResult(REQUEST_REMOVE_TRAKT, bundleOf(RESULT to true)) closeSheet() } } } } private fun renderSnackbar(message: MessageEvent) { when (message) { is MessageEvent.Info -> binding.viewRemoveTraktProgressSnackHost.showInfoSnackbar(getString(message.textRestId)) is MessageEvent.Error -> binding.viewRemoveTraktProgressSnackHost.showErrorSnackbar(getString(message.textRestId)) } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/remove_trakt/remove_trakt_progress/RemoveTraktProgressUiState.kt ================================================ package com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_progress data class RemoveTraktProgressUiState( val isLoading: Boolean? = null, val isFinished: Boolean? = null, ) ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/remove_trakt/remove_trakt_progress/RemoveTraktProgressViewModel.kt ================================================ package com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_progress import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.sheets.remove_trakt.RemoveTraktBottomSheet.Mode import com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_progress.cases.RemoveTraktProgressCase import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_model.IdTrakt import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class RemoveTraktProgressViewModel @Inject constructor( private val removeTraktProgressCase: RemoveTraktProgressCase ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val loadingState = MutableStateFlow(false) private val finishedState = MutableStateFlow(false) fun removeFromTrakt(traktIds: List, mode: Mode) { viewModelScope.launch { try { loadingState.value = true removeTraktProgressCase.removeTraktProgress(traktIds, mode) finishedState.value = true } catch (error: Throwable) { messageChannel.send(MessageEvent.Error(R.string.errorTraktSyncGeneral)) loadingState.value = false } } } val uiState = combine( loadingState, finishedState ) { s1, s2 -> RemoveTraktProgressUiState( isLoading = s1, isFinished = s2 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = RemoveTraktProgressUiState() ) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/remove_trakt/remove_trakt_progress/cases/RemoveTraktProgressCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_progress.cases import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.data_remote.trakt.model.SyncExportItem import com.michaldrabik.data_remote.trakt.model.SyncExportRequest import com.michaldrabik.repository.EpisodesManager import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.ui_base.common.sheets.remove_trakt.RemoveTraktBottomSheet.Mode import com.michaldrabik.ui_model.IdTrakt import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class RemoveTraktProgressCase @Inject constructor( private val remoteSource: RemoteDataSource, private val userManager: UserTraktManager, private val episodesManager: EpisodesManager ) { suspend fun removeTraktProgress(traktIds: List, mode: Mode) { userManager.checkAuthorization() val items = traktIds.map { SyncExportItem.create(it.id) } val request = when (mode) { Mode.SHOW -> SyncExportRequest(shows = items) Mode.MOVIE -> SyncExportRequest(movies = items) Mode.EPISODE -> SyncExportRequest(episodes = items) } remoteSource.trakt.postDeleteProgress(request) if (mode == Mode.SHOW && traktIds.isNotEmpty()) { episodesManager.setAllUnwatched(traktIds.first()) } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/remove_trakt/remove_trakt_watchlist/RemoveTraktWatchlistBottomSheet.kt ================================================ package com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_watchlist import android.os.Bundle import android.view.View import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.sheets.remove_trakt.RemoveTraktBottomSheet import com.michaldrabik.ui_base.databinding.ViewRemoveTraktWatchlistBinding import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.showErrorSnackbar import com.michaldrabik.ui_base.utilities.extensions.showInfoSnackbar import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_REMOVE_TRAKT import com.michaldrabik.ui_navigation.java.NavigationArgs.RESULT import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class RemoveTraktWatchlistBottomSheet : RemoveTraktBottomSheet(R.layout.view_remove_trakt_watchlist) { private val viewModel by viewModels() private val binding by viewBinding(ViewRemoveTraktWatchlistBinding::bind) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() launchAndRepeatStarted( { viewModel.messageFlow.collect { renderSnackbar(it) } }, { viewModel.uiState.collect { render(it) } } ) } private fun setupView() { with(binding) { viewRemoveTraktWatchlistButtonNo.onClick { setFragmentResult(REQUEST_REMOVE_TRAKT, bundleOf(RESULT to false)) closeSheet() } viewRemoveTraktWatchlistButtonYes.onClick { viewModel.removeFromTrakt(itemIds, itemType) } } } private fun render(uiState: RemoveTraktWatchlistUiState) { uiState.run { isLoading?.let { with(binding) { viewRemoveTraktWatchlistProgress.visibleIf(it) viewRemoveTraktWatchlistButtonNo.visibleIf(!it, gone = false) viewRemoveTraktWatchlistButtonNo.isClickable = !it viewRemoveTraktWatchlistButtonYes.visibleIf(!it, gone = false) viewRemoveTraktWatchlistButtonYes.isClickable = !it } } isFinished?.let { if (it) { setFragmentResult(REQUEST_REMOVE_TRAKT, bundleOf(RESULT to true)) closeSheet() } } } } private fun renderSnackbar(message: MessageEvent) { when (message) { is MessageEvent.Info -> binding.viewRemoveTraktWatchlistSnackHost.showInfoSnackbar(getString(message.textRestId)) is MessageEvent.Error -> binding.viewRemoveTraktWatchlistSnackHost.showErrorSnackbar(getString(message.textRestId)) } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/remove_trakt/remove_trakt_watchlist/RemoveTraktWatchlistUiState.kt ================================================ package com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_watchlist data class RemoveTraktWatchlistUiState( val isLoading: Boolean? = null, val isFinished: Boolean? = null, ) ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/remove_trakt/remove_trakt_watchlist/RemoveTraktWatchlistViewModel.kt ================================================ package com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_watchlist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.sheets.remove_trakt.RemoveTraktBottomSheet.Mode import com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_watchlist.cases.RemoveTraktWatchlistCase import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_model.IdTrakt import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class RemoveTraktWatchlistViewModel @Inject constructor( private val removeTraktWatchlistCase: RemoveTraktWatchlistCase ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val loadingState = MutableStateFlow(false) private val finishedState = MutableStateFlow(false) fun removeFromTrakt(traktIds: List, mode: Mode) { viewModelScope.launch { try { loadingState.value = true removeTraktWatchlistCase.removeTraktWatchlist(traktIds, mode) finishedState.value = true } catch (error: Throwable) { messageChannel.send(MessageEvent.Error(R.string.errorTraktSyncGeneral)) loadingState.value = false } } } val uiState = combine( loadingState, finishedState ) { s1, s2 -> RemoveTraktWatchlistUiState( isLoading = s1, isFinished = s2 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = RemoveTraktWatchlistUiState() ) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/remove_trakt/remove_trakt_watchlist/cases/RemoveTraktWatchlistCase.kt ================================================ package com.michaldrabik.ui_base.common.sheets.remove_trakt.remove_trakt_watchlist.cases import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.data_remote.trakt.model.SyncExportItem import com.michaldrabik.data_remote.trakt.model.SyncExportRequest import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.ui_base.common.sheets.remove_trakt.RemoveTraktBottomSheet.Mode import com.michaldrabik.ui_model.IdTrakt import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class RemoveTraktWatchlistCase @Inject constructor( private val remoteSource: RemoteDataSource, private val userManager: UserTraktManager, ) { suspend fun removeTraktWatchlist(traktIds: List, mode: Mode) { userManager.checkAuthorization() val items = traktIds.map { SyncExportItem.create(it.id) } val request = when (mode) { Mode.SHOW -> SyncExportRequest(shows = items) Mode.MOVIE -> SyncExportRequest(movies = items) else -> throw IllegalStateException() } remoteSource.trakt.postDeleteWatchlist(request) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/sort_order/SortOrderBottomSheet.kt ================================================ package com.michaldrabik.ui_base.common.sheets.sort_order import android.annotation.SuppressLint import android.graphics.Typeface import android.os.Bundle import android.view.View import androidx.core.os.bundleOf import androidx.core.view.children import androidx.fragment.app.setFragmentResult import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.sheets.sort_order.views.SortOrderItemView import com.michaldrabik.ui_base.databinding.ViewSortOrderBinding import com.michaldrabik.ui_base.utilities.extensions.colorFromAttr import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.requireSerializable import com.michaldrabik.ui_base.utilities.extensions.requireString import com.michaldrabik.ui_base.utilities.extensions.requireStringArray import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortType import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_REQUEST_KEY import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_SELECTED_NEW_AT_TOP import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_SELECTED_SORT_ORDER import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_SELECTED_SORT_TYPE import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_SORT_ORDERS import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_SORT_ORDER import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class SortOrderBottomSheet : BaseBottomSheetFragment(R.layout.view_sort_order) { companion object { fun createBundle( options: List, selectedOrder: SortOrder, selectedType: SortType, requestKey: String = REQUEST_SORT_ORDER, newAtTop: Pair = Pair(false, false), ) = bundleOf( ARG_SORT_ORDERS to options.map { it.name }, ARG_SELECTED_SORT_ORDER to selectedOrder, ARG_SELECTED_SORT_TYPE to selectedType, ARG_SELECTED_NEW_AT_TOP to newAtTop, ARG_REQUEST_KEY to requestKey ) } private val binding by viewBinding(ViewSortOrderBinding::bind) private val requestKey by lazy { requireString(ARG_REQUEST_KEY, default = REQUEST_SORT_ORDER) } private val initialSortOrder by lazy { requireSerializable(ARG_SELECTED_SORT_ORDER) } private val initialSortType by lazy { requireSerializable(ARG_SELECTED_SORT_TYPE) } private val initialNewAtTop by lazy { requireSerializable>(ARG_SELECTED_NEW_AT_TOP) } private val initialOptions by lazy { requireStringArray(ARG_SORT_ORDERS).map { SortOrder.valueOf(it) } } private lateinit var selectedSortOrder: SortOrder private lateinit var selectedSortType: SortType override fun getTheme(): Int = R.style.CustomBottomSheetDialog override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) selectedSortOrder = initialSortOrder selectedSortType = initialSortType setupView() } @SuppressLint("SetTextI18n") private fun setupView() { with(binding) { viewSortOrderItemsLayout.removeAllViews() initialOptions.forEach { item -> val itemView = SortOrderItemView(requireContext()).apply { onItemClickListener = itemClickListener bind(item, initialSortType, item == initialSortOrder) } viewSortOrderItemsLayout.addView(itemView) } with(viewSortOrderNewCheckbox) { visibleIf(initialNewAtTop.first) setOnCheckedChangeListener { _, isChecked -> val color = if (isChecked) android.R.attr.textColorPrimary else android.R.attr.textColorSecondary val typeface = if (isChecked) Typeface.DEFAULT_BOLD else Typeface.DEFAULT setTextColor(context.colorFromAttr(color)) setTypeface(typeface) } isChecked = initialNewAtTop.second } viewSortOrderButtonApply.onClick { onApplySortOrder() } } } private fun onItemClicked( sortOrder: SortOrder, sortType: SortType, ) { binding.viewSortOrderItemsLayout.children.forEach { child -> with(child as SortOrderItemView) { if (sortOrder == child.sortOrder) { if (sortOrder == selectedSortOrder) { val newSortType = if (sortType == SortType.ASCENDING) SortType.DESCENDING else SortType.ASCENDING selectedSortType = newSortType bind(sortOrder, newSortType, true, animate = true) } else { bind(sortOrder, selectedSortType, true) } selectedSortOrder = sortOrder } else { bind(child.sortOrder, child.sortType, false) } } } } private fun onApplySortOrder() { val selectedNewAtTop = binding.viewSortOrderNewCheckbox.isChecked if (selectedSortOrder != initialSortOrder || initialSortType != selectedSortType || initialNewAtTop.second != selectedNewAtTop ) { val result = bundleOf( ARG_SELECTED_SORT_ORDER to selectedSortOrder, ARG_SELECTED_SORT_TYPE to selectedSortType, ARG_SELECTED_NEW_AT_TOP to selectedNewAtTop, ) setFragmentResult(requestKey, result) } closeSheet() } private val itemClickListener: (SortOrder, SortType) -> Unit = { sortOrder, sortType -> onItemClicked(sortOrder, sortType) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/sheets/sort_order/views/SortOrderItemView.kt ================================================ package com.michaldrabik.ui_base.common.sheets.sort_order.views import android.content.Context import android.graphics.Typeface import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import androidx.constraintlayout.widget.ConstraintLayout import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.databinding.ViewSortOrderItemBinding import com.michaldrabik.ui_base.utilities.extensions.addRipple import com.michaldrabik.ui_base.utilities.extensions.colorFromAttr import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortType class SortOrderItemView : ConstraintLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewSortOrderItemBinding.inflate(LayoutInflater.from(context), this) var onItemClickListener: ((SortOrder, SortType) -> Unit)? = null lateinit var sortOrder: SortOrder lateinit var sortType: SortType var isChecked: Boolean = false init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) val paddingHorizontal = context.dimenToPx(R.dimen.spaceNormal) setPadding(paddingHorizontal, 0, paddingHorizontal, 0) addRipple() onClick(safe = false) { onItemClickListener?.invoke(sortOrder, sortType) } } fun bind( sortOrder: SortOrder, sortType: SortType, isChecked: Boolean, animate: Boolean = false ) { this.sortOrder = sortOrder this.sortType = sortType this.isChecked = isChecked with(binding) { viewSortOrderItemBadge.visibleIf(isChecked) with(viewSortOrderItemTitle) { val color = if (isChecked) android.R.attr.textColorPrimary else android.R.attr.textColorSecondary val typeface = if (isChecked) Typeface.DEFAULT_BOLD else Typeface.DEFAULT setTextColor(context.colorFromAttr(color)) setTypeface(typeface) text = context.getString(sortOrder.displayString) } with(viewSortOrderItemAscDesc) { visibleIf(isChecked) val rotation = if (sortType == SortType.ASCENDING) -90F else 90F val duration = if (animate) 200L else 0 animate().rotation(rotation).setDuration(duration).start() } } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/EmptySearchView.kt ================================================ package com.michaldrabik.ui_base.common.views import android.content.Context import android.util.AttributeSet import android.view.Gravity.CENTER import android.widget.LinearLayout import com.michaldrabik.ui_base.R class EmptySearchView : LinearLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) init { inflate(context, R.layout.view_search_empty, this) orientation = VERTICAL gravity = CENTER } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/FoldableTextView.kt ================================================ package com.michaldrabik.ui_base.common.views import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView class FoldableTextView : AppCompatTextView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) companion object { private const val MIN_LINES = 3 private const val MAX_LINES = 100 } private var initialLines = MIN_LINES init { maxLines = initialLines enableFoldOnClick() } fun setInitialLines(lines: Int) { initialLines = lines maxLines = lines } fun enableFoldOnClick() { setOnClickListener { maxLines = if (maxLines == MAX_LINES) initialLines else MAX_LINES } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/ModeTabsView.kt ================================================ package com.michaldrabik.ui_base.common.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.LinearLayout import com.michaldrabik.common.Mode import com.michaldrabik.common.Mode.MOVIES import com.michaldrabik.common.Mode.SHOWS import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.databinding.ViewModeTabsBinding import com.michaldrabik.ui_base.utilities.extensions.colorFromAttr import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.visibleIf class ModeTabsView : LinearLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewModeTabsBinding.inflate(LayoutInflater.from(context), this) init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) orientation = HORIZONTAL with(binding) { viewMovies.onClick { onModeSelected?.invoke(MOVIES) } viewShows.onClick { onModeSelected?.invoke(SHOWS) } viewLists.onClick { onListsSelected?.invoke() } } } var onModeSelected: ((Mode) -> Unit)? = null var onListsSelected: (() -> Unit)? = null fun selectShows() { with(binding) { viewShows.setTextColor(context.colorFromAttr(R.attr.textColorTabSelected)) viewMovies.setTextColor(context.colorFromAttr(R.attr.textColorTab)) viewLists.setTextColor(context.colorFromAttr(R.attr.textColorTab)) } } fun selectMovies() { with(binding) { viewShows.setTextColor(context.colorFromAttr(R.attr.textColorTab)) viewMovies.setTextColor(context.colorFromAttr(R.attr.textColorTabSelected)) viewLists.setTextColor(context.colorFromAttr(R.attr.textColorTab)) } } fun selectLists() { with(binding) { viewShows.setTextColor(context.colorFromAttr(R.attr.textColorTab)) viewMovies.setTextColor(context.colorFromAttr(R.attr.textColorTab)) viewLists.setTextColor(context.colorFromAttr(R.attr.textColorTabSelected)) } } fun showMovies(show: Boolean) = binding.viewMovies.visibleIf(show) fun showLists(show: Boolean, anchorEnd: Boolean = true) { with(binding) { viewLists.visibleIf(show) viewSpacer.visibleIf(anchorEnd) } } override fun setEnabled(enabled: Boolean) { with(binding) { viewShows.isEnabled = enabled viewMovies.isEnabled = enabled viewLists.isEnabled = enabled } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/MovieView.kt ================================================ package com.michaldrabik.ui_base.common.views import android.content.Context import android.util.AttributeSet import android.widget.FrameLayout import android.widget.ImageView import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade import com.michaldrabik.common.Config.IMAGE_FADE_DURATION_MS import com.michaldrabik.common.Config.MAIN_GRID_SPAN import com.michaldrabik.common.Config.MAIN_GRID_SPAN_TABLET import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.MovieListItem import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.isTablet import com.michaldrabik.ui_base.utilities.extensions.screenWidth import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.withFailListener import com.michaldrabik.ui_base.utilities.extensions.withSuccessListener import com.michaldrabik.ui_model.ImageStatus.AVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNAVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNKNOWN abstract class MovieView : FrameLayout { companion object { const val ASPECT_RATIO = 1.4705 } constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val cornerRadius by lazy { context.dimenToPx(R.dimen.mediaTileCorner) } private val gridPadding by lazy { context.dimenToPx(R.dimen.gridPadding) } private val centerCropTransformation by lazy { CenterCrop() } private val cornersTransformation by lazy { RoundedCorners(cornerRadius) } private val isTablet by lazy { context.isTablet() } private val span by lazy { if (isTablet) MAIN_GRID_SPAN_TABLET else MAIN_GRID_SPAN } private val width by lazy { (screenWidth().toFloat() - (2.0 * gridPadding)) / span } private val height by lazy { width * ASPECT_RATIO } protected abstract val imageView: ImageView protected abstract val placeholderView: ImageView var itemClickListener: ((Item) -> Unit)? = null var itemLongClickListener: ((Item) -> Unit)? = null var imageLoadCompleteListener: (() -> Unit)? = null var missingImageListener: ((Item, Boolean) -> Unit)? = null var missingTranslationListener: ((Item) -> Unit)? = null open fun bind(item: Item) { layoutParams = LayoutParams( (width * item.image.type.getSpan(isTablet).toFloat()).toInt(), height.toInt() ) } protected open fun loadImage(item: Item) { if (item.isLoading) return if (item.image.status == UNAVAILABLE) { placeholderView.visible() return } if (item.image.status == UNKNOWN) { onImageLoadFail(item) return } Glide.with(this) .load(item.image.fullFileUrl) .transform(centerCropTransformation, cornersTransformation) .transition(withCrossFade(IMAGE_FADE_DURATION_MS)) .withSuccessListener { onImageLoadSuccess() } .withFailListener { onImageLoadFail(item) } .into(imageView) } protected open fun onImageLoadSuccess() = imageLoadCompleteListener?.invoke() protected open fun onImageLoadFail(item: Item) { if (item.image.status == AVAILABLE) { placeholderView.visible() imageLoadCompleteListener?.invoke() return } val force = (item.image.status == UNKNOWN) missingImageListener?.invoke(item, force) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/PremiumAdView.kt ================================================ package com.michaldrabik.ui_base.common.views import android.content.Context import android.util.AttributeSet import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import com.michaldrabik.ui_base.R class PremiumAdView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) init { inflate(context, R.layout.view_premium_ad, this) layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/RateValueView.kt ================================================ package com.michaldrabik.ui_base.common.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.databinding.ViewRateValueBinding class RateValueView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewRateValueBinding.inflate(LayoutInflater.from(context), this) private val translation by lazy { resources.getDimensionPixelSize(R.dimen.rateValueTranslation).toFloat() } init { layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) } fun setValue(value: String) { with(binding) { viewRateValueText1.text = value viewRateValueText1.alpha = 1F viewRateValueText2.text = "" viewRateValueText2.alpha = 0F } } fun setValueAnimated(value: String, direction: Direction = Direction.LEFT) { val translation = when (direction) { Direction.LEFT -> -translation Direction.RIGHT -> translation } with(binding) { viewRateValueText2.alpha = 0F viewRateValueText2.text = value viewRateValueText2.translationX = -translation viewRateValueText2.animate().translationX(0F).alpha(1F).setDuration(175L).start() viewRateValueText1.animate().translationX(translation).setDuration(175L).alpha(0F) .withEndAction { viewRateValueText2.alpha = 0F viewRateValueText1.translationX = 0F viewRateValueText1.alpha = 1F viewRateValueText1.text = value } .start() } } enum class Direction { LEFT, RIGHT } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/RatingsStripView.kt ================================================ package com.michaldrabik.ui_base.common.views import android.content.Context import android.util.AttributeSet import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.LinearLayout import android.widget.TextView import com.michaldrabik.common.Config.SPOILERS_RATINGS_HIDE_SYMBOL import com.michaldrabik.ui_base.databinding.ViewRatingsStripBinding import com.michaldrabik.ui_base.utilities.extensions.colorFromAttr import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_model.Ratings class RatingsStripView : LinearLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewRatingsStripBinding.inflate(LayoutInflater.from(context), this) var onTraktClick: ((Ratings) -> Unit)? = null var onImdbClick: ((Ratings) -> Unit)? = null var onMetaClick: ((Ratings) -> Unit)? = null var onRottenClick: ((Ratings) -> Unit)? = null private val colorPrimary by lazy { context.colorFromAttr(android.R.attr.textColorPrimary) } private val colorSecondary by lazy { context.colorFromAttr(android.R.attr.textColorSecondary) } private lateinit var ratings: Ratings init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) orientation = HORIZONTAL gravity = Gravity.TOP } fun bind(ratings: Ratings) { this.ratings = ratings with(binding) { bindValue( ratingsValue = ratings.trakt, layoutView = viewRatingsStripTrakt, valueView = viewRatingsStripTraktValue, progressView = viewRatingsStripTraktProgress, linkView = viewRatingsStripTraktLinkIcon, isHidden = ratings.isHidden, isTapToReveal = ratings.isTapToReveal, callback = onTraktClick ) bindValue( ratingsValue = ratings.imdb, layoutView = viewRatingsStripImdb, valueView = viewRatingsStripImdbValue, progressView = viewRatingsStripImdbProgress, linkView = viewRatingsStripImdbLinkIcon, isHidden = ratings.isHidden, isTapToReveal = ratings.isTapToReveal, callback = onImdbClick ) bindValue( ratingsValue = ratings.metascore, layoutView = viewRatingsStripMeta, valueView = viewRatingsStripMetaValue, progressView = viewRatingsStripMetaProgress, linkView = viewRatingsStripMetaLinkIcon, isHidden = ratings.isHidden, isTapToReveal = ratings.isTapToReveal, callback = onMetaClick ) bindValue( ratingsValue = ratings.rottenTomatoes, layoutView = viewRatingsStripRotten, valueView = viewRatingsStripRottenValue, progressView = viewRatingsStripRottenProgress, linkView = viewRatingsStripRottenLinkIcon, isHidden = ratings.isHidden, isTapToReveal = ratings.isTapToReveal, callback = onRottenClick ) } } private fun bindValue( ratingsValue: Ratings.Value?, layoutView: View, valueView: TextView, progressView: View, linkView: View, isHidden: Boolean, isTapToReveal: Boolean, callback: ((Ratings) -> Unit)?, ) { val rating = ratingsValue?.value val isLoading = ratingsValue?.isLoading == true with(valueView) { visibleIf(!isLoading && !rating.isNullOrBlank(), gone = false) text = if (isHidden) { tag = rating SPOILERS_RATINGS_HIDE_SYMBOL } else { rating } setTextColor(if (rating != null) colorPrimary else colorSecondary) } with(layoutView) { if (isHidden && isTapToReveal && !rating.isNullOrBlank()) { onClick { valueView.tag?.let { valueView.text = it.toString() } onClick { if (!ratings.isAnyLoading()) { callback?.invoke(ratings) } } } } else { onClick { if (!ratings.isAnyLoading()) { callback?.invoke(ratings) } } } } progressView.visibleIf(isLoading) linkView.visibleIf(!isLoading && rating.isNullOrBlank()) } fun isBound() = this::ratings.isInitialized && !this.ratings.isAnyLoading() } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/ScrollableImageView.kt ================================================ package com.michaldrabik.ui_base.common.views import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatImageView import androidx.coordinatorlayout.widget.CoordinatorLayout import com.michaldrabik.ui_base.common.behaviour.ScrollableViewBehaviour class ScrollableImageView : AppCompatImageView, CoordinatorLayout.AttachedBehavior { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) override fun getBehavior() = ScrollableViewBehaviour() } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/ScrollableTabLayout.kt ================================================ package com.michaldrabik.ui_base.common.views import android.content.Context import android.util.AttributeSet import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.tabs.TabLayout import com.michaldrabik.ui_base.common.behaviour.ScrollableViewBehaviour class ScrollableTabLayout : TabLayout, CoordinatorLayout.AttachedBehavior { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) override fun getBehavior() = ScrollableViewBehaviour() } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/SearchLocalView.kt ================================================ package com.michaldrabik.ui_base.common.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.widget.FrameLayout import androidx.coordinatorlayout.widget.CoordinatorLayout import com.michaldrabik.ui_base.common.behaviour.ScrollableViewBehaviour import com.michaldrabik.ui_base.databinding.ViewSearchLocalBinding import com.michaldrabik.ui_base.utilities.extensions.onClick class SearchLocalView : FrameLayout, CoordinatorLayout.AttachedBehavior { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) val binding = ViewSearchLocalBinding.inflate(LayoutInflater.from(context), this, true) var onCloseClickListener: (() -> Unit)? = null init { binding.searchViewLocalIcon.onClick { onCloseClickListener?.invoke() } } override fun getBehavior() = ScrollableViewBehaviour() override fun setEnabled(enabled: Boolean) { binding.searchViewLocalInput.isEnabled = enabled } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/SearchView.kt ================================================ package com.michaldrabik.ui_base.common.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.widget.FrameLayout import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.behaviour.SearchViewBehaviour import com.michaldrabik.ui_base.databinding.ViewSearchBinding import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.doOnApplyWindowInsets import com.michaldrabik.ui_base.utilities.extensions.expandTouch import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.visibleIf class SearchView : FrameLayout, CoordinatorLayout.AttachedBehavior { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) val binding = ViewSearchBinding.inflate(LayoutInflater.from(context), this, true) var onSettingsClickListener: (() -> Unit)? = null var onStatsClickListener: (() -> Unit)? = null var onTraktClickListener: (() -> Unit)? = null init { with(binding) { searchSettingsIcon.expandTouch() searchSettingsIcon.onClick { onSettingsClickListener?.invoke() } searchStatsIcon.onClick { onStatsClickListener?.invoke() } searchTraktIcon.onClick { onTraktClickListener?.invoke() } } } var hint: String get() = binding.searchViewInput.hint.toString() set(value) { with(binding) { searchViewInput.hint = value searchViewText.text = value } } var settingsIconVisible get() = binding.searchSettingsIcon.isVisible set(value) { binding.searchSettingsIcon.visibleIf(value) } var statsIconVisible get() = binding.searchStatsIcon.isVisible set(value) { binding.searchStatsIcon.visibleIf(value) } var traktIconVisible get() = binding.searchTraktIcon.isVisible set(value) { binding.searchTraktIcon.visibleIf(value) } var isSearching = false override fun onAttachedToWindow() { doOnApplyWindowInsets { _, insets, _, _ -> val inset = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top applyWindowInsetBehaviour(context.dimenToPx(R.dimen.spaceNormal) + inset) } super.onAttachedToWindow() } fun applyWindowInsetBehaviour(newInset: Int) { updateLayoutParams { (layoutParams as? CoordinatorLayout.LayoutParams)?.behavior = SearchViewBehaviour(newInset) } } override fun getBehavior() = SearchViewBehaviour(context.dimenToPx(R.dimen.spaceNormal)) override fun setEnabled(enabled: Boolean) { binding.searchViewInput.isEnabled = enabled super.setEnabled(enabled) } fun setTraktProgress(isProgress: Boolean, withIcon: Boolean = false) { with(binding) { searchViewIcon.visibleIf(!isProgress) searchViewText.visibleIf(!isProgress) searchTraktIcon.visibleIf(!isProgress && withIcon) searchViewTraktSync.visibleIf(isProgress) } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/SecretTextView.kt ================================================ package com.michaldrabik.ui_base.common.views import android.content.Context import android.text.TextUtils import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView class SecretTextView : AppCompatTextView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) var isRevealable = true set(value) { field = value if (value) { setOnClickListener { toggle() } } else { setOnClickListener(null) } } private var originalText: String? = null private var isSecret = true init { isRevealable = true } fun setSecretText(text: String?, isSecret: Boolean = true) { this.isSecret = isSecret this.originalText = text if (isSecret) { this.text = originalText?.map { '*' }?.joinToString("") ellipsize = null } else { this.text = originalText ellipsize = TextUtils.TruncateAt.END } } private fun toggle() { if (!isRevealable) return if (!isSecret) return text = if (isSecret) { setOnClickListener(null) ellipsize = TextUtils.TruncateAt.END originalText } else { ellipsize = null originalText?.map { '*' }?.joinToString("") } isSecret = !isSecret } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/ShowView.kt ================================================ package com.michaldrabik.ui_base.common.views import android.content.Context import android.util.AttributeSet import android.widget.FrameLayout import android.widget.ImageView import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade import com.michaldrabik.common.Config.IMAGE_FADE_DURATION_MS import com.michaldrabik.common.Config.MAIN_GRID_SPAN import com.michaldrabik.common.Config.MAIN_GRID_SPAN_TABLET import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.ListItem import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.isTablet import com.michaldrabik.ui_base.utilities.extensions.screenWidth import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.withFailListener import com.michaldrabik.ui_base.utilities.extensions.withSuccessListener import com.michaldrabik.ui_model.ImageStatus.AVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNAVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNKNOWN abstract class ShowView : FrameLayout { companion object { const val ASPECT_RATIO = 1.4705 } constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val cornerRadius by lazy { context.dimenToPx(R.dimen.mediaTileCorner) } private val gridPadding by lazy { context.dimenToPx(R.dimen.gridPadding) } private val centerCropTransformation by lazy { CenterCrop() } private val cornersTransformation by lazy { RoundedCorners(cornerRadius) } private val isTablet by lazy { context.isTablet() } private val span by lazy { if (isTablet) MAIN_GRID_SPAN_TABLET else MAIN_GRID_SPAN } private val width by lazy { (screenWidth().toFloat() - (2.0 * gridPadding)) / span } private val height by lazy { width * ASPECT_RATIO } protected abstract val imageView: ImageView protected abstract val placeholderView: ImageView var itemClickListener: ((Item) -> Unit)? = null var itemLongClickListener: ((Item) -> Unit)? = null var imageLoadCompleteListener: (() -> Unit)? = null var missingImageListener: ((Item, Boolean) -> Unit)? = null var missingTranslationListener: ((Item) -> Unit)? = null open fun bind(item: Item) { layoutParams = LayoutParams( (width * item.image.type.getSpan(isTablet).toFloat()).toInt(), height.toInt() ) } protected open fun loadImage(item: Item) { if (item.isLoading) return if (item.image.status == UNAVAILABLE) { placeholderView.visible() return } if (item.image.status == UNKNOWN) { onImageLoadFail(item) return } Glide.with(this) .load(item.image.fullFileUrl) .transform(centerCropTransformation, cornersTransformation) .transition(withCrossFade(IMAGE_FADE_DURATION_MS)) .withSuccessListener { onImageLoadSuccess() } .withFailListener { onImageLoadFail(item) } .into(imageView) } protected open fun onImageLoadSuccess() = imageLoadCompleteListener?.invoke() protected open fun onImageLoadFail(item: Item) { if (item.image.status == AVAILABLE) { placeholderView.visible() imageLoadCompleteListener?.invoke() return } val force = (item.image.status == UNKNOWN) missingImageListener?.invoke(item, force) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/tips/TipOverlayView.kt ================================================ package com.michaldrabik.ui_base.common.views.tips import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.dynamicanimation.animation.DynamicAnimation import androidx.dynamicanimation.animation.SpringAnimation import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.databinding.ViewTipOverlayBinding import com.michaldrabik.ui_base.utilities.extensions.fadeIn import com.michaldrabik.ui_base.utilities.extensions.fadeOut import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.screenHeight import com.michaldrabik.ui_model.Tip class TipOverlayView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewTipOverlayBinding.inflate(LayoutInflater.from(context), this) init { layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) setBackgroundResource(R.color.colorBlackTranslucent) setupView() } private val springStartValue by lazy { (screenHeight().toFloat()) / 3F } private val springAnimation by lazy { SpringAnimation(binding.tutorialTipView, DynamicAnimation.TRANSLATION_Y, 0F).apply { spring.stiffness = 300F spring.dampingRatio = 0.65F setStartValue(springStartValue) } } private fun setupView() { onClick { /* Block background clicks */ } binding.tutorialViewButton.onClick { fadeOut() } } fun showTip(tip: Tip) { binding.tutorialViewText.setText(tip.textResId) springAnimation.setStartValue(springStartValue) springAnimation.start() fadeIn() } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/common/views/tips/TipView.kt ================================================ package com.michaldrabik.ui_base.common.views.tips import android.animation.AnimatorSet import android.animation.ObjectAnimator import android.animation.ValueAnimator.INFINITE import android.content.Context import android.util.AttributeSet import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import com.michaldrabik.ui_base.R class TipView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) companion object { private const val ANIMATION_DURATION = 2500L } init { inflate(context, R.layout.view_tip, this) layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) clipChildren = false } private val animatorX by lazy { ObjectAnimator.ofFloat(this, "scaleX", 1F, 1.2F, 1F, 0.8F, 1F).apply { repeatCount = INFINITE } } private val animatorY by lazy { ObjectAnimator.ofFloat(this, "scaleY", 1F, 1.2F, 1F, 0.8F, 1F).apply { repeatCount = INFINITE } } private val animatorSet by lazy { AnimatorSet().apply { playTogether(animatorX, animatorY) duration = ANIMATION_DURATION } } override fun onAttachedToWindow() { super.onAttachedToWindow() animatorSet.start() } override fun onDetachedFromWindow() { animatorSet.cancel() super.onDetachedFromWindow() } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/dates/AppDateFormat.kt ================================================ package com.michaldrabik.ui_base.dates enum class AppDateFormat { DEFAULT_24, DEFAULT_12, TRAKT_24, TRAKT_12, MISC_24, MISC_12 } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/dates/DateFormatProvider.kt ================================================ package com.michaldrabik.ui_base.dates import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.dates.AppDateFormat.DEFAULT_12 import com.michaldrabik.ui_base.dates.AppDateFormat.DEFAULT_24 import com.michaldrabik.ui_base.dates.AppDateFormat.MISC_12 import com.michaldrabik.ui_base.dates.AppDateFormat.MISC_24 import com.michaldrabik.ui_base.dates.AppDateFormat.TRAKT_12 import com.michaldrabik.ui_base.dates.AppDateFormat.TRAKT_24 import com.michaldrabik.ui_base.dates.AppDateFormat.valueOf import java.time.format.DateTimeFormatter import javax.inject.Inject import javax.inject.Singleton @Singleton class DateFormatProvider @Inject constructor( private val settingsRepository: SettingsRepository, ) { companion object { const val DAY_1 = "dd MMM yyyy" const val DAY_2 = "MMM dd, yyyy" const val DAY_3 = "EEEE, dd MMMM yyyy" const val DAY_4 = "MMMM dd, yyyy (EEEE)" const val DAY_5 = "dd MMMM yyyy (EEEE)" const val DAY_HOUR_1 = "EEEE, dd MMM yyyy, h:mm a" const val DAY_HOUR_2 = "EEEE, dd MMM yyyy, HH:mm" const val DAY_HOUR_3 = "MMM dd, yyyy h:mm a (EEEE)" const val DAY_HOUR_4 = "MMM dd, yyyy HH:mm (EEEE)" const val DAY_HOUR_5 = "dd MMM yyyy, h:mm a (EEEE)" const val DAY_HOUR_6 = "dd MMM yyyy, HH:mm (EEEE)" fun loadSettingsFormat( format: AppDateFormat, language: String, ): DateTimeFormatter { val pattern = when (format) { DEFAULT_12 -> DAY_HOUR_1 DEFAULT_24 -> DAY_HOUR_2 TRAKT_12 -> DAY_HOUR_3 TRAKT_24 -> DAY_HOUR_4 MISC_12 -> DAY_HOUR_5 MISC_24 -> DAY_HOUR_6 } if (language == "zh") { return DateTimeFormatter.ofPattern(pattern.appendChineseDay()) } return DateTimeFormatter.ofPattern(pattern) } } fun loadShortDayFormat(): DateTimeFormatter { val pattern = when (valueOf(settingsRepository.dateFormat)) { DEFAULT_12 -> DAY_1 DEFAULT_24 -> DAY_1 TRAKT_12 -> DAY_2 TRAKT_24 -> DAY_2 MISC_12 -> DAY_1 MISC_24 -> DAY_1 } return createDateFormat(pattern) } fun loadFullDayFormat(): DateTimeFormatter { val pattern = when (valueOf(settingsRepository.dateFormat)) { DEFAULT_12 -> DAY_3 DEFAULT_24 -> DAY_3 TRAKT_12 -> DAY_4 TRAKT_24 -> DAY_4 MISC_12 -> DAY_5 MISC_24 -> DAY_5 } return createDateFormat(pattern) } fun loadFullHourFormat(): DateTimeFormatter { val pattern = when (valueOf(settingsRepository.dateFormat)) { DEFAULT_12 -> DAY_HOUR_1 DEFAULT_24 -> DAY_HOUR_2 TRAKT_12 -> DAY_HOUR_3 TRAKT_24 -> DAY_HOUR_4 MISC_12 -> DAY_HOUR_5 MISC_24 -> DAY_HOUR_6 } return createDateFormat(pattern) } private fun createDateFormat(pattern: String): DateTimeFormatter { val language = settingsRepository.language if (language == "zh") { return DateTimeFormatter.ofPattern(pattern.appendChineseDay()) } return DateTimeFormatter.ofPattern(pattern) } } // Adding special symbol in case of Chinese language as it is missing from DateTimeFormatter implementation. private fun String.appendChineseDay(): String { return this.replace("dd", "dd\'日\'") } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/events/Event.kt ================================================ package com.michaldrabik.ui_base.events sealed class Event object ReloadData : Event() // Trakt Sync object TraktSyncStart : Event() object TraktSyncSuccess : Event() object TraktSyncError : Event() object TraktSyncAuthError : Event() data class TraktSyncProgress(val status: String = "") : Event() // Trakt Instant Sync data class TraktQuickSyncSuccess(val count: Int) : Event() object TraktListQuickSyncSuccess : Event() // Shows, Movies Sync data class ShowsMoviesSyncComplete(val count: Int) : Event() ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/events/EventsManager.kt ================================================ package com.michaldrabik.ui_base.events import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class EventsManager @Inject constructor() { private val _events = MutableSharedFlow(extraBufferCapacity = 10) val events = _events.asSharedFlow() suspend fun sendEvent(event: Event) { _events.emit(event) Timber.d("Event emitted: $event") } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/fcm/NotificationChannel.kt ================================================ package com.michaldrabik.ui_base.fcm import androidx.core.app.NotificationManagerCompat enum class NotificationChannel( val displayName: String, val description: String, val importance: Int, val topicName: String ) { GENERAL_INFO( "General Info", "General information and announcements", NotificationManagerCompat.IMPORTANCE_HIGH, "general" ), SHOWS_INFO( "Shows Info", "Shows related information", NotificationManagerCompat.IMPORTANCE_DEFAULT, "shows" ), EPISODES_ANNOUNCEMENTS( "Episodes Announcements", "Episodes and seasons announcements", NotificationManagerCompat.IMPORTANCE_DEFAULT, "shows_announcements" ), MOVIES_ANNOUNCEMENTS( "Movies Announcements", "Movies announcements", NotificationManagerCompat.IMPORTANCE_DEFAULT, "movies_announcements" ) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/network/NetworkCallbackAdapter.kt ================================================ package com.michaldrabik.ui_base.network import android.net.ConnectivityManager abstract class NetworkCallbackAdapter : ConnectivityManager.NetworkCallback() ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/network/NetworkStatusProvider.kt ================================================ package com.michaldrabik.ui_base.network import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN import android.net.NetworkCapabilities.TRANSPORT_CELLULAR import android.net.NetworkCapabilities.TRANSPORT_ETHERNET import android.net.NetworkCapabilities.TRANSPORT_VPN import android.net.NetworkCapabilities.TRANSPORT_WIFI import android.net.NetworkRequest import android.os.Build import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class NetworkStatusProvider @Inject constructor( private val connectivityManager: ConnectivityManager ) : DefaultLifecycleObserver { private val _status = MutableStateFlow(false) val status = _status.asStateFlow() private val availableNetworksIds = mutableListOf() fun isOnline() = status.value override fun onStart(owner: LifecycleOwner) { super.onStart(owner) val networkRequest = NetworkRequest.Builder() .addTransportType(TRANSPORT_WIFI) .addTransportType(TRANSPORT_CELLULAR) .addTransportType(TRANSPORT_ETHERNET) .addTransportType(TRANSPORT_VPN) .addCapability(NET_CAPABILITY_INTERNET) .removeCapability(NET_CAPABILITY_NOT_VPN) .build() connectivityManager.registerNetworkCallback(networkRequest, networkCallback) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { connectivityManager.requestNetwork(networkRequest, networkCallback, 1000) } Timber.d("Registering network observer.") } override fun onStop(owner: LifecycleOwner) { connectivityManager.unregisterNetworkCallback(networkCallback) availableNetworksIds.clear() Timber.d("Unregistered network observer.") super.onStop(owner) } private val networkCallback = object : NetworkCallbackAdapter() { override fun onAvailable(network: Network) { availableNetworksIds.add(network.toString()) _status.update { true } Timber.d("Network available: $network") } override fun onLost(network: Network) { availableNetworksIds.remove(network.toString()) if (availableNetworksIds.isEmpty()) { _status.update { false } } Timber.d("Network lost: $network. Available networks: ${availableNetworksIds.size}") } override fun onUnavailable() { _status.update { false } Timber.d("Network unavailable") } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/notifications/AnnouncementManager.kt ================================================ package com.michaldrabik.ui_base.notifications import android.content.Context import androidx.work.WorkManager import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.common.extensions.nowUtcDay import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.common.extensions.toZonedDateTime import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.repository.OnHoldItemsRepository import com.michaldrabik.repository.TranslationsRepository import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.notifications.schedulers.MovieAnnouncementScheduler import com.michaldrabik.ui_base.notifications.schedulers.MovieAnnouncementScheduler.Companion.ANNOUNCEMENT_MOVIE_WORK_TAG import com.michaldrabik.ui_base.notifications.schedulers.ShowAnnouncementScheduler import com.michaldrabik.ui_base.notifications.schedulers.ShowAnnouncementScheduler.Companion.ANNOUNCEMENT_WORK_TAG import dagger.hilt.android.qualifiers.ApplicationContext import timber.log.Timber import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import javax.inject.Inject import javax.inject.Singleton @Singleton class AnnouncementManager @Inject constructor( @ApplicationContext private val context: Context, private val mappers: Mappers, private val localSource: LocalDataSource, private val settingsRepository: SettingsRepository, private val translationsRepository: TranslationsRepository, private val onHoldItemsRepository: OnHoldItemsRepository, private val showAnnouncementScheduler: ShowAnnouncementScheduler, private val movieAnnouncementScheduler: MovieAnnouncementScheduler, ) { companion object { private const val MOVIE_MIN_THRESHOLD_DAYS = 30 private const val MOVIE_THRESHOLD_HOUR = 12 } private val logFormatter by lazy { DateTimeFormatter.ofPattern("EEEE, dd MMM yyyy, HH:mm") } suspend fun refreshShowsAnnouncements() { Timber.d("Refreshing shows announcements") val now = nowUtc() val nowMillis = now.toMillis() val limit = now.plusMonths(3) WorkManager.getInstance(context).cancelAllWorkByTag(ANNOUNCEMENT_WORK_TAG) Timber.d("Current time: ${logFormatter.format(now)} UTC") val settings = settingsRepository.load() if (!settings.episodesNotificationsEnabled) { Timber.d("Episodes announcements are disabled. Exiting...") return } val myShows = localSource.myShows.getAll() val watchlistShows = localSource.watchlistShows.getAll() if (myShows.isEmpty() && watchlistShows.isEmpty()) { Timber.d("Nothing to process. Exiting...") return } val language = translationsRepository.getLanguage() val delay = settings.episodesNotificationsDelay val onHoldIds = onHoldItemsRepository.getAll().map { it.id } myShows .forEach { show -> Timber.d("Processing ${show.title} (${show.idTrakt})") if (onHoldIds.contains(show.idTrakt)) { Timber.d("${show.title} (${show.idTrakt}) is on hold. Skipping...") return@forEach } val fromTime = if (delay.isBefore()) nowMillis else nowMillis - delay.delayMs val episode = localSource.episodes.getFirstUnwatched(show.idTrakt, fromTime, limit.toMillis()) episode?.firstAired?.let { airDate -> when { delay.isBefore() -> { if (airDate.toMillis() + delay.delayMs >= nowMillis) { showAnnouncementScheduler.scheduleAnnouncement( showDb = show, episodeNumber = episode.episodeNumber, episodeSeasonNumber = episode.seasonNumber, episodeDate = episode.firstAired!!, delay = delay, language = language ) } else { Timber.d("Time with delay included has already passed.") } } else -> { showAnnouncementScheduler.scheduleAnnouncement( showDb = show, episodeNumber = episode.episodeNumber, episodeSeasonNumber = episode.seasonNumber, episodeDate = episode.firstAired!!, delay = delay, language = language ) } } } } for (show in watchlistShows) { Timber.d("Processing Watchlist ${show.title} (${show.idTrakt})") val fromTime = if (delay.isBefore()) nowMillis else nowMillis - delay.delayMs val airDate = show.firstAired.toZonedDateTime() ?: ZonedDateTime.now().minusYears(1) if (airDate.toMillis() <= fromTime) { continue } if (delay.isBefore()) { if (airDate.toMillis() + delay.delayMs >= nowMillis) { showAnnouncementScheduler.scheduleAnnouncement( showDb = show, episodeNumber = 1, episodeSeasonNumber = 1, episodeDate = airDate, delay = delay, language = language ) } else { Timber.d("Time with delay included has already passed.") } } else { showAnnouncementScheduler.scheduleAnnouncement( showDb = show, episodeNumber = 1, episodeSeasonNumber = 1, episodeDate = airDate, delay = delay, language = language ) } } } suspend fun refreshMoviesAnnouncements() { Timber.d("Refreshing movies announcements") val now = nowUtc() Timber.d("Current time: ${logFormatter.format(now)} UTC") WorkManager.getInstance(context).cancelAllWorkByTag(ANNOUNCEMENT_MOVIE_WORK_TAG) if (!settingsRepository.isMoviesEnabled) { Timber.d("Movies disabled. Skipping...") return } val movies = localSource.watchlistMovies.getAll() .map { mappers.movie.fromDatabase(it) } if (movies.isEmpty()) { Timber.d("Nothing to process. Exiting...") return } val language = translationsRepository.getLanguage() movies .filter { Timber.d("Processing ${it.title} (${it.traktId})") it.released != null && (!it.hasAired() || it.isToday()) && it.released!!.toEpochDay() - nowUtcDay().toEpochDay() < MOVIE_MIN_THRESHOLD_DAYS && ZonedDateTime.now().hour < MOVIE_THRESHOLD_HOUR // We want movies notifications to come out the release day at 12:00 local time } .forEach { movieAnnouncementScheduler.scheduleAnnouncement(context, it, language) } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/notifications/AnnouncementWorker.kt ================================================ package com.michaldrabik.ui_base.notifications import android.annotation.SuppressLint import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.app.UiModeManager.MODE_NIGHT_NO import android.app.UiModeManager.MODE_NIGHT_YES import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.work.Worker import androidx.work.WorkerParameters import com.bumptech.glide.Glide import com.michaldrabik.common.Config import com.michaldrabik.ui_base.R import kotlin.random.Random class AnnouncementWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) { companion object { const val DATA_SHOW_ID = "DATA_SHOW_ID" const val DATA_MOVIE_ID = "DATA_MOVIE_ID" const val DATA_TITLE = "DATA_TITLE" const val DATA_CONTENT = "DATA_CONTENT" const val DATA_CHANNEL = "DATA_CHANNEL" const val DATA_IMAGE_URL = "DATA_IMAGE_URL" const val DATA_THEME = "DATA_THEME" } @SuppressLint("MissingPermission") override fun doWork(): Result { val color = when (inputData.getInt(DATA_THEME, MODE_NIGHT_YES)) { MODE_NIGHT_YES -> R.color.colorNotificationDark MODE_NIGHT_NO -> R.color.colorNotificationLight else -> R.color.colorNotificationDark } val notification = NotificationCompat.Builder(applicationContext, inputData.getString(DATA_CHANNEL)!!) .setContentIntent(createIntent()) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(inputData.getString(DATA_TITLE)) .setContentText(inputData.getString(DATA_CONTENT)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) .setColor(ContextCompat.getColor(applicationContext, color)) val imageUrl = inputData.getString(DATA_IMAGE_URL) if ((imageUrl ?: "").isNotBlank()) { val target = Glide.with(applicationContext).asBitmap().load(imageUrl).submit() try { val bitmap = target.get() notification.setLargeIcon(bitmap) } catch (e: Exception) { // NOOP } finally { Glide.with(applicationContext).clear(target) } } NotificationManagerCompat.from(applicationContext) .notify(Random.nextInt(), notification.build()) return Result.success() } private fun createIntent(): PendingIntent { var requestCode = 0L val targetClass = Class.forName(Config.HOST_ACTIVITY_NAME) val notifyIntent = Intent(applicationContext, targetClass).apply { val showId = inputData.getLong(DATA_SHOW_ID, -1) val movieId = inputData.getLong(DATA_MOVIE_ID, -1) when { showId != -1L -> { putExtra("EXTRA_SHOW_ID", showId.toString()) requestCode = showId } movieId != -1L -> { putExtra("EXTRA_MOVIE_ID", movieId.toString()) requestCode = movieId } } flags = Intent.FLAG_ACTIVITY_SINGLE_TOP } return PendingIntent.getActivity( applicationContext, requestCode.toInt(), notifyIntent, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT ) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/notifications/schedulers/MovieAnnouncementScheduler.kt ================================================ package com.michaldrabik.ui_base.notifications.schedulers import android.content.Context import androidx.work.Data import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import com.michaldrabik.common.Config import com.michaldrabik.common.extensions.dateFromMillis import com.michaldrabik.common.extensions.nowUtcDay import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.repository.TranslationsRepository import com.michaldrabik.repository.images.MovieImagesProvider import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.fcm.NotificationChannel import com.michaldrabik.ui_base.notifications.AnnouncementWorker import com.michaldrabik.ui_model.ImageStatus import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Translation import dagger.hilt.android.qualifiers.ApplicationContext import timber.log.Timber import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.util.concurrent.TimeUnit import javax.inject.Inject class MovieAnnouncementScheduler @Inject constructor( @ApplicationContext private val context: Context, private val settingsRepository: SettingsRepository, private val moviesImagesProvider: MovieImagesProvider, private val translationsRepository: TranslationsRepository, ) { companion object { const val ANNOUNCEMENT_MOVIE_WORK_TAG = "ANNOUNCEMENT_MOVIE_WORK_TAG" private const val MOVIE_THRESHOLD_HOUR = 12 } private val logFormatter by lazy { DateTimeFormatter.ofPattern("EEEE, dd MMM yyyy, HH:mm") } suspend fun scheduleAnnouncement( context: Context, movie: Movie, language: String, ) { var translation: Translation? = null if (language != Config.DEFAULT_LANGUAGE) { translation = translationsRepository.loadTranslation(movie, language, onlyLocal = true) } val data = Data.Builder().apply { putLong(AnnouncementWorker.DATA_MOVIE_ID, movie.traktId) putString(AnnouncementWorker.DATA_CHANNEL, NotificationChannel.MOVIES_ANNOUNCEMENTS.name) putString(AnnouncementWorker.DATA_TITLE, if (translation?.hasTitle == true) translation.title else movie.title) putString(AnnouncementWorker.DATA_CONTENT, context.getString(R.string.textNewMovieAvailable)) putInt(AnnouncementWorker.DATA_THEME, settingsRepository.theme) val posterImage = moviesImagesProvider.findCachedImage(movie, ImageType.POSTER) if (posterImage.status == ImageStatus.AVAILABLE) { putString(AnnouncementWorker.DATA_IMAGE_URL, posterImage.fullFileUrl) } else { val fanartImage = moviesImagesProvider.findCachedImage(movie, ImageType.FANART) if (fanartImage.status == ImageStatus.AVAILABLE) { putString(AnnouncementWorker.DATA_IMAGE_URL, fanartImage.fullFileUrl) } } } val now = ZonedDateTime.now() val days = movie.released!!.toEpochDay() - nowUtcDay().toEpochDay() val offset = now.withHour(MOVIE_THRESHOLD_HOUR).withMinute(0).toMillis() - now.toMillis() val delayed = (days * TimeUnit.DAYS.toMillis(1)) + offset val request = OneTimeWorkRequestBuilder() .setInputData(data.build()) .setInitialDelay(delayed, TimeUnit.MILLISECONDS) .addTag(ANNOUNCEMENT_MOVIE_WORK_TAG) .build() WorkManager.getInstance(context).enqueue(request) val logTime = logFormatter.format(dateFromMillis(nowUtcMillis() + delayed)) Timber.d("Notification set for ${movie.title}: $logTime UTC") } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/notifications/schedulers/ShowAnnouncementScheduler.kt ================================================ package com.michaldrabik.ui_base.notifications.schedulers import android.content.Context import androidx.work.Data import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import com.michaldrabik.common.Config import com.michaldrabik.common.extensions.dateFromMillis import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.data_local.database.model.Show import com.michaldrabik.repository.TranslationsRepository import com.michaldrabik.repository.images.ShowImagesProvider import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.fcm.NotificationChannel import com.michaldrabik.ui_base.notifications.AnnouncementWorker import com.michaldrabik.ui_model.ImageStatus import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.NotificationDelay import com.michaldrabik.ui_model.Translation import dagger.hilt.android.qualifiers.ApplicationContext import timber.log.Timber import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.util.concurrent.TimeUnit import javax.inject.Inject class ShowAnnouncementScheduler @Inject constructor( @ApplicationContext private val context: Context, private val settingsRepository: SettingsRepository, private val showsImagesProvider: ShowImagesProvider, private val translationsRepository: TranslationsRepository, private val mappers: Mappers, ) { companion object { const val ANNOUNCEMENT_WORK_TAG = "ANNOUNCEMENT_WORK_TAG" private const val ANNOUNCEMENT_STATIC_DELAY_MS = 60000 // 1 min } private val logFormatter by lazy { DateTimeFormatter.ofPattern("EEEE, dd MMM yyyy, HH:mm") } suspend fun scheduleAnnouncement( showDb: Show, episodeNumber: Int, episodeSeasonNumber: Int, episodeDate: ZonedDateTime, delay: NotificationDelay, language: String, ) { val show = mappers.show.fromDatabase(showDb) var translation: Translation? = null if (language != Config.DEFAULT_LANGUAGE) { translation = translationsRepository.loadTranslation(show, language, onlyLocal = true) } val data = Data.Builder().apply { val title = if (translation?.hasTitle == true) translation.title else show.title val episode = context.getString(R.string.textSeasonEpisode, episodeSeasonNumber, episodeNumber) putLong(AnnouncementWorker.DATA_SHOW_ID, showDb.idTrakt) putString(AnnouncementWorker.DATA_TITLE, "$title - $episode") putInt(AnnouncementWorker.DATA_THEME, settingsRepository.theme) putString(AnnouncementWorker.DATA_CHANNEL, NotificationChannel.EPISODES_ANNOUNCEMENTS.name) val stringResId = when (episodeNumber) { 1 -> if (delay.isBefore()) R.string.textNewSeasonAvailableSoon else R.string.textNewSeasonAvailable else -> if (delay.isBefore()) R.string.textNewEpisodeAvailableSoon else R.string.textNewEpisodeAvailable } putString(AnnouncementWorker.DATA_CONTENT, context.getString(stringResId)) val posterImage = showsImagesProvider.findCachedImage(show, ImageType.POSTER) if (posterImage.status == ImageStatus.AVAILABLE) { putString(AnnouncementWorker.DATA_IMAGE_URL, posterImage.fullFileUrl) } else { val fanartImage = showsImagesProvider.findCachedImage(show, ImageType.FANART) if (fanartImage.status == ImageStatus.AVAILABLE) { putString(AnnouncementWorker.DATA_IMAGE_URL, fanartImage.fullFileUrl) } } } val delayed = (episodeDate.toMillis() - nowUtcMillis()) + delay.delayMs + ANNOUNCEMENT_STATIC_DELAY_MS val request = OneTimeWorkRequestBuilder() .setInputData(data.build()) .setInitialDelay(delayed, TimeUnit.MILLISECONDS) .addTag(ANNOUNCEMENT_WORK_TAG) .build() WorkManager.getInstance(context).enqueue(request) val logTime = logFormatter.format(dateFromMillis(nowUtcMillis() + delayed)) Timber.d("Notification set for ${show.title}: $logTime UTC") } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/sync/ShowsMoviesSyncWorker.kt ================================================ package com.michaldrabik.ui_base.sync import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingWorkPolicy.KEEP import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.ui_base.events.EventsManager import com.michaldrabik.ui_base.events.ShowsMoviesSyncComplete import com.michaldrabik.ui_base.sync.runners.MoviesSyncRunner import com.michaldrabik.ui_base.sync.runners.ShowsSyncRunner import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import timber.log.Timber @HiltWorker class ShowsMoviesSyncWorker @AssistedInject constructor( @Assisted val context: Context, @Assisted workerParams: WorkerParameters, private val showsSyncRunner: ShowsSyncRunner, private val moviesSyncRunner: MoviesSyncRunner, private val eventsManager: EventsManager, private val dispatchers: CoroutineDispatchers ) : CoroutineWorker(context, workerParams) { companion object { private const val TAG = "ShowsMoviesSyncWorker" fun schedule(workManager: WorkManager) { val request = OneTimeWorkRequestBuilder() .setConstraints( Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() ) .addTag(TAG) .build() workManager.enqueueUniqueWork(TAG, KEEP, request) Timber.i("ShowsMoviesSyncWorker scheduled.") } } override suspend fun doWork() = withContext(dispatchers.IO) { Timber.d("Doing work...") val showsAsync = async { try { Timber.d("Starting shows runner...") showsSyncRunner.run() } catch (error: Throwable) { Timber.e(error) 0 } } val moviesAsync = async { try { Timber.d("Starting movies runner...") moviesSyncRunner.run() } catch (error: Throwable) { Timber.e(error) 0 } } val (showsCount, moviesCount) = awaitAll(showsAsync, moviesAsync) eventsManager.sendEvent(ShowsMoviesSyncComplete(showsCount + moviesCount)) Timber.d("Work finished. Shows: $showsCount Movies: $moviesCount") Result.success() } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/sync/runners/MoviesSyncRunner.kt ================================================ package com.michaldrabik.ui_base.sync.runners import com.michaldrabik.common.ConfigVariant.MOVIE_SYNC_COOLDOWN import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.repository.movies.MoviesRepository import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_model.MovieStatus.IN_PRODUCTION import com.michaldrabik.ui_model.MovieStatus.PLANNED import com.michaldrabik.ui_model.MovieStatus.POST_PRODUCTION import com.michaldrabik.ui_model.MovieStatus.RUMORED import kotlinx.coroutines.delay import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton /** * This class is responsible for fetching and syncing missing/updated movies data. */ @Singleton class MoviesSyncRunner @Inject constructor( private val localSource: LocalDataSource, private val moviesRepository: MoviesRepository, private val settingsRepository: SettingsRepository, ) { companion object { private const val DELAY_MS = 10L } suspend fun run(): Int { Timber.i("Movies sync initialized.") if (!settingsRepository.isMoviesEnabled) { Timber.i("Movies are disabled. Exiting...") return 0 } val movies = moviesRepository.loadCollection() val moviesToSync = movies.filter { it.status in arrayOf(PLANNED, IN_PRODUCTION, POST_PRODUCTION, RUMORED) } if (moviesToSync.isEmpty()) { Timber.i("Nothing to process. Exiting...") return 0 } Timber.i("Movies to sync count: ${moviesToSync.size}.") var syncCount = 0 val syncLog = localSource.moviesSyncLog.getAll() moviesToSync.forEach { movie -> val lastSync = syncLog.find { it.idTrakt == movie.ids.trakt.id }?.syncedAt ?: 0 if (nowUtcMillis() - lastSync < MOVIE_SYNC_COOLDOWN) { Timber.i("${movie.title} is on cooldown. No need to sync.") return@forEach } try { Timber.i("Syncing ${movie.title}(${movie.ids.trakt}) details...") moviesRepository.movieDetails.load(movie.ids.trakt, force = true) syncCount++ Timber.i("${movie.title}(${movie.ids.trakt}) movie synced.") } catch (t: Throwable) { Timber.e("${movie.title}(${movie.ids.trakt}) movie sync error. Skipping... \n$t") } finally { delay(DELAY_MS) } } return syncCount } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/sync/runners/ShowsSyncRunner.kt ================================================ package com.michaldrabik.ui_base.sync.runners import com.michaldrabik.common.ConfigVariant.SHOW_SYNC_COOLDOWN import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.EpisodesSyncLog import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.EpisodesManager import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.shows.ShowsRepository import com.michaldrabik.ui_model.ShowStatus.CANCELED import com.michaldrabik.ui_model.ShowStatus.ENDED import com.michaldrabik.ui_model.ShowStatus.UNKNOWN import kotlinx.coroutines.delay import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton /** * This class is responsible for fetching and syncing missing/updated episodes data for current progress shows. */ @Singleton class ShowsSyncRunner @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers, private val episodesManager: EpisodesManager, private val showsRepository: ShowsRepository, ) { companion object { private const val DELAY_MS = 10L } suspend fun run(): Int { Timber.i("Shows sync initialized.") val myShows = showsRepository.myShows.loadAll() val watchlistShows = showsRepository.watchlistShows.loadAll() val watchlistShowsIds = watchlistShows.map { it.traktId } val showsToSync = (myShows + watchlistShows) .filter { it.status !in arrayOf(ENDED, CANCELED, UNKNOWN) } if (showsToSync.isEmpty()) { Timber.i("Nothing to process. Exiting...") return 0 } Timber.i("Shows to sync: ${showsToSync.size}.") var syncCount = 0 val syncLog = localSource.episodesSyncLog.getAll() showsToSync.forEach { show -> val isInWatchlist = show.traktId in watchlistShowsIds val lastSync = syncLog.find { it.idTrakt == show.traktId }?.syncedAt ?: 0 if (nowUtcMillis() - lastSync < SHOW_SYNC_COOLDOWN) { Timber.i("${show.title} is on cooldown. No need to sync.") return@forEach } try { Timber.i("Syncing ${show.title}(${show.ids.trakt}) details...") showsRepository.detailsShow.load(show.ids.trakt, force = true) syncCount++ Timber.i("${show.title}(${show.ids.trakt}) show synced.") } catch (t: Throwable) { Timber.e("${show.title}(${show.ids.trakt}) show sync error. Skipping... \n$t") } if (isInWatchlist) { localSource.episodesSyncLog.upsert(EpisodesSyncLog(show.traktId, nowUtcMillis())) } else { try { Timber.i("Syncing ${show.title}(${show.ids.trakt}) episodes...") val remoteSeasons = remoteSource.trakt.fetchSeasons(show.traktId) .map { mappers.season.fromNetwork(it) } episodesManager.invalidateSeasons(show, remoteSeasons) syncCount++ Timber.i("${show.title}(${show.ids.trakt}) episodes synced.") } catch (t: Throwable) { Timber.e("${show.title}(${show.ids.trakt}) episodes sync error. Skipping... \n$t") } finally { delay(DELAY_MS) } } } return syncCount } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/TraktNotificationWorker.kt ================================================ package com.michaldrabik.ui_base.trakt import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.UiModeManager import android.content.Context import android.os.Build import androidx.annotation.StringRes import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE import androidx.core.content.ContextCompat import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.utilities.extensions.notificationManager import java.util.concurrent.TimeUnit abstract class TraktNotificationWorker constructor( val context: Context, workerParams: WorkerParameters ) : CoroutineWorker(context, workerParams) { private fun createBaseNotification(theme: Int): NotificationCompat.Builder { val color = when (theme) { UiModeManager.MODE_NIGHT_YES -> R.color.colorNotificationDark UiModeManager.MODE_NIGHT_NO -> R.color.colorNotificationLight else -> R.color.colorNotificationDark } return NotificationCompat.Builder(applicationContext, createNotificationChannel()) .setForegroundServiceBehavior(FOREGROUND_SERVICE_IMMEDIATE) .setContentTitle(context.getString(R.string.textTraktSync)) .setSmallIcon(R.drawable.ic_notification) .setAutoCancel(true) .setColor(ContextCompat.getColor(applicationContext, color)) } protected fun createProgressNotification( theme: Int, content: String?, maxProgress: Int, progress: Int, isIntermediate: Boolean ): Notification = createBaseNotification(theme) .setContentText(content ?: context.getString(R.string.textTraktSyncRunning)) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setOngoing(true) .setAutoCancel(false) .setProgress(maxProgress, progress, isIntermediate) .build() protected fun createSuccessNotification(theme: Int): Notification = createBaseNotification(theme) .setTimeoutAfter(TimeUnit.SECONDS.toMillis(3)) .setContentText(context.getString(R.string.textTraktSyncComplete)) .setPriority(NotificationCompat.PRIORITY_HIGH) .build() protected fun createErrorNotification( theme: Int, @StringRes titleTextRes: Int, @StringRes bigTextRes: Int, action: NotificationCompat.Action? = null ): Notification = createBaseNotification(theme) .setContentTitle(context.getString(titleTextRes)) .setContentText(context.getString(bigTextRes)) .setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(bigTextRes))) .setPriority(NotificationCompat.PRIORITY_HIGH) .apply { action?.let { addAction(it) } } .build() private fun createNotificationChannel(): String { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val id = "Showly Trakt Sync Service" val name = "Showly Trakt Sync" val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_LOW).apply { lockscreenVisibility = Notification.VISIBILITY_PRIVATE setSound(null, null) } applicationContext.notificationManager().createNotificationChannel(channel) return id } return "" } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/TraktSyncRunner.kt ================================================ package com.michaldrabik.ui_base.trakt import com.michaldrabik.common.errors.ShowlyError import com.michaldrabik.repository.UserTraktManager import timber.log.Timber import java.util.concurrent.atomic.AtomicInteger abstract class TraktSyncRunner( private val userTraktManager: UserTraktManager, ) { companion object { const val TRAKT_LIMIT_DELAY_MS = 1250L const val RETRY_DELAY_MS = 5000L const val MAX_EXPORT_RETRY_COUNT = 1 const val MAX_IMPORT_RETRY_COUNT = 3 } val retryCount = AtomicInteger(0) var progressListener: (suspend (String, Int, Int) -> Unit)? = null abstract suspend fun run(): Int protected fun checkAuthorization() { try { Timber.d("Checking authorization...") userTraktManager.checkAuthorization() } catch (error: Throwable) { throw ShowlyError.UnauthorizedError(error.message) } } protected fun resetRetries() { retryCount.set(0) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/TraktSyncWorker.kt ================================================ package com.michaldrabik.ui_base.trakt import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.net.Uri import androidx.annotation.StringRes import androidx.core.app.NotificationCompat import androidx.hilt.work.HiltWorker import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.ForegroundInfo import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.Analytics import com.michaldrabik.ui_base.Logger import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.events.EventsManager import com.michaldrabik.ui_base.events.TraktSyncAuthError import com.michaldrabik.ui_base.events.TraktSyncError import com.michaldrabik.ui_base.events.TraktSyncProgress import com.michaldrabik.ui_base.events.TraktSyncStart import com.michaldrabik.ui_base.events.TraktSyncSuccess import com.michaldrabik.ui_base.trakt.exports.TraktExportListsRunner import com.michaldrabik.ui_base.trakt.exports.TraktExportWatchedRunner import com.michaldrabik.ui_base.trakt.exports.TraktExportWatchlistRunner import com.michaldrabik.ui_base.trakt.imports.TraktImportListsRunner import com.michaldrabik.ui_base.trakt.imports.TraktImportWatchedRunner import com.michaldrabik.ui_base.trakt.imports.TraktImportWatchlistRunner import com.michaldrabik.ui_base.utilities.extensions.notificationManager import com.michaldrabik.ui_model.TraktSyncSchedule import dagger.assisted.Assisted import dagger.assisted.AssistedInject import timber.log.Timber import javax.inject.Named @HiltWorker class TraktSyncWorker @AssistedInject constructor( @Assisted context: Context, @Assisted workerParams: WorkerParameters, private val importWatchedRunner: TraktImportWatchedRunner, private val importWatchlistRunner: TraktImportWatchlistRunner, private val importListsRunner: TraktImportListsRunner, private val exportWatchedRunner: TraktExportWatchedRunner, private val exportWatchlistRunner: TraktExportWatchlistRunner, private val exportListsRunner: TraktExportListsRunner, private val settingsRepository: SettingsRepository, private val eventsManager: EventsManager, private val userManager: UserTraktManager, @Named("miscPreferences") private val miscPreferences: SharedPreferences, ) : TraktNotificationWorker(context, workerParams) { companion object { const val TAG_ID = "TRAKT_SYNC_WORK_ID" private const val TAG = "TRAKT_SYNC_WORK" private const val TAG_ONE_OFF = "TRAKT_SYNC_WORK_ONE_OFF" private const val SYNC_NOTIFICATION_COMPLETE_SUCCESS_ID = 827 private const val SYNC_NOTIFICATION_COMPLETE_PROGRESS_ID = 823 private const val SYNC_NOTIFICATION_COMPLETE_ERROR_ID = 828 private const val SYNC_NOTIFICATION_COMPLETE_ERROR_LISTS_ID = 832 const val KEY_LAST_SYNC_TIMESTAMP = "KEY_LAST_SYNC_TIMESTAMP" private const val ARG_IS_IMPORT = "ARG_IS_IMPORT" private const val ARG_IS_EXPORT = "ARG_IS_EXPORT" private const val ARG_IS_SILENT = "ARG_IS_SILENT" private const val TRAKT_LISTS_INFO_URL = "https://twitter.com/trakt/status/1536751362943332352?s=20&t=bdlxpzlDIclkLqdihaAXqw" fun scheduleOneOff( workManager: WorkManager, isImport: Boolean, isExport: Boolean, isSilent: Boolean, ) { val inputData = workDataOf( ARG_IS_IMPORT to isImport, ARG_IS_EXPORT to isExport, ARG_IS_SILENT to isSilent ) val request = OneTimeWorkRequestBuilder() .setConstraints( Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresBatteryNotLow(false) .setRequiresStorageNotLow(false) .build() ) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .setInputData(inputData) .addTag(TAG_ID) .addTag(TAG_ONE_OFF) .build() workManager.enqueueUniqueWork(TAG_ONE_OFF, ExistingWorkPolicy.KEEP, request) } fun schedulePeriodic( workManager: WorkManager, schedule: TraktSyncSchedule, cancelExisting: Boolean, ) { if (cancelExisting) { workManager.cancelUniqueWork(TAG) } if (schedule == TraktSyncSchedule.OFF) { cancelAllPeriodic(workManager) Timber.i("Trakt sync scheduled: $schedule") return } val inputData = workDataOf( ARG_IS_IMPORT to true, ARG_IS_EXPORT to true, ARG_IS_SILENT to true ) val request = PeriodicWorkRequestBuilder(schedule.duration, schedule.durationUnit) .setConstraints( Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() ) .setInputData(inputData) .setInitialDelay(schedule.duration, schedule.durationUnit) .addTag(TAG_ID) .addTag(TAG) .build() workManager.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request) Timber.i("Trakt sync scheduled: $schedule") } fun cancelAllPeriodic(workManager: WorkManager) { workManager.cancelUniqueWork(TAG) } } override suspend fun doWork(): Result { val isImport = inputData.getBoolean(ARG_IS_IMPORT, false) val isExport = inputData.getBoolean(ARG_IS_EXPORT, false) val isSilent = inputData.getBoolean(ARG_IS_SILENT, false) val theme = settingsRepository.theme try { eventsManager.sendEvent(TraktSyncStart) if (isImport) { var resultCount = runImportWatched() resultCount += runImportWatchlist(resultCount) runImportLists(resultCount) } if (isExport) { runExportWatched() runExportWatchlist() runExportLists() } miscPreferences.edit().putLong(KEY_LAST_SYNC_TIMESTAMP, nowUtcMillis()).apply() eventsManager.sendEvent(TraktSyncSuccess) Analytics.logTraktFullSyncSuccess(isImport, isExport) if (!isSilent) { notificationManager().notify(SYNC_NOTIFICATION_COMPLETE_SUCCESS_ID, createSuccessNotification(theme)) } return Result.success() } catch (error: Throwable) { handleError(error, isSilent) return Result.failure() } finally { clearRunners() notificationManager().cancel(SYNC_NOTIFICATION_COMPLETE_PROGRESS_ID) } } override suspend fun getForegroundInfo(): ForegroundInfo { val theme = settingsRepository.theme val notification = createProgressNotification(theme, null, 0, 0, true) return ForegroundInfo(SYNC_NOTIFICATION_COMPLETE_PROGRESS_ID, notification) } private suspend fun runImportWatched(): Int { val theme = settingsRepository.theme importWatchedRunner.progressListener = { title: String, progress: Int, total: Int -> val status = "Importing \'$title\'..." setProgressNotification(theme, status, total, progress, false) eventsManager.sendEvent(TraktSyncProgress(status)) } return importWatchedRunner.run() } private suspend fun runImportWatchlist(totalProgress: Int): Int { val theme = settingsRepository.theme importWatchlistRunner.progressListener = { title: String, progress: Int, total: Int -> val status = "Importing \'$title\'..." setProgressNotification(theme, status, totalProgress + total, totalProgress + progress, false) eventsManager.sendEvent(TraktSyncProgress(status)) } return importWatchlistRunner.run() } private suspend fun runImportLists(totalProgress: Int) { val theme = settingsRepository.theme importListsRunner.progressListener = { title: String, progress: Int, total: Int -> val status = "Importing \'$title\'..." setProgressNotification(theme, status, totalProgress + total, totalProgress + progress, false) eventsManager.sendEvent(TraktSyncProgress(status)) } importListsRunner.run() } private suspend fun runExportWatched() { val status = "Exporting progress..." val theme = settingsRepository.theme setProgressNotification(theme, status, 0, 0, true) eventsManager.sendEvent(TraktSyncProgress(status)) exportWatchedRunner.run() } private suspend fun runExportWatchlist() { val status = "Exporting watchlist..." val theme = settingsRepository.theme setProgressNotification(theme, status, 0, 0, true) eventsManager.sendEvent(TraktSyncProgress(status)) try { exportWatchlistRunner.run() } catch (error: Throwable) { handleListsError(error, R.string.errorTraktSyncWatchlistLimitsReached) } } private suspend fun runExportLists() { val status = "Exporting custom lists..." val theme = settingsRepository.theme setProgressNotification(theme, status, 0, 0, true) eventsManager.sendEvent(TraktSyncProgress(status)) try { exportListsRunner.run() } catch (error: Throwable) { handleListsError(error, R.string.errorTraktSyncListsLimitsReached) } } private fun setProgressNotification( theme: Int, content: String?, maxProgress: Int, progress: Int, isIntermediate: Boolean, ) { notificationManager().notify( SYNC_NOTIFICATION_COMPLETE_PROGRESS_ID, createProgressNotification(theme, content, maxProgress, progress, isIntermediate) ) } private suspend fun handleError(error: Throwable, isSilent: Boolean) { val showlyError = ErrorHelper.parse(error) if (showlyError is ShowlyError.UnauthorizedError) { eventsManager.sendEvent(TraktSyncAuthError) userManager.revokeToken() } else { eventsManager.sendEvent(TraktSyncError) } if (!isSilent) { val message = if (showlyError is ShowlyError.UnauthorizedError) R.string.errorTraktAuthorization else R.string.textTraktSyncErrorFull val theme = settingsRepository.theme notificationManager().notify( SYNC_NOTIFICATION_COMPLETE_ERROR_ID, createErrorNotification(theme, R.string.textTraktSyncError, message) ) } Logger.record(error, "TraktSyncWorker::handleError()") } private fun handleListsError( error: Throwable, @StringRes notificationMessageResId: Int, ) { when (ErrorHelper.parse(error)) { ShowlyError.AccountLimitsError -> { val theme = settingsRepository.theme val intent = Intent(Intent.ACTION_VIEW, Uri.parse(TRAKT_LISTS_INFO_URL)) val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT) val action = NotificationCompat.Action(R.drawable.ic_info, "More Info", pendingIntent) notificationManager().notify( SYNC_NOTIFICATION_COMPLETE_ERROR_LISTS_ID, createErrorNotification(theme, R.string.textTraktSync, notificationMessageResId, action) ) } else -> throw error } } private fun clearRunners() { arrayOf( importWatchedRunner, importWatchedRunner, importWatchlistRunner, importListsRunner, exportWatchedRunner, exportWatchlistRunner, exportListsRunner, ).forEach { it.progressListener = null } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/exports/TraktExportListsRunner.kt ================================================ package com.michaldrabik.ui_base.trakt.exports import com.michaldrabik.common.Mode import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_remote.trakt.TraktRemoteDataSource import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.trakt.TraktSyncRunner import com.michaldrabik.ui_model.CustomList import kotlinx.coroutines.delay import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class TraktExportListsRunner @Inject constructor( private val remoteSource: TraktRemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers, private val settingsRepository: SettingsRepository, userTraktManager: UserTraktManager, ) : TraktSyncRunner(userTraktManager) { private var hasAccountLimitsOccurred = false override suspend fun run(): Int { Timber.d("Initialized.") hasAccountLimitsOccurred = false checkAuthorization() resetRetries() runExport() if (hasAccountLimitsOccurred) { throw ShowlyError.AccountLimitsError } Timber.d("Finished with success.") return 0 } private suspend fun runExport() { try { exportLists() } catch (error: Throwable) { if (retryCount.getAndIncrement() < MAX_EXPORT_RETRY_COUNT) { Timber.w("exportLists failed. Will retry in $RETRY_DELAY_MS ms... $error") delay(RETRY_DELAY_MS) runExport() } else { throw error } } } private suspend fun exportLists() { Timber.d("Exporting lists...") val localLists = localSource.customLists.getAll() .map { mappers.customList.fromDatabase(it) } val remoteLists = remoteSource.fetchSyncLists() .map { mappers.customList.fromNetwork(it) } localLists .sortedByDescending { it.updatedAt } .forEach { localList -> Timber.d("Processing ${localList.name}...") try { val isNewList = remoteLists.none { it.idTrakt == localList.idTrakt } if (isNewList) { Timber.d("List not found in Trakt. Creating and uploading items...") exportNewList(localList) } else { Timber.d("List found in Trakt.") exportExistingList(remoteLists, localList) } } catch (error: Throwable) { val showlyError = ErrorHelper.parse(error) if (showlyError == ShowlyError.AccountLimitsError) { Timber.w("Account limits reached. Skipping the rest of lists exporting.") hasAccountLimitsOccurred = true return@forEach } throw error } } } private suspend fun exportNewList(localList: CustomList) { delay(TRAKT_LIMIT_DELAY_MS) val listNet = remoteSource.postCreateList(localList.name, localList.description) Timber.d("List created in Trakt.") val list = mappers.customList.fromNetwork(listNet) val listDb = mappers.customList.toDatabase(list).copy(id = localList.id) localSource.customLists.update(listOf(listDb)) delay(TRAKT_LIMIT_DELAY_MS) val localItems = localSource.customListsItems.getItemsById(localList.id) if (localItems.isNotEmpty()) { val showsIds = localItems.filter { it.type == Mode.SHOWS.type }.map { it.idTrakt } val moviesIds = localItems.filter { it.type == Mode.MOVIES.type }.map { it.idTrakt } remoteSource.postAddListItems(listNet.ids.trakt, showsIds, moviesIds) Timber.d("Items added into Trakt list.") delay(TRAKT_LIMIT_DELAY_MS) } } private suspend fun exportExistingList( remoteLists: List, localList: CustomList, ) { val moviesEnabled = settingsRepository.isMoviesEnabled val remoteList = remoteLists.first { it.idTrakt == localList.idTrakt } if (localList.updatedAt.isEqual(remoteList.updatedAt)) { Timber.d("Timestamps are the same.") return } Timber.d("Timestamps are different.") if (localList.updatedAt.isAfter(remoteList.updatedAt)) { Timber.d("Local list timestamp is newer.") if (localList.name != remoteList.name || localList.description != remoteList.description) { Timber.d("Name or description are different. Updating...") val updateList = mappers.customList.toNetwork(localList) val resultList = remoteSource.postUpdateList(updateList) val listDb = mappers.customList.fromNetwork(resultList).copy(id = localList.id) localSource.customLists.update(listOf(mappers.customList.toDatabase(listDb))) delay(TRAKT_LIMIT_DELAY_MS) } } Timber.d("Processing list items...") val listTraktId = localList.idTrakt!! val remoteItems = remoteSource.fetchSyncListItems(listTraktId, moviesEnabled) .filter { it.movie != null || it.show != null } val localItems = localSource.customListsItems.getItemsById(localList.id) .filter { localItem -> remoteItems.none { it.getTraktId() == localItem.idTrakt && it.getType() == localItem.type } } if (localItems.isNotEmpty()) { Timber.d("${localItems.size} to be exported...") val showsIds = localItems.filter { it.type == Mode.SHOWS.type }.map { it.idTrakt } val moviesIds = localItems.filter { it.type == Mode.MOVIES.type }.map { it.idTrakt } remoteSource.postAddListItems(listTraktId, showsIds, moviesIds) Timber.d("Exported!") delay(TRAKT_LIMIT_DELAY_MS) } updateListTimestamp(localList.id, listTraktId) } private suspend fun updateListTimestamp(listId: Long, listTraktId: Long) { try { Timber.d("Updating timestamp...") val list = remoteSource.fetchSyncList(listTraktId) .run { mappers.customList.fromNetwork(this) } localSource.customLists.updateTimestamp(listId, list.updatedAt.toMillis()) Timber.d("Local list timestamp updated.") } catch (error: Throwable) { Timber.w(error) // Skip timestamp update in case of failure. } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/exports/TraktExportWatchedRunner.kt ================================================ package com.michaldrabik.ui_base.trakt.exports import com.michaldrabik.common.extensions.dateIsoStringFromMillis import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.Episode import com.michaldrabik.data_local.database.model.Movie import com.michaldrabik.data_local.database.model.Show import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.data_remote.trakt.model.SyncExportItem import com.michaldrabik.data_remote.trakt.model.SyncExportRequest import com.michaldrabik.data_remote.trakt.model.SyncItem import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.Analytics import com.michaldrabik.ui_base.trakt.TraktSyncRunner import com.michaldrabik.ui_base.utilities.extensions.rethrowCancellation import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class TraktExportWatchedRunner @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val settingsRepository: SettingsRepository, userTraktManager: UserTraktManager, ) : TraktSyncRunner(userTraktManager) { override suspend fun run(): Int { Timber.d("Initialized.") checkAuthorization() resetRetries() runExport() Timber.d("Finished with success.") return 0 } private suspend fun runExport() { try { exportWatched() } catch (error: Throwable) { rethrowCancellation(error) if (retryCount.getAndIncrement() < MAX_EXPORT_RETRY_COUNT) { Timber.w("exportWatched failed. Will retry in $RETRY_DELAY_MS ms... $error") delay(RETRY_DELAY_MS) runExport() } else { throw error } } } private suspend fun exportWatched() { Timber.d("Exporting watched...") val remoteShows = remoteSource.trakt.fetchSyncWatchedShows() .filter { it.show != null } val localMyShows = localSource.myShows.getAll() val localEpisodes = batchEpisodes(localMyShows.map { it.idTrakt }) .filter { !hasEpisodeBeenWatched(remoteShows, it) } val movies = mutableListOf() if (settingsRepository.isMoviesEnabled) { val remoteMovies = remoteSource.trakt.fetchSyncWatchedMovies() .filter { it.movie != null } val localMyMoviesIds = localSource.myMovies.getAllTraktIds() val localMyMovies = batchMovies(localMyMoviesIds) .filter { movie -> remoteMovies.none { it.movie?.ids?.trakt == movie.idTrakt } } localMyMovies.mapTo(movies) { SyncExportItem.create(it.idTrakt, dateIsoStringFromMillis(it.updatedAt)) } } val episodes = localEpisodes.map { ep -> val episodeTimestamp = ep.lastWatchedAt?.toMillis() ?: 0 val showTimestamp = localMyShows.find { it.idTrakt == ep.idShowTrakt }?.updatedAt ?: 0 val timestamp = when { episodeTimestamp > 0 -> episodeTimestamp showTimestamp > 0 -> showTimestamp else -> nowUtcMillis() } SyncExportItem.create(ep.idTrakt, dateIsoStringFromMillis(timestamp)) } Timber.d("Exporting ${episodes.size} episodes & ${movies.size} movies...") if (episodes.isNotEmpty() || movies.isNotEmpty()) { Analytics.logExportHistory(episodes.size, movies.size, retryCount.get()) val request = SyncExportRequest(episodes = episodes, movies = movies) postExportWatched(request) } else { Timber.d("Nothing to export. Skipping...") } delay(TRAKT_LIMIT_DELAY_MS) exportHidden() } private suspend fun postExportWatched( request: SyncExportRequest, ) { val episodes = request.episodes.toList() val movies = request.movies.toList() if (episodes.isEmpty() && movies.isEmpty()) { Timber.d("All batches exported.") return } val batchRequest = request.copy( episodes = episodes.take(1000), movies = movies.take(500) ) Timber.d("Exporting batch ${batchRequest.episodes.size} episodes & ${batchRequest.movies.size} movies...") remoteSource.trakt.postSyncWatched(batchRequest) delay(TRAKT_LIMIT_DELAY_MS) postExportWatched( request.copy( episodes = request.episodes.filter { it !in batchRequest.episodes }, movies = request.movies.filter { it !in batchRequest.movies } ) ) } private suspend fun exportHidden() = coroutineScope { Timber.d("Exporting hidden items...") val remoteShowsAsync = async { remoteSource.trakt.fetchHiddenShows() } val remoteMoviesAsync = async { remoteSource.trakt.fetchHiddenMovies() } val (remoteShows, remoteMovies) = awaitAll(remoteShowsAsync, remoteMoviesAsync) val showsAsync = async { localSource.archiveShows.getAll() } val moviesAsync = async { localSource.archiveMovies.getAll() } val (localShows, localMovies) = awaitAll(showsAsync, moviesAsync) val remoteShowsIds = remoteShows.mapNotNull { it.show?.ids?.trakt } val remoteMoviesIds = remoteMovies.mapNotNull { it.movie?.ids?.trakt } val showsItems = localShows .filter { (it as Show).idTrakt !in remoteShowsIds } .map { (it as Show).let { show -> SyncExportItem.create( traktId = show.idTrakt, hiddenAt = dateIsoStringFromMillis(show.updatedAt) ) } } val moviesItems = localMovies .filter { (it as Movie).idTrakt !in remoteMoviesIds } .map { (it as Movie).let { movie -> SyncExportItem.create( traktId = movie.idTrakt, hiddenAt = dateIsoStringFromMillis(movie.updatedAt) ) } } Timber.d("Exporting ${showsItems.size} hidden shows...") if (showsItems.isNotEmpty()) { showsItems.chunked(500).forEach { chunk -> remoteSource.trakt.postHiddenShows(shows = chunk) delay(TRAKT_LIMIT_DELAY_MS) } delay(TRAKT_LIMIT_DELAY_MS) } else { Timber.d("Nothing to export. Skipping...") } Timber.d("Exporting ${moviesItems.size} hidden movies...") if (moviesItems.isNotEmpty()) { moviesItems.chunked(500).forEach { chunk -> remoteSource.trakt.postHiddenMovies(movies = chunk) delay(TRAKT_LIMIT_DELAY_MS) } delay(TRAKT_LIMIT_DELAY_MS) } else { Timber.d("Nothing to export. Skipping...") } } private suspend fun batchEpisodes( showsIds: List, allEpisodes: MutableList = mutableListOf(), ): List { val batch = showsIds.take(250) if (batch.isEmpty()) return allEpisodes val episodes = localSource.episodes.getAllWatchedForShows(batch) allEpisodes.addAll(episodes) return batchEpisodes(showsIds.filter { it !in batch }, allEpisodes) } private suspend fun batchMovies( moviesIds: List, result: MutableList = mutableListOf(), ): List { val batch = moviesIds.take(500) if (batch.isEmpty()) return result val movies = localSource.myMovies.getAll(batch) result.addAll(movies) return batchMovies(moviesIds.filter { it !in batch }, result) } private fun hasEpisodeBeenWatched(remoteWatched: List, episodeDb: Episode): Boolean { val find = remoteWatched .find { it.show?.ids?.trakt == episodeDb.idShowTrakt } ?.seasons?.find { it.number == episodeDb.seasonNumber } ?.episodes?.find { it.number == episodeDb.episodeNumber } return find != null } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/exports/TraktExportWatchlistRunner.kt ================================================ package com.michaldrabik.ui_base.trakt.exports import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.data_remote.trakt.model.SyncExportItem import com.michaldrabik.data_remote.trakt.model.SyncExportRequest import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.trakt.TraktSyncRunner import kotlinx.coroutines.delay import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class TraktExportWatchlistRunner @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val settingsRepository: SettingsRepository, userTraktManager: UserTraktManager, ) : TraktSyncRunner(userTraktManager) { override suspend fun run(): Int { Timber.d("Initialized.") checkAuthorization() resetRetries() runExport() Timber.d("Finished with success.") return 0 } private suspend fun runExport() { try { exportWatchlist() } catch (error: Throwable) { handleError(error) } } private suspend fun exportWatchlist() { Timber.d("Exporting watchlist...") val shows = localSource.watchlistShows.getAll() .map { SyncExportItem.create(it.idTrakt) } .toMutableList() val movies = mutableListOf() if (settingsRepository.isMoviesEnabled) { localSource.watchlistMovies.getAll() .mapTo(movies) { SyncExportItem.create(it.idTrakt) } } Timber.d("Exporting ${shows.size} shows & ${movies.size} movies...") while (true) { val showsChunk = shows.take(250) val moviesChunk = movies.take(250) if (showsChunk.isEmpty() && moviesChunk.isEmpty()) { Timber.d("No more chunks. Breaking.") break } Timber.d("Exporting chunk of ${showsChunk.size} shows & ${moviesChunk.size} movies...") val request = SyncExportRequest(shows = showsChunk, movies = moviesChunk) remoteSource.trakt.postSyncWatchlist(request) shows.removeAll(showsChunk) movies.removeAll(moviesChunk) delay(TRAKT_LIMIT_DELAY_MS) } } private suspend fun handleError(error: Throwable) { val showlyError = ErrorHelper.parse(error) when { showlyError == ShowlyError.AccountLimitsError -> { Timber.w("Account limits reached for Watchlist.") throw error } retryCount.getAndIncrement() < MAX_EXPORT_RETRY_COUNT -> { Timber.w("exportWatchlist failed. Will retry in $RETRY_DELAY_MS ms... $error") delay(RETRY_DELAY_MS) runExport() } else -> throw error } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/imports/TraktImportListsRunner.kt ================================================ package com.michaldrabik.ui_base.trakt.imports import com.michaldrabik.common.Mode import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.CustomListItem import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.trakt.TraktSyncRunner import kotlinx.coroutines.delay import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class TraktImportListsRunner @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers, private val settingsRepository: SettingsRepository, userTraktManager: UserTraktManager, ) : TraktSyncRunner(userTraktManager) { override suspend fun run(): Int { Timber.d("Initialized.") var syncedCount = 0 checkAuthorization() resetRetries() syncedCount += runLists() Timber.d("Finished with success.") return syncedCount } private suspend fun runLists(): Int { return try { importLists() } catch (error: Throwable) { if (retryCount.getAndIncrement() < MAX_IMPORT_RETRY_COUNT) { Timber.w("runLists HTTP failed. Will retry in $RETRY_DELAY_MS ms... $error") delay(RETRY_DELAY_MS) runLists() } else { throw error } } } private suspend fun importLists(): Int { Timber.d("Importing custom lists...") val nowUtcMillis = nowUtcMillis() val moviesEnabled = settingsRepository.isMoviesEnabled val localLists = localSource.customLists.getAll() .map { mappers.customList.fromDatabase(it) } val remoteLists = remoteSource.trakt.fetchSyncLists() .map { mappers.customList.fromNetwork(it) } remoteLists.forEach { remoteList -> Timber.d("Processing '${remoteList.name}' ...") val local = localLists.find { it.idTrakt == remoteList.idTrakt } transactions.withTransaction { when { local == null -> { Timber.d("Local list not found. Creating...") val listDb = mappers.customList.toDatabase(remoteList) val id = localSource.customLists.insert(listOf(listDb)).first() importListItems(id, remoteList.idTrakt!!, moviesEnabled, nowUtcMillis) } remoteList.updatedAt.isEqual(local.updatedAt).not() -> { Timber.d("Local list found and timestamp is different. Updating...") if (remoteList.updatedAt.isAfter(local.updatedAt)) { val listDb = mappers.customList.toDatabase(remoteList) .copy(id = local.id) localSource.customLists.update(listOf(listDb)) } importListItems(local.id, local.idTrakt!!, moviesEnabled, nowUtcMillis) } else -> { Timber.d("Local list found but timestamp is the same. Skipping...") } } } } return remoteLists.size } private suspend fun importListItems( listId: Long, listIdTrakt: Long, moviesEnabled: Boolean, nowUtcMillis: Long, ) { Timber.d("Importing list items...") val localItems = localSource.customListsItems.getItemsById(listId) val items = remoteSource.trakt.fetchSyncListItems(listIdTrakt, moviesEnabled) .filter { item -> localItems.none { it.idTrakt == item.getTraktId() && it.type == item.getType() } } .filter { it.movie != null || it.show != null } val shows = items .filter { it.show != null } .map { mappers.show.fromNetwork(it.show!!) } localSource.shows.upsert(shows.map { mappers.show.toDatabase(it) }) Timber.d("Shows to insert: ${shows.size}") val movies = items .filter { it.movie != null } .map { mappers.movie.fromNetwork(it.movie!!) } localSource.movies.upsert(movies.map { mappers.movie.toDatabase(it) }) Timber.d("Movies to insert: ${movies.size}") items.forEach { remoteItem -> remoteItem.show?.let { remoteShow -> val show = shows.first { remoteShow.ids?.trakt == it.traktId } val itemDb = CustomListItem( id = 0, idList = listId, idTrakt = show.traktId, type = Mode.SHOWS.type, rank = 0, listedAt = remoteItem.lastListedMillis(), createdAt = nowUtcMillis, updatedAt = nowUtcMillis ) localSource.customListsItems.insertItem(itemDb) } remoteItem.movie?.let { remoteMovie -> val movie = movies.first { remoteMovie.ids?.trakt == it.traktId } localSource.movies.upsert(listOf(mappers.movie.toDatabase(movie))) val itemDb = CustomListItem( id = 0, idList = listId, idTrakt = movie.traktId, type = Mode.MOVIES.type, rank = 0, listedAt = remoteItem.lastListedMillis(), createdAt = nowUtcMillis, updatedAt = nowUtcMillis ) localSource.customListsItems.insertItem(itemDb) } } if (items.isNotEmpty()) { Timber.d("Updating list timestamp...") localSource.customLists.updateTimestamp(listId, nowUtcMillis) } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/imports/TraktImportWatchedRunner.kt ================================================ package com.michaldrabik.ui_base.trakt.imports import com.michaldrabik.common.extensions.toZonedDateTime import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.ArchiveMovie import com.michaldrabik.data_local.database.model.ArchiveShow import com.michaldrabik.data_local.database.model.Episode import com.michaldrabik.data_local.database.model.MyMovie import com.michaldrabik.data_local.database.model.MyShow import com.michaldrabik.data_local.database.model.Season import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.data_remote.trakt.model.SyncItem import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.images.MovieImagesProvider import com.michaldrabik.repository.images.ShowImagesProvider import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.Logger import com.michaldrabik.ui_base.trakt.TraktSyncRunner import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.ImageType.FANART import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Show import kotlinx.coroutines.delay import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class TraktImportWatchedRunner @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers, private val transactions: TransactionsProvider, private val showImagesProvider: ShowImagesProvider, private val movieImagesProvider: MovieImagesProvider, private val settingsRepository: SettingsRepository, userTraktManager: UserTraktManager, ) : TraktSyncRunner(userTraktManager) { override suspend fun run(): Int { Timber.d("Initialized.") var syncedCount = 0 checkAuthorization() resetRetries() syncedCount += runShows() resetRetries() syncedCount += runMovies() Timber.d("Finished with success.") return syncedCount } private suspend fun runShows(): Int = try { importWatchedShows() } catch (error: Throwable) { if (retryCount.getAndIncrement() < MAX_IMPORT_RETRY_COUNT) { Timber.w("runShows HTTP failed. Will retry in $RETRY_DELAY_MS ms... $error") delay(RETRY_DELAY_MS) runShows() } else { throw error } } private suspend fun runMovies(): Int { if (!settingsRepository.isMoviesEnabled) { Timber.d("Movies are disabled. Exiting...") return 0 } return try { importWatchedMovies() } catch (error: Throwable) { if (retryCount.getAndIncrement() < MAX_IMPORT_RETRY_COUNT) { Timber.w("runMovies HTTP failed. Will retry in $RETRY_DELAY_MS ms... $error") delay(RETRY_DELAY_MS) runMovies() } else { throw error } } } private suspend fun importWatchedShows(): Int { Timber.d("Importing watched shows...") val syncResults = remoteSource.trakt.fetchSyncWatchedShows("full") .filter { it.show != null } .distinctBy { it.show?.ids?.trakt } Timber.d("Importing hidden shows...") val hiddenShows = remoteSource.trakt.fetchHiddenShows() hiddenShows.forEach { hiddenShow -> hiddenShow.show?.let { val show = mappers.show.fromNetwork(it) val dbShow = mappers.show.toDatabase(show) val archiveShow = ArchiveShow.fromTraktId(show.traktId, hiddenShow.hiddenAtMillis()) transactions.withTransaction { with(localSource) { shows.upsert(listOf(dbShow)) archiveShows.insert(archiveShow) myShows.deleteById(show.traktId) watchlistShows.deleteById(show.traktId) } } } } val myShowsIds = localSource.myShows.getAllTraktIds() val watchlistShowsIds = localSource.watchlistShows.getAllTraktIds() val hiddenShowsIds = localSource.archiveShows.getAllTraktIds() val traktSyncLogs = localSource.traktSyncLog.getAllShows() syncResults .forEachIndexed { index, result -> val showUi = mappers.show.fromNetwork(result.show!!) progressListener?.invoke(showUi.title, index, syncResults.size) Timber.d("Processing \'${showUi.title}\'...") val log = traktSyncLogs.firstOrNull { it.idTrakt == result.show?.ids?.trakt } if (result.lastUpdateMillis() == (log?.syncedAt ?: 0)) { Timber.d("Nothing changed in \'${result.show!!.title}\'. Skipping...") return@forEachIndexed } try { val showId = result.show!!.ids!!.trakt!! val (seasons, episodes) = loadSeasons(showId, result) transactions.withTransaction { if (showId !in myShowsIds && showId !in hiddenShowsIds) { val show = mappers.show.fromNetwork(result.show!!) val showDb = mappers.show.toDatabase(show) val myShow = MyShow.fromTraktId( traktId = showDb.idTrakt, createdAt = result.lastWatchedMillis(), updatedAt = result.lastWatchedMillis(), watchedAt = result.lastWatchedMillis() ) localSource.shows.upsert(listOf(showDb)) localSource.myShows.insert(listOf(myShow)) loadImage(show) if (showId in watchlistShowsIds) { localSource.watchlistShows.deleteById(showId) } } localSource.seasons.upsert(seasons) localSource.episodes.upsert(episodes) localSource.myShows.updateWatchedAt(showId, result.lastWatchedMillis()) localSource.traktSyncLog.upsertShow(showId, result.lastUpdateMillis()) } } catch (error: Throwable) { Timber.w("Processing \'${result.show!!.title}\' failed. Skipping...") Logger.record(error, "TraktImportWatchedRunner::importWatchedShows()") } } return syncResults.size } private suspend fun loadSeasons(showId: Long, syncItem: SyncItem): Pair, List> { val remoteSeasons = remoteSource.trakt.fetchSeasons(showId) val localSeasonsIds = localSource.seasons.getAllWatchedIdsForShows(listOf(showId)) val localEpisodesIds = localSource.episodes.getAllWatchedIdsForShows(listOf(showId)) val seasons = remoteSeasons .filterNot { localSeasonsIds.contains(it.ids?.trakt) } .map { mappers.season.fromNetwork(it) } .map { remoteSeason -> val isWatched = syncItem.seasons?.any { it.number == remoteSeason.number && it.episodes?.size == remoteSeason.episodes.size } ?: false mappers.season.toDatabase(remoteSeason, IdTrakt(showId), isWatched) } val episodes = remoteSeasons.flatMap { season -> season.episodes ?.filterNot { localEpisodesIds.contains(it.ids?.trakt) } ?.map { episode -> val syncEpisode = syncItem.seasons ?.find { it.number == season.number }?.episodes ?.find { it.number == episode.number } val isWatched = syncEpisode != null val watchedAt = syncEpisode?.last_watched_at?.toZonedDateTime() val seasonDb = mappers.season.fromNetwork(season) val episodeDb = mappers.episode.fromNetwork(episode) mappers.episode.toDatabase(episodeDb, seasonDb, IdTrakt(showId), isWatched, watchedAt) } ?: emptyList() } return Pair(seasons, episodes) } private suspend fun importWatchedMovies(): Int { Timber.d("Importing watched movies...") val syncResults = remoteSource.trakt.fetchSyncWatchedMovies("full") .filter { it.movie != null } .distinctBy { it.movie?.ids?.trakt } Timber.d("Importing hidden movies...") val hiddenMovies = remoteSource.trakt.fetchHiddenMovies() hiddenMovies.forEach { hiddenMovie -> hiddenMovie.movie?.let { val movie = mappers.movie.fromNetwork(it) val dbMovie = mappers.movie.toDatabase(movie) val archiveMovie = ArchiveMovie.fromTraktId(movie.traktId, hiddenMovie.hiddenAtMillis()) transactions.withTransaction { with(localSource) { movies.upsert(listOf(dbMovie)) archiveMovies.insert(archiveMovie) myMovies.deleteById(movie.traktId) watchlistMovies.deleteById(movie.traktId) } } } } val myMoviesIds = localSource.myMovies.getAllTraktIds() val watchlistMoviesIds = localSource.watchlistMovies.getAllTraktIds() val hiddenMoviesIds = localSource.archiveMovies.getAllTraktIds() syncResults .forEachIndexed { index, result -> Timber.d("Processing \'${result.movie!!.title}\'...") val movieUi = mappers.movie.fromNetwork(result.movie!!) progressListener?.invoke(movieUi.title, index, syncResults.size) try { val movieId = result.movie!!.ids!!.trakt!! transactions.withTransaction { if (movieId !in myMoviesIds && movieId !in hiddenMoviesIds) { val movie = mappers.movie.fromNetwork(result.movie!!) val movieDb = mappers.movie.toDatabase(movie) val myMovie = MyMovie.fromTraktId(movieDb.idTrakt, result.lastWatchedMillis()) localSource.movies.upsert(listOf(movieDb)) localSource.myMovies.insert(listOf(myMovie)) loadImage(movie) if (movieId in watchlistMoviesIds) { localSource.watchlistMovies.deleteById(movieId) } } } } catch (error: Throwable) { Timber.w("Processing \'${result.movie!!.title}\' failed. Skipping...") Logger.record(error, "TraktImportWatchedRunner::importWatchedMovies()") } } return syncResults.size } private suspend fun loadImage(show: Show) { try { showImagesProvider.loadRemoteImage(show, FANART) } catch (error: Throwable) { Timber.w(error) // Ignore image for now. It will be fetched later if needed. } } private suspend fun loadImage(movie: Movie) { try { movieImagesProvider.loadRemoteImage(movie, FANART) } catch (error: Throwable) { Timber.w(error) // Ignore image for now. It will be fetched later if needed. } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/imports/TraktImportWatchlistRunner.kt ================================================ package com.michaldrabik.ui_base.trakt.imports import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.WatchlistMovie import com.michaldrabik.data_local.database.model.WatchlistShow import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.Logger import com.michaldrabik.ui_base.trakt.TraktSyncRunner import kotlinx.coroutines.delay import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class TraktImportWatchlistRunner @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val mappers: Mappers, private val settingsRepository: SettingsRepository, userTraktManager: UserTraktManager, ) : TraktSyncRunner(userTraktManager) { override suspend fun run(): Int { Timber.d("Initialized.") var syncedCount = 0 checkAuthorization() resetRetries() syncedCount += runShows() resetRetries() syncedCount += runMovies() Timber.d("Finished with success.") return syncedCount } private suspend fun runShows(): Int = try { importShowsWatchlist() } catch (error: Throwable) { if (retryCount.getAndIncrement() < MAX_IMPORT_RETRY_COUNT) { Timber.w("runShows HTTP failed. Will retry in $RETRY_DELAY_MS ms... $error") delay(RETRY_DELAY_MS) runShows() } else { throw error } } private suspend fun runMovies(): Int { if (!settingsRepository.isMoviesEnabled) { Timber.d("Movies are disabled. Exiting...") return 0 } return try { importMoviesWatchlist() } catch (error: Throwable) { if (retryCount.getAndIncrement() < MAX_IMPORT_RETRY_COUNT) { Timber.w("runMovies HTTP failed. Will retry in $RETRY_DELAY_MS ms... $error") delay(RETRY_DELAY_MS) runMovies() } else { throw error } } } private suspend fun importShowsWatchlist(): Int { Timber.d("Importing shows watchlist...") val syncResults = remoteSource.trakt.fetchSyncShowsWatchlist() .filter { it.show != null } .distinctBy { it.show!!.ids?.trakt } val localShowsIds = localSource.watchlistShows.getAllTraktIds() .plus(localSource.myShows.getAllTraktIds()) .plus(localSource.archiveShows.getAllTraktIds()) .distinct() syncResults .forEachIndexed { index, result -> Timber.d("Processing \'${result.show!!.title}\'...") val showUi = mappers.show.fromNetwork(result.show!!) progressListener?.invoke(showUi.title, index, syncResults.size) try { val showId = result.show!!.ids?.trakt ?: -1 transactions.withTransaction { if (showId !in localShowsIds) { val show = mappers.show.fromNetwork(result.show!!) val showDb = mappers.show.toDatabase(show) localSource.shows.upsert(listOf(showDb)) localSource.watchlistShows.insert(WatchlistShow.fromTraktId(showId, result.lastListedMillis())) } } } catch (error: Throwable) { Timber.w("Processing \'${result.show!!.title}\' failed. Skipping...") Logger.record(error, "TraktImportWatchlistRunner::importShowsWatchlist()") } } return syncResults.size } private suspend fun importMoviesWatchlist(): Int { Timber.d("Importing movies watchlist...") val syncResults = remoteSource.trakt.fetchSyncMoviesWatchlist() .filter { it.movie != null } .distinctBy { it.movie!!.ids?.trakt } val localMoviesIds = localSource.watchlistMovies.getAllTraktIds() .plus(localSource.myMovies.getAllTraktIds()) .plus(localSource.archiveMovies.getAllTraktIds()) .distinct() syncResults .forEachIndexed { index, result -> Timber.d("Processing \'${result.movie!!.title}\'...") val movieUi = mappers.movie.fromNetwork(result.movie!!) progressListener?.invoke(movieUi.title, index, syncResults.size) try { val movieId = result.movie!!.ids?.trakt ?: -1 transactions.withTransaction { if (movieId !in localMoviesIds) { val movie = mappers.movie.fromNetwork(result.movie!!) val movieDb = mappers.movie.toDatabase(movie) localSource.movies.upsert(listOf(movieDb)) localSource.watchlistMovies.insert(WatchlistMovie.fromTraktId(movieId, result.lastListedMillis())) } } } catch (error: Throwable) { Timber.w("Processing \'${result.movie!!.title}\' failed. Skipping...") Logger.record(error, "TraktImportWatchlistRunner::importMoviesWatchlist()") } } return syncResults.size } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/quicksync/QuickSyncManager.kt ================================================ package com.michaldrabik.ui_base.trakt.quicksync import androidx.work.WorkManager import com.michaldrabik.common.Mode import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.TraktSyncQueue import com.michaldrabik.data_local.database.model.TraktSyncQueue.Operation import com.michaldrabik.data_local.database.model.TraktSyncQueue.Type import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.settings.SettingsRepository import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class QuickSyncManager @Inject constructor( private val userTraktManager: UserTraktManager, private val settingsRepository: SettingsRepository, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val workManager: WorkManager ) { suspend fun scheduleEpisodes( episodesIds: List, showId: Long, clearProgress: Boolean = false ) { if (!ensureQuickSync() && !(clearProgress && ensureQuickRemove())) { return } val time = nowUtcMillis() val items = episodesIds.map { TraktSyncQueue.createEpisode(it, showId, time, time, clearProgress) } localSource.traktSyncQueue.insert(items) Timber.d("Episodes added into sync queue. Count: ${items.size}") QuickSyncWorker.schedule(workManager) } suspend fun scheduleMovies(moviesIds: List) { if (!ensureQuickSync()) return val time = nowUtcMillis() val items = moviesIds.map { TraktSyncQueue.createMovie(it, time, time) } localSource.traktSyncQueue.insert(items) Timber.d("Movies added into sync queue. Count: ${items.size}") QuickSyncWorker.schedule(workManager) } suspend fun scheduleShowsWatchlist(showsIds: List) { if (!ensureQuickSync()) return val time = nowUtcMillis() val items = showsIds.map { TraktSyncQueue.createShowWatchlist(it, time, time) } localSource.traktSyncQueue.insert(items) Timber.d("Shows added into sync queue. Count: ${items.size}") QuickSyncWorker.schedule(workManager) } suspend fun scheduleMoviesWatchlist(moviesIds: List) { if (!ensureQuickSync()) return val time = nowUtcMillis() val items = moviesIds.map { TraktSyncQueue.createMovieWatchlist(it, time, time) } localSource.traktSyncQueue.insert(items) Timber.d("Movies added into sync queue. Count: ${items.size}") QuickSyncWorker.schedule(workManager) } suspend fun scheduleAddToList(idTrakt: Long, idList: Long, type: Mode) { if (!ensureQuickSync()) return val time = nowUtcMillis() val item = when (type) { Mode.SHOWS -> TraktSyncQueue.createListShow(idTrakt, idList, Operation.ADD, time, time) Mode.MOVIES -> TraktSyncQueue.createListMovie(idTrakt, idList, Operation.ADD, time, time) } val itemType = when (type) { Mode.SHOWS -> Type.LIST_ITEM_SHOW Mode.MOVIES -> Type.LIST_ITEM_MOVIE } transactions.withTransaction { localSource.traktSyncQueue.delete(idTrakt, idList, itemType.slug, Operation.ADD.slug) val count = localSource.traktSyncQueue.delete(idTrakt, idList, itemType.slug, Operation.REMOVE.slug) if (count == 0) { localSource.traktSyncQueue.insert(listOf(item)) Timber.d("Added ${type.type} list item into add to list queue.") } } QuickSyncWorker.schedule(workManager) } suspend fun scheduleRemoveFromList(idTrakt: Long, idList: Long, type: Mode) { if (!ensureQuickRemove()) return val time = nowUtcMillis() val item = when (type) { Mode.SHOWS -> TraktSyncQueue.createListShow(idTrakt, idList, Operation.REMOVE, time, time) Mode.MOVIES -> TraktSyncQueue.createListMovie(idTrakt, idList, Operation.REMOVE, time, time) } val itemType = when (type) { Mode.SHOWS -> Type.LIST_ITEM_SHOW Mode.MOVIES -> Type.LIST_ITEM_MOVIE } transactions.withTransaction { localSource.traktSyncQueue.delete(idTrakt, idList, itemType.slug, Operation.REMOVE.slug) val count = localSource.traktSyncQueue.delete(idTrakt, idList, itemType.slug, Operation.ADD.slug) if (count == 0 && ensureQuickRemove()) { localSource.traktSyncQueue.insert(listOf(item)) Timber.d("Added ${type.type} list item into remove from list queue.") } } QuickSyncWorker.schedule(workManager) } suspend fun scheduleHidden(idTrakt: Long, type: Mode, operation: Operation) { if (!ensureQuickSync()) return val time = nowUtcMillis() val item = when (type) { Mode.SHOWS -> TraktSyncQueue.createHiddenShow(idTrakt, operation, time, time) Mode.MOVIES -> TraktSyncQueue.createHiddenMovie(idTrakt, operation, time, time) } localSource.traktSyncQueue.insert(listOf(item)) when (type) { Mode.SHOWS -> Timber.d("Hidden show added into sync queue. #$idTrakt") Mode.MOVIES -> Timber.d("Hidden movie added into sync queue. #$idTrakt") } QuickSyncWorker.schedule(workManager) } suspend fun clearEpisodes(episodesIds: List) { if (!ensureQuickRemove()) return val count = localSource.traktSyncQueue.deleteAll(episodesIds, Type.EPISODE.slug) Timber.d("Episodes removed from sync queue. Count: $count") } suspend fun clearEpisodes() { if (!ensureQuickRemove()) return localSource.traktSyncQueue.deleteAll(Type.EPISODE.slug) Timber.d("Episodes removed from sync queue.") } suspend fun clearMovies(moviesIds: List) { if (!ensureQuickRemove()) return localSource.traktSyncQueue.deleteAll(moviesIds, Type.MOVIE.slug) Timber.d("Movies removed from sync queue. Count: ${moviesIds.size}") } suspend fun clearWatchlistShows(showsIds: List) { if (!ensureQuickRemove()) return localSource.traktSyncQueue.deleteAll(showsIds, Type.SHOW_WATCHLIST.slug) Timber.d("Shows removed from sync queue. Count: ${showsIds.size}") } suspend fun clearWatchlistMovies(moviesIds: List) { if (!ensureQuickRemove()) return localSource.traktSyncQueue.deleteAll(moviesIds, Type.MOVIE_WATCHLIST.slug) Timber.d("Movies removed from sync queue. Count: ${moviesIds.size}") } suspend fun clearHiddenShows(ids: List) { if (!ensureQuickRemove()) return localSource.traktSyncQueue.deleteAll(ids, Type.HIDDEN_SHOW.slug) Timber.d("Hidden shows removed from sync queue. Count: ${ids.size}") } suspend fun clearHiddenMovies(ids: List) { if (!ensureQuickRemove()) return localSource.traktSyncQueue.deleteAll(ids, Type.HIDDEN_MOVIE.slug) Timber.d("Hidden shows removed from sync queue. Count: ${ids.size}") } suspend fun isAnyScheduled(): Boolean { if (!ensureAuthorized()) return false return localSource.traktSyncQueue.getAll().isNotEmpty() } private suspend fun ensureQuickSync(): Boolean { if (!ensureAuthorized()) return false val settings = settingsRepository.load() if (!settings.traktQuickSyncEnabled) { Timber.d("Quick Sync is disabled. Skipping...") return false } return true } private suspend fun ensureQuickRemove(): Boolean { if (!ensureAuthorized()) return false val settings = settingsRepository.load() if (!settings.traktQuickRemoveEnabled) { Timber.d("Quick Remove is disabled. Skipping...") return false } return true } private fun ensureAuthorized(): Boolean { if (!userTraktManager.isAuthorized()) { Timber.d("User not logged into Trakt. Skipping...") return false } return true } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/quicksync/QuickSyncWorker.kt ================================================ package com.michaldrabik.ui_base.trakt.quicksync import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.Constraints import androidx.work.ExistingWorkPolicy.REPLACE import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError.AccountLimitsError import com.michaldrabik.common.errors.ShowlyError.UnauthorizedError import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.Analytics import com.michaldrabik.ui_base.Logger import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.events.EventsManager import com.michaldrabik.ui_base.events.TraktQuickSyncSuccess import com.michaldrabik.ui_base.events.TraktSyncAuthError import com.michaldrabik.ui_base.trakt.TraktNotificationWorker import com.michaldrabik.ui_base.trakt.quicksync.runners.QuickSyncListsRunner import com.michaldrabik.ui_base.trakt.quicksync.runners.QuickSyncRunner import com.michaldrabik.ui_base.utilities.extensions.notificationManager import dagger.assisted.Assisted import dagger.assisted.AssistedInject import timber.log.Timber import java.util.concurrent.TimeUnit.SECONDS @HiltWorker class QuickSyncWorker @AssistedInject constructor( @Assisted context: Context, @Assisted workerParams: WorkerParameters, private val quickSyncRunner: QuickSyncRunner, private val quickSyncListsRunner: QuickSyncListsRunner, private val settingsRepository: SettingsRepository, private val userManager: UserTraktManager, private val eventsManager: EventsManager, ) : TraktNotificationWorker(context, workerParams) { companion object { private const val TAG = "TRAKT_QUICK_SYNC_WORK" private const val SYNC_NOTIFICATION_PROGRESS_ID = 916 private const val SYNC_NOTIFICATION_ERROR_ID = 917 fun schedule(workManager: WorkManager) { val request = OneTimeWorkRequestBuilder() .setConstraints( Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() ) .setInitialDelay(3, SECONDS) .addTag(TAG) .build() workManager.enqueueUniqueWork(TAG, REPLACE, request) Timber.i("Trakt QuickSync scheduled.") } } override suspend fun doWork(): Result { Timber.d("Initialized.") val theme = settingsRepository.theme notificationManager().notify( SYNC_NOTIFICATION_PROGRESS_ID, createProgressNotification(theme, null, 0, 0, true) ) try { var count = quickSyncRunner.run() count += quickSyncListsRunner.run() if (count > 0) { eventsManager.sendEvent(TraktQuickSyncSuccess(count)) Analytics.logTraktQuickSyncSuccess(count) } } catch (error: Throwable) { handleError(error) } finally { clearRunners() notificationManager().cancel(SYNC_NOTIFICATION_PROGRESS_ID) Timber.d("Quick Sync completed.") } return Result.success() } private suspend fun handleError(error: Throwable) { val showlyError = ErrorHelper.parse(error) if (showlyError is UnauthorizedError) { eventsManager.sendEvent(TraktSyncAuthError) userManager.revokeToken() } val notificationMessage = when (showlyError) { is AccountLimitsError -> R.string.errorAccountListsLimitsReached is UnauthorizedError -> R.string.errorTraktAuthorization else -> R.string.textTraktSyncErrorFull } val theme = settingsRepository.theme applicationContext.notificationManager().notify( SYNC_NOTIFICATION_ERROR_ID, createErrorNotification(theme, R.string.textTraktQuickSyncError, notificationMessage) ) Logger.record(error, "QuickSyncWorker::handleError()") } private fun clearRunners() { arrayOf( quickSyncRunner, quickSyncListsRunner, ).forEach { it.progressListener = null } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/quicksync/runners/QuickSyncListsRunner.kt ================================================ package com.michaldrabik.ui_base.trakt.quicksync.runners import com.michaldrabik.common.Mode import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.TraktSyncQueue import com.michaldrabik.data_local.database.model.TraktSyncQueue.Operation import com.michaldrabik.data_local.database.model.TraktSyncQueue.Type import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.ListsRepository import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_base.trakt.TraktSyncRunner import com.michaldrabik.ui_model.CustomList import kotlinx.coroutines.delay import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class QuickSyncListsRunner @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val mappers: Mappers, private val listsRepository: ListsRepository, userTraktManager: UserTraktManager ) : TraktSyncRunner(userTraktManager) { companion object { private const val TRAKT_DELAY = 1200L } private val syncTypes = listOf( Type.LIST_ITEM_SHOW, Type.LIST_ITEM_MOVIE ).map { it.slug } override suspend fun run(): Int { Timber.d("Initialized.") var count = 0 checkAuthorization() val items = localSource.traktSyncQueue.getAll(syncTypes) .groupBy { it.idList } .filter { it.key != null } if (items.isEmpty()) { Timber.d("Nothing to sync. Cancelling..") return 0 } count += processItems(items, count) Timber.d("Finished with success.") return count } private suspend fun processItems( items: Map>, count: Int ): Int { var counted = count for (syncListItem in items) { val listId = syncListItem.key!! var localList = localSource.customLists.getById(listId)?.run { mappers.customList.fromDatabase(this) } if (localList == null) { localSource.traktSyncQueue.deleteAllForList(listId) Timber.d("List with ID: $listId does not exist anymore. Skipping...") continue } val addItems = syncListItem.value.filter { it.operation == Operation.ADD.slug } val removeItems = syncListItem.value.filter { it.operation == Operation.REMOVE.slug } if (localList.idTrakt == null && addItems.isNotEmpty()) { Timber.d("List with ID: $listId does not exist in Trakt. Creating...") localList = createMissingList(localList, addItems) } else if (localList.idTrakt == null) { Timber.d("List with ID: $listId does not exist in Trakt. No need to remove items...") localSource.traktSyncQueue.delete(removeItems) continue } // Handle remove items operation handleRemoveItems(removeItems, localList) // Handle add items operation handleAddItems(addItems, localList) counted++ delay(TRAKT_DELAY) } // Check in case more items appeared in the meantime. val itemsCheck = localSource.traktSyncQueue.getAll(syncTypes) .groupBy { it.idList } .filter { it.key != null } if (itemsCheck.isNotEmpty()) { return processItems(itemsCheck, counted) } return counted } private suspend fun handleRemoveItems( removeItems: List, list: CustomList ) { try { val showIds = removeItems .filter { it.type == Type.LIST_ITEM_SHOW.slug } .map { it.idTrakt } val movieIds = removeItems .filter { it.type == Type.LIST_ITEM_MOVIE.slug } .map { it.idTrakt } if (showIds.isNotEmpty() || movieIds.isNotEmpty()) { remoteSource.trakt.postRemoveListItems(list.idTrakt!!, showIds, movieIds) localSource.traktSyncQueue.delete(removeItems) } } catch (error: Throwable) { when (ErrorHelper.parse(error)) { is ShowlyError.ResourceNotFoundError -> { localSource.traktSyncQueue.delete(removeItems) Timber.d("Tried to remove from list but it does not exist anymore. Skipping...") } else -> throw error } } } private suspend fun handleAddItems( addItems: List, localList: CustomList ) { val showIds = addItems .filter { it.type == Type.LIST_ITEM_SHOW.slug } .map { it.idTrakt } val movieIds = addItems .filter { it.type == Type.LIST_ITEM_MOVIE.slug } .map { it.idTrakt } try { if (showIds.isNotEmpty() || movieIds.isNotEmpty()) { remoteSource.trakt.postAddListItems(localList.idTrakt!!, showIds, movieIds) localSource.traktSyncQueue.delete(addItems) } } catch (error: Throwable) { when (ErrorHelper.parse(error)) { is ShowlyError.AccountLimitsError -> { Timber.d("Account limits for lists reached.") localSource.traktSyncQueue.delete(addItems) throw error } is ShowlyError.ResourceNotFoundError -> { Timber.d("Tried to add to list but it does not exist. Creating...") delay(TRAKT_DELAY) createMissingList(localList, addItems) localSource.traktSyncQueue.delete(addItems) } else -> throw error } } } private suspend fun createMissingList( localList: CustomList, addItems: List ): CustomList { try { val result = remoteSource.trakt.postCreateList(localList.name, localList.description) .run { mappers.customList.fromNetwork(this) } listsRepository.updateList(localList.id, result.idTrakt, result.idSlug, result.name, result.description) val localItems = listsRepository.loadListItemsForId(localList.id) if (localItems.isNotEmpty()) { val showsIds = localItems.filter { it.type == Mode.SHOWS.type }.map { it.idTrakt } val moviesIds = localItems.filter { it.type == Mode.MOVIES.type }.map { it.idTrakt } delay(TRAKT_DELAY) remoteSource.trakt.postAddListItems(result.idTrakt!!, showsIds, moviesIds) } return listsRepository.updateList(localList.id, result.idTrakt, result.idSlug, result.name, result.description) } catch (error: Throwable) { when (ErrorHelper.parse(error)) { ShowlyError.AccountLimitsError -> { localSource.traktSyncQueue.delete(addItems) throw error } else -> throw error } } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/quicksync/runners/QuickSyncRunner.kt ================================================ package com.michaldrabik.ui_base.trakt.quicksync.runners import com.michaldrabik.common.extensions.dateIsoStringFromMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.TraktSyncQueue import com.michaldrabik.data_local.database.model.TraktSyncQueue.Type.EPISODE import com.michaldrabik.data_local.database.model.TraktSyncQueue.Type.HIDDEN_MOVIE import com.michaldrabik.data_local.database.model.TraktSyncQueue.Type.HIDDEN_SHOW import com.michaldrabik.data_local.database.model.TraktSyncQueue.Type.MOVIE import com.michaldrabik.data_local.database.model.TraktSyncQueue.Type.MOVIE_WATCHLIST import com.michaldrabik.data_local.database.model.TraktSyncQueue.Type.SHOW_WATCHLIST import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.data_remote.trakt.model.SyncExportItem import com.michaldrabik.data_remote.trakt.model.SyncExportRequest import com.michaldrabik.data_remote.trakt.model.SyncItem import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.Analytics import com.michaldrabik.ui_base.trakt.TraktSyncRunner import com.michaldrabik.ui_base.trakt.quicksync.runners.cases.QuickSyncDuplicateEpisodesCase import com.michaldrabik.ui_base.trakt.quicksync.runners.cases.QuickSyncDuplicateMoviesCase import kotlinx.coroutines.delay import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class QuickSyncRunner @Inject constructor( private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource, private val transactions: TransactionsProvider, private val settingsRepository: SettingsRepository, private val duplicateEpisodesCase: QuickSyncDuplicateEpisodesCase, private val duplicateMoviesCase: QuickSyncDuplicateMoviesCase, userTraktManager: UserTraktManager, ) : TraktSyncRunner(userTraktManager) { companion object { private const val BATCH_LIMIT = 100 private const val DELAY = 2000L } private val syncTypes = listOf( MOVIE, EPISODE, MOVIE_WATCHLIST, SHOW_WATCHLIST, HIDDEN_SHOW, HIDDEN_MOVIE ).map { it.slug } override suspend fun run(): Int { Timber.d("Initialized.") try { checkAuthorization() val moviesEnabled = settingsRepository.isMoviesEnabled val historyCount = exportHistoryItems(moviesEnabled) val watchlistCount = exportWatchlistItems(moviesEnabled) val hiddenCount = exportHiddenItems(moviesEnabled) Timber.d("Finished with success.") return historyCount + watchlistCount + hiddenCount } catch (error: Throwable) { throw error } finally { syncTypes.forEach { localSource.traktSyncQueue.deleteAll(it) } } } private suspend fun exportHistoryItems( moviesEnabled: Boolean, remoteFetchedShows: List = emptyList(), remoteFetchedMovies: List = emptyList(), count: Int = 0, clearedProgressIds: MutableSet = mutableSetOf(), ): Int { val types = if (moviesEnabled) listOf(MOVIE, EPISODE) else listOf(EPISODE) val items = localSource.traktSyncQueue.getAll(types.map { it.slug }) if (items.isEmpty()) { Timber.d("Nothing to export. Cancelling..") return count } Timber.d("Exporting ${items.size} items...") val batch = items.take(BATCH_LIMIT) val exportEpisodes = batch.filter { it.type == EPISODE.slug }.distinctBy { it.idTrakt } val exportMovies = batch.filter { it.type == MOVIE.slug }.distinctBy { it.idTrakt } val clearProgress = items.any { it.operation == TraktSyncQueue.Operation.ADD_WITH_CLEAR.slug } if (clearProgress) { Timber.d("Clearing progress for shows...") val requestItems = items .mapNotNull { it.idList?.let { id -> SyncExportItem.create(id) } } .distinctBy { it.ids.trakt } .filterNot { clearedProgressIds.contains(it.ids.trakt) } if (requestItems.isNotEmpty()) { remoteSource.trakt.postDeleteProgress(SyncExportRequest(shows = requestItems)) clearedProgressIds.addAll(requestItems.map { it.ids.trakt }) delay(DELAY) } } transactions.withTransaction { val batchIds = batch.map { it.idTrakt } with(localSource.traktSyncQueue) { deleteAll(batchIds, EPISODE.slug) deleteAll(batchIds, MOVIE.slug) } } val (duplicateEpisodes, remoteShows) = duplicateEpisodesCase.checkDuplicateEpisodes(exportEpisodes, remoteFetchedShows) val (duplicateMovies, remoteMovies) = duplicateMoviesCase.checkDuplicateMovies(exportMovies, remoteFetchedMovies) val request = SyncExportRequest( episodes = exportEpisodes .map { SyncExportItem.create(it.idTrakt, dateIsoStringFromMillis(it.updatedAt)) } .filter { it.ids.trakt !in duplicateEpisodes }, movies = exportMovies .map { SyncExportItem.create(it.idTrakt, dateIsoStringFromMillis(it.updatedAt)) } .filter { it.ids.trakt !in duplicateMovies }, ) if (request.episodes.isNotEmpty() || request.movies.isNotEmpty()) { Analytics.logQuickExportHistory(request.episodes.size, request.movies.size, retryCount.get()) remoteSource.trakt.postSyncWatched(request) } val currentCount = count + exportEpisodes.count() + exportMovies.count() // Check for more items val newItems = localSource.traktSyncQueue.getAll(types.map { it.slug }) if (newItems.isNotEmpty()) { delay(DELAY) return exportHistoryItems( moviesEnabled = moviesEnabled, remoteFetchedShows = remoteShows, remoteFetchedMovies = remoteMovies, count = currentCount, clearedProgressIds = clearedProgressIds.toMutableSet() ) } return currentCount } private suspend fun exportWatchlistItems( moviesEnabled: Boolean, count: Int = 0, ): Int { val types = if (moviesEnabled) listOf(MOVIE_WATCHLIST, SHOW_WATCHLIST) else listOf(SHOW_WATCHLIST) val items = localSource.traktSyncQueue.getAll(types.map { it.slug }).take(BATCH_LIMIT) if (items.isEmpty()) { Timber.d("Nothing to export. Cancelling..") return count } Timber.d("Exporting watchlist items...") val exportShows = items.filter { it.type == SHOW_WATCHLIST.slug }.distinctBy { it.idTrakt } val exportMovies = items.filter { it.type == MOVIE_WATCHLIST.slug }.distinctBy { it.idTrakt } val request = SyncExportRequest( shows = exportShows.map { SyncExportItem.create(it.idTrakt, dateIsoStringFromMillis(it.updatedAt)) }, movies = exportMovies.map { SyncExportItem.create(it.idTrakt, dateIsoStringFromMillis(it.updatedAt)) } ) transactions.withTransaction { val ids = items.map { it.idTrakt } localSource.traktSyncQueue.deleteAll(ids, MOVIE_WATCHLIST.slug) localSource.traktSyncQueue.deleteAll(ids, SHOW_WATCHLIST.slug) } remoteSource.trakt.postSyncWatchlist(request) val currentCount = count + exportShows.count() + exportMovies.count() // Check for more items val newItems = localSource.traktSyncQueue.getAll(types.map { it.slug }) if (newItems.isNotEmpty()) { delay(DELAY) return exportWatchlistItems(moviesEnabled, currentCount) } return currentCount } private suspend fun exportHiddenItems( moviesEnabled: Boolean, count: Int = 0, ): Int { val types = if (moviesEnabled) listOf(HIDDEN_SHOW, HIDDEN_MOVIE) else listOf(HIDDEN_SHOW) val items = localSource.traktSyncQueue.getAll(types.map { it.slug }).take(BATCH_LIMIT) if (items.isEmpty()) { Timber.d("Nothing to export. Cancelling..") return count } Timber.d("Exporting hidden items...") val exportShows = items.filter { it.type == HIDDEN_SHOW.slug }.distinctBy { it.idTrakt } val exportMovies = items.filter { it.type == HIDDEN_MOVIE.slug }.distinctBy { it.idTrakt } transactions.withTransaction { val ids = items.map { it.idTrakt } with(localSource.traktSyncQueue) { deleteAll(ids, HIDDEN_SHOW.slug) deleteAll(ids, HIDDEN_MOVIE.slug) } } if (exportShows.isNotEmpty()) { remoteSource.trakt.postHiddenShows( shows = exportShows.map { SyncExportItem.create(it.idTrakt, hiddenAt = dateIsoStringFromMillis(it.updatedAt)) } ) delay(1500) } if (exportMovies.isNotEmpty()) { remoteSource.trakt.postHiddenMovies( movies = exportMovies.map { SyncExportItem.create(it.idTrakt, hiddenAt = dateIsoStringFromMillis(it.updatedAt)) } ) } val currentCount = count + exportShows.count() + exportMovies.count() // Check for more items val newItems = localSource.traktSyncQueue.getAll(types.map { it.slug }) if (newItems.isNotEmpty()) { delay(DELAY) return exportHiddenItems(moviesEnabled, currentCount) } return currentCount } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/quicksync/runners/cases/QuickSyncDuplicateEpisodesCase.kt ================================================ package com.michaldrabik.ui_base.trakt.quicksync.runners.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.TraktSyncQueue import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.data_remote.trakt.model.SyncItem import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject class QuickSyncDuplicateEpisodesCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val remoteSource: RemoteDataSource, private val localSource: LocalDataSource ) { suspend fun checkDuplicateEpisodes( episodes: List, fetchedSyncItems: List ): Result { if (episodes.isEmpty()) { return Result(emptyList(), fetchedSyncItems) } return withContext(dispatchers.IO) { val remoteShows = if (fetchedSyncItems.isNotEmpty()) { fetchedSyncItems.toList() } else { remoteSource.trakt.fetchSyncWatchedShows() } val duplicateEpisodesIds = mutableListOf() val localEpisodes = localSource.episodes.getAllByShowsIds( episodes.filter { it.idList != null }.map { it.idList!! } ) episodes.forEach { item -> val showId = item.idList showId?.let { val localEpisode = localEpisodes.find { it.idTrakt == item.idTrakt } if (localEpisode == null) { duplicateEpisodesIds.add(item.idTrakt) } else { if (remoteShows .filter { it.show?.ids?.trakt == showId } .any { remoteShow -> remoteShow.seasons ?.find { it.number == localEpisode.seasonNumber } ?.episodes ?.any { it.number == localEpisode.episodeNumber } == true } ) { duplicateEpisodesIds.add(item.idTrakt) } } } } Timber.d("Duplicated episodes count: ${duplicateEpisodesIds.size}") Result( duplicateEpisodesIds = duplicateEpisodesIds, remoteShows = remoteShows ) } } data class Result( val duplicateEpisodesIds: List, val remoteShows: List ) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/trakt/quicksync/runners/cases/QuickSyncDuplicateMoviesCase.kt ================================================ package com.michaldrabik.ui_base.trakt.quicksync.runners.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.data_local.database.model.TraktSyncQueue import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.data_remote.trakt.model.SyncItem import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject class QuickSyncDuplicateMoviesCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val remoteSource: RemoteDataSource ) { suspend fun checkDuplicateMovies( exportMovies: List, fetchedSyncItems: List ): Result { if (exportMovies.isEmpty()) { return Result(emptyList(), fetchedSyncItems) } return withContext(dispatchers.IO) { val remoteMovies = if (fetchedSyncItems.isNotEmpty()) { fetchedSyncItems.toList() } else { remoteSource.trakt.fetchSyncWatchedMovies() } val duplicateMoviesIds = mutableListOf() exportMovies.forEach { movie -> remoteMovies .find { it.getTraktId() == movie.idTrakt } ?.let { duplicateMoviesIds.add(movie.idTrakt) } } Timber.d("Duplicated movies count: ${duplicateMoviesIds.size}") Result( duplicateMoviesIds = duplicateMoviesIds, remoteMovies = remoteMovies ) } } data class Result( val duplicateMoviesIds: List, val remoteMovies: List ) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/DurationPrinter.kt ================================================ package com.michaldrabik.ui_base.utilities import android.content.Context import com.michaldrabik.common.extensions.nowUtc import com.michaldrabik.ui_base.R import java.time.Duration import java.time.ZonedDateTime class DurationPrinter(private val context: Context) { fun print(date: ZonedDateTime?): String { if (date == null) return context.getString(R.string.textTba) val duration = Duration.between(nowUtc(), date) if (duration.isNegative) return context.getString(R.string.textAiredAlready) val days = duration.toDays().toInt() if (days == 0) { val hours = duration.toHours().toInt() if (hours == 0) { val minutes = duration.toMinutes().toInt() if (minutes == 0) { return context.getString(R.string.textAirsNow) } return context.resources.getQuantityString(R.plurals.textMinutesToAir, minutes, minutes) } return context.resources.getQuantityString(R.plurals.textHoursToAir, hours + 1, hours + 1) } return context.resources.getQuantityString(R.plurals.textDaysToAir, days + 1, days + 1) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/FragmentViewBindingDelegate.kt ================================================ /* * Copyright 2021 Gabor Varadi * * 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. */ package com.michaldrabik.ui_base.utilities import android.view.View import androidx.fragment.app.Fragment import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.Observer import androidx.viewbinding.ViewBinding import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KProperty class FragmentViewBindingDelegate( val fragment: Fragment, val viewBindingFactory: (View) -> T, ) : ReadOnlyProperty { private var binding: T? = null init { fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { val viewLifecycleOwnerObserver = Observer { owner -> if (owner == null) { binding = null } } override fun onCreate(owner: LifecycleOwner) { fragment.viewLifecycleOwnerLiveData.observeForever(viewLifecycleOwnerObserver) } override fun onDestroy(owner: LifecycleOwner) { fragment.viewLifecycleOwnerLiveData.removeObserver(viewLifecycleOwnerObserver) } }) } override fun getValue(thisRef: Fragment, property: KProperty<*>): T { val binding = binding if (binding != null && binding.root === thisRef.view) { return binding } val view = thisRef.view @Suppress("FoldInitializerAndIfToElvis") if (view == null) { throw IllegalStateException("Should not attempt to get bindings when the Fragment's view is null.") } return viewBindingFactory(view).also { this.binding = it } } } fun Fragment.viewBinding(viewBindingFactory: (View) -> T) = FragmentViewBindingDelegate(this, viewBindingFactory) ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/ModeHost.kt ================================================ package com.michaldrabik.ui_base.utilities import com.michaldrabik.common.Mode interface ModeHost { fun setMode(mode: Mode, force: Boolean = false) fun getMode(): Mode } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/MoviesStatusHost.kt ================================================ package com.michaldrabik.ui_base.utilities interface MoviesStatusHost { fun hasMoviesEnabled(): Boolean } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/NavigationHost.kt ================================================ package com.michaldrabik.ui_base.utilities import androidx.navigation.NavController interface NavigationHost { fun findNavControl(): NavController? fun hideNavigation(animate: Boolean) fun showNavigation(animate: Boolean) fun navigateToDiscover() } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/NetworkIconProvider.kt ================================================ package com.michaldrabik.ui_base.utilities import androidx.annotation.DrawableRes import com.michaldrabik.ui_base.R import com.michaldrabik.ui_model.Network import javax.inject.Inject import javax.inject.Singleton @Singleton class NetworkIconProvider @Inject constructor() { @DrawableRes fun getIcon(network: Network): Int = when (network) { Network.ABC -> R.drawable.ic_abc Network.AMC -> R.drawable.ic_amc Network.APPLE -> R.drawable.ic_apple Network.AMAZON -> R.drawable.ic_amazon Network.BBC -> R.drawable.ic_bbc Network.CBS -> R.drawable.ic_cbs Network.CW -> R.drawable.ic_cw Network.DISCOVERY -> R.drawable.ic_discovery Network.DISNEY -> R.drawable.ic_disney Network.HBO -> R.drawable.ic_hbo Network.FOX -> R.drawable.ic_fox Network.HULU -> R.drawable.ic_hulu Network.ITV -> R.drawable.ic_itv Network.NBC -> R.drawable.ic_nbc Network.NETFLIX -> R.drawable.ic_netflix Network.SHOWTIME -> R.drawable.ic_showtime Network.RAKUTEN -> R.drawable.ic_rakuten Network.PARAMOUNT -> R.drawable.ic_paramount Network.PEACOCK -> R.drawable.ic_peacock } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/SnackbarHost.kt ================================================ package com.michaldrabik.ui_base.utilities import android.view.ViewGroup interface SnackbarHost { fun provideSnackbarLayout(): ViewGroup } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/TipsHost.kt ================================================ package com.michaldrabik.ui_base.utilities import com.michaldrabik.ui_model.Tip interface TipsHost { fun isTipShown(tip: Tip): Boolean fun showTip(tip: Tip) fun setTipShow(tip: Tip) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/events/Event.kt ================================================ package com.michaldrabik.ui_base.utilities.events open class Event( private val action: T, ) { private var isConsumed: Boolean = false fun peek(): T? = action fun consume(): T? = if (!isConsumed) { isConsumed = true action } else { null } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/events/MessageEvent.kt ================================================ package com.michaldrabik.ui_base.utilities.events import androidx.annotation.StringRes sealed class MessageEvent( val textResId: Int ) : Event(textResId) { data class Info( @StringRes val textRestId: Int, val isIndefinite: Boolean = false ) : MessageEvent(textRestId) data class Error( @StringRes val textRestId: Int ) : MessageEvent(textRestId) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/extensions/BuildExtensions.kt ================================================ package com.michaldrabik.ui_base.utilities.extensions import android.os.Build import androidx.annotation.ChecksSdkIntAtLeast @ChecksSdkIntAtLeast(parameter = 0, lambda = 1) inline fun withApiAtLeast(value: Int, action: () -> Unit) { if (Build.VERSION.SDK_INT >= value) { action() } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/extensions/BundleExtensions.kt ================================================ package com.michaldrabik.ui_base.utilities.extensions import android.os.Build import android.os.Bundle import android.os.Parcelable import androidx.fragment.app.Fragment fun Fragment.requireString(key: String?, default: String? = null) = requireArguments().getString(key, default)!! fun Fragment.requireStringArray(key: String?) = requireArguments().getStringArrayList(key)!! fun Fragment.requireLong(key: String?) = requireArguments().getLong(key) fun Fragment.requireLongArray(key: String?) = requireArguments().getLongArray(key)!! fun Fragment.requireBoolean(key: String?) = requireArguments().getBoolean(key) @Suppress("UNCHECKED_CAST") fun Fragment.requireSerializable(key: String?) = requireArguments().getSerializable(key) as T fun Fragment.requireParcelable(key: String?) = optionalParcelable(key)!! fun Fragment.optionalParcelable(key: String?) = requireArguments().getParcelable(key) inline fun Bundle.requireParcelable(key: String): T = when { Build.VERSION.SDK_INT >= 33 -> getParcelable(key, T::class.java)!! else -> @Suppress("DEPRECATION") (getParcelable(key) as? T)!! } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/extensions/ContextExtensions.kt ================================================ package com.michaldrabik.ui_base.utilities.extensions import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.res.ColorStateList import android.content.res.Configuration import android.util.TypedValue import androidx.annotation.AttrRes import androidx.annotation.ColorInt import androidx.annotation.DimenRes import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import com.michaldrabik.ui_base.R import java.util.Locale fun Context.isTablet() = resources.getBoolean(R.bool.isTablet) fun Context.notificationManager() = NotificationManagerCompat.from(this) fun Context.dimenToPx(@DimenRes dimenResId: Int) = resources.getDimensionPixelSize(dimenResId) @ColorInt fun Context.colorFromAttr( @AttrRes attrColor: Int, typedValue: TypedValue = TypedValue(), resolveRefs: Boolean = true, ): Int { theme.resolveAttribute(attrColor, typedValue, resolveRefs) return typedValue.data } fun Context.colorStateListFromAttr( @AttrRes attrColor: Int, typedValue: TypedValue = TypedValue(), resolveRefs: Boolean = true, ): ColorStateList = ColorStateList.valueOf(colorFromAttr(attrColor, typedValue, resolveRefs)) fun Context.getLocaleStringResource(requestedLocale: Locale?, resourceId: Int): String { val result: String val config = Configuration(resources.configuration) config.setLocale(requestedLocale) result = createConfigurationContext(config).getText(resourceId).toString() return result } fun Context.copyToClipboard(text: String) { val clip = ClipData.newPlainText("label", text) ContextCompat.getSystemService(this, ClipboardManager::class.java) .apply { this?.setPrimaryClip(clip) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/extensions/Extensions.kt ================================================ package com.michaldrabik.ui_base.utilities.extensions import android.content.Context import android.content.res.Resources import android.graphics.Rect import android.util.TypedValue import android.view.TouchDelegate import android.view.View import android.view.inputmethod.InputMethodManager import android.widget.CompoundButton import androidx.annotation.DimenRes import androidx.fragment.app.Fragment import androidx.recyclerview.widget.GridLayoutManager import androidx.viewpager.widget.ViewPager import androidx.viewpager2.widget.ViewPager2 import androidx.work.CoroutineWorker import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.SafeOnClickListener import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch fun CoroutineWorker.notificationManager() = applicationContext.notificationManager() fun View.onClick(safe: Boolean = true, action: (View) -> Unit) = setOnClickListener(SafeOnClickListener(safe, action)) fun View.onLongClick(action: (View) -> Unit) = setOnLongClickListener { action(it) true } fun List.onClick(safe: Boolean = true, action: (View) -> Unit) = forEach { it.onClick(safe, action) } fun Fragment.dimenToPx(@DimenRes dimenResId: Int) = resources.getDimensionPixelSize(dimenResId) fun screenWidth() = Resources.getSystem().displayMetrics.widthPixels fun screenHeight() = Resources.getSystem().displayMetrics.heightPixels fun GridLayoutManager.withSpanSizeLookup(action: (Int) -> Int) { spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int) = action(position) } } fun View.showKeyboard() { val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager requestFocus() inputMethodManager.showSoftInput(this, 0) } fun View.hideKeyboard() { (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).apply { hideSoftInputFromWindow(windowToken, 0) } } fun View.addRipple() = with(TypedValue()) { context.theme.resolveAttribute(R.attr.selectableItemBackground, this, true) setBackgroundResource(resourceId) } fun View.expandTouch(amount: Int = 50) { val rect = Rect() this.getHitRect(rect) rect.top -= amount rect.right += amount rect.bottom += amount rect.left -= amount (this.parent as View).touchDelegate = TouchDelegate(rect, this) } fun CompoundButton.setCheckedSilent(isChecked: Boolean, action: (View, Boolean) -> Unit = { _, _ -> }) { setOnCheckedChangeListener { _, _ -> } setChecked(isChecked) setOnCheckedChangeListener(action) } fun ViewPager.nextPage() { val itemsCount = adapter?.count ?: 0 if (itemsCount == 0) return when (currentItem) { itemsCount - 1 -> currentItem = 0 else -> currentItem += 1 } } fun ViewPager2.nextPage() { val itemsCount = adapter?.itemCount ?: 0 if (itemsCount == 0) return when (currentItem) { itemsCount - 1 -> currentItem = 0 else -> currentItem += 1 } } fun MutableList.replaceItem(oldItem: T, newItem: T) { val index = indexOf(oldItem) removeAt(index) add(index, newItem) } inline fun MutableList.findReplace(newItem: T, predicate: (T) -> Boolean) { find(predicate)?.let { replaceItem(it, newItem) } } fun MutableList.replace(newItems: Collection) { clear() addAll(newItems) } fun CoroutineScope.launchDelayed(delayMs: Long, action: suspend () -> Unit): Job { return launch { delay(delayMs) action() } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/extensions/FlowCombineExtensions.kt ================================================ package com.michaldrabik.ui_base.utilities.extensions import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.combine as combineKtx fun combine( flow: Flow, transform: suspend (T1) -> R, ): Flow = combineKtx( flow, emptyFlow() ) { t1, _ -> transform(t1) } fun combine( flow: Flow, flow2: Flow, flow3: Flow, flow4: Flow, flow5: Flow, flow6: Flow, transform: suspend (T1, T2, T3, T4, T5, T6) -> R, ): Flow = combineKtx( combineKtx(flow, flow2, flow3, ::Triple), combineKtx(flow4, flow5, flow6, ::Triple) ) { t1, t2 -> transform( t1.first, t1.second, t1.third, t2.first, t2.second, t2.third ) } fun combine( flow: Flow, flow2: Flow, flow3: Flow, flow4: Flow, flow5: Flow, flow6: Flow, flow7: Flow, transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R, ): Flow = combineKtx( combineKtx(flow, flow2, flow3) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow4, flow5) { t1, t2 -> Pair(t1, t2) }, combineKtx(flow6, flow7) { t1, t2 -> Pair(t1, t2) }, ) { t1, t2, t3 -> transform( t1.first, t1.second, t1.third, t2.first, t2.second, t3.first, t3.second ) } fun combine( flow: Flow, flow2: Flow, flow3: Flow, flow4: Flow, flow5: Flow, flow6: Flow, flow7: Flow, flow8: Flow, transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R, ): Flow = combineKtx( combineKtx(flow, flow2, flow3) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow4, flow5) { t1, t2 -> Pair(t1, t2) }, combineKtx(flow6, flow7, flow8) { t1, t2, t3 -> Triple(t1, t2, t3) }, ) { t1, t2, t3 -> transform( t1.first, t1.second, t1.third, t2.first, t2.second, t3.first, t3.second, t3.third ) } fun combine( flow: Flow, flow2: Flow, flow3: Flow, flow4: Flow, flow5: Flow, flow6: Flow, flow7: Flow, flow8: Flow, flow9: Flow, transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R, ): Flow = combineKtx( combineKtx(flow, flow2, flow3) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow4, flow5, flow6) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow7, flow8, flow9) { t1, t2, t3 -> Triple(t1, t2, t3) } ) { t1, t2, t3 -> transform( t1.first, t1.second, t1.third, t2.first, t2.second, t2.third, t3.first, t3.second, t3.third ) } fun combine( flow: Flow, flow2: Flow, flow3: Flow, flow4: Flow, flow5: Flow, flow6: Flow, flow7: Flow, flow8: Flow, flow9: Flow, flow10: Flow, transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10) -> R, ): Flow = combineKtx( combineKtx(flow, flow2, flow3) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow4, flow5, flow6) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow7, flow8) { t1, t2 -> Pair(t1, t2) }, combineKtx(flow9, flow10) { t1, t2 -> Pair(t1, t2) } ) { t1, t2, t3, t4 -> transform( t1.first, t1.second, t1.third, t2.first, t2.second, t2.third, t3.first, t3.second, t4.first, t4.second ) } fun combine( flow: Flow, flow2: Flow, flow3: Flow, flow4: Flow, flow5: Flow, flow6: Flow, flow7: Flow, flow8: Flow, flow9: Flow, flow10: Flow, flow11: Flow, transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11) -> R, ): Flow = combineKtx( combineKtx(flow, flow2, flow3) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow4, flow5, flow6) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow7, flow8, flow9) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow10, flow11) { t1, t2 -> Pair(t1, t2) } ) { t1, t2, t3, t4 -> transform( t1.first, t1.second, t1.third, t2.first, t2.second, t2.third, t3.first, t3.second, t3.third, t4.first, t4.second ) } fun combine( flow: Flow, flow2: Flow, flow3: Flow, flow4: Flow, flow5: Flow, flow6: Flow, flow7: Flow, flow8: Flow, flow9: Flow, flow10: Flow, flow11: Flow, flow12: Flow, transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12) -> R, ): Flow = combineKtx( combineKtx(flow, flow2, flow3) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow4, flow5, flow6) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow7, flow8, flow9) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow10, flow11, flow12) { t1, t2, t3 -> Triple(t1, t2, t3) } ) { t1, t2, t3, t4 -> transform( t1.first, t1.second, t1.third, t2.first, t2.second, t2.third, t3.first, t3.second, t3.third, t4.first, t4.second, t4.third ) } fun combine( flow: Flow, flow2: Flow, flow3: Flow, flow4: Flow, flow5: Flow, flow6: Flow, flow7: Flow, flow8: Flow, flow9: Flow, flow10: Flow, flow11: Flow, flow12: Flow, flow13: Flow, transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13) -> R, ): Flow = combineKtx( combineKtx(flow, flow2, flow3) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow4, flow5, flow6) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow7, flow8, flow9) { t1, t2, t3 -> Triple(t1, t2, t3) }, combineKtx(flow10, flow11) { t1, t2 -> Pair(t1, t2) }, combineKtx(flow12, flow13) { t1, t2 -> Pair(t1, t2) }, ) { t1, t2, t3, t4, t5 -> transform( t1.first, t1.second, t1.third, t2.first, t2.second, t2.third, t3.first, t3.second, t3.third, t4.first, t4.second, t5.first, t5.second ) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/extensions/GlideExtensions.kt ================================================ package com.michaldrabik.ui_base.utilities.extensions import android.graphics.drawable.Drawable import com.bumptech.glide.RequestBuilder import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target inline fun RequestBuilder.withFailListener(crossinline action: () -> Unit) = addListener(object : RequestListener { override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { action() return false } override fun onResourceReady( resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean ) = false }) inline fun RequestBuilder.withSuccessListener(crossinline action: () -> Unit) = addListener(object : RequestListener { override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean) = false override fun onResourceReady( resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean ): Boolean { action() return false } }) ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/extensions/LayoutExtensions.kt ================================================ package com.michaldrabik.ui_base.utilities.extensions import android.view.View import android.view.ViewGroup import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL import androidx.recyclerview.widget.RecyclerView fun RecyclerView.addDivider(@DrawableRes dividerRes: Int, direction: Int = VERTICAL) { addItemDecoration( DividerItemDecoration(context, direction).apply { setDrawable(ContextCompat.getDrawable(context, dividerRes)!!) } ) } /** * https://chris.banes.dev/2019/04/12/insets-listeners-to-layouts/ */ fun View.requestApplyInsetsWhenAttached() { if (isAttachedToWindow) { requestApplyInsets() } else { addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) { v.removeOnAttachStateChangeListener(this) v.requestApplyInsets() } override fun onViewDetachedFromWindow(v: View) = Unit }) } } /** * https://chris.banes.dev/2019/04/12/insets-listeners-to-layouts/ */ fun View.doOnApplyWindowInsets(f: (View, WindowInsetsCompat, InitialSpacing, InitialSpacing) -> Unit) { // Create a snapshot of the view's padding state val initialPadding = recordInitialPaddingForView(this) val initialMargin = recordInitialMarginForView(this) // Set an actual OnApplyWindowInsetsListener which proxies to the given // lambda, also passing in the original padding state ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets -> f(v, insets, initialPadding, initialMargin) insets } // request some insets requestApplyInsetsWhenAttached() } data class InitialSpacing( val left: Int, val top: Int, val right: Int, val bottom: Int, ) private fun recordInitialPaddingForView(view: View) = InitialSpacing( view.paddingLeft, view.paddingTop, view.paddingRight, view.paddingBottom ) private fun recordInitialMarginForView(view: View): InitialSpacing { val lp = view.layoutParams as? ViewGroup.MarginLayoutParams return InitialSpacing( lp?.leftMargin ?: 0, lp?.topMargin ?: 0, lp?.rightMargin ?: 0, lp?.bottomMargin ?: 0 ) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/extensions/LifecycleExtensions.kt ================================================ package com.michaldrabik.ui_base.utilities.extensions import android.os.Bundle import androidx.annotation.IdRes import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.michaldrabik.ui_base.BaseFragment import com.michaldrabik.ui_base.utilities.NavigationHost import kotlinx.coroutines.launch fun Fragment.launchAndRepeatStarted( vararg launchBlock: suspend () -> Unit, doAfterLaunch: (() -> Unit)? = null ) { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { launchBlock.forEach { launch { it.invoke() } } doAfterLaunch?.invoke() } } } fun BaseFragment<*>.navigateTo( @IdRes destination: Int, bundle: Bundle? = null ) { (requireActivity() as NavigationHost).findNavControl()?.navigate(destination, bundle) } fun BaseFragment<*>.navigateToSafe( @IdRes destination: Int, bundle: Bundle? = null ) { check(navigationId != 0) { "Navigation ID not provided!" } (requireActivity() as NavigationHost).findNavControl()?.let { navController -> if (navController.currentDestination?.id == navigationId) { navigateTo(destination, bundle) } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/extensions/SnackbarExtensions.kt ================================================ package com.michaldrabik.ui_base.utilities.extensions import android.view.ViewGroup import androidx.annotation.ColorInt import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar.LENGTH_INDEFINITE import com.google.android.material.snackbar.Snackbar.LENGTH_SHORT import com.michaldrabik.ui_base.R fun ViewGroup.showSnackbar( message: String, actionText: Int, textColor: Int, @ColorInt backgroundColor: Int, length: Int, action: (() -> Unit)? = null, ): Snackbar { return Snackbar.make(this, message, length).apply { setTextMaxLines(5) setTextColor(textColor) setBackgroundTint(backgroundColor) setActionTextColor(textColor) if (action != null) { setAction(actionText) { dismiss() action() } } show() } } fun ViewGroup.showInfoSnackbar( message: String, actionText: Int = R.string.textOk, length: Int = LENGTH_SHORT, action: (() -> Unit)? = null, ): Snackbar { return showSnackbar( message = message, actionText = actionText, textColor = context.colorFromAttr(R.attr.textColorInfoSnackbar), backgroundColor = context.colorFromAttr(R.attr.colorInfoSnackbar), length = length, action = action ) } fun ViewGroup.showErrorSnackbar( message: String, actionText: Int = R.string.textOk, action: () -> Unit = {}, ): Snackbar { return showSnackbar( message = message, actionText = actionText, textColor = context.colorFromAttr(R.attr.textColorErrorSnackbar), backgroundColor = context.colorFromAttr(R.attr.colorErrorSnackbar), length = LENGTH_INDEFINITE, action = action ) } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/extensions/UiExtensions.kt ================================================ package com.michaldrabik.ui_base.utilities.extensions import android.animation.Animator import android.animation.AnimatorSet import android.animation.ObjectAnimator import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.Path import android.graphics.RectF import android.graphics.drawable.RippleDrawable import android.view.View import android.view.ViewGroup import android.view.ViewPropertyAnimator import android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE import android.widget.TextView import androidx.annotation.Px import androidx.core.animation.doOnEnd import androidx.core.graphics.toRect import androidx.core.view.doOnLayout import androidx.core.view.updateMargins import androidx.fragment.app.Fragment import timber.log.Timber import java.util.Locale fun View.visible() { if (visibility != View.VISIBLE) visibility = View.VISIBLE } fun View.gone() { if (visibility != View.GONE) visibility = View.GONE } fun View.invisible() { if (visibility != View.INVISIBLE) visibility = View.INVISIBLE } fun View.visibleIf(condition: Boolean, gone: Boolean = true) = if (condition) { visible() } else { if (gone) gone() else invisible() } fun View.fadeIf( condition: Boolean, duration: Long = 250, startDelay: Long = 0, hardware: Boolean = false, ) = if (condition) { fadeIn(duration, startDelay, hardware) } else { fadeOut(duration, startDelay, hardware) } fun View.fadeIn( duration: Long = 250, startDelay: Long = 0, withHardware: Boolean = false, endAction: () -> Unit = {}, ): ViewPropertyAnimator? { if (visibility == View.VISIBLE) { endAction() return null } visibility = View.VISIBLE alpha = 0F val animation = animate() .alpha(1F) .setDuration(duration) .setStartDelay(startDelay) .apply { if (withHardware) withLayer() } .withEndAction(endAction) return animation.also { it.start() } } fun View.fadeOut( duration: Long = 250, startDelay: Long = 0, withHardware: Boolean = false, endAction: () -> Unit = {}, ): ViewPropertyAnimator? { if (visibility == View.GONE) { endAction() return null } val animation = animate() .alpha(0F) .setDuration(duration) .setStartDelay(startDelay) .apply { if (withHardware) withLayer() } .withEndAction { gone() endAction() } return animation.also { it.start() } } fun ViewPropertyAnimator?.add(animations: MutableList): ViewPropertyAnimator? { animations.add(this) return this } fun Animator?.add(animators: MutableList) { animators.add(this) } fun View.shake() = ObjectAnimator.ofFloat(this, "translationX", 0F, -15F, 15F, -10F, 10F, -5F, 5F, 0F) .setDuration(500) .start() fun View.bump( duration: Long = 250, startDelay: Long = 0, action: () -> Unit = {} ) { val x = ObjectAnimator.ofFloat(this, "scaleX", 1F, 1.1F, 1F) val y = ObjectAnimator.ofFloat(this, "scaleY", 1F, 1.1F, 1F) AnimatorSet().apply { playTogether(x, y) this.startDelay = startDelay this.duration = duration doOnEnd { action() } start() } } fun View.updateTopMargin(margin: Int) { (layoutParams as ViewGroup.MarginLayoutParams).updateMargins(top = margin) } fun TextView.setTextFade(text: String, duration: Long = 125) { fadeOut( duration = duration, endAction = { setText(text) fadeIn(duration = duration) } ) } fun TextView.isSpoilerHidden(): Boolean { return text.toString() .trim() .replace(" ", "") .none { it.isLetterOrDigit() } } fun Fragment.disableUi() { activity?.window?.setFlags(FLAG_NOT_TOUCHABLE, FLAG_NOT_TOUCHABLE) Timber.d("UI disabled.") } fun Fragment.enableUi() { activity?.window?.clearFlags(FLAG_NOT_TOUCHABLE) Timber.d("UI enabled.") } fun String.capitalizeWords() = this .split(" ") .joinToString(separator = " ") { it.replaceFirstChar { string -> if (string.isLowerCase()) string.titlecase(Locale.getDefault()) else string.toString() } } fun String.trimWithSuffix(length: Int, suffix: String): String { if (this.length <= length) return this return this.take(length).plus(suffix) } fun View.setOutboundRipple( @Px size: Float = 0F, @Px corner: Float = 0F, ) { val color = context.colorFromAttr(android.R.attr.colorControlHighlight) doOnLayout { view -> val boundsF = RectF(-size, -size, (view.width + size), (view.height + size)) val path = Path().apply { addRoundRect(boundsF, corner, corner, Path.Direction.CW) } background = object : RippleDrawable( ColorStateList.valueOf(color), null, null ) { override fun draw(canvas: Canvas) { canvas.clipPath(path) super.draw(canvas) } }.apply { val bounds = boundsF.toRect() setHotspotBounds( bounds.left, bounds.top, bounds.right, bounds.bottom ) } } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/extensions/ViewModelExtensions.kt ================================================ package com.michaldrabik.ui_base.utilities.extensions import kotlinx.coroutines.CancellationException import timber.log.Timber const val SUBSCRIBE_STOP_TIMEOUT = 5000L fun rethrowCancellation(error: Throwable) { if (error is CancellationException) { Timber.d("Rethrowing CancellationException") throw error } else { Timber.e(error) } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/extensions/WebExtensions.kt ================================================ package com.michaldrabik.ui_base.utilities.extensions import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.Uri import android.view.View import androidx.fragment.app.Fragment import com.michaldrabik.ui_model.IdImdb fun Context.openWebUrl(url: String): String? { val i = Intent(Intent.ACTION_VIEW) i.data = Uri.parse(url) return try { startActivity(i) url } catch (error: ActivityNotFoundException) { null } } fun Context.openImdbUrl(idImdb: IdImdb): String? { val i = Intent(Intent.ACTION_VIEW) i.data = Uri.parse("imdb:///title/${idImdb.id}") return try { startActivity(i) i.data?.toString() } catch (e: ActivityNotFoundException) { // IMDb App not installed. Start in web browser openWebUrl("http://www.imdb.com/title/${idImdb.id}") } } fun Fragment.openWebUrl(url: String) = requireContext().openWebUrl(url) fun Fragment.openImdbUrl(id: IdImdb) = requireContext().openImdbUrl(id) fun View.openWebUrl(url: String) = context.openWebUrl(url) ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/utilities/ui/EqualSpacingItemDecoration.kt ================================================ package com.michaldrabik.ui_base.utilities.ui import android.content.Context import android.graphics.Rect import android.view.View import androidx.annotation.DimenRes import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.OrientationHelper import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ItemDecoration import androidx.recyclerview.widget.StaggeredGridLayoutManager class EqualSpacingItemDecoration : ItemDecoration { private var orientation = -1 private var spanCount = -1 private var spacing: Int private var halfSpacing: Int constructor(context: Context, @DimenRes spacingDimen: Int) { spacing = context.resources.getDimensionPixelSize(spacingDimen) halfSpacing = spacing / 2 } constructor(spacingPx: Int) { spacing = spacingPx halfSpacing = spacing / 2 } override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { super.getItemOffsets(outRect, view, parent, state) if (orientation == -1) { orientation = getOrientation(parent) } if (spanCount == -1) { spanCount = getTotalSpan(parent) } val childCount = parent.layoutManager!!.itemCount val childIndex = parent.getChildAdapterPosition(view) val itemSpanSize = getItemSpanSize(parent, childIndex) val spanIndex = getItemSpanIndex(parent, childIndex) /* INVALID SPAN */if (spanCount < 1) return setSpacings(outRect, parent, childCount, childIndex, itemSpanSize, spanIndex) } private fun setSpacings(outRect: Rect, parent: RecyclerView, childCount: Int, childIndex: Int, itemSpanSize: Int, spanIndex: Int) { outRect.top = halfSpacing outRect.bottom = halfSpacing outRect.left = halfSpacing outRect.right = halfSpacing if (isTopEdge(parent, childCount, childIndex, itemSpanSize, spanIndex)) { outRect.top = spacing } if (isLeftEdge(parent, childCount, childIndex, itemSpanSize, spanIndex)) { outRect.left = spacing } if (isRightEdge(parent, childCount, childIndex, itemSpanSize, spanIndex)) { outRect.right = spacing } if (isBottomEdge(parent, childCount, childIndex, itemSpanSize, spanIndex)) { outRect.bottom = spacing } } private fun getTotalSpan(parent: RecyclerView): Int { val mgr = parent.layoutManager if (mgr is GridLayoutManager) { return mgr.spanCount } else if (mgr is StaggeredGridLayoutManager) { return mgr.spanCount } else if (mgr is LinearLayoutManager) { return 1 } return -1 } private fun getItemSpanSize(parent: RecyclerView, childIndex: Int): Int { val mgr = parent.layoutManager if (mgr is GridLayoutManager) { return mgr.spanSizeLookup.getSpanSize(childIndex) } else if (mgr is StaggeredGridLayoutManager) { return 1 } else if (mgr is LinearLayoutManager) { return 1 } return -1 } private fun getItemSpanIndex(parent: RecyclerView, childIndex: Int): Int { val mgr = parent.layoutManager if (mgr is GridLayoutManager) { return mgr.spanSizeLookup.getSpanIndex(childIndex, spanCount) } else if (mgr is StaggeredGridLayoutManager) { return childIndex % spanCount } else if (mgr is LinearLayoutManager) { return 0 } return -1 } private fun getOrientation(parent: RecyclerView): Int { val mgr = parent.layoutManager if (mgr is LinearLayoutManager) { return mgr.orientation } else if (mgr is GridLayoutManager) { return mgr.orientation } else if (mgr is StaggeredGridLayoutManager) { return mgr.orientation } return VERTICAL } private fun isLeftEdge(parent: RecyclerView, childCount: Int, childIndex: Int, itemSpanSize: Int, spanIndex: Int): Boolean { return if (orientation == VERTICAL) { spanIndex == 0 } else { childIndex == 0 || isFirstItemEdgeValid(childIndex < spanCount, parent, childIndex) } } private fun isRightEdge(parent: RecyclerView, childCount: Int, childIndex: Int, itemSpanSize: Int, spanIndex: Int): Boolean { return if (orientation == VERTICAL) { spanIndex + itemSpanSize == spanCount } else { isLastItemEdgeValid(childIndex >= childCount - spanCount, parent, childCount, childIndex, spanIndex) } } private fun isTopEdge(parent: RecyclerView, childCount: Int, childIndex: Int, itemSpanSize: Int, spanIndex: Int): Boolean { return if (orientation == VERTICAL) { childIndex == 0 || isFirstItemEdgeValid(childIndex < spanCount, parent, childIndex) } else { spanIndex == 0 } } private fun isBottomEdge(parent: RecyclerView, childCount: Int, childIndex: Int, itemSpanSize: Int, spanIndex: Int): Boolean { return if (orientation == VERTICAL) { isLastItemEdgeValid(childIndex >= childCount - spanCount, parent, childCount, childIndex, spanIndex) } else { spanIndex + itemSpanSize == spanCount } } private fun isFirstItemEdgeValid(isOneOfFirstItems: Boolean, parent: RecyclerView, childIndex: Int): Boolean { var totalSpanArea = 0 if (isOneOfFirstItems) { for (i in childIndex downTo 0) { totalSpanArea += getItemSpanSize(parent, i) } } return isOneOfFirstItems && totalSpanArea <= spanCount } private fun isLastItemEdgeValid(isOneOfLastItems: Boolean, parent: RecyclerView, childCount: Int, childIndex: Int, spanIndex: Int): Boolean { var totalSpanRemaining = 0 if (isOneOfLastItems) { for (i in childIndex until childCount) { totalSpanRemaining += getItemSpanSize(parent, i) } } return isOneOfLastItems && totalSpanRemaining <= spanCount - spanIndex } companion object { private const val VERTICAL = OrientationHelper.VERTICAL } } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/viewmodel/ChannelsDelegate.kt ================================================ package com.michaldrabik.ui_base.viewmodel import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.events.MessageEvent import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow interface ChannelsDelegate { val messageFlow: Flow val messageChannel: Channel val eventFlow: Flow> val eventChannel: Channel> } ================================================ FILE: ui-base/src/main/java/com/michaldrabik/ui_base/viewmodel/DefaultChannelsDelegate.kt ================================================ package com.michaldrabik.ui_base.viewmodel import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.events.MessageEvent import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow class DefaultChannelsDelegate : ChannelsDelegate { override val messageChannel = Channel(Channel.BUFFERED) override val messageFlow = messageChannel.receiveAsFlow() override val eventChannel = Channel>(Channel.BUFFERED) override val eventFlow = eventChannel.receiveAsFlow() } ================================================ FILE: ui-base/src/main/res/anim/anim_recycler_fall_down.xml ================================================ ================================================ FILE: ui-base/src/main/res/anim/anim_recycler_fall_down_fast.xml ================================================ ================================================ FILE: ui-base/src/main/res/anim/anim_recycler_fall_down_item.xml ================================================ ================================================ FILE: ui-base/src/main/res/anim/anim_recycler_fall_down_item_fast.xml ================================================ ================================================ FILE: ui-base/src/main/res/anim/anim_slide_in_from_left.xml ================================================ ================================================ FILE: ui-base/src/main/res/anim/anim_slide_in_from_right.xml ================================================ ================================================ FILE: ui-base/src/main/res/anim/anim_slide_out_from_left.xml ================================================ ================================================ FILE: ui-base/src/main/res/anim/anim_slide_out_from_right.xml ================================================ ================================================ FILE: ui-base/src/main/res/anim-ar/anim_slide_in_from_left.xml ================================================ ================================================ FILE: ui-base/src/main/res/anim-ar/anim_slide_in_from_right.xml ================================================ ================================================ FILE: ui-base/src/main/res/anim-ar/anim_slide_out_from_left.xml ================================================ ================================================ FILE: ui-base/src/main/res/anim-ar/anim_slide_out_from_right.xml ================================================ ================================================ FILE: ui-base/src/main/res/color/selector_chip_background.xml ================================================ ================================================ FILE: ui-base/src/main/res/color/selector_chip_stroke.xml ================================================ ================================================ FILE: ui-base/src/main/res/color/selector_chip_text.xml ================================================ ================================================ FILE: ui-base/src/main/res/color/selector_discover_chip_background.xml ================================================ ================================================ FILE: ui-base/src/main/res/color/selector_discover_chip_text.xml ================================================ ================================================ FILE: ui-base/src/main/res/color/selector_main_button.xml ================================================ ================================================ FILE: ui-base/src/main/res/color/selector_main_checkbox.xml ================================================ ================================================ FILE: ui-base/src/main/res/color-notnight/selector_chip_background.xml ================================================ ================================================ FILE: ui-base/src/main/res/color-notnight/selector_chip_stroke.xml ================================================ ================================================ FILE: ui-base/src/main/res/color-notnight/selector_main_checkbox.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_badge.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_bottom_sheet.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_dialog.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_filters_sheet.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_item_menu_elevation.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_item_menu_placeholder.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_link_item.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_link_item_ripple.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_media_view_elevation.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_media_view_elevation_card.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_media_view_placeholder.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_media_view_ripple.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_premium_ad.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_snackbar_error.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_snackbar_info.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_sort_item_badge.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_text_on_surface.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/bg_tip_view.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/divider_item_menu.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_abc.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_amc.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_anim_search_to_close.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_arrow_alt.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_arrow_alt_down.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_arrow_alt_up.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_arrow_back.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_arrow_right.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_bookmark.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_bookmark_full.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_calendar.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_check.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_check_small.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_circle.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_clock.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_clock_compact.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_clock_small.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_close.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_comment.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_crown.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_crown_small.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_custom_image.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_delete.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_duckduck.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_eye.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_eye_no.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_film.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_giphy.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_github.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_google.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_history.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_imdb.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_info.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_insight.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_link.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_link_color.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_list_alt.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_lists.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_news.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_notification_bell.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_open_browser.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_pause.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_person_outline.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_pin.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_play.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_remove_list.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_search.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_settings.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_share.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_star.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_star_empty.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_star_small.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_stars_round.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_television.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_tmdb.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_trakt.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_twitter.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_view_grid.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_view_list.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_wikipedia.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable/ic_youtube.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable-ar/ic_arrow_back.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable-notnight/ic_github.xml ================================================ ================================================ FILE: ui-base/src/main/res/drawable-notnight/ic_wikipedia.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_context_menu.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_links.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_links_item.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_mode_tabs.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_premium_ad.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_premium_ad_list.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_rate_sheet.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_rate_value.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_ratings_strip.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_remove_trakt_hidden.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_remove_trakt_progress.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_remove_trakt_watchlist.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_search.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_search_empty.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_search_local.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_sort_order.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_sort_order_item.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_tip.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout/view_tip_overlay.xml ================================================ ================================================ FILE: ui-base/src/main/res/layout-sw600dp/view_ratings_strip.xml ================================================ ================================================ FILE: ui-base/src/main/res/values/attrs.xml ================================================ ================================================ FILE: ui-base/src/main/res/values/bool.xml ================================================ false ================================================ FILE: ui-base/src/main/res/values/colors.xml ================================================ #131313 #222327 #121212 #F44336 #F44336 #00000000 #000000 #B2000000 #40000000 #FFFFFF #AAAAAA #636363 #303030 #17283A #70767C #91989F #F44336 #4B6383 #1DA1F2 ================================================ FILE: ui-base/src/main/res/values/dimens.xml ================================================ 1dp 2dp 4dp 8dp 12dp 16dp 24dp 48dp 12dp 0dp 0dp 0dp 1dp 68dp 76dp 24dp 56dp 16dp 4dp 4dp 8dp 9dp 9dp 4dp 8dp 40dp 40dp 16dp 22dp 24dp 22dp 24dp 48dp 64dp 24dp -10dp 67dp 100dp 40dp 60dp 12dp 32dp 16dp 1dp 158dp 116dp 42dp 50dp 26dp 8dp 194dp 70dp 106dp 66dp 16dp 28dp 32dp 3dp 80dp 16dp 120dp 80dp 6dp 4dp ================================================ FILE: ui-base/src/main/res/values/misc.xml ================================================ 0.35 true ================================================ FILE: ui-base/src/main/res/values/strings.xml ================================================ OK Yes No Apply Cancel Close Not Now New Select Hot Submit Hide Remove There were no results… Search Tip: Season %1$d Specials Episode %1$d S.%02d E.%02d Sort by: This comment contains spoilers.\nTap to read. Commented by %s %1$s (%2$s) Network: Genres: TBA Shows Movies Lists min %s\n(%s) Overview not available. Please wait… Rate Link Disabled Your rating has been saved. Your rating has been removed. Copied to clipboard %1$d days %d left %d left People: Directing Director Writing Screenplay Sound Music Acting Custom Images Pick your own poster and fanart that are going to be used across the app. Aired Airs Now Airs tomorrow Airs in %d days Airs in 1 hour Airs in %d hours Airs now Airs in %d minutes 1 item synced successfully. %d items synced successfully. Today Tomorrow This Week Next Week This Month Next Month This Year Next Year Later Yesterday Last 7 Days Last 30 Days Last 90 Days New episode is available now! New episode will be available soon! New season is available now! New season will be available soon! Movie has been released! Trakt.tv Sync Sync completed successfully. Running… Trakt.tv sync failed. Trakt.tv sync failed. Please check your internet connection and try again or contact us if this keeps happening. Instant Sync failed. Instant Sync failed. We will try again next time you open the app. Please check your internet connection. Please grant the application a permission to display information about synchronization progress and results.\n\nWould you like to do it now? Remove from Trakt.tv? Would you like to remove this item from your Trakt.tv account \'Hidden Items\'? Would you like to remove this item from your Trakt.tv account \'Watchlist\'? Would you like to remove this item from your Trakt.tv account \'Progress\'? Add to My Shows Add to My Movies Add to Watchlist Add to Hidden Move to My Shows Move to My Movies Move to Watchlist Move to Hidden Remove from My Shows Remove from My Movies Remove from Watchlist Remove from Hidden Watchlist Upcoming New episodes always first Showly Premium Become a supporter and get access to bonus features! Click to see more. Pin to top Unpin from top Pin to \'on hold\' Unpin from \'on hold\' Oops… Something went wrong.\nPlease contact us if this keeps happening. Authorization failed. Please contact us if this keeps happening. Trakt account authorization failed.\nPlease login and try again. Please wait for seasons data to load. This episode hasn\'t been aired yet. We could not load discover page at this moment.\nPlease contact us if this keeps happening. It looks like this show no longer exists in Trakt.tv database or is a duplicate.\n\nTap \'OK\' to remove it from the app. It looks like this movie no longer exists in Trakt.tv database or is a duplicate.\n\nTap \'OK\' to remove it from the app. We could not load this show\'s details at this moment.\nPlease contact us if this keeps happening. We could not load this movie\'s details at this moment.\nPlease contact us if this keeps happening. We could not load search results at this moment.\nPlease contact us if this keeps happening. No Internet. Please check your connection. Trakt.tv sync error.\nPlease check your internet connection and try again. Your Trakt.tv account is currently locked.\nPlease contact Trakt.tv support at https://support.trakt.tv/ to unlock your account. We were not able to load purchase options. Please make sure your Google Play Store app is updated to the latest version and try again. Google Play Subscriptions are not available on this device. Sorry, we could not find app on this device that could handle this link. Sorry, you have reached your Trakt account limit for personal lists!\n\nPlease contact us if you need more assistance. You have reached your Trakt account limit for personal lists! Not all of your personal lists were synchronized. You have reached your Trakt account limit for watchlist items! Please contact us if you need more assistance. ================================================ FILE: ui-base/src/main/res/values/styles.xml ================================================ ================================================ FILE: ui-base/src/main/res/values-ar/dimens.xml ================================================ 23dp ================================================ FILE: ui-base/src/main/res/values-ar/strings.xml ================================================ حسنًا نعم لا تطبيق إلغاء إغلاق ليس الآن جديد اختر شائع إرسال إخفاء حذف التقييم لا توجد نتائج… البحث نصيحة: الموسم %1$d الحلقات الخاصة الحلقة %1$d S.%02d E.%02d ترتيب حسب: هذا التعليق يحتوى على حرق للأحداث.\nاضغط للقراءة. تعليق بواسطة %s (%2$s) %1$s الشبكة التلفزيونية: التصنيفات: مسلسلات أفلام قوائم دقائق لا يتوفر وصف. الرجاء الانتظار… قيم إفتح في تعطيل حُفظ تقييمك. تم حذف تقييمك. %1$d يوم بقي %d بقي %d بقي %d بقي %d بقي %d بقي %d الأشخاص: اخراج المخرج الكتابة النص السينمائي الصوت موسيقى التمثيل تخصيص الصور اِختر المُلصق والفان أرت الذان يُعجبانك لاِستخدامهما داخل التطبيق. بُثت يُبث الآن سيُعرض بعد 0 يوم سيُعرض غدًا سيُعرض بعد يومين سيُعرض بعد %d أيام سيُعرض بعد %d أيام سيُعرض بعد %d أيام سيُعرض بعد 0 ساعة سيُعرض بعد ساعة سيُعرض بعد ساعتين سيُعرض بعد %d ساعات سيُعرض بعد %d ساعة تُعرض بعد %d ساعة سيُعرض بعد 0 دقيقة سيُعرض الآن سيُعرض بعد دقيقتين سيُعرض بعد %d دقائق سيُعرض بعد %d دقيقة سيُعرض بعد %d دقيقة لم تتم مزامنة أي عنصر. عنصر واحد تمت مزامنته بنجاح. تمت مزامنة %d عناصر بنجاح. تمت مزامنة %d عناصر بنجاح. تمت مزامنة %d عناصر بنجاح. تمت مزامنة %d عناصر بنجاح. اليوم غدًا هذا الأسبوع الأسبوع القادم هذا الشهر الشهر القادم هذه السنة السنة القادمة لاحقًا أمس آخر 7 أيام آخر 30 يوم آخر 90 يوم توفرت حلقة جديدة! ستتوفر حلقة جديدة قريباً! توفر موسم جديد! موسم جديد قادم قريباً! توفر الفيلم! مزامنة Trakt.tv إكتملت المزامنة بنجاح. بدء المزامنة… فشلت المزامنة مع Trakt.tv. فشلت المزامنة مع Trakt.tv. رجاءً تأكد من أنك مُتصل بالإنترنت ثم حاول مجدداً، وإذا إستمرت المشكلة بالحدوث، تواصل معنا. فشلت المزامنة الفورية. فشلت المزامنة الفورية. سيتم محاولة المزامنة الفورية بعد تشغيل التطبيق مرة أخرى، رجاءً تأكد من إتصالك بالإنترنت. الرجاء منح التطبيق الإذن لعرض معلومات حول تقدم المزامنة ونتائجها.\n\nهل ترغب في القيام بذلك الآن؟ حذف من Trakt.tv؟ أترغب في إزالته من قائمة \"المحتويات المخفية\" في حسابك على موقع Trakt.tv؟ أترغب في إزالته من \"قائمة المشاهدة\" في حسابك على موقع Trakt.tv؟ أترغب في إزالته من قائمة \"مستوى التقدم\" في حسابك على موقع Trakt.tv؟ إضافة إلى مسلسلاتي إضافة إلى أفلامي إضافة إلى قائمة المشاهدة إضافة إلى المخفية نقل إلى مسلسلاتي نقل إلى أفلامي نقل إلى قائمة المشاهدة نقل إلى المخفية إزالة من مسلسلاتي إزالة من أفلامي إزالة من قائمة المشاهدة إزالة من المخفية قائمة المشاهدة يُعرض قريباً الحلقات الجديدة دائماً أولاً إدعم تطوير التطبيق واحصل على مميزات إضافية! انقر لرؤية المزيد. تثبيت في الأعلى إلغاء التثبيت من الأعلى إضافة إلى \"المعلقة\" إزالة من \"المعلقة\" عفواً… حدث خطأ ما.\nإذا استمرت المشكلة بالحدوث، رجاءً تواصل معنا. فشلت المصادقة. إذا استمرت المشكلة بالحدوث، رجاءً تواصل معنا. فشلت مصادقة Trakt.\nقُم بتسجيل الدخول وحاول مرة أخرى. رجاءً إنتظر قليلاً حتى يكتمل تحميل بيانات المواسم. لم تُعرض الحلقة حتى الآن. لم نستطع تحميل صفحة إكتشف.\nرجاءً تواصل معنا إذا استمرت هذه المشكلة بالحدوث. يبدو أن هذا المسلسل لم يعد موجودًا في قاعدة بيانات موقع Trakt.tv، أو أنه مُكرر.\n\nأنقر على زر \"موافق\" لِحذفه مِن التطبيق. يبدو أن هذا الفيلم لم يعد موجودًا في قاعدة بيانات موقع Trakt.tv، أو أنه مُكرر.\n\nأنقر على زر \"موافق\" لِحذفه مِن التطبيق. لم نستطع تحميل معلومات هذا المسلسل.\nرجاءً تواصل معنا إذا إستمرت هذه المشكلة بالحدوث لم نستطع تحميل معلومات هذا الفيلم.\nرجاءً تواصل معنا إذا إستمرت هذه المشكلة بالحدوث. لم نستطع تحميل نتائج البحث.\nرجاءً تواصل معنا إذا استمرت هذه المشكلة بالحدوث. لا يوجد إتصال بالإنترنت. رجاءً تحقق من إتصالك. فشلت مزامنة Trakt.tv.\nرجاءً تحقق من إتصالك بالإنترنت ثم حاول مرة أخرى. حسابك في Trakt.tv مغلق. تواصل مع دعم Trakt.tv عبر الرابط https://support.trakt.tv/ لِفتح حسابك. الدفع عبر Google Play غير متوفر على هذا الجهاز. ================================================ FILE: ui-base/src/main/res/values-de/strings.xml ================================================ OK Ja Nein Anwenden Abbrechen Schließen Nicht jetzt Neu Ausgewählt Hot Absenden Verstecken Löschen Keine Ergebnisse… Suche Tipp: Staffel %1$d Specials Folge %1$d S.%02d E.%02d Sortiert nach: Dieser Kommentar enthält Spoiler.\nTippen um zu lesen. Kommentiert von %s %1$s (%2$s) Fernseh-Network: Genres: Serien Filme Listen min Übersicht nicht verfügbar. Bitte warten… Bewerten Link Deine Bewertung wurde gespeichert. Deine Bewertung wurde gelöscht. %d übrig %d übrig Personen: Regie Regisseur Drehbuchautor Drehbuch Ton Musik Darsteller Benutzerdefinierte Bilder Wähle dein eigenes Poster und Fanart, diese werden in der gesamten App verwendet. Veröffentlicht Jetzt live Morgen verfügbar Verfügbar in %d Tagen Veröffentlichung in 1 Stunde Verfügbar in %d Stunden Jetzt verfügbar Verfügbar in %d Minuten 1 Element erfolgreich synchronisiert. %d Items erfolgreich synchronisiert. Heute Morgen Diese Woche Nächste Woche Diesen Monat Nächsten Monat Dieses Jahr Nächstes Jahr Später Gestern Letzten 7 Tage Letzten 30 Tage Letzten 90 Tage Neue Folge jetzt verfügbar! Neue Folge bald verfügbar! Neue Staffel jetzt verfügbar! Neue Staffel bald verfügbar! Film wurde veroffentlicht! Trakt.tv Sync Synchronisation erfolgreich abgeschlossen! Läuft… Synchronisation mit Trakt.tv fehlgeschlagen. Trakt.tv Synchronisation fehlgeschlagen. Bitte überprüfe deine Internetverbindung und versuch es erneut oder kontaktiere uns, wenn der Fehler weiterhin besteht. Sofortige Synchronisation fehlgeschlagen. Sofortige Synchronisation fehlgeschlagen. Wir versuchen es erneut beim nächsten Öffnen der App. Bitte überprüfe deine Internetverbindung. Von Trakt.tv entfernen? Möchtest du dieses Element aus den \'Versteckten Elementen\' deines Trakt.tv-Accounts entfernen? Möchtest du dieses Element aus der \'Watchlist\' deines Trakt.tv-Accounts entfernen? Möchtest du dieses Element aus der Kategorie \'Fortschritt\' deines Trakt.tv-Accounts entfernen? Zu Meine Serien Zu Meinen Filmen Zu Watchlist Zu Versteckt Zu meinen Serien verschieben In meine Filme verschieben Zur Watchlist verschieben In Versteckt verschieben Von meinen Serien entfernen Von meinen Filmen entfernen Von Watchlist entfernen Von Versteckt entfernen Watchlist Demnächst Neue Folgen immer zuerst Werde Unterstützer und erhalte Zugang zu Bonusfunktionen! Klicke hier, um mehr zu sehen. Oben anpinnen Pin von Oben lösen Pin auf \'Angehalten\' Pin von \'Angehalten\' lösen Hoppla… Da ging etwas schief.\nBitte kontaktiere uns, wenn dies weiterhin geschieht. Authentifizierung fehlgeschlagen. Bitte kontaktiere uns, wenn dies weiterhin geschieht. Trakt.tv Authentifizierung fehlgeschlagen.\nBitte einloggen und erneut versuchen. Bitte warte bis die Staffelinformationen geladen sind. Diese Folge wurde bisher noch nicht veröffentlicht. Wir können die Entdecken Seite aktuell nicht laden.\nBitte kontaktiere uns, wenn dieser Fehler weiterhin besteht. Es sieht so aus als ob diese Sendung nicht länger in der Trakt.tv Datenbank existiert oder doppelt ist.\n\n Tippe auf \"OK\" um sie aus der App zu entfernen. Es sieht so aus als ob dieser Film nicht länger in der Trakt.tv Datenbank existiert oder doppelt ist.\n\n Tippe auf \"OK\" um ihn auf der App zu entfernen. Wir können die Details zu dieser Serie aktuell nicht laden.\nBitte kontaktiere uns, wenn dieser Fehler weiterhin passiert. Wir können die Details zu diesem Filme aktuell nicht laden.\nBitte kontaktiere uns, wenn dieser Fehler weiterhin passiert. Wir können die Suchergebnisse aktuell nicht laden.\nBitte schreib uns, wenn dieser Fehler weiterhin passiert. Kein Internet. Bitte überprüfe deine Verbindung. Synchronisierungsfehler mit Trakt.tv.\nBitte prüfe deine Internetverbindung und versuch es erneut. Dein Trakt.tv-Account ist derzeit gesperrt.\nWende dich an den Support unter https://support.trakt.tv/. Google Play Abonnements sind auf diesem Gerät nicht verfügbar. ================================================ FILE: ui-base/src/main/res/values-es/strings.xml ================================================ OK Si No Aceptar Cancelar Cerrar Ahora no Nuevo Seleccionar Tendencia Enviar Ocultar Eliminar No hay resultados... Buscar Consejo: Temporada %1$d Especiales Episodio %1$d T.%02d E.%02d Ordenar por: Este comentario contiene spoilers.\nTap para leer. Comentado por %s %1$s (%2$s) Cadena: Géneros: Series Películas Listas min Resumen no disponible. Espera, por favor… Calificar Enlace Deshabilitado Tu calificación se ha guardado. Tu calificación ha sido eliminada. %1$d días %d restante %d restantes Personas: Dirección Director Escritores Guión Sonido Música Actuación Imágenes personalizadas Elige tu propio póster y fanart que se utilizará en toda la aplicación. Emitido Se emite ahora Se emite mañana Se emite en %d días Se emite en 1 hora Se emite en %d horas Se emite ahora Se emite en %d minutos 1 elemento sincronizado correctamente. %d elementos sincronizados correctamente. Hoy Mañana Esta Semana La Próxima Semana Este Mes El Próximo Mes Este Año El Próximo Año Más Tarde Ayer Últimos 7 Días Últimos 30 Días Últimos 90 Días Nuevo episodio disponible! ¡Nuevo episodio disponible próximamente! Nueva temporada disponible! ¡Nuevo temporada disponible próximamente! ¡La película ha sido estrenada! Sincronizar Trakt.tv Sincronizar completado satisfactoriamente. En ejecución Sincronización Trakt.tv fallida. Sincronización Trakt.tv fallida. Por favor compruebe su conexión a Internet y pruebe de nuevo o contáctenos si sigue sucediendo. Sincronización instantánea fallida. Sincronización instantánea fallida. Lo intentaremos de nuevo la próxima vez que abras la aplicación. Por favor revisa tu conexión a internet. Por favor, concede a la aplicación permiso para mostrar información sobre el progreso y los resultados de la sincronización.\n\n¿Te gustaría hacerlo ahora? ¿Eliminar de Trakt.tv? ¿Te gustaría eliminar este elemento de \'Elementos Ocultos\' de tu cuenta Trakt.tv? ¿Te gustaría eliminar este elemento de \'Pendientes\' de tu cuenta Trakt.tv? ¿Te gustaría eliminar este elemento de \'Progreso\' de tu cuenta Trakt.tv? Añadir a Mis Series Añadir a Mis Películas Añadir a Pendientes Añadir a Ocultos Mover a Mis Series Mover a Mis Películas Mover a Pendientes Mover a Ocultos Eliminar de Mis Series Eliminar de Mis Películas Eliminar de Pendientes Eliminar de Ocultos Pendientes Próximamente Nuevos episodios siempre primero ¡Conviértete en un seguidor y consigue acceso a funciones extra! Haz clic para ver más. Anclar a la parte superior Desanclar de la parte superior Anclar a \"en espera\" Desanclar de \"en espera\" Ups… Algo salió mal.\nPor favor, contáctanos si esto sigue sucediendo. Autorización fallida. Por favor contáctenos si continúa sucediendo. Autorización de cuenta Trakt fallida.\nPor favor ingrese e inténtelo de nuevo. Por favor, espera a que se carguen los datos de las temporadas. Este episodio aún no se ha emitido. No pudimos cargar los resultados de la página descubrir en este momento.\nPor favor, contáctanos si esto sigue sucediendo. Parece que esta serie ya no existe en la base de datos de Trakt.tv o está duplicada.\n\nPulsa \'OK\' para eliminarla de la aplicación. Parece que esta película ya no existe en la base de datos de Trakt.tv o está duplicada.\n\nPulsa \'OK\' para eliminarla de la aplicación. No pudimos cargar los detalles de esta serie en este momento.\nPor favor, contáctanos si esto sigue sucediendo. No pudimos cargar los detalles de esta película en este momento.\nPor favor, contáctanos si esto sigue sucediendo. No pudimos cargar los resultados de la búsqueda en este momento.\nPor favor, contáctanos si esto sigue sucediendo. Sin Internet. Por favor compruebe su conexión. Error de sincronización de Trakt.tv.\nPor favor comprueba tu conexión a Internet y prueba de nuevo o contáctanos si sigue sucediendo. Tu cuenta de Trakt.tv está bloqueada actualmente.\nPor favor, ponte en contacto con el soporte de Trakt.tv en https://support.trakt.tv/ para desbloquear tu cuenta. Las suscripciones de Google Play no están disponibles en este dispositivo. ================================================ FILE: ui-base/src/main/res/values-fi/strings.xml ================================================ OK Kyllä Ei Käytä Peruuta Sulje Ei nyt Uusi Valitse Kuuma Lähetä Piilota Poista Ei tuloksia… Haku Vinkki: Kausi %1$d Erikoisjaksot Jakso %1$d K.%02d J.%02d Lajittele: Kommentti sisältää juonipaljastuksia.\nLue napauttamalla. Kirjoittanut %s %1$s (%2$s) Verkko: Tyylilajit: Sarjat Elokuvat Listat min Yhteenvetoa ei ole saatavilla. Odota… Arvioi Linkki Ei käytössä Arviosi tallennettiin. Arviosi poistettiin. %1$d päivää %d jäljellä %d jäljellä Henkilöt: Ohjaus Ohjaaja Käsikirjoitus Käsikirjoitus Ääni Musiikki Näyttely Omat kuvat Valitse oma juliste ja fanitaide, joita käytetään eri puolella sovellusta. Esitetty Esitetään nyt Esitetään huomenna Esitetään %d päivän kuluttua Esitetään tunnin kuluttua Esitetään %d tunnin kuluttua Esitetään nyt Esitetään %d minuutin kuluttua 1 kohde synkronoitiin. %d kohdetta synkronoitiin. Tänään Huomenna Tällä viikolla Ensi viikolla Tässä kuussa Ensi kuussa Tänä vuonna Ensi vuonna Myöhemmin Eilen Viimeisinä 7 päivänä Viimeisinä 30 päivänä Viimeisinä 90 päivänä Uusi jakso on julkaistu! Uusi jakso julkaistaan pian! Uusi tuotantokausi on julkaistu! Uusi tuotantokausi julkaistaan pian! Elokuva on julkaistu! Trakt.tv-synkronointi Synkronointi suoritettiin. Käynnissä… Trakt.tv-synkronointi epäonnistui. Trakt.tv-synkronointi epäonnistui. Tarkista Internet-yhteytesi ja yritä uudelleen tai ota meihin yhteyttä, jos tämä jatkuu. Pikasynkronointi epäonnistui. Pikasynkronointi epäonnistui. Yritämme uudelleen, kun avaat sovelluksen seuraavan kerran. Tarkista Internet-yhteytesi. Myönnä sovellukselle tietojen synkronoinnin edistymisen ja tulosten näyttöoikeus.\n\nHaluatko tehdä tämän nyt? Poistetaanko Trakt.tv-kokoelmastasi? Haluatko poistaa kohteen Trakt.tv-tilisi \'Hidden Items\' (Piilotetut kohteet) -osiosta? Haluatko poistaa kohteen Trakt.tv-tilisi \'Watchlist\' (Katselulista) -osiosta? Haluatko poistaa kohteen Trakt.tv-tilisi \"Progress\" (Katselutila) -osiosta? Lisää omiin sarjoihin Lisää omiin elokuviin Lisää katselulistalle Lisää piilotettuihin Siirrä omiin sarjoihin Siirrä omiin elokuviin Siirrä katselulistalle Siirrä piilotettuihin Poista omista sarjoista Poista omista elokuvista Poista katselulistalta Poista piilotetuista Katselulista Tulossa Uudet jaksot aina ensin Ryhdy tukijaksi, niin saat käyttöösi lisäominaisuuksia! Napauta nähdäksesi lisää. Kiinnitä listan ylälaitaan Irrota listan ylälaidasta Kiinnitä \'pitoon\' Irrota \'pidosta\' Hups… Jotain meni pieleen.\nOta meihin yhteyttä, jos tämä jatkuu. Valtuutus epäonnistui. Ota meihin yhteyttä, jos tämä jatkuu. Trakt.tv-tilin valtuutus epäonnistui.\nKirjaudu sisään ja yritä uudelleen. Odota kun tuotantokausien tiedot ladataan. Jaksoa ei ole vielä esitetty. Etsintäosion lataus ei juuri nyt onnistunut.\nOta yhteyttä, jos tämä jatkuu. Sarjaa ei näyttäisi enää olevan Trakt.tv:n tietokannassa tai se on kaksoiskappale.\n\nPoista se sovelluksesta napauttamalla \'OK\'. Näyttäisi siltä, ettei elokuvaa enää ole Trakt.tv:n tietokannassa tai se on kaksoiskappale.\n\nPoista se sovelluksesta napauttamalla \'OK\'. Sarjan tietoja ei voitu ladata.\nOta meihin yhteyttä, jos tämä jatkuu. Elokuvan tietoja ei voitu ladata.\nOta meihin yhteyttä, jos tämä jatkuu. Hakutuloksia ei voitu ladata.\nOta meihin yhteyttä, jos tämä jatkuu. Ei Internet-yhteyttä. Tarkista yhteys. Trakt.tv-synkronointivirhe.\nTarkista Internet-yhteys ja yritä uudelleen. Trakt.tv-tilisi on lukittu.\nOta yhteyttä Trakt.tv:n tukeen osoitteessa https://support.trakt.tv/ poistaaksesi lukituksen. Google Play -tilaukset eivät ole käytettävissä tällä laitteella. ================================================ FILE: ui-base/src/main/res/values-fr/strings.xml ================================================ OK Oui Non Appliquer Annuler Fermer Pas maintenant Nouveau Sélectionner Tendance Envoyer Masquer Supprimer Il n\'y a aucun résultat… Recherche Astuce : Saison %1$d Hors-séries Épisode %1$d S.%02d E.%02d Trier par : Ce commentaire contient des spoilers.\nAppuyer pour lire. Commenté par %s %1$s (%2$s) Réseau : Genres : Séries Films Listes min Aperçu non disponible. Veuillez patienter… Noter Liens Désactivé Votre note a été enregistrée. Votre note a été supprimée. %1$d jours %d restant %d restants Personnes : Réalisation Réalisateur Écriture Scénario Son Musique Jeu d\'acteur Images personnalisées Choisissez vous-même l\'affiche et le fanart qui seront utilisés dans l\'application. Diffusé Diffusé en ce moment Diffusé demain Diffusé dans %d jours Diffusé dans 1 heure Diffusé dans %d heures Diffusé maintenant Diffusé dans %d minutes 1 élément synchronisé avec succès. %d éléments synchronisés avec succès. Aujourd\'hui Demain Cette Semaine La semaine prochaine Ce mois Le mois prochain Cette année L\'Année Prochaine Plus tard Hier 7 derniers jours 30 derniers jours 90 derniers jours Un nouvel épisode est disponible dès maintenant ! Un nouvel épisode sera bientôt disponible! Une nouvelle saison est disponible dès maintenant ! Une nouvelle saison sera bientôt disponible ! Le film est sorti ! Synchronisation Trakt.tv Synchronisation terminée avec succès. En cours d\'exécution… La synchronisation de Trakt.tv a échoué. La synchronisation avec Trakt.tv a échoué. Veuillez vérifier votre connexion Internet et réessayer ou contactez-nous si cela se produit à nouveau. La synchronisation instantanée a échoué. La synchronisation instantanée a échoué. Nous allons réessayer la prochaine fois que vous ouvrirez l\'application. Veuillez vérifier votre connexion Internet. Veuillez accorder à l\'application la permission d\'afficher des informations sur la progression et les résultats de la synchronisation.\n\nVoulez-vous le faire maintenant ? Retirer de Trakt.tv? Voulez-vous retirer cet élément des \'Éléments masqués\' de votre compte Trakt.tv ? Voulez-vous retirer cet élément de la liste \'Watchlist\' de votre compte Trakt.tv ? Voulez-vous retirer cet élément de la liste \'Progression\' de votre compte Trakt.tv ? Ajouter à Mes Séries Ajouter à Mes Films Ajouter à la Watchlist Ajouter aux éléments Masqués Déplacer vers Mes Séries Déplacer vers Mes Films Déplacer vers la Watchlist Déplacer vers les éléments Masqués Supprimer de Mes Séries Supprimer de Mes Films Supprimer de la Watchlist Supprimer des éléments Masqués Watchlist À venir Nouveaux épisodes en premier Devenez un supporteur et accédez à des fonctionnalités bonus ! Cliquez pour en savoir plus. Épingler en haut Désépingler du haut Épingler vers \"En attente\" Désépingler de \"En attente\" Oups… Quelque chose s\'est mal passé.\nVeuillez nous contacter si cela continue de se produire. Échec de l\'autorisation. Veuillez nous contacter si cela se produit à nouveau. L\'autorisation du compte Trakt a échoué.\nVeuillez vous connecter et réessayer. Veuillez attendre que les données des saisons soient chargées. Cet épisode n\'a pas encore été diffusé. Impossible de charger la page Découvrir pour le moment.\nVeuillez nous contacter si cela persiste. On dirait que cette série n\'existe plus dans la base de données Trakt.tv ou est un doublon.\n\nAppuyez sur \'OK\' pour la retirer de l\'application. On dirait que ce film n\'existe plus dans la base de données Trakt.tv ou est un doublon.\n\nAppuyez sur \'OK\' pour le retirer de l\'application. Nous n\'avons pas pu charger les détails de cette série pour le moment.\nVeuillez nous contacter si cela se produit à nouveau. Nous n\'avons pas pu charger les détails de ce film pour le moment.\nVeuillez nous contacter si cela se produit à nouveau. Nous n\'avons pas pu charger les résultats de recherche pour le moment.\nVeuillez nous contacter si cela se produit à nouveau. Pas de connexion Internet. Vérifiez votre connexion. Erreur de synchronisation Trakt.tv.\nVeuillez vérifier votre connexion internet et réessayer. Votre compte Trakt.tv est actuellement verrouillé.\nVeuillez contacter l\'assistance Trakt.tv à https://support.trakt.tv/ pour déverrouiller votre compte. Les abonnements via Google Play ne sont pas disponibles sur cet appareil. ================================================ FILE: ui-base/src/main/res/values-it/strings.xml ================================================ OK Si No Applica Annulla Chiudi Non Ora Nuovo Seleziona Popolari Invia Nascondi Rimuovi Non ci sono risultati... Cerca Consiglio: Stagione %1$d Speciali Episodio %1$d S.%02d E.%02d Ordina: Questo commento contiene spoiler.\nTocca per leggerlo. Commentato da %s %1$s (%2$s) Rete: Generi: Show Film Liste min Panoramica non disponibile. Attendere prego… Valuta Link Disabilitato La tua valutazione è stata salvata. La tua valutazione è stata rimossa. %1$d giorni Ancora %d Ancora %d Persone: Regia Regista Scrittura Sceneggiatura Suono Musica Recitazione Immagini personalizzate Scegli il tuo poster e la tua fanart che verranno utilizzati all\'interno dell\'app. Trasmesso Trasmesso ora Trasmesso domani Trasmesso tra %d giorni Trasmesso tra 1 ora Trasmesso tra %d ore Trasmesso ora Trasmesso tra %d minuti 1 elemento sincronizzato correttamente. %d elementi sincronizzati correttamente. Oggi Domani Questa settimana Settimana prossima Questo mese Il mese prossimo Quest\'anno L\'anno prossimo Più avanti Ieri Ultimi 7 giorni Ultimi 30 giorni Ultimi 90 giorni Nuovi episodi disponibili ora! Il nuovo episodio sarà disponibile a breve! Nuova stagione disponibile ora! La nuova stagione sarà disponibile a breve! Il film è uscito! Sincronizzazione Trakt.tv Sincronizzazione completata correttamente. Eseguo... Sincronizzazione Trakt.tv fallita. Sincronizzazione Trakt.tv fallita. Per favore controlla la tua connessione internet e riprova oppure contattaci se il problema persiste. Sincronizzazione istantanea fallita. Sincronizzazione istantanea fallita. Per favore controlla la tua connessione internet e riprova oppure contattaci se il problema persiste. È necessario concedere all\'applicazione il permesso di visualizzare le informazioni sui progressi e i risultati della sincronizzazione.\n\nVuoi farlo ora? Rimuovere da Trakt.tv? Vuoi rimuovere questo elemento dagli \"Elementi nascosti\" del tuo account Trakt.tv? Vuoi rimuovere questo elemento dalla lista \"Da vedere\" del tuo account Trakt.tv? Vuoi rimuovere questo elemento dai \"Progressi\" del tuo account Trakt.tv? Aggiungi a I miei show Aggiungi a I miei film Aggiungi alla lista Da vedere Aggiungi a Nascosti Sposta in I miei show Sposta in I miei film Sposta nella lista Da vedere Sposta in Nascosti Rimuovi da I miei show Rimuovi da I miei film Rimuovi dalla lista Da vedere Rimuovi da Nascosti Da vedere In arrivo I nuovi episodi sempre per primi Diventa un sostenitore e ottieni l\'accesso alle funzionalità bonus! Clicca per saperne di più. Fissa in cima Rimuovi Aggiungi a \'In pausa\' Togli da \'In pausa\' Oops... Qualcosa è andato storto.\nPer favore contattaci se il problema persiste. Autorizzazione fallita. Per favore contattaci se il problema persiste. Autorizzazione account Trakt fallita.\nPer favore accedi e prova di nuovo. Per favore attendi che i dati delle stagione siano scaricati. Questo episodio non è ancora stato trasmesso. Non siamo riusciti a caricare la pagina scopri.\nPer favore contattaci se il problema persiste. Sembra che questo show non sia più presente nel database di Trakt.tv oppure è un duplicato.\n\nPremi \"OK\" per rimuoverlo dall\'app. Sembra che questo film non sia più presente nel database di Trakt.tv oppure è un duplicato.\n\nPremi \"OK\" per rimuoverlo dall\'app. Non siamo riusciti a scaricare i dettagli di questo show.\nPer favore contattaci se il problema persiste. Non siamo riusciti a scaricare i dettagli del film.\nPer favore contattaci se il problema persiste. Non siamo riusciti a caricare i risultati di ricerca.\nPer favore contattaci se il problema persiste. Nessuna connessione. Per favore controlla la tua connessione internet. Errore sincronizzazione Trakt.tv .\nPer favore controlla la tua connessione internet e prova di nuovo. Il tuo account Trakt.tv è bloccato.\nPer favore contatta il supporto di Trakt.tv al link https://support.trakt.tv/ per sbloccare il tuo account. Gli abbonamenti tramite Google Play non sono disponibili su questo dispositivo. ================================================ FILE: ui-base/src/main/res/values-notnight/colors.xml ================================================ #F6F7F9 #FDFDFD #E1E6EC #4B6383 ================================================ FILE: ui-base/src/main/res/values-notnight/dimens.xml ================================================ 2dp 3dp 6dp 3dp ================================================ FILE: ui-base/src/main/res/values-notnight/misc.xml ================================================ 0.38 false ================================================ FILE: ui-base/src/main/res/values-notnight/styles.xml ================================================ ================================================ FILE: ui-base/src/main/res/values-notnight-v27/styles.xml ================================================ ================================================ FILE: ui-base/src/main/res/values-pl/strings.xml ================================================ OK Tak Nie Zastosuj Anuluj Zamknij Nie Teraz Nowy Wybierz Gorące Wyślij Ukryj Usuń Brak wyników… Szukaj Wskazówka: Sezon %1$d Specjalne Odcinek %1$d S.%02d E.%02d Sortuj: Opinia zawiera spoilery.\nKliknij aby przeczytać. Komentarz od %s %1$s (%2$s) Sieć: Gatunki: Seriale Filmy Listy min Opis niedostępny. Proszę czekać… Oceń Link Wyłączone Ocena została zapisana. Ocena została usunięta. %1$d dni %d do obejrzenia %d do obejrzenia %d do obejrzenia %d do obejrzenia Osoby: Reżyseria Reżyseria Scenariusz Scenariusz Dźwięk Muzyka Aktorstwo Własne Grafiki Wybierz swój własny plakat i fanart, które będą używane w aplikacji. Po premierze Premiera teraz Premiera jutro Premiera za %d dni Premiera za %d dni Premiera za %d dni Premiera za godzinę Premiera za %d godzin Premiera za %d godzin Premiera za %d godzin Premiera teraz Premiera za %d minut Premiera za %d minut Premiera za %d minut Zsynchronizowano 1 element. Zsynchronizowano %d elementy. Zsynchronizowano %d elementów. Zsynchronizowano %d elementów. Dzisiaj Jutro Ten Tydzień Następny Tydzień Ten Miesiąc Następny Miesiąc Ten Rok Następny Rok Później Wczoraj Poprzednie 7 Dni Poprzednie 30 Dni Poprzednie 90 Dni Nowy odcinek jest już dostępny! Nowy odcinek będzie wkrótce dostępny! Nowy sezon jest już dostępny! Nowy sezon będzie wkrótce dostępny! Nowy film jest już dostępny! Synchronizacja Trakt.tv Synchronizacja zakończona. Przetwarzanie… Błąd synchronizacji Trakt.tv. Błąd synchronizacji Trakt.tv. Sprawdź połączenie z internetem lub skontaktuj się z nami jeżeli błąd się powtarza. Błąd szybkiej synchronizacji. Błąd szybkiej synchronizacji. Spróbujemy ponownie przy następnym uruchomieniu aplikacji. Aby poprawnie wyświetlać informacje związane z synchronizacją aplikacja potrzebuje twojej zgody.\n\nCzy chcesz jej udzielić teraz? Usunąć z Trakt.tv? Czy chcesz usunąć ten element z sekcji \'Ukryte\' na twoim koncie Trakt.tv? Czy chcesz usunąć ten element z sekcji \'Na Później\' na twoim koncie Trakt.tv? Czy chcesz usunąć ten element z sekcji \'Postęp\' na twoim koncie Trakt.tv? Dodaj do moich seriali Dodaj do moich filmów Dodaj do \'Na Później\' Dodaj do ukrytych Przenieś do moich seriali Przenieś do moich filmów Przenieś do \'Na Później\' Przenieś do ukrytych Usuń z moich seriali Usuń z moich filmów Usuń z \'Na Później\' Usuń z ukrytych Na Później Nadchodzące Nowe odcinki zawsze u góry Zostań subskrybentem i uzyskaj dostęp do dodatkowych funkcji! Kliknij, aby zobaczyć więcej. Przypnij u góry Odepnij z góry Przypnij do wstrzymanych Odepnij ze wstrzymanych Oj… Coś poszło nie tak.\nSkontaktuj się z nami jeżeli błąd się powtarza. Błąd autoryzacji.\nSkontaktuj się z nami jeżeli błąd się powtarza. Błąd autoryzacji Trakt.tv.\nZaloguj się i spróbuj ponownie. Proszę poczekać na dane o sezonach. Ten odcinek nie miał jeszcze premiery. W tej chwili nie można załadować świeżych danych.\nSkontaktuj się z nami jeżeli błąd się powtarza. Wygląda na to, że ten program nie istnieje już w bazie danych Trakt.tv lub jest duplikatem.\n\nNaciśnij \"OK\", aby usunąć go z aplikacji. Wygląda na to, że ten film nie istnieje już w bazie danych Trakt.tv lub jest duplikatem.\n\nNaciśnij \"OK\", aby usunąć go z aplikacji. W tej chwili nie można załadować informacji o serialu.\nSkontaktuj się z nami jeżeli błąd się powtarza. W tej chwili nie można załadować informacji o filmie.\nSkontaktuj się z nami jeżeli błąd się powtarza. W tej chwili nie można załadować wyników wyszukiwania.\nSkontaktuj się z nami jeżeli błąd się powtarza. Brak Internetu. Sprawdź swoje połączenie. Błąd synchronizacji Trakt.tv\nSprawdź połączenie z internetem i spróbuj ponownie. Twoje konto Trakt.tv jest obecnie zablokowane.\nSkontaktuj się z obsługą Trakt.tv aby je odblokować: https://support.trakt.tv/ Subskrypcje Google Play nie są dostępne na tym urządzeniu. ================================================ FILE: ui-base/src/main/res/values-pt/strings.xml ================================================ OK Sim Não Aplicar Cancelar Fechar Agora não Novo Selecionar Em alta Enviar Esconder Remover Não há resultados… Pesquisar Dica: Temporada %1$d Especiais Episódio %1$d S.%02d E.%02d Ordenar por: Este comentário contém spoilers.\nToque para ler. Comentado por %s %1$s (%2$s) Rede de televisão: Gêneros: Séries Filmes Listas min Resumo não disponível. Por favor, aguarde… Avaliar Link Desativado Sua avaliação foi salva. Sua avaliação foi removida. %1$d dias %d restante %d restantes Pessoas: Direcionando Diretor Escrita Screenplay Som Música Atuação Imagens personalizadas Escolha seu próprio pôster e fanart que serão usados em todo o aplicativo. Exibido No ar agora No ar amanhã No ar em %d dias No ar em 1 hora No ar em %d horas No ar agora No ar em %d minutos 1 item sincronizado com sucesso. %d itens sincronizados com sucesso. Hoje Amanhã Esta semana Próxima semana Este mês Próximo mês Este ano Próximo ano Mais tarde Ontem Últimos 7 dias Últimos 30 dias Últimos 90 dias Novo episódio está disponível agora! Novo episódio estará disponível em breve! Nova temporada está disponível agora! Nova temporada estará disponível em breve! O filme foi lançado! Sincronizar Trakt.tv Sincronização concluída com sucesso. Em andamento… A sincronização Trakt.tv falhou. A sincronização do Trakt.tv falhou. Por favor, verifique sua conexão com a internet e tente novamente ou entre em contato conosco se isso continuar acontecendo. Sincronização instantânea falhou. A sincronização instantânea falhou. Tentaremos de novo na próxima vez que você abrir o aplicativo. Por favor, verifique sua conexão com a internet. Por favor, conceda à aplicação permissão para exibir informações sobre o progresso e os resultados da sincronização.\n\nGostaria de fazê-lo agora? Remover do Trakt.tv? Você gostaria de remover este item da sua conta Trakt.tv \'Oculto\'? Você gostaria de remover este item da sua conta do Trakt.tv \"Watchlist\"? Você gostaria de remover este item da sua conta do Trakt.tv \"Progresso\"? Adicionar às minhas séries Adicionar aos meus filmes Adicionar à Lista de Desejos Adicionar ao Oculto Mover para Minhas Séries Mover para Meus filmes Mover para a Lista de Desejos Mover para Oculto Remover das Minhas Séries Remover dos Meus Filmes Remover da Lista de Desejos Remover do Oculto Interesses Em breve Novos episódios sempre primeiro Torne-se um apoiador e tenha acesso a recursos premium! Clique para ver mais. Fixar no topo Desafixar do topo Fixar em \'em espera\' Desafixar de \'em espera\' Ops… Algo deu errado.\nEntre em contato conosco se isto continuar acontecendo. A autorização falhou. Entre em contato conosco se isso continuar acontecendo. Falha na autorização da conta Trakt.\nPor favor, faça o login e tente novamente. Por favor, aguarde até que os dados da temporada sejam carregados. Este episódio ainda não foi exibido. Não foi possível carregar a página descoberta no momento.\nEntre em contato conosco se isso continuar acontecendo. Parece que esta série não existe mais no banco de dados do Trakt.tv ou é uma duplicação.\n\nToque em \'OK\' para removê-la do aplicativo. Parece que esse filme não existe mais no banco de dados do Trakt.tv ou é uma duplicação.\n\nToque em \'OK\' para removê-lo do aplicativo. Não foi possível carregar os detalhes desta série no momento.\nPor favor, entre em contato conosco se isto continuar acontecendo. Não foi possível carregar os detalhes deste filme no momento.\nPor favor, entre em contato conosco se isto continuar acontecendo. Não foi possível carregar os resultados da pesquisa no momento.\nEntre em contato conosco se isso continuar acontecendo. Sem conexão com a internet. Por favor, verifique sua conexão. Erro de sincronização Trakt.tv.\nPor favor, verifique sua conexão com a internet e tente novamente. Sua conta Trakt.tv está bloqueada no momento.\nPor favor, entre em contato com o suporte de Trakt.tv em https://support.trakt.tv/ para desbloquear sua conta. As assinaturas do Google Play não estão disponíveis neste dispositivo. ================================================ FILE: ui-base/src/main/res/values-ru/strings.xml ================================================ ОК Да Нет Применить Отменить Закрыть Позже Новое Выбрать Горячее Отправить Скрыть Удалить Ничего не найдено… Поиск Подсказка: Сезон %1$d Спецвыпуски Эпизод %1$d S.%02d Е.%02d Сортировать по: Этот комментарий содержит спойлеры.\nНажмите, чтобы прочитать. Прокомментировал %s %1$s (%2$s) Студия: Жанры: Сериалы Фильмы Списки мин Описание недоступно. Подождите… Оценить Ссылка Ваша оценка была сохранена. Ваша оценка была удалена. %d осталось %d осталось %d осталось %d осталось Люди: Режиссура Режиссер Сценарий Сценарист Звук Музыка Фильмография Пользовательские изображения Выберите свой собственный постер и фанарт, который будет использоваться в приложении. Вышло Уже вышло Выходит через %d день Выходит через %d дней Выходит через %d дней Выходит через %d дней Выходит через 1 час Выходит через %d часа Выходит через %d часов Выходит через %d часов Уже вышло Выходит через %d минут Выходит через %d минут Выходит через %d минут 1 элемент успешно синхронизирован. %d элементов успешно синхронизировано. %d элементов успешно синхронизировано. %d элементов успешно синхронизировано. Сегодня Завтра На этой неделе Следующая неделя В этом месяце Следующий месяц В этом году В следующем году Позже Вчера Последние 7 дней Последние 30 дней Последние 90 дней Новый эпизод уже доступен! Новый эпизод скоро будет доступен! Новый сезон уже доступен! Новый сезон скоро будет доступен! Фильм был выпущен! Синхронизация Trakt.tv Синхронизация успешно завершена. Запуск… Сбой синхронизации Trakt.tv. Сбой синхронизации Trakt.tv. Пожалуйста, проверьте подключение к Интернету и повторите попытку или свяжитесь с нами, если это повторится. Ошибка мгновенной синхронизации. Ошибка мгновенной синхронизации. Попробуем снова при следующем запуске приложения. Пожалуйста, проверьте подключение к Интернету. Пожалуйста, предоставьте приложению разрешение на отображение информации о ходе и результатах синхронизации.\n\n Хотели бы вы сделать это сейчас? Удалить из Trakt.tv? Вы хотите удалить этот предмет из вашего аккаунта Trakt.tv \'Скрытое\'? Вы хотите удалить этот предмет из вашего аккаунта Trakt.tv \'Буду смотреть\'? Вы хотите удалить этот предмет из вашего аккаунта Trakt.tv \'Прогресс\'? Добавить в Мои Сериалы Добавить в Мои Фильмы Добавить в Буду Смотреть Добавить в Скрытое Переместить в Мои Сериалы Переместить в Мои Фильмы Переместить в Буду Смотреть Переместить в Скрытое Удалить из Моих Сериалов Удалить из Моих Фильмов Удалить из Буду Смотреть Удалить из Скрытое Буду смотреть Предстоящие Новые серии всегда первыми Станьте спонсором и получите доступ к бонусным функциям! Нажмите, чтобы увидеть больше. Закрепить Открепить Закрепить в \'На паузе\' Открепить от \'На паузе\' Ой,… Что-то пошло не так.\nПожалуйста, свяжитесь с нами, если это повторится. Авторизация не удалась. Пожалуйста, свяжитесь с нами, если это произойдет снова. Ошибка авторизации учетной записи Trakt.\nПожалуйста, войдите в систему и повторите попытку. Пожалуйста, дождитесь загрузки данных сезонов. Этот эпизод еще не вышел. Мы не смогли загрузить Открытия данный момент.\nПожалуйста, свяжитесь с нами, если это произойдет снова. Похоже, это шоу больше не существует в базе данных Trakt.tv или является дубликатом.\n\nНажмите \'OK\' для его удаления из приложения. Похоже, этот фильм больше не существует в базе данных Trakt.tv или является дубликатом.\n\nНажмите \'OK\' для его удаления из приложения. Мы не смогли загрузить детали этого сериала.\nПожалуйста, свяжитесь с нами, если это произойдет снова. Мы не смогли загрузить детали этого фильма.\nПожалуйста, свяжитесь с нами, если это произойдет снова. Мы не смогли загрузить результаты поиска.\nПожалуйста, свяжитесь с нами, если это произойдет снова. Нет интернет соединения. Проверьте своё подключение. Ошибка синхронизации Trakt.tv. \nПроверьте доступ в Интернет и повторите попытку. Ваша учетная запись Trakt.tv в настоящее время заблокирована.\nПожалуйста, свяжитесь со службой поддержки Trakt.tv по адресу https://support.trakt.tv/ для разблокировки вашей учетной записи. Сервисы Google Play недоступны на этом устройстве. ================================================ FILE: ui-base/src/main/res/values-sw600dp/bool.xml ================================================ true ================================================ FILE: ui-base/src/main/res/values-sw600dp/dimens.xml ================================================ 4dp 20dp 24dp 6dp 34dp 128dp ================================================ FILE: ui-base/src/main/res/values-sw600dp/styles.xml ================================================ ================================================ FILE: ui-base/src/main/res/values-tr/strings.xml ================================================ Tamam Evet Hayır Uygula İptal Kapat Şimdi değil Yeni Seç Popüler Gönder Saklamak Kaldır Hiçbir sonuç bulunamadı… Ara İpucu: %1$d. Sezon Özeller %1$d. Bölüm S.%02d B.%02d Sırala: Bu yorum sürpriz bozan içermektedir.\nOkumak için dokunun. Yorum yapan: %s %1$s (%2$s) Ağ: Türler: Diziler Filmler Listeler dk Tanıtım mevcut değil. Lütfen bekleyin… Oy Ver Link Devre dışı Oyunuz kaydedildi. Verdiğiniz oy kaldırıldı. %1$d gün %d kaldı %d kaldı Kişiler: Reji Yönetmen Yazarlık Senaryo Ses Müzik Oyunculuk Özel Fotoğraflar Uygulama genelinde kullanılacak kendi afişinizi ve hayran çalışmasını seçin. Yayınlandı Şimdi Yayında Yarın yayında %d gün içinde yayınlanacak 1 saat içinde yayınlanacak %d saat içinde yayınlanır Şimdi yayında %d dakika içinde yayında 1 öge başarıyla eşitlendi. %d öge başarıyla senkronize edildi. Bugün Yarın Bu Hafta Gelecek Hafta Bu Ay Gelecek Ay Bu Yıl Gelecek Yıl Daha sonra Dün Son 7 Gün Son 30 Gün Son 90 Gün Yeni bölüm yayınlandı! Yeni bölüm yakında yayınlanacak! Yeni sezon yayınlandı! Yeni sezon yakında yayınlanacak! Film yayınlandı! Trakt.tv Eşitlemesi Eşitleme başarıyla tamamlandı. Çalışıyor… Trakt.tv eşitlemesi başarısız. Trakt.tv eşitlemesi başarısız oldu. Lütfen internet bağlantınızı kontrol edin ve tekrar deneyin veya bu sorun devam ederse bizimle iletişime geçin. Anlık Eşitleme başarısız. Anlık Eşitleme başarısız oldu. Uygulamayı bir sonraki başlattığınızda tekrar deneyeceğiz. Lütfen internet bağlantınızı kontrol edin. Lütfen uygulamaya senkronizasyon ilerlemesi ve sonuçları hakkında bilgiler göstermesi için izin verin\n\nBunu şimdi yapmak ister misiniz? Trakt.tv\'den silinsin mi? Bu öğeyi Trakt.tv hesabınızdan \'Gizli\' kaldırmak ister misiniz? Bu öğeyi Trakt.tv hesabınızdaki \'Watchlist\' kaldırmak ister misiniz? Bu öğeyi Trakt.tv hesabınızdaki \'İlerleme\'den kaldırmak ister misiniz? Dizilerime Ekle Filmlerime Ekle İzleme Listesine Ekle Gizlenene Ekle Dizilerime Taşı Filmlerime Taşı İzleme Listesine Taşı Gizlenene Taşı Dizilerimden Kaldır Filmlerimden Kaldır İzleme Listesinden Kaldır Gizlenenlerden Kaldır İstek Listesi Yakında Her zaman önce yeni bölümler Destekçi olun ve bonus özelliklere erişin! Daha fazlasını görmek için dokunun. En üste sabitle Sabitlemeyi kaldır \"Beklemeye\" sabitle \"Beklemeye\" sabitlemeyi kaldır Hay aksi… Bir şeyler ters gitti.\nBu sorun devam ederse lütfen bizimle iletişime geçin. Yetkilendirme başarısız oldu. Bu durum devam ederse lütfen bizimle iletişime geçin. Trakt hesabı yetkilendirmesi başarısız oldu.\nLütfen giriş yapın ve tekrar deneyin. Lütfen sezon verilerinin yüklenmesini bekleyin. Bu bölüm henüz yayınlanmadı. Şu anda keşfet sayfasını yükleyemiyoruz.\nBu durum devam ederse lütfen bizimle iletişime geçin. Bu dizi artık Trakt.tv veritabanında yok veya çift girilmiş gibi görünüyor.\n\nUygulamadan da silmek için \'Tamam\'a dokunun. Bu film artık Trakt.tv veritabanında yok veya çift girilmiş gibi görünüyor.\n\nUygulamadan da silmek için \'Tamam\'a dokunun. Bu dizinin ayrıntılarını şu anda yükleyemiyoruz.\nBu durum devam ederse lütfen bizimle iletişime geçin. Bu filmin ayrıntılarını şu anda yükleyemiyoruz.\nBu durum devam ederse lütfen bizimle iletişime geçin. Şu anda arama sonuçlarını yükleyemiyoruz.\nBu durum devam ederse lütfen bizimle iletişime geçin. İnternet bağlantısı yok. Lütfen bağlantınızı kontrol edin. Trakt.tv eşitleme hatası.\nLütfen internet bağlantınızı kontrol edin ve tekrar deneyin. Trakt.tv hesabınız şu anda kilitli.\nHesabınızın kilidini açmak için lütfen https://support.trakt.tv/ adresinden Trakt.tv destek ekibi ile iletişime geçin. Google Play Abonelikleri bu cihazda kullanılamaz. ================================================ FILE: ui-base/src/main/res/values-uk/strings.xml ================================================ OK Так Ні Застосувати Скасувати Закрити Не зараз Нове Обрати Популярне Надіслати Приховати Видалити Результатів немає… Пошук Порада: Сезон %1$d Спецвипуски Серія %1$d S.%02d E.%02d Сортувати: Цей коментар містить спойлери.\nТоркніться, щоб прочитати. Коментар від %s %1$s (%2$s) Мережа: Жанри: Серіали Фільми Списки хв Опис недоступний. Будь ласка, зачекайте… Оцінити Посилання Вимкнено Ваша оцінка була збережена. Ваша оцінка була видалена. %1$d днів %d залишився %d залишилось %d залишилось %d залишилось Люди: Режисура Режисер Сценарій Сценарій Звук Музика Акторство Користувацькі зображення Оберіть свій власний постер і фанарт, які будуть використані в додатку. Прем\'єра відбулася Прем\'єра зараз Прем\'єра за %d день Прем\'єра за %d дні Прем\'єра за %d днів Прем\'єра за %d днів Прем\'єра за годину Прем\'єра за %d години Прем\'єра за %d годин Прем\'єра за %d годин Прем\'єра зараз Прем\'єра за %d хвилини Прем\'єра за %d хвилин Прем\'єра за %d хвилин 1 елемент успішно синхронізовано. %d елементи успішно синхронізовано. %d елементів успішно синхронізовано. %d елементів успішно синхронізовано. Сьогодні Завтра Цього тижня Наступного тижня Цього місяця Наступного місяця Цього року Наступного року Пізніше Вчора Останні 7 днів Останні 30 днів Останні 90 днів Нова серія вже доступна! Нова серія буде доступна найближчим часом! Новий сезон вже доступний! Новий сезон буде доступний найближчим часом! Фільм вийшов в прокат! Синхронізація Trakt.tv Синхронізацію успішно завершено. Виконується… Помилка синхронізації Trakt.tv. Помилка синхронізації Trakt.tv. Будь ласка, перевірте ваше з\'єднання з інтернетом і спробуйте ще раз або зв\'яжіться з нами, якщо помилка не зникає. Помилка миттєвої синхронізації. Помилка миттєвої синхронізації. Ми спробуємо наступного разу, коли ви відкриєте додаток. Будь ласка, перевірте підключення до Інтернету. Будь ласка, надайте додатку дозвіл на показ інформації про прогрес та результати синхронізації.\n\nВи хотіли б зробити це зараз? Видалити з Trakt.tv? Бажаєте видалити цей елемент з розділу \'Приховане\' вашого облікового запису Trakt.tv? Бажаєте видалити цей елемент з розділу \'На потім\' вашого облікового запису Trakt.tv? Бажаєте видалити цей елемент з розділу \'Прогрес\' вашого облікового запису Trakt.tv? Додати до моїх серіалів Додати до моїх фільмів Додати в \'На потім\' Додати в \'Приховане\' Перемістити до моїх серіалів Перемістити до моїх фільмів Перемістити в \'На потім\' Перемістити в \'Приховане\' Видалити з моїх серіалів Видалити з моїх фільмів Видалити з \'На потім\' Видалити з \'Приховане\' На потім Незабаром Спочатку нові серії Станьте підписником та отримуйте доступ до бонусних функцій! Натисніть, щоб побачити більше. Закріпити Відкріпити Закріпити в \'Призупинено\' Відкріпити з \'Призупинено\' Ой… Щось пішло не так.\nБудь ласка, зв\'яжіться з нами, якщо помилка не зникає. Не вдалося авторизуватись. Будь ласка, зв\'яжіться з нами, якщо помилка не зникає. Помилка авторизації облікового запису Trakt.\nБудь ласка, увійдіть і спробуйте знову. Будь ласка, дочекайтеся завантаження даних про сезони. Прем\'єра цієї серії ще не відбулася. Нам не вдалося завантажити сторінку Огляд.\nБудь ласка, зв\'яжіться з нами, якщо помилка не зникає. Схоже, цього серіалу більше не існує в базі даних Trakt.tv або воно є дублюючим.\n\nНатисніть \'ОК\', щоб видалити його з додатка. Схоже, цього фільму більше не існує в базі даних Trakt.tv або воно є дублюючим.\n\nНатисніть \'ОК\', щоб видалити його з додатка. Нам не вдалося завантажити інформацію про цей серіал.\nБудь ласка, зв\'яжіться з нами, якщо помилка не зникає. Нам не вдалося завантажити інформацію про цей фільм.\nБудь ласка, зв\'яжіться з нами, якщо помилка не зникає. Нам не вдалося завантажити результати пошуку.\nБудь ласка, зв\'яжіться з нами, якщо помилка не зникає. Немає доступу до інтернету. Перевірте підключення до мережі. Помилка синхронізації Trakt.tv.\nБудь ласка, перевірте підключення до Інтернету і спробуйте ще раз. Ваш обліковий запис Trakt.tv заблоковано.\nЗверніться до служби підтримки Trakt.tv за посиланням https://support.trakt.tv/ щоб розблокувати ваш обліковий запис. Підписки Google Play недоступні на цьому пристрої. ================================================ FILE: ui-base/src/main/res/values-vi/strings.xml ================================================ OK Không Áp dụng Hủy Đóng Không phải bây giờ Mới Chọn Nóng Gửi Ẩn Loại bỏ Không có kết quả nào… Tìm kiếm Mẹo: Mùa %1$d Đặc biệt Tập %1$d M.%02d T.%02d Sắp xếp theo: Nhận xét này có tiết lộ nội dung.\nNhấn để đọc. Được bình luận bởi %s %1$s (%2$s) Mạng: Thể loại: TBA Chương trình Phim Danh sách phút %s\n(%s) Tổng quan không có sẵn. Xin vui lòng đợi… Xếp hạng Liên kết Đã tắt Đánh giá của bạn đã được lưu. Đánh giá của bạn đã bị xóa. Đã sao chép vào clipboard %1$d ngày %d còn lại %d còn lại Mọi người: Chỉ đạo Đạo diễn Tác giả Kịch bản Âm thanh Âm nhạc Diễn xuất Hình ảnh tùy chỉnh Chọn áp phích và fanart của riêng bạn để sử dụng trên ứng dụng. Đã phát sóng Đang phát sóng Phát sóng vào ngày mai Phát sóng sau %d ngày Phát sau 1 giờ Phát sóng sau %d giờ Đang phát sóng Sẽ phát sóng sau %d phút 1 mục đã được đồng bộ hóa thành công . %d mục đã được đồng bộ hóa thành công. Hôm nay Ngày mai Tuần này Tuần tới Tháng này Tháng tới Năm nay Năm tới Sau Hôm qua 7 ngày qua 30 ngày qua 90 ngày qua Hiện đã có tập mới! Tập mới sẽ sớm có! Hiện đã có mùa mới! Mùa giải mới sẽ sớm có! Phim đã được phát hành! Đồng bộ hóa Trakt.tv Hoàn tất đồng bộ hóa thành công. Đang chạy… Đồng bộ hóa Trakt.tv thất bại. Đồng bộ hóa Trakt.tv thất bại. Vui lòng kiểm tra kết nối Internet của bạn và thử lại hoặc liên hệ với chúng tôi nếu điều này vẫn tiếp diễn. Đồng bộ hóa tức thì thất bại. Đồng bộ hóa tức thì thất bại. Chúng tôi sẽ thử lại vào lần tới khi bạn mở ứng dụng. Vui lòng kiểm tra kết nối internet của bạn. Vui lòng cấp cho ứng dụng quyền hiển thị thông tin về tiến trình và kết quả đồng bộ hóa.\n\nBạn có muốn thực hiện việc đó ngay bây giờ không? Loại khỏi Trakt.tv? Bạn có muốn loại mục này khỏi tài khoản Trakt.tv \'Mục ẩn\' của mình không? Bạn có muốn loại mục này khỏi tài khoản Trakt.tv \'Danh sách theo dõi\' của mình không? Bạn có muốn loại mục này khỏi tài khoản Trakt.tv \'Tiến độ\' của mình không? Thêm vào chương trình của tôi Thêm vào Phim của tôi Thêm vào Danh sách theo dõi Thêm vào Ẩn Di chuyển tới Chương trình của tôi Di chuyển tới Phim của tôi Di chuyển đến Danh sách theo dõi Di chuyển đến Ẩn Loại khỏi Chương trình của tôi Loại khỏi Phim của tôi Loại khỏi Danh sách theo dõi Loại khỏi Ẩn Danh sách theo dõi Sắp tới Các tập mới luôn trước tiên Showly Premium Trở thành người ủng hộ và có quyền truy cập vào các tính năng thưởng! Bấm vào để xem thêm. Ghim lên đầu Bỏ ghim khỏi đầu Ghim vào \'đang chờ\' Bỏ ghim khỏi \'đang chờ\' Ôpss… Đã xảy ra lỗi.\nVui lòng liên hệ với chúng tôi nếu điều này vẫn tiếp diễn. Ủy quyển thất bại. Vui lòng liên hệ với chúng tôi nếu điều này vẫn tiếp diễn. Ủy quyền tài khoản Trakt thất bại.\nVui lòng đăng nhập và thử lại. Vui lòng đợi dữ liệu các mùa được tải. Tập này chưa được phát sóng. Chúng tôi không thể tải trang khám phá vào lúc này.\nVui lòng liên hệ với chúng tôi nếu điều này vẫn tiếp diễn. Có vẻ như chương trình này không còn tồn tại trong cơ sở dữ liệu Trakt.tv hoặc là một trùng lặp.\n\nNhấn \'OK\' để xóa nó khỏi ứng dụng. Có vẻ như phim này không còn tồn tại trong cơ sở dữ liệu Trakt.tv hoặc là một trùng lặp.\n\nNhấn \'OK\' để xóa phim khỏi ứng dụng. Chúng tôi không thể tải thông tin chi tiết về chương trình này vào lúc này.\nVui lòng liên hệ với chúng tôi nếu điều này vẫn tiếp diễn. Chúng tôi không thể tải thông tin chi tiết về phim này vào lúc này.\nVui lòng liên hệ với chúng tôi nếu sự cố này vẫn tiếp diễn. Chúng tôi không thể tải kết quả tìm kiếm vào lúc này.\nVui lòng liên hệ với chúng tôi nếu điều này vẫn tiếp diễn. Không có mạng. Vui lòng kiểm tra kết nối của bạn. Lỗi đồng bộ hóa Trakt.tv.\nVui lòng kiểm tra kết nối Internet của bạn và thử lại. Tài khoản Trakt.tv của bạn hiện bị khóa.\nVui lòng liên hệ với bộ phận hỗ trợ Trakt.tv tại https://support.trakt.tv/ để mở khóa tài khoản của bạn. Chúng tôi không thể tải các tùy chọn mua hàng. Hãy đảm bảo ứng dụng Cửa hàng Google Play của bạn được cập nhật lên phiên bản mới nhất và thử lại. Đăng ký Google Play không khả dụng trên thiết bị này. Rất tiếc, chúng tôi không thể tìm thấy ứng dụng trên thiết bị này có thể xử lý liên kết này. Rất tiếc, bạn đã đạt đến giới hạn tài khoản Trakt cho danh sách cá nhân!\n\nVui lòng liên hệ với chúng tôi nếu bạn cần thêm trợ giúp. Bạn đã đạt đến giới hạn tài khoản Trakt cho danh sách cá nhân! Không phải tất cả danh sách cá nhân của bạn đều được đồng bộ hóa. Bạn đã đạt đến giới hạn tài khoản Trakt cho các mục trong danh sách theo dõi! Vui lòng liên hệ với chúng tôi nếu bạn cần thêm trợ giúp. ================================================ FILE: ui-base/src/main/res/values-zh/strings.xml ================================================ 确定 确定 取消 应用 取消 关闭 稍后​​​​​ 新播 选择 热门 提交 隐藏 移除 暂无结果… 搜索 提示: 第 %1$d 季 特别集 第 %1$d 集 %02d 季 %02d 集 排序: 此评论包含剧透。\n点后阅读。 %s 的评论 %1$s (%2$s) 流媒体: 类型: 剧集 电影 列表 分钟 暂无概览信息。 请稍等… 评分 链接 您的评分已发布。 您的评分已移除。 剩余 %d 集 演职人员: 导演 导演 编剧 剧本 声效 音乐 出演 自定义图片 选择您自己喜欢的海报或粉丝作品(将全局生效)。 已播出 现已开播 %d 天后开播 %d 小时后开播 %d 分钟后开播 已成功同步 %d 条记录。 今日 明日 本周 下周 本月 下月 今年 明年 随后 昨日 最近 7 天 最近 30 天 最近 90 天 新剧集现已更新! 新剧集即将更新! 新一季现已更新! 新一季即将更新! 电影已经上映! Trakt.tv 同步 已成功同步。 运行中... Trakt.tv 同步失败。 Trakt.tv 同步失败。请检查您的网络连接并重试,若该问题持续存在,请联系我们。 即时同步失败。 即时同步失败。下次打开应用时会自动重试。请检查您的网络连接。 是否从 Trakt.tv 中移除? 是否想要从您的 Trakt.tv「隐藏项目」中移除此项? 是否想要从您的 Trakt.tv「观看列表」中移除此项? 是否想要从您的 Trakt.tv「进行中」中移除此项? 加入我的剧集 加入我的电影 加入观看列表 加入隐藏项目 移至我的剧集 移动至我的电影 移至观看列表 移至隐藏项目 从我的剧集移除 从我的电影移除 从观看列表中移除 从隐藏项目中移除 待看列表 即将到来 始终置顶新剧集 成为支持者并获得额外功能!点击查看更多信息。 置顶 取消置顶 设为「暂缓中」 取消「暂缓中」 哎呀~ 出错了。\n如果这种情况持续发生,请联系我们。 授权失败。如果这种情况持续发生,请联系我们。 Trakt 账号授权失败。\n请登录并重试。 剧集数据加载中,请等待。 该集尚未播出。 当前暂时无法加载发现页面。\n如果这种情况持续发生,请联系我们。 似乎 Trakt.tv 数据库中已不存在这部剧,或存在重复。\n\n点击 「确定」 将其移除。 似乎在 Trakt.tv 数据库中该电影已不存在,或存在重复。\n\n点击 「确定」 移除。 当前暂时无法加载该剧详情。\n如果这种情况持续发生,请联系我们。 当前暂时无法加载该电影详情。\n如果这种情况持续发生,请联系我们。 当前暂时无法加载搜索结果。\n如果这种情况持续发生,请联系我们。 无互联网连接。请检查您的网络。 Trakt.tv 同步错误。\n请检查您的网络连接后重试。 您的 Trakt.tv 账号目前已被锁定。\n请在 https://support.trakt.tv/ 联系 Trakt.tv 客服解锁。 Google Play 服务在此设备上无法使用。 ================================================ FILE: ui-comments/.gitignore ================================================ /build ================================================ FILE: ui-comments/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'kotlin-parcelize' apply plugin: 'dagger.hilt.android.plugin' apply from: '../versions.gradle' android { kotlinOptions { jvmTarget = "17" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } buildFeatures { viewBinding true } compileSdkVersion versions.compileSdk defaultConfig { minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk compileSdkVersion versions.compileSdk testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false } } namespace 'com.michaldrabik.ui_comments' } dependencies { implementation project(':common') implementation project(':data-remote') implementation project(':ui-base') implementation project(':ui-model') implementation project(':repository') implementation project(':ui-navigation') implementation libs.hilt.android ksp libs.hilt.compiler coreLibraryDesugaring libs.android.desugar } ================================================ FILE: ui-comments/src/main/AndroidManifest.xml ================================================ ================================================ FILE: ui-comments/src/main/java/com/michaldrabik/ui_comments/CommentItemDiffCallback.kt ================================================ package com.michaldrabik.ui_comments import androidx.recyclerview.widget.DiffUtil import com.michaldrabik.ui_model.Comment class CommentItemDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Comment, newItem: Comment) = oldItem.id == newItem.id override fun areContentsTheSame(oldItem: Comment, newItem: Comment) = oldItem == newItem } ================================================ FILE: ui-comments/src/main/java/com/michaldrabik/ui_comments/CommentView.kt ================================================ package com.michaldrabik.ui_comments import android.annotation.SuppressLint import android.content.Context import android.graphics.Typeface import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import androidx.constraintlayout.widget.ConstraintLayout import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.CircleCrop import com.michaldrabik.common.extensions.toLocalZone import com.michaldrabik.ui_base.utilities.extensions.colorFromAttr import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.expandTouch import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_comments.databinding.ViewCommentBinding import com.michaldrabik.ui_comments.utilities.refreshTextSelection import com.michaldrabik.ui_model.Comment import java.time.format.DateTimeFormatter import java.util.Locale class CommentView : ConstraintLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewCommentBinding.inflate(LayoutInflater.from(context), this) private val colorTextPrimary by lazy { context.colorFromAttr(android.R.attr.textColorPrimary) } private val colorTextSecondary by lazy { context.colorFromAttr(android.R.attr.textColorSecondary) } private val colorTextAccent by lazy { context.colorFromAttr(android.R.attr.colorAccent) } private val commentSpace by lazy { context.dimenToPx(R.dimen.commentViewSpace) } init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) with(binding) { arrayOf(commentReplies, commentRepliesCount).forEach { with(it) { expandTouch() onClick { if (!comment.isLoading) { onRepliesClickListener?.invoke(comment) } } } } commentReply.onClick { onReplyClickListener?.invoke(comment) } commentDelete.onClick { onDeleteClickListener?.invoke(comment) } } } var onRepliesClickListener: ((Comment) -> Unit)? = null var onReplyClickListener: ((Comment) -> Unit)? = null var onDeleteClickListener: ((Comment) -> Unit)? = null private lateinit var comment: Comment @SuppressLint("SetTextI18n", "DefaultLocale") fun bind(comment: Comment, dateFormat: DateTimeFormatter?) { clear() this.comment = comment with(binding) { commentSpacer.setGuidelineBegin(if (comment.isReply()) commentSpace else 0) commentHeader.text = context.getString(R.string.textCommentedOn, comment.user.username) commentDate.text = comment.updatedAt?.toLocalZone()?.let { dateFormat?.format(it) } if (comment.isMe) { commentDate.setTextColor(colorTextAccent) commentHeader.setTextColor(colorTextAccent) } commentRating.visibleIf(comment.userRating > 0) commentRating.text = String.format(Locale.ENGLISH, "%d", comment.userRating) commentReplies.visibleIf(comment.replies > 0 && !comment.isLoading && !comment.hasRepliesLoaded) commentRepliesCount.visibleIf(comment.replies > 0 && !comment.isLoading && !comment.hasRepliesLoaded) commentRepliesCount.text = comment.replies.toString() commentProgress.visibleIf(comment.isLoading || comment.isLoading) commentSpacerLine.visibleIf(comment.isReply()) commentReply.visibleIf(comment.isSignedIn && !comment.isLoading) commentDelete.visibleIf(comment.isSignedIn && comment.isMe && comment.replies == 0L && !comment.isLoading) if (comment.hasSpoilers()) { with(commentText) { text = context.getString(R.string.textSpoilersWarning) commentText.setTypeface(null, Typeface.BOLD_ITALIC) commentText.setTextColor(colorTextSecondary) onClick { text = comment.comment commentText.setTypeface(null, Typeface.NORMAL) commentText.setTextColor(colorTextPrimary) } } } else { commentText.text = comment.comment } if (comment.user.avatarUrl.isNotEmpty()) { Glide.with(this@CommentView) .load(comment.user.avatarUrl) .placeholder(R.drawable.ic_person_placeholder) .transform(CircleCrop()) .into(commentImage) } } } private fun clear() { with(binding) { commentText.refreshTextSelection() commentText.setTypeface(null, Typeface.NORMAL) commentText.setTextColor(colorTextPrimary) commentDate.setTextColor(colorTextSecondary) commentHeader.setTextColor(colorTextSecondary) Glide.with(this@CommentView).clear(commentImage) } } } ================================================ FILE: ui-comments/src/main/java/com/michaldrabik/ui_comments/CommentsAdapter.kt ================================================ package com.michaldrabik.ui_comments import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView import com.michaldrabik.ui_model.Comment import java.time.format.DateTimeFormatter class CommentsAdapter( val onDeleteClickListener: ((Comment) -> Unit)? = null, val onReplyClickListener: ((Comment) -> Unit)? = null, val onRepliesClickListener: ((Comment) -> Unit)? = null ) : RecyclerView.Adapter() { private val asyncDiffer = AsyncListDiffer(this, CommentItemDiffCallback()) private var dateFormat: DateTimeFormatter? = null fun setItems(newItems: List, dateFormat: DateTimeFormatter?) { this.dateFormat = dateFormat asyncDiffer.submitList(newItems) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolderShow( CommentView(parent.context).apply { onRepliesClickListener = this@CommentsAdapter.onRepliesClickListener onReplyClickListener = this@CommentsAdapter.onReplyClickListener onDeleteClickListener = this@CommentsAdapter.onDeleteClickListener } ) override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val item = asyncDiffer.currentList[position] (holder.itemView as CommentView).bind(item, dateFormat) } override fun getItemCount() = asyncDiffer.currentList.size class ViewHolderShow(itemView: View) : RecyclerView.ViewHolder(itemView) } ================================================ FILE: ui-comments/src/main/java/com/michaldrabik/ui_comments/fragment/CommentsFragment.kt ================================================ package com.michaldrabik.ui_comments.fragment import android.annotation.SuppressLint import android.os.Bundle import android.os.Parcelable import android.view.View import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.michaldrabik.common.Mode import com.michaldrabik.ui_base.BaseFragment import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.addDivider import com.michaldrabik.ui_base.utilities.extensions.doOnApplyWindowInsets import com.michaldrabik.ui_base.utilities.extensions.fadeIf import com.michaldrabik.ui_base.utilities.extensions.fadeIn import com.michaldrabik.ui_base.utilities.extensions.fadeOut import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.navigateToSafe import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.requireParcelable import com.michaldrabik.ui_base.utilities.extensions.updateTopMargin import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_comments.CommentsAdapter import com.michaldrabik.ui_comments.R import com.michaldrabik.ui_comments.databinding.FragmentCommentsBinding import com.michaldrabik.ui_model.Comment import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_navigation.java.NavigationArgs.ACTION_NEW_COMMENT import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_COMMENT import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_COMMENT_ACTION import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_COMMENT_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_MOVIE_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_OPTIONS import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_REPLY_USER import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_SHOW_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_COMMENT import dagger.hilt.android.AndroidEntryPoint import kotlinx.parcelize.Parcelize @SuppressLint("SetTextI18n", "DefaultLocale", "SourceLockedOrientationActivity") @AndroidEntryPoint class CommentsFragment : BaseFragment(R.layout.fragment_comments) { companion object { const val BACK_UP_BUTTON_THRESHOLD = 25 fun createBundle(movie: Movie): Bundle = bundleOf(ARG_OPTIONS to Options(movie.ids.trakt, Mode.MOVIES)) fun createBundle(show: Show): Bundle = bundleOf(ARG_OPTIONS to Options(show.ids.trakt, Mode.SHOWS)) } override val navigationId = R.id.commentsFragment override val viewModel by viewModels() private val binding by viewBinding(FragmentCommentsBinding::bind) private var commentsAdapter: CommentsAdapter? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() setupRecycler() setupStatusBar() launchAndRepeatStarted( { viewModel.uiState.collect { render(it) } }, { viewModel.messageFlow.collect { showSnack(it) } } ) } private fun setupView() { hideNavigation() with(binding) { commentsBackArrow.onClick { requireActivity().onBackPressed() } commentsPostButton.onClick { openPostCommentSheet() } commentsUpButton.onClick { commentsUpButton.fadeOut(150) resetScroll() } } } private fun setupStatusBar() { with(binding) { commentsRecycler.doOnApplyWindowInsets { _, insets, padding, _ -> val inset = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top commentsRecycler.updatePadding(top = padding.top + inset) commentsTitle.updateTopMargin(inset) commentsBackArrow.updateTopMargin(inset) } } } private fun setupRecycler() { commentsAdapter = CommentsAdapter( onDeleteClickListener = { openDeleteCommentDialog(it) }, onReplyClickListener = { openPostCommentSheet(it) }, onRepliesClickListener = { viewModel.loadCommentReplies(it) } ) binding.commentsRecycler.apply { setHasFixedSize(true) adapter = commentsAdapter layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) itemAnimator = null addDivider(R.drawable.divider_comments_list) addOnScrollListener(recyclerScrollListener) } } private fun openPostCommentSheet(comment: Comment? = null) { setFragmentResultListener(REQUEST_COMMENT) { _, bundle -> showSnack(MessageEvent.Info(R.string.textCommentPosted)) when (bundle.getString(ARG_COMMENT_ACTION)) { ACTION_NEW_COMMENT -> { val newComment = bundle.getParcelable(ARG_COMMENT) newComment?.let { viewModel.addNewComment(newComment) } if (comment == null) { binding.commentsRecycler.smoothScrollToPosition(0) } } } } val bundle = when { comment != null -> bundleOf( ARG_COMMENT_ID to comment.getReplyId(), ARG_REPLY_USER to comment.user.username ) else -> { val (id, mode) = requireParcelable(ARG_OPTIONS) when (mode) { Mode.SHOWS -> bundleOf(ARG_SHOW_ID to id.id) Mode.MOVIES -> bundleOf(ARG_MOVIE_ID to id.id) } } } navigateToSafe(R.id.actionCommentsFragmentToPostComment, bundle) } private fun openDeleteCommentDialog(comment: Comment) { MaterialAlertDialogBuilder(requireContext(), R.style.AlertDialog) .setBackground(ContextCompat.getDrawable(requireContext(), R.drawable.bg_dialog)) .setTitle(R.string.textCommentConfirmDeleteTitle) .setMessage(R.string.textCommentConfirmDelete) .setPositiveButton(R.string.textYes) { _, _ -> viewModel.deleteComment(comment) } .setNegativeButton(R.string.textNo) { _, _ -> } .show() } private fun resetScroll() { with(binding) { commentsRecycler.smoothScrollToPosition(0) commentsBackArrow.animate().translationY(0F).start() commentsTitle.animate().translationY(0F).start() } } private fun render(uiState: CommentsUiState) { with(uiState) { comments?.let { commentsAdapter?.setItems(comments, dateFormat) with(binding) { commentsProgress.gone() commentsEmpty.visibleIf(comments.isEmpty()) commentsPostButton.fadeIf(isSignedIn, duration = 200, startDelay = 150) } } } } override fun onDestroyView() { commentsAdapter = null super.onDestroyView() } private val recyclerScrollListener = object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (newState != RecyclerView.SCROLL_STATE_IDLE) { return } val layoutManager = (binding.commentsRecycler.layoutManager as? LinearLayoutManager) if ((layoutManager?.findFirstVisibleItemPosition() ?: 0) >= BACK_UP_BUTTON_THRESHOLD) { binding.commentsUpButton.fadeIn(150) } else { binding.commentsUpButton.fadeOut(150) } } } @Parcelize data class Options(val id: IdTrakt, val mode: Mode) : Parcelable } ================================================ FILE: ui-comments/src/main/java/com/michaldrabik/ui_comments/fragment/CommentsUiState.kt ================================================ package com.michaldrabik.ui_comments.fragment import com.michaldrabik.ui_model.Comment import java.time.format.DateTimeFormatter data class CommentsUiState( val comments: List? = null, val dateFormat: DateTimeFormatter? = null, val isLoading: Boolean = false, val isSignedIn: Boolean = false ) ================================================ FILE: ui-comments/src/main/java/com/michaldrabik/ui_comments/fragment/CommentsViewModel.kt ================================================ package com.michaldrabik.ui_comments.fragment import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.common.Mode import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.ui_base.dates.DateFormatProvider import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.utilities.extensions.findReplace import com.michaldrabik.ui_base.utilities.extensions.rethrowCancellation import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_comments.R import com.michaldrabik.ui_comments.fragment.CommentsFragment.Options import com.michaldrabik.ui_comments.fragment.cases.DeleteCommentCase import com.michaldrabik.ui_comments.fragment.cases.LoadCommentsCase import com.michaldrabik.ui_comments.fragment.cases.LoadRepliesCase import com.michaldrabik.ui_model.Comment import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_OPTIONS import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber import java.time.format.DateTimeFormatter import javax.inject.Inject @HiltViewModel class CommentsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val commentsCase: LoadCommentsCase, private val repliesCase: LoadRepliesCase, private val deleteCase: DeleteCommentCase, private val userManager: UserTraktManager, private val dateFormatProvider: DateFormatProvider ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val commentsState = MutableStateFlow?>(null) private val loadingState = MutableStateFlow(false) private val signedInState = MutableStateFlow(false) private val dateFormatState = MutableStateFlow(null) init { loadInitialState() savedStateHandle.get(ARG_OPTIONS)?.let { loadComments(it.id, it.mode) } } private fun loadInitialState() { viewModelScope.launch { signedInState.update { userManager.isAuthorized() } dateFormatState.update { dateFormatProvider.loadFullHourFormat() } } } private fun loadComments(id: IdTrakt, mode: Mode) { viewModelScope.launch { try { val comments = commentsCase.loadComments(id, mode) commentsState.update { comments } } catch (error: Throwable) { commentsState.update { emptyList() } Timber.e(error) } } } fun loadCommentReplies(comment: Comment) { var currentComments = uiState.value.comments?.toMutableList() ?: mutableListOf() if (currentComments.any { it.parentId == comment.id }) { return } viewModelScope.launch { try { val parent = currentComments.find { it.id == comment.id } parent?.let { p -> val copy = p.copy(isLoading = true) currentComments.findReplace(copy) { it.id == p.id } commentsState.value = currentComments } val replies = repliesCase.loadReplies(comment) currentComments = uiState.value.comments?.toMutableList() ?: mutableListOf() val parentIndex = currentComments.indexOfFirst { it.id == comment.id } if (parentIndex > -1) currentComments.addAll(parentIndex + 1, replies) parent?.let { currentComments.findReplace(parent.copy(isLoading = false, hasRepliesLoaded = true)) { it.id == comment.id } } commentsState.value = currentComments } catch (t: Throwable) { commentsState.value = currentComments messageChannel.send(MessageEvent.Error(R.string.errorGeneral)) } } } fun addNewComment(comment: Comment) { val currentComments = uiState.value.comments?.toMutableList() ?: mutableListOf() if (!comment.isReply()) { currentComments.add(0, comment) } else { val parentIndex = currentComments.indexOfLast { it.id == comment.parentId } if (parentIndex > -1) { val parent = currentComments[parentIndex] currentComments.add(parentIndex + 1, comment) val repliesCount = currentComments.count { it.parentId == parent.id }.toLong() currentComments.findReplace(parent.copy(replies = repliesCount)) { it.id == comment.parentId } } } commentsState.update { currentComments } } fun deleteComment(comment: Comment) { var currentComments = uiState.value.comments?.toMutableList() ?: mutableListOf() val target = currentComments.find { it.id == comment.id } ?: return viewModelScope.launch { try { val copy = target.copy(isLoading = true) currentComments.findReplace(copy) { it.id == target.id } commentsState.value = currentComments deleteCase.delete(target) currentComments = uiState.value.comments?.toMutableList() ?: mutableListOf() val targetIndex = currentComments.indexOfFirst { it.id == target.id } if (targetIndex > -1) { currentComments.removeAt(targetIndex) if (target.isReply()) { val parent = currentComments.first { it.id == target.parentId } val repliesCount = currentComments.count { it.parentId == parent.id }.toLong() currentComments.findReplace(parent.copy(replies = repliesCount)) { it.id == target.parentId } } } commentsState.value = currentComments messageChannel.send(MessageEvent.Info(R.string.textCommentDeleted)) } catch (t: Throwable) { when (ErrorHelper.parse(t)) { is ShowlyError.CoroutineCancellation -> rethrowCancellation(t) is ShowlyError.ResourceConflictError -> messageChannel.send(MessageEvent.Error(R.string.errorCommentDelete)) else -> messageChannel.send(MessageEvent.Error(R.string.errorGeneral)) } } } } val uiState = combine( commentsState, loadingState, signedInState, dateFormatState ) { s1, s2, s3, s4 -> CommentsUiState( comments = s1, isLoading = s2, isSignedIn = s3, dateFormat = s4 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = CommentsUiState() ) } ================================================ FILE: ui-comments/src/main/java/com/michaldrabik/ui_comments/fragment/cases/DeleteCommentCase.kt ================================================ package com.michaldrabik.ui_comments.fragment.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.repository.CommentsRepository import com.michaldrabik.ui_model.Comment import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.withContext import okhttp3.internal.EMPTY_RESPONSE import retrofit2.HttpException import retrofit2.Response import java.util.concurrent.TimeUnit import javax.inject.Inject @ViewModelScoped class DeleteCommentCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val commentsRepository: CommentsRepository ) { suspend fun delete(comment: Comment) { val dateMillis = comment.createdAt?.toMillis() dateMillis?.let { if (nowUtcMillis() - it >= TimeUnit.DAYS.toMillis(13)) { throw HttpException(Response.error(409, EMPTY_RESPONSE)) } } withContext(dispatchers.IO) { commentsRepository.deleteComment(comment.id) } } } ================================================ FILE: ui-comments/src/main/java/com/michaldrabik/ui_comments/fragment/cases/LoadCommentsCase.kt ================================================ package com.michaldrabik.ui_comments.fragment.cases import com.michaldrabik.common.Mode import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.repository.CommentsRepository import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.ui_model.Comment import com.michaldrabik.ui_model.IdTrakt import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class LoadCommentsCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val commentsRepository: CommentsRepository, private val userManager: UserTraktManager ) { suspend fun loadComments(id: IdTrakt, mode: Mode): List = withContext(dispatchers.IO) { val isSignedIn = userManager.isAuthorized() val username = userManager.getUsername() val comments = commentsRepository.loadComments(id, mode) .map { it.copy( isSignedIn = isSignedIn, isMe = it.user.username == username ) } .partition { it.isMe } comments.first + comments.second } } ================================================ FILE: ui-comments/src/main/java/com/michaldrabik/ui_comments/fragment/cases/LoadRepliesCase.kt ================================================ package com.michaldrabik.ui_comments.fragment.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.repository.CommentsRepository import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.ui_model.Comment import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class LoadRepliesCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val commentsRepository: CommentsRepository, private val userManager: UserTraktManager ) { suspend fun loadReplies(comment: Comment): List = withContext(dispatchers.IO) { val isSignedIn = userManager.isAuthorized() val username = userManager.getUsername() commentsRepository.loadReplies(comment.id) .map { it.copy( isSignedIn = isSignedIn, isMe = it.user.username == username ) } } } ================================================ FILE: ui-comments/src/main/java/com/michaldrabik/ui_comments/post/PostCommentBottomSheet.kt ================================================ package com.michaldrabik.ui_comments.post import android.annotation.SuppressLint import android.os.Bundle import android.view.View import androidx.core.os.bundleOf import androidx.core.widget.doOnTextChanged import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.requireLong import com.michaldrabik.ui_base.utilities.extensions.requireString import com.michaldrabik.ui_base.utilities.extensions.showErrorSnackbar import com.michaldrabik.ui_base.utilities.extensions.showInfoSnackbar import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_comments.R import com.michaldrabik.ui_comments.databinding.ViewPostCommentBinding import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_COMMENT import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_COMMENT_ACTION import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_COMMENT_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_EPISODE_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_MOVIE_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_REPLY_USER import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_SHOW_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_COMMENT import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class PostCommentBottomSheet : BaseBottomSheetFragment(R.layout.view_post_comment) { private val showTraktId by lazy { IdTrakt(requireLong(ARG_SHOW_ID)) } private val movieTraktId by lazy { IdTrakt(requireLong(ARG_MOVIE_ID)) } private val episodeTraktId by lazy { IdTrakt(requireLong(ARG_EPISODE_ID)) } private val replyCommentId by lazy { IdTrakt(requireLong(ARG_COMMENT_ID)) } private val replyUser by lazy { requireString(ARG_REPLY_USER, default = "") } private val binding by viewBinding(ViewPostCommentBinding::bind) private val viewModel by viewModels() override fun getTheme(): Int = R.style.CustomBottomSheetDialog override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() launchAndRepeatStarted( { viewModel.uiState.collect { render(it) } }, { viewModel.messageFlow.collect { renderSnackbar(it) } }, ) } @SuppressLint("SetTextI18n") private fun setupView() { with(binding) { viewPostCommentInputValue.doOnTextChanged { text, _, _, _ -> val isValid = !text?.trim().isNullOrEmpty() && (text?.trim()?.split(" ")?.count { it.length > 1 } ?: 0) >= 5 viewPostCommentButton.isEnabled = isValid } viewPostCommentButton.onClick { val commentText = viewPostCommentInputValue.text.toString() val isSpoiler = viewPostCommentSpoilersCheck.isChecked when { replyCommentId.id > 0 -> viewModel.postReply(replyCommentId, commentText, isSpoiler) showTraktId.id > 0 -> viewModel.postShowComment(showTraktId, commentText, isSpoiler) movieTraktId.id > 0 -> viewModel.postMovieComment(movieTraktId, commentText, isSpoiler) episodeTraktId.id > 0 -> viewModel.postEpisodeComment(episodeTraktId, commentText, isSpoiler) else -> error("Invalid comment target.") } } if (replyUser.isNotEmpty() && replyCommentId.id != 0L) { viewPostCommentInputValue.setText("@$replyUser ") } } } @SuppressLint("SetTextI18n") private fun render(uiState: PostCommentUiState) { uiState.run { isLoading.let { with(binding) { viewPostCommentInput.isEnabled = !it viewPostCommentInputValue.isEnabled = !it viewPostCommentSpoilersCheck.isEnabled = !it viewPostCommentProgress.visibleIf(it) val commentText = viewPostCommentInputValue.text.toString() viewPostCommentButton.isEnabled = !it && isCommentValid(commentText) viewPostCommentButton.visibleIf(!it, gone = false) } } isSuccess?.let { it.consume()?.let { commentBundle -> setFragmentResult( REQUEST_COMMENT, bundleOf( ARG_COMMENT_ACTION to commentBundle.first, ARG_COMMENT to commentBundle.second ) ) closeSheet() } } } } private fun renderSnackbar(message: MessageEvent) { when (message) { is MessageEvent.Info -> binding.viewPostCommentSnackHost.showInfoSnackbar(getString(message.textRestId)) is MessageEvent.Error -> binding.viewPostCommentSnackHost.showErrorSnackbar(getString(message.textRestId)) } } private fun isCommentValid(text: String) = text.trim().isNotEmpty() && text.trim().split(" ").count { it.length > 1 } >= 5 } ================================================ FILE: ui-comments/src/main/java/com/michaldrabik/ui_comments/post/PostCommentUiState.kt ================================================ package com.michaldrabik.ui_comments.post import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_model.Comment data class PostCommentUiState( val isLoading: Boolean = false, val isSuccess: Event>? = null, ) ================================================ FILE: ui-comments/src/main/java/com/michaldrabik/ui_comments/post/PostCommentViewModel.kt ================================================ package com.michaldrabik.ui_comments.post import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError.CoroutineCancellation import com.michaldrabik.common.errors.ShowlyError.UnauthorizedError import com.michaldrabik.common.errors.ShowlyError.ValidationError import com.michaldrabik.repository.CommentsRepository import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.utilities.extensions.rethrowCancellation import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_comments.R import com.michaldrabik.ui_model.Comment import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_navigation.java.NavigationArgs.ACTION_NEW_COMMENT import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class PostCommentViewModel @Inject constructor( private val commentsRepository: CommentsRepository, private val userTraktManager: UserTraktManager, ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val loadingState = MutableStateFlow(false) private val successState = MutableStateFlow>?>(null) fun postShowComment(showId: IdTrakt, commentText: String, isSpoiler: Boolean) { if (!isValid(commentText)) return viewModelScope.launch { try { loadingState.value = true val show = Show.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = showId)) val comment = commentsRepository .postComment(show, commentText, isSpoiler) .copy(isMe = true, isSignedIn = true) successState.value = Event(Pair(ACTION_NEW_COMMENT, comment)) } catch (error: Throwable) { handleError(error) } } } fun postMovieComment(movieId: IdTrakt, commentText: String, isSpoiler: Boolean) { if (!isValid(commentText)) return viewModelScope.launch { try { loadingState.value = true val movie = Movie.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = movieId)) val comment = commentsRepository .postComment(movie, commentText, isSpoiler) .copy(isMe = true, isSignedIn = true) successState.value = Event(Pair(ACTION_NEW_COMMENT, comment)) } catch (error: Throwable) { handleError(error) } } } fun postEpisodeComment(episodeId: IdTrakt, commentText: String, isSpoiler: Boolean) { if (!isValid(commentText)) return viewModelScope.launch { try { loadingState.value = true val episode = Episode.EMPTY.copy(ids = Ids.EMPTY.copy(trakt = episodeId)) val comment = commentsRepository .postComment(episode, commentText, isSpoiler) .copy(isMe = true, isSignedIn = true) successState.value = Event(Pair(ACTION_NEW_COMMENT, comment)) } catch (error: Throwable) { handleError(error) } } } fun postReply(commentId: IdTrakt, commentText: String, isSpoiler: Boolean) { if (!isValid(commentText)) return viewModelScope.launch { try { loadingState.value = true val comment = commentsRepository .postReply(commentId.id, commentText, isSpoiler) .copy(isMe = true, isSignedIn = true) successState.value = Event(Pair(ACTION_NEW_COMMENT, comment)) } catch (error: Throwable) { handleError(error) } } } private fun isValid(commentText: String) = commentText .trim().split(" ") .filter { !it.startsWith("@") } .count { it.length > 1 } >= 5 private suspend fun handleError(error: Throwable) { loadingState.value = false when (ErrorHelper.parse(error)) { is CoroutineCancellation -> rethrowCancellation(error) is ValidationError -> messageChannel.send(MessageEvent.Error(R.string.errorCommentFormat)) is UnauthorizedError -> { messageChannel.send(MessageEvent.Error(R.string.errorTraktAuthorization)) userTraktManager.revokeToken() } else -> messageChannel.send(MessageEvent.Error(R.string.errorGeneral)) } } val uiState = combine( loadingState, successState ) { loadingState, successState -> PostCommentUiState( isLoading = loadingState, isSuccess = successState ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = PostCommentUiState() ) } ================================================ FILE: ui-comments/src/main/java/com/michaldrabik/ui_comments/utilities/Extensions.kt ================================================ package com.michaldrabik.ui_comments.utilities import android.widget.TextView fun TextView.refreshTextSelection() { setTextIsSelectable(false) post { setTextIsSelectable(true) } } ================================================ FILE: ui-comments/src/main/res/color/selector_comment_button.xml ================================================ ================================================ FILE: ui-comments/src/main/res/color/selector_comment_input.xml ================================================ ================================================ FILE: ui-comments/src/main/res/drawable/bg_comment_rating.xml ================================================ ================================================ FILE: ui-comments/src/main/res/drawable/divider_comments_list.xml ================================================ ================================================ FILE: ui-comments/src/main/res/drawable/ic_add_comment.xml ================================================ ================================================ FILE: ui-comments/src/main/res/drawable/ic_delete.xml ================================================ ================================================ FILE: ui-comments/src/main/res/drawable/ic_reply.xml ================================================ ================================================ FILE: ui-comments/src/main/res/layout/fragment_comments.xml ================================================ ================================================ FILE: ui-comments/src/main/res/layout/view_comment.xml ================================================ ================================================ FILE: ui-comments/src/main/res/layout/view_post_comment.xml ================================================ ================================================ FILE: ui-comments/src/main/res/layout-sw600dp/view_comment.xml ================================================ ================================================ FILE: ui-comments/src/main/res/values/dimens.xml ================================================ 16dp 16dp 88dp ================================================ FILE: ui-comments/src/main/res/values/strings.xml ================================================ Comment Comments: Comments No comments. English only, 5+ words, be respectful, mark spoilers! Spoilers Your comment has been submitted. Your comment has been deleted. Delete Comment Are you sure you want to delete this comment? Please sign in before rating a show. Please sign in before rating a movie. Your comment must be in English and have 5 words or more. This comment can\'t be deleted anymore. ================================================ FILE: ui-comments/src/main/res/values-ar/strings.xml ================================================ تعليق التعليقات: التعليقات لا توجد تعليقات. يجب أن يكون التعليق بالإنجليزية، وأكثر من 5 كلمات، وعند الحرق حدد أنه حرق! حرق تم إرسال تعليقك. تم حذف تعليقك. حذف التعليق أمتأكد أنك تريد حذف هذا التعليق؟ رجاءً قُم بتسجيل الدخول قبل تقييم المسلسل. رجاءً قُم بتسجيل الدخول قبل تقييم الفيلم. يجب أن يكون التعليق بالإنجليزية، وأكثر من 5 كلمات. لا يُمكن حذف التعليق. ================================================ FILE: ui-comments/src/main/res/values-de/strings.xml ================================================ Kommentar Kommentare: Kommentare Keine Kommentare. Nur Englisch, 5+ Wörter, sei respektvoll, markiere Spoiler! Spoiler Ihr Kommentar wurde abgeschickt. Dein Kommentar wurde gelöscht. Kommentar löschen Möchtest du diesen Kommentar wirklich löschen? Bitte melde dich an, um eine Serie zu bewerten. Bitte melde dich an bevor du einen Film bewerten kannst. Dein Kommentar muss auf Englisch sein und mindestens 5 Wörter haben. Dieser Kommentar kann nicht mehr gelöscht werden. ================================================ FILE: ui-comments/src/main/res/values-es/strings.xml ================================================ Comentar Comentarios: Comentarios Sin comentarios. ¡Sólo en inglés, más de 5 palabras, sé respetuoso/a, marca spoilers! Spoilers Tu comentario ha sido enviado. Tu comentario ha sido borrado. Borrar Comentario ¿Estás seguro de que quieres eliminar este comentario? Por favor inicia sesión antes de calificar una serie. Por favor inicia sesión antes de calificar una película. Tu comentario debe estar en inglés y tener 5 palabras o más. Este comentario ya no puede ser eliminado. ================================================ FILE: ui-comments/src/main/res/values-fi/strings.xml ================================================ Kommentoi Kommentit: Kommentit Ei kommentteja. Vain englanniksi, 5+ sanaa, kunnioita muita, merkitse juonipaljastukset! Juonipaljastukset Kommenttisi julkaistiin. Kommenttisi poistettiin. Poista kommentti Haluatko varmasti poistaa kommentin? Kirjaudu sisään ennen sarjojen arviointia. Kirjaudu sisään ennen elokuvien arviointia. Kommenttisi on oltava englanninkielinen ja sisältää vähintään 5 sanaa. Kommentin poisto ei ole enää mahdollista. ================================================ FILE: ui-comments/src/main/res/values-fr/strings.xml ================================================ Commentaire Commentaires : Commentaires Aucun commentaire. En anglais uniquement, plus de 5 mots, soyez respectueux, indiquez les spoilers ! Spoilers Votre commentaire a été soumis. Votre commentaire a été supprimé. Supprimer le commentaire Voulez-vous vraiment supprimer ce commentaire ? Veuillez vous connecter avant de noter une série. Veuillez vous connecter avant de noter un film. Votre commentaire doit être en anglais et avoir 5 mots ou plus. Ce commentaire ne peut plus être supprimé. ================================================ FILE: ui-comments/src/main/res/values-it/strings.xml ================================================ Commenta Commenti: Commenti Nessun commento. Solo inglese, 5+ parole, sii rispettoso, segnala gli spoiler! Spoiler Il tuo commento è stato inviato. Il tuo commento è stato eliminato. Elimina commento Sei sicuro di voler eliminare questo commento? Per favore accedi prima di valutare uno show. Per favore accedi prima di valutare un film. Il tuo commento deve essere in inglese e avere 5 o più parole. Questo commento non può più essere eliminato. ================================================ FILE: ui-comments/src/main/res/values-pl/strings.xml ================================================ Komentarz Opinie: Opinie Brak opinii. Tylko po angielsku, minimum 5 słów, szanuj innych i oznaczaj spoilery! Spoilery Twój komentarz został zapisany. Twój komentarz został usunięty. Usuń Komentarz Czy na pewno chcesz usunąć ten komentarz? Zaloguj się aby móc ocenić. Zaloguj się aby móc ocenić. Twój komentarz musi być w języku angielskim i mieć 5 lub więcej słów. Ten komentarz nie może być już usunięty. ================================================ FILE: ui-comments/src/main/res/values-pt/strings.xml ================================================ Comente Comentários: Comentários Sem comentários. Apenas inglês, mais de 5 palavras, sejam respeitosas, marquem spoilers! Spoilers O seu comentário foi publicado. Seu comentário foi excluído. Excluir comentário Você tem certeza de que deseja excluir este comentário? Por favor, inicie a sessão antes de avaliar uma série. Por favor, inicie a sessão antes de avaliar um filme. O seu comentário deve ser em inglês e ter 5 palavras ou mais. Este comentário não pode ser mais excluído. ================================================ FILE: ui-comments/src/main/res/values-ru/strings.xml ================================================ Комментировать Мнения: Мнения Нет комментариев. Только английский, 5+ слов, будьте уважительны, помечайте спойлеры! Спойлеры Ваш комментарий был отправлен. Ваш комментарий был удалён. Удалить комментарий Вы уверены, что хотите удалить этот комментарий? Пожалуйста, войдите перед оценкой сериала. Пожалуйста, войдите перед оценкой фильма. Ваш комментарий должен быть на английском языке и иметь более 5 слов. Этот комментарий больше не может быть удален. ================================================ FILE: ui-comments/src/main/res/values-sw600dp/dimens.xml ================================================ 24dp 24dp 96dp ================================================ FILE: ui-comments/src/main/res/values-tr/strings.xml ================================================ Yorum Yorumlar: Yorumlar Hiçbir yorum yok. Saygılı bir şekilde İngilizce dilinde en az 5 kelimeyle yazın ve spoilerları belirtin! Spoiler Yorumunuz gönderildi. Yorumunuz silindi. Yorumu Sil Bu yorumu silmek istediğinizden emin misiniz? Bir diziye oy vermeden önce lütfen oturum açın. Bir filme oy vermeden önce lütfen oturum açın. Yorumunuz İngilizce dilinde olmalı ve en az 5 kelime içermelidir. Bu yorum artık silinemez. ================================================ FILE: ui-comments/src/main/res/values-uk/strings.xml ================================================ Коментувати Коментарі: Коментарі Коментарі відсутні. Тільки англійською, не менше 5 слів, поважайте інших та позначайте спойлери! Спойлери Ваш коментар відправлено. Ваш коментар видалено. Видалити коментар Ви дійсно бажаєте видалити цей коментар? Увійдіть, перш ніж оцінювати. Увійдіть, перш ніж оцінювати. Ваш коментар повинен бути англійською і містити 5 слів або більше. Цей коментар більше не можна видалити. ================================================ FILE: ui-comments/src/main/res/values-vi/strings.xml ================================================ Bình luận Bình luận: Bình luận Không có bình luận nào. Chỉ tiếng Anh, 5 từ trở lên, hãy tôn trọng, đánh dấu phần tiết lộ nội dung! Tiết lộ nội dung Bình luận của bạn đã được gửi. Bình luận của bạn đã bị xóa. Xóa bình luận Bạn có chắc chắn muốn xóa bình luận này? Vui lòng đăng nhập trước khi xếp hạng một chương trình. Vui lòng đăng nhập trước khi xếp hạng phim. Bình luận của bạn phải bằng tiếng Anh và có 5 từ trở lên. Bình luận này không thể bị xóa được nữa. ================================================ FILE: ui-comments/src/main/res/values-zh/strings.xml ================================================ 评论 评论: 评论 暂无评论。 只能使用英文,5个单词以上,尊重他人,标记剧透! 剧透 您的评论已提交。 您的评论已删除。 删除评论 确实是否删除此评论? 给剧集评分前请先登录。 给电影评分前请先登录。 您的评论必须是英文且至少 5 个单词。 此评论不能再被删除。 ================================================ FILE: ui-discover/.gitignore ================================================ /build ================================================ FILE: ui-discover/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'dagger.hilt.android.plugin' apply from: '../versions.gradle' android { kotlinOptions { jvmTarget = "17" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } buildFeatures { viewBinding true } compileSdkVersion versions.compileSdk defaultConfig { minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk compileSdkVersion versions.compileSdk testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false } } namespace 'com.michaldrabik.ui_discover' } dependencies { implementation project(':common') implementation project(':data-local') implementation project(':ui-base') implementation project(':repository') implementation project(':ui-model') implementation project(':ui-navigation') implementation libs.hilt.android ksp libs.hilt.compiler testImplementation project(':common-test') testImplementation libs.bundles.testing androidTestImplementation libs.android.test.runner coreLibraryDesugaring libs.android.desugar } ================================================ FILE: ui-discover/src/main/AndroidManifest.xml ================================================ ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/DiscoverFragment.kt ================================================ package com.michaldrabik.ui_discover import android.os.Bundle import android.view.View import android.view.ViewGroup.MarginLayoutParams import androidx.activity.addCallback import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateMargins import androidx.core.view.updatePadding import androidx.fragment.app.clearFragmentResultListener import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy import androidx.recyclerview.widget.SimpleItemAnimator import com.michaldrabik.common.Config import com.michaldrabik.ui_base.BaseFragment import com.michaldrabik.ui_base.common.OnTabReselectedListener import com.michaldrabik.ui_base.common.sheets.context_menu.ContextMenuBottomSheet import com.michaldrabik.ui_base.utilities.extensions.add import com.michaldrabik.ui_base.utilities.extensions.colorFromAttr import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.disableUi import com.michaldrabik.ui_base.utilities.extensions.doOnApplyWindowInsets import com.michaldrabik.ui_base.utilities.extensions.enableUi import com.michaldrabik.ui_base.utilities.extensions.fadeIn import com.michaldrabik.ui_base.utilities.extensions.fadeOut import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.navigateToSafe import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.openWebUrl import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.extensions.withSpanSizeLookup import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_discover.databinding.FragmentDiscoverBinding import com.michaldrabik.ui_discover.helpers.DiscoverLayoutManagerProvider import com.michaldrabik.ui_discover.recycler.DiscoverAdapter import com.michaldrabik.ui_discover.recycler.DiscoverListItem import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_SHOW_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_ITEM_MENU import dagger.hilt.android.AndroidEntryPoint import kotlin.random.Random @AndroidEntryPoint internal class DiscoverFragment : BaseFragment(R.layout.fragment_discover), OnTabReselectedListener { companion object { const val REQUEST_DISCOVER_FILTERS = "REQUEST_DISCOVER_FILTERS" } override val navigationId = R.id.discoverFragment override val viewModel by viewModels() private val binding by viewBinding(FragmentDiscoverBinding::bind) private val swipeRefreshStartOffset by lazy { requireContext().dimenToPx(R.dimen.swipeRefreshStartOffset) } private val swipeRefreshEndOffset by lazy { requireContext().dimenToPx(R.dimen.swipeRefreshEndOffset) } private var adapter: DiscoverAdapter? = null private var layoutManager: GridLayoutManager? = null private var searchViewPosition = 0F private var tabsViewPosition = 0F private var filtersViewPosition = 0F override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) savedInstanceState?.let { searchViewPosition = it.getFloat("ARG_SEARCH_POS", 0F) tabsViewPosition = it.getFloat("ARG_TABS_POS", 0F) filtersViewPosition = it.getFloat("ARG_FILTERS_POS", 0F) } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putFloat("ARG_SEARCH_POS", searchViewPosition) outState.putFloat("ARG_TABS_POS", tabsViewPosition) outState.putFloat("ARG_FILTERS_POS", filtersViewPosition) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() setupRecycler() setupSwipeRefresh() setupStatusBar() launchAndRepeatStarted( { viewModel.uiState.collect { render(it) } }, { viewModel.messageFlow.collect { showSnack(it) } }, doAfterLaunch = { viewModel.loadShows() } ) setFragmentResultListener(REQUEST_DISCOVER_FILTERS) { _, _ -> viewModel.loadShows(scrollToTop = true, skipCache = true, instantProgress = true) } } override fun onResume() { super.onResume() showNavigation() } override fun onPause() { enableUi() with(binding) { searchViewPosition = discoverSearchView.translationY tabsViewPosition = discoverModeTabsView.translationY filtersViewPosition = discoverFiltersView.translationY } super.onPause() } override fun onDestroyView() { adapter = null layoutManager = null super.onDestroyView() } private fun setupView() { with(binding) { discoverSearchView.run { settingsIconVisible = true isEnabled = false onClick { openSearch() } onSettingsClickListener = { hideNavigation() navigateToSafe(R.id.actionDiscoverFragmentToSettingsFragment) } translationY = searchViewPosition } discoverModeTabsView.run { visibleIf(moviesEnabled) translationY = tabsViewPosition onModeSelected = { mode = it } selectShows() } discoverFiltersView.run { translationY = filtersViewPosition onGenresChipClick = { navigateToSafe(R.id.actionDiscoverFragmentToFiltersGenres) } onNetworksChipClick = { navigateToSafe(R.id.actionDiscoverFragmentToFiltersNetworks) } onFeedChipClick = { navigateToSafe(R.id.actionDiscoverFragmentToFiltersFeed) } onHideAnticipatedChipClick = { viewModel.toggleAnticipated() } onHideCollectionChipClick = { viewModel.toggleCollection() } } } } private fun setupRecycler() { layoutManager = DiscoverLayoutManagerProvider.provideLayoutManager(requireContext()) adapter = DiscoverAdapter( itemClickListener = { /*when (it.image.type) { ImageType.TWITTER -> openWebUrl(Config.TWITTER_URL) ImageType.PREMIUM -> openPremium() else -> openDetails(it) }*/ openDetails(it) }, itemLongClickListener = { item -> openShowMenu(item.show) }, missingImageListener = { ids, force -> viewModel.loadMissingImage(ids, force) }, listChangeListener = { binding.discoverRecycler.scrollToPosition(0) }, twitterCancelClickListener = { viewModel.cancelTwitterAd() } ).apply { stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY } binding.discoverRecycler.apply { adapter = this@DiscoverFragment.adapter layoutManager = this@DiscoverFragment.layoutManager (itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false setHasFixedSize(true) } } private fun setupSwipeRefresh() { binding.discoverSwipeRefresh.apply { val color = requireContext().colorFromAttr(R.attr.colorAccent) setProgressBackgroundColorSchemeColor(requireContext().colorFromAttr(R.attr.colorSearchViewBackground)) setColorSchemeColors(color, color, color) setOnRefreshListener { searchViewPosition = 0F tabsViewPosition = 0F viewModel.loadShows(pullToRefresh = true) } } } private fun setupStatusBar() { with(binding) { discoverRoot.doOnApplyWindowInsets { _, insets, _, _ -> val tabletOffset = if (isTablet) dimenToPx(R.dimen.spaceMedium) else 0 val statusBarSize = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top + tabletOffset val recyclerPadding = if (moviesEnabled) R.dimen.discoverRecyclerPadding else R.dimen.discoverRecyclerPaddingNoTabs val filtersPadding = if (moviesEnabled) R.dimen.collectionFiltersMargin else R.dimen.collectionFiltersMarginNoTabs discoverRecycler .updatePadding(top = statusBarSize + dimenToPx(recyclerPadding)) (discoverSearchView.layoutParams as MarginLayoutParams) .updateMargins(top = statusBarSize + dimenToPx(R.dimen.spaceMedium)) (discoverModeTabsView.layoutParams as MarginLayoutParams) .updateMargins(top = statusBarSize + dimenToPx(R.dimen.collectionTabsMargin)) (discoverFiltersView.layoutParams as MarginLayoutParams) .updateMargins(top = statusBarSize + dimenToPx(filtersPadding)) discoverSwipeRefresh.setProgressViewOffset( true, swipeRefreshStartOffset + statusBarSize, swipeRefreshEndOffset ) } } } override fun setupBackPressed() { val dispatcher = requireActivity().onBackPressedDispatcher dispatcher.addCallback(viewLifecycleOwner) { isEnabled = false activity?.onBackPressed() } } private fun openSearch() { disableUi() hideNavigation() with(binding) { discoverModeTabsView.fadeOut(duration = 200).add(animations) discoverFiltersView.fadeOut(duration = 200).add(animations) discoverRecycler.fadeOut(duration = 200) { navigateToSafe(R.id.actionDiscoverFragmentToSearchFragment) }.add(animations) } } private fun openDetails(item: DiscoverListItem) { if (!binding.discoverRecycler.isEnabled) return disableUi() hideNavigation() animateItemsExit(item) } private fun openPremium() { if (!binding.discoverRecycler.isEnabled) return disableUi() hideNavigation() navigateToSafe(R.id.actionDiscoverFragmentToPremium, Bundle.EMPTY) } private fun openShowMenu(show: Show) { if (!binding.discoverRecycler.isEnabled) return setFragmentResultListener(REQUEST_ITEM_MENU) { requestKey, _ -> if (requestKey == REQUEST_ITEM_MENU) { viewModel.loadShows() } clearFragmentResultListener(REQUEST_ITEM_MENU) } val bundle = ContextMenuBottomSheet.createBundle(show.ids.trakt) navigateToSafe(R.id.actionDiscoverFragmentToItemMenu, bundle) } private fun animateItemsExit(item: DiscoverListItem) { with(binding) { discoverSearchView.fadeOut().add(animations) discoverModeTabsView.fadeOut().add(animations) discoverFiltersView.fadeOut().add(animations) val clickedIndex = adapter?.indexOf(item) ?: 0 val itemsCount = adapter?.itemCount ?: 0 (0..itemsCount).forEach { if (it != clickedIndex) { val view = discoverRecycler.findViewHolderForAdapterPosition(it) view?.let { v -> val randomDelay = Random.nextLong(50, 200) v.itemView.fadeOut(duration = 150, startDelay = randomDelay).add(animations) } } } val clickedView = discoverRecycler.findViewHolderForAdapterPosition(clickedIndex) clickedView?.itemView?.fadeOut( duration = 150, startDelay = 350, endAction = { if (!isResumed) return@fadeOut val bundle = Bundle().apply { putLong(ARG_SHOW_ID, item.show.traktId) } navigateToSafe(R.id.actionDiscoverFragmentToShowDetailsFragment, bundle) } ).add(animations) } } private fun render(uiState: DiscoverUiState) { uiState.run { with(binding) { items?.let { val resetScroll = resetScroll?.consume() == true adapter?.setItems(it, resetScroll) layoutManager?.withSpanSizeLookup { pos -> adapter?.getItems()?.get(pos)?.image?.type?.getSpan(isTablet)!! } discoverRecycler.fadeIn(200, withHardware = true) } isSyncing?.let { discoverSearchView.setTraktProgress(it) discoverSearchView.isEnabled = !it } isLoading?.let { discoverSearchView.isEnabled = !it discoverSwipeRefresh.isRefreshing = it discoverModeTabsView.isEnabled = !it discoverFiltersView.isEnabled = !it discoverRecycler.isEnabled = !it } filters?.let { if (discoverFiltersView.visibility != View.VISIBLE) { discoverFiltersView.visible() } discoverFiltersView.bind(it) } } } } override fun onTabReselected() = openSearch() } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/DiscoverUiState.kt ================================================ package com.michaldrabik.ui_discover import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_discover.recycler.DiscoverListItem import com.michaldrabik.ui_model.DiscoverFilters data class DiscoverUiState( val items: List? = null, val isLoading: Boolean? = null, val isSyncing: Boolean? = null, var filters: DiscoverFilters? = null, var resetScroll: Event? = null, ) ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/DiscoverViewModel.kt ================================================ package com.michaldrabik.ui_discover import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting.Companion.PRIVATE import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.WorkInfo import androidx.work.WorkManager import com.michaldrabik.common.Config import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.repository.images.ShowImagesProvider import com.michaldrabik.ui_base.trakt.TraktSyncWorker import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.utilities.extensions.findReplace import com.michaldrabik.ui_base.utilities.extensions.rethrowCancellation import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_discover.cases.DiscoverFiltersCase import com.michaldrabik.ui_discover.cases.DiscoverShowsCase import com.michaldrabik.ui_discover.cases.DiscoverTwitterCase import com.michaldrabik.ui_discover.recycler.DiscoverListItem import com.michaldrabik.ui_model.DiscoverFilters import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageFamily.MOVIE import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @HiltViewModel internal class DiscoverViewModel @Inject constructor( private val showsCase: DiscoverShowsCase, private val filtersCase: DiscoverFiltersCase, private val twitterCase: DiscoverTwitterCase, private val imagesProvider: ShowImagesProvider, workManager: WorkManager, ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val itemsState = MutableStateFlow?>(null) private val loadingState = MutableStateFlow(false) private val syncingState = MutableStateFlow(false) private val filtersState = MutableStateFlow(null) private val scrollState = MutableStateFlow(Event(false)) @VisibleForTesting(otherwise = PRIVATE) var lastPullToRefreshMs = 0L private var initialFilters: DiscoverFilters? = null init { workManager.getWorkInfosByTagLiveData(TraktSyncWorker.TAG_ID).observeForever { work -> syncingState.value = work.any { it.state == WorkInfo.State.RUNNING } } viewModelScope.launch { initialFilters = filtersCase.loadFilters() } } fun loadShows( pullToRefresh: Boolean = false, scrollToTop: Boolean = false, skipCache: Boolean = false, instantProgress: Boolean = false, ) { loadingState.value = true if (pullToRefresh && nowUtcMillis() - lastPullToRefreshMs < Config.PULL_TO_REFRESH_COOLDOWN_MS) { loadingState.value = false return } loadingState.value = pullToRefresh viewModelScope.launch { val progressJob = launch { delay(if (pullToRefresh || instantProgress) 0 else 750) loadingState.value = true } try { val filters = filtersCase.loadFilters() filtersState.value = filters if (!pullToRefresh && !skipCache) { val shows = showsCase.loadCachedShows(filters) itemsState.value = shows scrollState.value = Event(scrollToTop) } if (pullToRefresh || skipCache || !showsCase.isCacheValid()) { val shows = showsCase.loadRemoteShows(filters) itemsState.value = shows scrollState.value = Event(scrollToTop) initialFilters = filters } if (pullToRefresh) { lastPullToRefreshMs = nowUtcMillis() } } catch (error: Throwable) { onError(error) } finally { loadingState.value = false progressJob.cancel() } } } fun loadMissingImage(item: DiscoverListItem, force: Boolean) { fun updateItem(newItem: DiscoverListItem) { val currentItems = uiState.value.items?.toMutableList() currentItems?.findReplace(newItem) { it.isSameAs(newItem) } itemsState.value = currentItems scrollState.value = Event(false) } viewModelScope.launch { val loadingJob = launch { delay(750) updateItem(item.copy(isLoading = true)) } try { val image = imagesProvider.loadRemoteImage(item.show, item.image.type, force) updateItem(item.copy(isLoading = false, image = image)) } catch (t: Throwable) { updateItem(item.copy(isLoading = false, image = Image.createUnavailable(item.image.type, MOVIE))) rethrowCancellation(t) } finally { loadingJob.cancel() } } } fun cancelTwitterAd() { twitterCase.cancelTwitterAd() loadShows() } fun toggleAnticipated() { viewModelScope.launch { filtersCase.toggleAnticipated() loadShows(scrollToTop = true, skipCache = true, instantProgress = true) } } fun toggleCollection() { viewModelScope.launch { filtersCase.toggleCollection() loadShows(scrollToTop = true, skipCache = true, instantProgress = true) } } private suspend fun onError(error: Throwable) { if (error !is CancellationException) { messageChannel.send(MessageEvent.Error(R.string.errorCouldNotLoadDiscover)) Timber.e(error) } rethrowCancellation(error) } override fun onCleared() { filtersCase.revertFilters( initialFilters = initialFilters, currentFilters = filtersState.value ) super.onCleared() } val uiState = combine( itemsState, loadingState, syncingState, filtersState, scrollState ) { s1, s2, s3, s4, s5 -> DiscoverUiState( items = s1, isLoading = s2, isSyncing = s3, filters = s4, resetScroll = s5 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = DiscoverUiState() ) } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/cases/DiscoverFiltersCase.kt ================================================ package com.michaldrabik.ui_discover.cases import android.content.Context import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.common.AppScopeProvider import com.michaldrabik.ui_base.utilities.extensions.rethrowCancellation import com.michaldrabik.ui_model.DiscoverFilters import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class DiscoverFiltersCase @Inject constructor( @ApplicationContext private val context: Context, private val dispatchers: CoroutineDispatchers, private val settingsRepository: SettingsRepository, ) { suspend fun loadFilters(): DiscoverFilters = withContext(dispatchers.IO) { val settings = settingsRepository.load() DiscoverFilters( feedOrder = settings.discoverFilterFeed, hideAnticipated = !settings.showAnticipatedShows, hideCollection = !settings.showCollectionShows, genres = settings.discoverFilterGenres.toList(), networks = settings.discoverFilterNetworks.toList() ) } fun revertFilters( initialFilters: DiscoverFilters?, currentFilters: DiscoverFilters?, ) { (context as AppScopeProvider).appScope.launch { try { if (initialFilters != currentFilters) { initialFilters?.let { initial -> val settings = settingsRepository.load() settingsRepository.update( settings.copy( discoverFilterFeed = initial.feedOrder, discoverFilterGenres = initial.genres, discoverFilterNetworks = initial.networks, showAnticipatedShows = !initial.hideAnticipated, showCollectionShows = !initial.hideCollection ) ) } } } catch (error: Throwable) { rethrowCancellation(error) } } } suspend fun toggleAnticipated() { withContext(dispatchers.IO) { val settings = settingsRepository.load() settingsRepository.update( settings.copy(showAnticipatedShows = !settings.showAnticipatedShows) ) } } suspend fun toggleCollection() { withContext(dispatchers.IO) { val settings = settingsRepository.load() settingsRepository.update( settings.copy(showCollectionShows = !settings.showCollectionShows) ) } } } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/cases/DiscoverShowsCase.kt ================================================ package com.michaldrabik.ui_discover.cases import com.michaldrabik.common.Config import com.michaldrabik.common.ConfigVariant import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.repository.TranslationsRepository import com.michaldrabik.repository.images.ShowImagesProvider import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.repository.shows.ShowsRepository import com.michaldrabik.ui_discover.helpers.itemtype.ImageTypeProvider import com.michaldrabik.ui_discover.recycler.DiscoverListItem import com.michaldrabik.ui_model.DiscoverFilters import com.michaldrabik.ui_model.DiscoverSortOrder import com.michaldrabik.ui_model.DiscoverSortOrder.HOT import com.michaldrabik.ui_model.DiscoverSortOrder.NEWEST import com.michaldrabik.ui_model.DiscoverSortOrder.RATING import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.Show import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped internal class DiscoverShowsCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val showsRepository: ShowsRepository, private val imageTypeProvider: ImageTypeProvider, private val imagesProvider: ShowImagesProvider, private val translationsRepository: TranslationsRepository, private val settingsRepository: SettingsRepository, ) { suspend fun isCacheValid() = withContext(dispatchers.IO) { showsRepository.discoverShows.isCacheValid() } suspend fun loadCachedShows(filters: DiscoverFilters) = withContext(dispatchers.IO) { val myShowsIds = async { showsRepository.myShows.loadAllIds() } val watchlistShowsIds = async { showsRepository.watchlistShows.loadAllIds() } val archiveShowsIds = async { showsRepository.hiddenShows.loadAllIds() } val cachedShows = async { showsRepository.discoverShows.loadAllCached() } prepareItems( shows = cachedShows.await(), myShowsIds = myShowsIds.await(), watchlistShowsIds = watchlistShowsIds.await(), hiddenShowsIds = archiveShowsIds.await(), filters = filters ) } suspend fun loadRemoteShows(filters: DiscoverFilters) = withContext(dispatchers.IO) { val showAnticipated = !filters.hideAnticipated val showCollection = !filters.hideCollection val genres = filters.genres.toList() val networks = filters.networks.toList() val myAsync = async { showsRepository.myShows.loadAllIds() } val watchlistSync = async { showsRepository.watchlistShows.loadAllIds() } val archiveAsync = async { showsRepository.hiddenShows.loadAllIds() } val (myIds, watchlistIds, hiddenIds) = awaitAll(myAsync, watchlistSync, archiveAsync) val collectionSize = myIds.size + watchlistIds.size + hiddenIds.size val remoteShows = showsRepository.discoverShows.loadAllRemote( showAnticipated, showCollection, collectionSize, genres, networks ) showsRepository.discoverShows.cacheDiscoverShows(remoteShows) prepareItems( shows = remoteShows, myShowsIds = myIds, watchlistShowsIds = watchlistIds, hiddenShowsIds = hiddenIds, filters = filters ) } private suspend fun prepareItems( shows: List, myShowsIds: List, watchlistShowsIds: List, hiddenShowsIds: List, filters: DiscoverFilters?, ) = coroutineScope { val language = translationsRepository.getLanguage() val collectionIds = myShowsIds + watchlistShowsIds + hiddenShowsIds shows .filter { it.traktId !in hiddenShowsIds } .filter { if (filters?.hideCollection == false) true else it.traktId !in collectionIds } .sortedBy(filters?.feedOrder ?: HOT) .mapIndexed { index, show -> async { val itemType = imageTypeProvider.getImageType(index) val image = imagesProvider.findCachedImage(show, itemType) val translation = loadTranslation(language, itemType, show) DiscoverListItem( show = show, image = image, isFollowed = show.traktId in myShowsIds, isWatchlist = show.traktId in watchlistShowsIds, translation = translation ) } }.awaitAll() .toMutableList() //.apply { insertTwitterAdItem(this) } //.apply { insertPremiumAdItem(this) } .toList() } private fun insertTwitterAdItem(items: MutableList) { val isEnabled = settingsRepository.isTwitterAdEnabled val isTimePassed = (nowUtcMillis() - settingsRepository.installTimestamp) > ConfigVariant.TWITTER_AD_DELAY if (!isEnabled || !isTimePassed) return val twitterAd = DiscoverListItem(Show.EMPTY, Image.createUnknown(ImageType.TWITTER)) if (items.size >= imageTypeProvider.twitterAdPosition) { items.add(imageTypeProvider.twitterAdPosition, twitterAd) } else { items.add(twitterAd) } } private fun insertPremiumAdItem(items: MutableList) { val isPremium = settingsRepository.isPremium val isTimePassed = (nowUtcMillis() - settingsRepository.installTimestamp) > ConfigVariant.PREMIUM_AD_DELAY if (isPremium || !isTimePassed) return val premiumAd = DiscoverListItem(Show.EMPTY, Image.createUnknown(ImageType.PREMIUM)) var position = imageTypeProvider.premiumAdPosition if (items.size >= position) { if (items.any { it.image.type == ImageType.TWITTER }) { position++ } items.add(position, premiumAd) } else if (items.isNotEmpty()) { items.add(premiumAd) } } private suspend fun loadTranslation(language: String, itemType: ImageType, show: Show) = if (language == Config.DEFAULT_LANGUAGE || itemType == ImageType.POSTER) null else translationsRepository.loadTranslation(show, language, true) private fun List.sortedBy(order: DiscoverSortOrder) = when (order) { HOT -> this RATING -> this.sortedWith(compareByDescending { it.votes }.thenBy { it.rating }) NEWEST -> this.sortedByDescending { it.year } } } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/cases/DiscoverTwitterCase.kt ================================================ package com.michaldrabik.ui_discover.cases import com.michaldrabik.repository.settings.SettingsRepository import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class DiscoverTwitterCase @Inject constructor( private val settingsRepository: SettingsRepository, ) { fun cancelTwitterAd() { settingsRepository.isTwitterAdEnabled = false } } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/di/DiscoverModule.kt ================================================ package com.michaldrabik.ui_discover.di import android.content.Context import com.michaldrabik.ui_base.utilities.extensions.isTablet import com.michaldrabik.ui_discover.helpers.itemtype.ImageTypeProvider import com.michaldrabik.ui_discover.helpers.itemtype.PhoneImageTypeProvider import com.michaldrabik.ui_discover.helpers.itemtype.TabletImageTypeProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) class DiscoverModule { @Provides internal fun providesItemTypeProvider(@ApplicationContext context: Context): ImageTypeProvider { return if (context.isTablet()) { TabletImageTypeProvider() } else { PhoneImageTypeProvider() } } } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/filters/feed/DiscoverFiltersFeedBottomSheet.kt ================================================ package com.michaldrabik.ui_discover.filters.feed import android.annotation.SuppressLint import android.os.Bundle import android.view.View import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.screenHeight import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_discover.DiscoverFragment.Companion.REQUEST_DISCOVER_FILTERS import com.michaldrabik.ui_discover.R import com.michaldrabik.ui_discover.databinding.ViewDiscoverFiltersFeedBinding import com.michaldrabik.ui_discover.filters.feed.DiscoverFiltersFeedUiEvent.ApplyFilters import com.michaldrabik.ui_discover.filters.feed.DiscoverFiltersFeedUiEvent.CloseFilters import com.michaldrabik.ui_model.DiscoverSortOrder import com.michaldrabik.ui_model.DiscoverSortOrder.HOT import com.michaldrabik.ui_model.DiscoverSortOrder.NEWEST import com.michaldrabik.ui_model.DiscoverSortOrder.RATING import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint internal class DiscoverFiltersFeedBottomSheet : BaseBottomSheetFragment(R.layout.view_discover_filters_feed) { private val viewModel by viewModels() private val binding by viewBinding(ViewDiscoverFiltersFeedBinding::bind) override fun getTheme(): Int = R.style.CustomBottomSheetDialog override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() launchAndRepeatStarted( { viewModel.uiState.collect { render(it) } }, { viewModel.eventFlow.collect { handleEvent(it) } } ) } @SuppressLint("SetTextI18n") private fun setupView() { val behavior: BottomSheetBehavior<*> = (dialog as BottomSheetDialog).behavior behavior.skipCollapsed = true behavior.maxHeight = (screenHeight() * 0.9).toInt() with(binding) { applyButton.onClick { saveFeedOrder() } } } private fun saveFeedOrder() { with(binding) { val feedOrder = when { feedChipHot.isChecked -> HOT feedChipTopRated.isChecked -> RATING feedChipRecent.isChecked -> NEWEST else -> throw IllegalStateException() } viewModel.saveFeedOrder(feedOrder) } } private fun render(uiState: DiscoverFiltersFeedUiState) { with(uiState) { feedOrder?.let { renderFilters(it) } } } private fun renderFilters(feedOrder: DiscoverSortOrder) { with(binding) { feedChipHot.isChecked = feedOrder == HOT feedChipTopRated.isChecked = feedOrder == RATING feedChipRecent.isChecked = feedOrder == NEWEST } } private fun handleEvent(event: Event<*>) { when (event) { is ApplyFilters -> { setFragmentResult(REQUEST_DISCOVER_FILTERS, Bundle.EMPTY) closeSheet() } is CloseFilters -> closeSheet() } } } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/filters/feed/DiscoverFiltersFeedUiEvent.kt ================================================ // ktlint-disable filename package com.michaldrabik.ui_discover.filters.feed import com.michaldrabik.ui_base.utilities.events.Event internal sealed class DiscoverFiltersFeedUiEvent(action: T) : Event(action) { object ApplyFilters : DiscoverFiltersFeedUiEvent(Unit) object CloseFilters : DiscoverFiltersFeedUiEvent(Unit) } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/filters/feed/DiscoverFiltersFeedUiState.kt ================================================ package com.michaldrabik.ui_discover.filters.feed import com.michaldrabik.ui_model.DiscoverSortOrder internal data class DiscoverFiltersFeedUiState( val feedOrder: DiscoverSortOrder? = null, val isLoading: Boolean? = null, ) ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/filters/feed/DiscoverFiltersFeedViewModel.kt ================================================ package com.michaldrabik.ui_discover.filters.feed import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_discover.filters.feed.DiscoverFiltersFeedUiEvent.ApplyFilters import com.michaldrabik.ui_discover.filters.feed.DiscoverFiltersFeedUiEvent.CloseFilters import com.michaldrabik.ui_model.DiscoverSortOrder import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel internal class DiscoverFiltersFeedViewModel @Inject constructor( private val settingsRepository: SettingsRepository, ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val feedOrderState = MutableStateFlow(null) private val loadingState = MutableStateFlow(false) init { loadFilters() } private fun loadFilters() { viewModelScope.launch { val settings = settingsRepository.load() feedOrderState.value = settings.discoverFilterFeed } } fun saveFeedOrder(feedOrder: DiscoverSortOrder) { viewModelScope.launch { if (feedOrder == feedOrderState.value) { eventChannel.send(CloseFilters) return@launch } val settings = settingsRepository.load() settingsRepository.update( settings.copy(discoverFilterFeed = feedOrder) ) eventChannel.send(ApplyFilters) } } val uiState = combine( feedOrderState, loadingState, ) { s1, s2 -> DiscoverFiltersFeedUiState( feedOrder = s1, isLoading = s2, ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = DiscoverFiltersFeedUiState() ) } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/filters/genres/DiscoverFiltersGenresBottomSheet.kt ================================================ package com.michaldrabik.ui_discover.filters.genres import android.annotation.SuppressLint import android.os.Bundle import android.view.View import androidx.core.content.ContextCompat import androidx.core.view.forEach import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.chip.Chip import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.screenHeight import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_discover.DiscoverFragment.Companion.REQUEST_DISCOVER_FILTERS import com.michaldrabik.ui_discover.R import com.michaldrabik.ui_discover.databinding.ViewDiscoverFiltersGenresBinding import com.michaldrabik.ui_discover.filters.genres.DiscoverFiltersGenresUiEvent.ApplyFilters import com.michaldrabik.ui_discover.filters.genres.DiscoverFiltersGenresUiEvent.CloseFilters import com.michaldrabik.ui_model.Genre import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint internal class DiscoverFiltersGenresBottomSheet : BaseBottomSheetFragment(R.layout.view_discover_filters_genres) { private val viewModel by viewModels() private val binding by viewBinding(ViewDiscoverFiltersGenresBinding::bind) override fun getTheme(): Int = R.style.CustomBottomSheetDialog override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() launchAndRepeatStarted( { viewModel.uiState.collect { render(it) } }, { viewModel.eventFlow.collect { handleEvent(it) } } ) } @SuppressLint("SetTextI18n") private fun setupView() { val behavior: BottomSheetBehavior<*> = (dialog as BottomSheetDialog).behavior behavior.skipCollapsed = true behavior.maxHeight = (screenHeight() * 0.9).toInt() with(binding) { applyButton.onClick { saveGenres() } clearButton.onClick { renderGenres(emptyList()) } } } private fun saveGenres() { with(binding) { val genres = mutableListOf().apply { genresChipGroup.forEach { chip -> if ((chip as Chip).isChecked) { add(Genre.valueOf(chip.tag.toString())) } } } viewModel.saveGenres(genres) } } private fun render(uiState: DiscoverFiltersGenresUiState) { with(uiState) { genres?.let { renderGenres(it) } } } private fun renderGenres(genres: List) { binding.genresChipGroup.removeAllViews() binding.clearButton.visibleIf(genres.isNotEmpty()) val genresNames = genres.map { it.name } Genre.values() .sortedBy { requireContext().getString(it.displayName) } .forEach { genre -> val chip = Chip(requireContext()).apply { tag = genre.name text = requireContext().getString(genre.displayName) isCheckable = true isCheckedIconVisible = false setEnsureMinTouchTargetSize(false) shapeAppearanceModel = shapeAppearanceModel.toBuilder() .setAllCornerSizes(100f) .build() chipBackgroundColor = ContextCompat.getColorStateList(context, R.color.selector_discover_chip_background) setChipStrokeColorResource(R.color.selector_discover_chip_text) setChipStrokeWidthResource(R.dimen.discoverFilterChipStroke) setTextColor(ContextCompat.getColorStateList(context, R.color.selector_discover_chip_text)) isChecked = genre.name in genresNames } binding.genresChipGroup.addView(chip) } } private fun handleEvent(event: Event<*>) { when (event) { is ApplyFilters -> { setFragmentResult(REQUEST_DISCOVER_FILTERS, Bundle.EMPTY) closeSheet() } is CloseFilters -> closeSheet() } } } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/filters/genres/DiscoverFiltersGenresUiEvent.kt ================================================ // ktlint-disable filename package com.michaldrabik.ui_discover.filters.genres import com.michaldrabik.ui_base.utilities.events.Event internal sealed class DiscoverFiltersGenresUiEvent(action: T) : Event(action) { object ApplyFilters : DiscoverFiltersGenresUiEvent(Unit) object CloseFilters : DiscoverFiltersGenresUiEvent(Unit) } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/filters/genres/DiscoverFiltersGenresUiState.kt ================================================ package com.michaldrabik.ui_discover.filters.genres import com.michaldrabik.ui_model.Genre internal data class DiscoverFiltersGenresUiState( val genres: List? = null, val isLoading: Boolean? = null, ) ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/filters/genres/DiscoverFiltersGenresViewModel.kt ================================================ package com.michaldrabik.ui_discover.filters.genres import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_discover.filters.genres.DiscoverFiltersGenresUiEvent.ApplyFilters import com.michaldrabik.ui_discover.filters.genres.DiscoverFiltersGenresUiEvent.CloseFilters import com.michaldrabik.ui_model.Genre import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel internal class DiscoverFiltersGenresViewModel @Inject constructor( private val settingsRepository: SettingsRepository, ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val genresState = MutableStateFlow?>(null) private val loadingState = MutableStateFlow(false) init { loadGenres() } private fun loadGenres() { viewModelScope.launch { val settings = settingsRepository.load() genresState.value = settings.discoverFilterGenres.toList() } } fun saveGenres(genres: List) { viewModelScope.launch { if (genres == genresState.value) { eventChannel.send(CloseFilters) return@launch } val settings = settingsRepository.load() settingsRepository.update( settings.copy( discoverFilterGenres = genres.toList(), ) ) eventChannel.send(ApplyFilters) } } val uiState = combine( genresState, loadingState, ) { s1, s2 -> DiscoverFiltersGenresUiState( genres = s1, isLoading = s2, ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = DiscoverFiltersGenresUiState() ) } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/filters/networks/DiscoverFiltersNetworksBottomSheet.kt ================================================ package com.michaldrabik.ui_discover.filters.networks import android.annotation.SuppressLint import android.os.Bundle import android.view.View import androidx.core.content.ContextCompat import androidx.core.view.forEach import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.chip.Chip import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.utilities.NetworkIconProvider import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.screenHeight import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_discover.DiscoverFragment.Companion.REQUEST_DISCOVER_FILTERS import com.michaldrabik.ui_discover.R import com.michaldrabik.ui_discover.databinding.ViewDiscoverFiltersNetworksBinding import com.michaldrabik.ui_discover.filters.networks.DiscoverFiltersNetworksUiEvent.ApplyFilters import com.michaldrabik.ui_discover.filters.networks.DiscoverFiltersNetworksUiEvent.CloseFilters import com.michaldrabik.ui_model.Network import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint internal class DiscoverFiltersNetworksBottomSheet : BaseBottomSheetFragment(R.layout.view_discover_filters_networks) { private val viewModel by viewModels() private val binding by viewBinding(ViewDiscoverFiltersNetworksBinding::bind) @Inject lateinit var networkIconProvider: NetworkIconProvider override fun getTheme(): Int = R.style.CustomBottomSheetDialog override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() launchAndRepeatStarted( { viewModel.uiState.collect { render(it) } }, { viewModel.eventFlow.collect { handleEvent(it) } } ) } @SuppressLint("SetTextI18n") private fun setupView() { val behavior: BottomSheetBehavior<*> = (dialog as BottomSheetDialog).behavior behavior.skipCollapsed = true behavior.maxHeight = (screenHeight() * 0.9).toInt() with(binding) { applyButton.onClick { saveNetworks() } clearButton.onClick { renderNetworks(emptyList()) } } } private fun saveNetworks() { with(binding) { val networks = mutableListOf().apply { networksChipGroup.forEach { chip -> if ((chip as Chip).isChecked) { add(Network.valueOf(chip.tag.toString())) } } } viewModel.saveNetworks(networks) } } private fun render(uiState: DiscoverFiltersNetworksUiState) { with(uiState) { networks?.let { renderNetworks(it) } } } private fun renderNetworks(networks: List) { binding.networksChipGroup.removeAllViews() binding.clearButton.visibleIf(networks.isNotEmpty()) val networksNames = networks.map { it.name } Network.values() .sortedBy { it.name } .forEach { network -> val icon = networkIconProvider.getIcon(network) val chip = Chip(requireContext()).apply { tag = network.name text = network.channels.first() isCheckable = true isCheckedIconVisible = false shapeAppearanceModel = shapeAppearanceModel.toBuilder() .setAllCornerSizes(100f) .build() setEnsureMinTouchTargetSize(false) setChipIconResource(icon) chipBackgroundColor = ContextCompat.getColorStateList(requireContext(), R.color.selector_discover_chip_background) setChipStrokeColorResource(R.color.selector_discover_chip_text) setChipStrokeWidthResource(R.dimen.discoverFilterChipStroke) setTextColor(ContextCompat.getColorStateList(requireContext(), R.color.selector_discover_chip_text)) isChecked = network.name in networksNames } binding.networksChipGroup.addView(chip) } } private fun handleEvent(event: Event<*>) { when (event) { is ApplyFilters -> { setFragmentResult(REQUEST_DISCOVER_FILTERS, Bundle.EMPTY) closeSheet() } is CloseFilters -> closeSheet() } } } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/filters/networks/DiscoverFiltersNetworksUiEvent.kt ================================================ // ktlint-disable filename package com.michaldrabik.ui_discover.filters.networks import com.michaldrabik.ui_base.utilities.events.Event internal sealed class DiscoverFiltersNetworksUiEvent(action: T) : Event(action) { object ApplyFilters : DiscoverFiltersNetworksUiEvent(Unit) object CloseFilters : DiscoverFiltersNetworksUiEvent(Unit) } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/filters/networks/DiscoverFiltersNetworksUiState.kt ================================================ package com.michaldrabik.ui_discover.filters.networks import com.michaldrabik.ui_model.Network internal data class DiscoverFiltersNetworksUiState( val networks: List? = null, val isLoading: Boolean? = null, ) ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/filters/networks/DiscoverFiltersNetworksViewModel.kt ================================================ package com.michaldrabik.ui_discover.filters.networks import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_discover.filters.networks.DiscoverFiltersNetworksUiEvent.ApplyFilters import com.michaldrabik.ui_discover.filters.networks.DiscoverFiltersNetworksUiEvent.CloseFilters import com.michaldrabik.ui_model.Network import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel internal class DiscoverFiltersNetworksViewModel @Inject constructor( private val settingsRepository: SettingsRepository, ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val networksState = MutableStateFlow?>(null) private val loadingState = MutableStateFlow(false) init { loadNetworks() } private fun loadNetworks() { viewModelScope.launch { val settings = settingsRepository.load() networksState.value = settings.discoverFilterNetworks.toList() } } fun saveNetworks(networks: List) { viewModelScope.launch { if (networks == networksState.value) { eventChannel.send(CloseFilters) return@launch } val settings = settingsRepository.load() settingsRepository.update( settings.copy( discoverFilterNetworks = networks.toList(), ) ) eventChannel.send(ApplyFilters) } } val uiState = combine( networksState, loadingState, ) { s1, s2 -> DiscoverFiltersNetworksUiState( networks = s1, isLoading = s2, ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = DiscoverFiltersNetworksUiState() ) } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/filters/views/DiscoverFiltersView.kt ================================================ package com.michaldrabik.ui_discover.filters.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import androidx.core.view.children import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_discover.R import com.michaldrabik.ui_discover.databinding.ViewDiscoverFiltersBinding import com.michaldrabik.ui_model.DiscoverFilters import com.michaldrabik.ui_model.DiscoverSortOrder import com.michaldrabik.ui_model.DiscoverSortOrder.HOT import com.michaldrabik.ui_model.DiscoverSortOrder.NEWEST import com.michaldrabik.ui_model.DiscoverSortOrder.RATING import com.michaldrabik.ui_model.Genre import com.michaldrabik.ui_model.Network class DiscoverFiltersView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewDiscoverFiltersBinding.inflate(LayoutInflater.from(context), this) var onFeedChipClick: (() -> Unit)? = null var onGenresChipClick: (() -> Unit)? = null var onNetworksChipClick: (() -> Unit)? = null var onHideCollectionChipClick: (() -> Unit)? = null var onHideAnticipatedChipClick: (() -> Unit)? = null private lateinit var filters: DiscoverFilters init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) with(binding) { discoverGenresChip.text = discoverGenresChip.text.toString().filter { it.isLetter() } discoverGenresChip.onClick { onGenresChipClick?.invoke() } discoverNetworksChip.text = discoverNetworksChip.text.toString().filter { it.isLetter() } discoverNetworksChip.onClick { onNetworksChipClick?.invoke() } discoverFeedChip.isSelected = true discoverFeedChip.onClick { onFeedChipClick?.invoke() } discoverCollectionChip.onClick { onHideCollectionChipClick?.invoke() } discoverAnticipatedChip.onClick { onHideAnticipatedChipClick?.invoke() } } } fun bind(filters: DiscoverFilters) { this.filters = filters bindFeed(filters.feedOrder) bindGenres(filters.genres) bindNetworks(filters.networks) with(binding) { discoverCollectionChip.isChecked = filters.hideCollection discoverAnticipatedChip.isChecked = filters.hideAnticipated } } private fun bindFeed(feed: DiscoverSortOrder) { with(binding) { discoverFeedChip.text = when (feed) { HOT -> context.getString(R.string.textHot) RATING -> context.getString(R.string.textSortRated) NEWEST -> context.getString(R.string.textSortNewest) } } } private fun bindGenres(genres: List) { with(binding) { discoverGenresChip.isSelected = genres.isNotEmpty() discoverGenresChip.text = when { genres.isEmpty() -> context.getString(R.string.textGenres).filter { it.isLetter() } genres.size == 1 -> context.getString(genres.first().displayName) genres.size == 2 -> "${context.getString(genres[0].displayName)}, ${context.getString(genres[1].displayName)}" else -> "${context.getString(genres[0].displayName)}, ${context.getString(genres[1].displayName)} + ${genres.size - 2}" } } } private fun bindNetworks(networks: List) { with(binding) { discoverNetworksChip.isSelected = networks.isNotEmpty() discoverNetworksChip.text = when { networks.isEmpty() -> context.getString(R.string.textNetworks).filter { it.isLetter() } networks.size == 1 -> networks[0].channels.first() else -> throw IllegalStateException() } } } override fun setEnabled(enabled: Boolean) { binding.discoverChips.children.forEach { it.isEnabled = enabled } } } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/helpers/DiscoverLayoutManagerProvider.kt ================================================ package com.michaldrabik.ui_discover.helpers import android.content.Context import androidx.recyclerview.widget.GridLayoutManager import com.michaldrabik.common.Config.MAIN_GRID_SPAN import com.michaldrabik.common.Config.MAIN_GRID_SPAN_TABLET import com.michaldrabik.ui_base.utilities.extensions.isTablet internal object DiscoverLayoutManagerProvider { fun provideLayoutManager(context: Context): GridLayoutManager { val span = if (context.isTablet()) MAIN_GRID_SPAN_TABLET else MAIN_GRID_SPAN return GridLayoutManager(context, span) } } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/helpers/itemtype/ImageTypeProvider.kt ================================================ package com.michaldrabik.ui_discover.helpers.itemtype import com.michaldrabik.ui_model.ImageType internal interface ImageTypeProvider { val twitterAdPosition: Int val premiumAdPosition: Int fun getImageType(position: Int): ImageType } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/helpers/itemtype/PhoneImageTypeProvider.kt ================================================ package com.michaldrabik.ui_discover.helpers.itemtype import com.michaldrabik.ui_model.ImageType private const val BUFFER = 14 internal class PhoneImageTypeProvider : ImageTypeProvider { override val twitterAdPosition = 14 override val premiumAdPosition = 29 override fun getImageType(position: Int): ImageType { if (position % BUFFER == 0) return ImageType.FANART_WIDE if ((position + (BUFFER - 5)) % BUFFER == 0) return ImageType.FANART if ((position + (BUFFER - 9)) % BUFFER == 0) return ImageType.FANART return ImageType.POSTER } } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/helpers/itemtype/TabletImageTypeProvider.kt ================================================ package com.michaldrabik.ui_discover.helpers.itemtype import com.michaldrabik.ui_model.ImageType private const val BUFFER = 11 internal class TabletImageTypeProvider : ImageTypeProvider { override val twitterAdPosition = 14 override val premiumAdPosition = 30 override fun getImageType(position: Int): ImageType { if (position % BUFFER == 0) return ImageType.FANART_WIDE if ((position + (BUFFER - 10)) % BUFFER == 0) return ImageType.FANART_WIDE if ((position + (BUFFER - 2)) % BUFFER == 0) return ImageType.FANART if ((position + (BUFFER - 5)) % BUFFER == 0) return ImageType.FANART if ((position + (BUFFER - 8)) % BUFFER == 0) return ImageType.FANART return ImageType.POSTER } } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/recycler/DiscoverAdapter.kt ================================================ package com.michaldrabik.ui_discover.recycler import android.view.ViewGroup import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView import com.michaldrabik.ui_base.BaseAdapter import com.michaldrabik.ui_discover.views.ShowFanartView import com.michaldrabik.ui_discover.views.ShowPosterView import com.michaldrabik.ui_discover.views.ShowPremiumView import com.michaldrabik.ui_discover.views.ShowTwitterView import com.michaldrabik.ui_model.ImageType.FANART import com.michaldrabik.ui_model.ImageType.FANART_WIDE import com.michaldrabik.ui_model.ImageType.POSTER import com.michaldrabik.ui_model.ImageType.PREMIUM import com.michaldrabik.ui_model.ImageType.TWITTER class DiscoverAdapter( private val itemClickListener: (DiscoverListItem) -> Unit, private val itemLongClickListener: (DiscoverListItem) -> Unit, private val missingImageListener: (DiscoverListItem, Boolean) -> Unit, private val twitterCancelClickListener: (() -> Unit)?, listChangeListener: () -> Unit, ) : BaseAdapter( listChangeListener = listChangeListener ) { override val asyncDiffer = AsyncListDiffer(this, DiscoverItemDiffCallback()) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { POSTER.id -> BaseViewHolder( ShowPosterView(parent.context).apply { itemClickListener = this@DiscoverAdapter.itemClickListener itemLongClickListener = this@DiscoverAdapter.itemLongClickListener missingImageListener = this@DiscoverAdapter.missingImageListener } ) FANART.id, FANART_WIDE.id -> BaseViewHolder( ShowFanartView(parent.context).apply { itemClickListener = this@DiscoverAdapter.itemClickListener itemLongClickListener = this@DiscoverAdapter.itemLongClickListener missingImageListener = this@DiscoverAdapter.missingImageListener } ) TWITTER.id -> BaseViewHolder( ShowTwitterView(parent.context).apply { itemClickListener = this@DiscoverAdapter.itemClickListener twitterCancelClickListener = this@DiscoverAdapter.twitterCancelClickListener } ) PREMIUM.id -> BaseViewHolder( ShowPremiumView(parent.context).apply { itemClickListener = this@DiscoverAdapter.itemClickListener } ) else -> throw IllegalStateException("Unknown view type.") } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val item = asyncDiffer.currentList[position] when (holder.itemViewType) { POSTER.id -> (holder.itemView as ShowPosterView).bind(item) FANART.id, FANART_WIDE.id -> (holder.itemView as ShowFanartView).bind(item) TWITTER.id -> (holder.itemView as ShowTwitterView).bind(item) PREMIUM.id -> (holder.itemView as ShowPremiumView).bind(item) } } override fun getItemViewType(position: Int) = asyncDiffer.currentList[position].image.type.id } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/recycler/DiscoverItemDiffCallback.kt ================================================ package com.michaldrabik.ui_discover.recycler import androidx.recyclerview.widget.DiffUtil class DiscoverItemDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: DiscoverListItem, newItem: DiscoverListItem) = oldItem.show.ids.trakt == newItem.show.ids.trakt override fun areContentsTheSame(oldItem: DiscoverListItem, newItem: DiscoverListItem) = oldItem.image == newItem.image && oldItem.isLoading == newItem.isLoading && oldItem.isFollowed == newItem.isFollowed && oldItem.isWatchlist == newItem.isWatchlist && oldItem.translation == newItem.translation } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/recycler/DiscoverListItem.kt ================================================ package com.michaldrabik.ui_discover.recycler import com.michaldrabik.ui_base.common.ListItem import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_model.Translation data class DiscoverListItem( override val show: Show, override val image: Image, override var isLoading: Boolean = false, val isFollowed: Boolean = false, val isWatchlist: Boolean = false, val translation: Translation? = null ) : ListItem ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/views/ShowFanartView.kt ================================================ package com.michaldrabik.ui_discover.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.widget.ImageView import com.bumptech.glide.Glide import com.michaldrabik.ui_base.common.views.ShowView import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.onLongClick import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_discover.R import com.michaldrabik.ui_discover.databinding.ViewShowFanartBinding import com.michaldrabik.ui_discover.recycler.DiscoverListItem import com.michaldrabik.ui_model.ImageStatus.AVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNAVAILABLE class ShowFanartView : ShowView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewShowFanartBinding.inflate(LayoutInflater.from(context), this) init { with(binding) { showFanartRoot.onClick { itemClickListener?.invoke(item) } showFanartRoot.onLongClick { itemLongClickListener?.invoke(item) } } } override val imageView: ImageView = binding.showFanartImage override val placeholderView: ImageView = binding.showFanartPlaceholder private lateinit var item: DiscoverListItem override fun bind(item: DiscoverListItem) { super.bind(item) clear() this.item = item with(binding) { showFanartTitle.text = if (item.translation?.title.isNullOrBlank()) item.show.title else item.translation?.title showFanartProgress.visibleIf(item.isLoading) showFanartBadge.visibleIf(item.isFollowed) showFanartBadgeLater.visibleIf(item.isWatchlist) } loadImage(item) } override fun loadImage(item: DiscoverListItem) { super.loadImage(item) if (item.image.status == UNAVAILABLE) { binding.showFanartRoot.setBackgroundResource(R.drawable.bg_media_view_placeholder) } } override fun onImageLoadFail(item: DiscoverListItem) { super.onImageLoadFail(item) if (item.image.status == AVAILABLE) { binding.showFanartRoot.setBackgroundResource(R.drawable.bg_media_view_placeholder) } } private fun clear() { with(binding) { showFanartTitle.text = "" showFanartProgress.gone() showFanartPlaceholder.gone() showFanartRoot.setBackgroundResource(R.drawable.bg_media_view_elevation) showFanartBadge.gone() Glide.with(this@ShowFanartView).clear(showFanartImage) } } } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/views/ShowPosterView.kt ================================================ package com.michaldrabik.ui_discover.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.widget.ImageView import com.bumptech.glide.Glide import com.michaldrabik.ui_base.common.views.ShowView import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.onLongClick import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_discover.R import com.michaldrabik.ui_discover.databinding.ViewShowPosterBinding import com.michaldrabik.ui_discover.recycler.DiscoverListItem import com.michaldrabik.ui_model.ImageStatus.AVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNAVAILABLE class ShowPosterView : ShowView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewShowPosterBinding.inflate(LayoutInflater.from(context), this) init { with(binding) { showPosterRoot.onClick { itemClickListener?.invoke(item) } showPosterRoot.onLongClick { itemLongClickListener?.invoke(item) } } } override val imageView: ImageView = binding.showPosterImage override val placeholderView: ImageView = binding.showPosterPlaceholder private lateinit var item: DiscoverListItem override fun bind(item: DiscoverListItem) { super.bind(item) clear() this.item = item with(binding) { showPosterTitle.text = item.show.title showPosterProgress.visibleIf(item.isLoading) showPosterBadge.visibleIf(item.isFollowed) showPosterLaterBadge.visibleIf(item.isWatchlist) } loadImage(item) } override fun loadImage(item: DiscoverListItem) { if (item.image.status == UNAVAILABLE) { with(binding) { showPosterTitle.visible() showPosterRoot.setBackgroundResource(R.drawable.bg_media_view_placeholder) } } super.loadImage(item) } override fun onImageLoadFail(item: DiscoverListItem) { super.onImageLoadFail(item) if (item.image.status == AVAILABLE) { with(binding) { showPosterTitle.visible() showPosterRoot.setBackgroundResource(R.drawable.bg_media_view_placeholder) } } } private fun clear() { with(binding) { showPosterTitle.text = "" showPosterTitle.gone() showPosterRoot.setBackgroundResource(R.drawable.bg_media_view_elevation) showPosterPlaceholder.gone() showPosterProgress.gone() showPosterBadge.gone() Glide.with(this@ShowPosterView).clear(showPosterImage) } } } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/views/ShowPremiumView.kt ================================================ package com.michaldrabik.ui_discover.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.widget.ImageView import com.michaldrabik.ui_base.common.views.ShowView import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_discover.databinding.ViewShowPremiumBinding import com.michaldrabik.ui_discover.recycler.DiscoverListItem class ShowPremiumView : ShowView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewShowPremiumBinding.inflate(LayoutInflater.from(context), this, true) init { binding.viewShowPremiumRoot.onClick { itemClickListener?.invoke(item) } } override val imageView: ImageView = binding.viewShowPremiumImageStub override val placeholderView: ImageView = binding.viewShowPremiumImageStub private lateinit var item: DiscoverListItem override fun bind(item: DiscoverListItem) { super.bind(item) this.item = item } } ================================================ FILE: ui-discover/src/main/java/com/michaldrabik/ui_discover/views/ShowTwitterView.kt ================================================ package com.michaldrabik.ui_discover.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.widget.ImageView import com.michaldrabik.ui_base.common.views.ShowView import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_discover.databinding.ViewShowTwitterBinding import com.michaldrabik.ui_discover.recycler.DiscoverListItem class ShowTwitterView : ShowView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewShowTwitterBinding.inflate(LayoutInflater.from(context), this) var twitterCancelClickListener: (() -> Unit)? = null init { with(binding) { viewTwitterRoot.onClick { itemClickListener?.invoke(item) } viewTwitterCancel.onClick { twitterCancelClickListener?.invoke() } } } override val imageView: ImageView = binding.viewTwitterLogo override val placeholderView: ImageView = binding.viewTwitterLogo private lateinit var item: DiscoverListItem override fun bind(item: DiscoverListItem) { super.bind(item) this.item = item } } ================================================ FILE: ui-discover/src/main/res/color/selector_filters_button.xml ================================================ ================================================ FILE: ui-discover/src/main/res/drawable/bg_twitter.xml ================================================ ================================================ FILE: ui-discover/src/main/res/drawable/bg_twitter_cancel.xml ================================================ ================================================ FILE: ui-discover/src/main/res/layout/fragment_discover.xml ================================================ ================================================ FILE: ui-discover/src/main/res/layout/view_discover_filters.xml ================================================ ================================================ FILE: ui-discover/src/main/res/layout/view_discover_filters_feed.xml ================================================ ================================================ FILE: ui-discover/src/main/res/layout/view_discover_filters_genres.xml ================================================ ================================================ FILE: ui-discover/src/main/res/layout/view_discover_filters_networks.xml ================================================ ================================================ FILE: ui-discover/src/main/res/layout/view_show_fanart.xml ================================================ ================================================ FILE: ui-discover/src/main/res/layout/view_show_poster.xml ================================================ ================================================ FILE: ui-discover/src/main/res/layout/view_show_premium.xml ================================================ ================================================ FILE: ui-discover/src/main/res/layout/view_show_twitter.xml ================================================ ================================================ FILE: ui-discover/src/main/res/values/strings.xml ================================================ Feed: Hide Anticipated Shows Hide Collection Showly is on Twitter! Follow @AppShowly and get the latest news and information about the app. ================================================ FILE: ui-discover/src/main/res/values-ar/strings.xml ================================================ ترتيب حسب: إخفاء المسلسلات المرتقبة إخفاء المجموعة ================================================ FILE: ui-discover/src/main/res/values-de/strings.xml ================================================ Feed: Bald erscheinende Serien ausblenden Sammlung ausblenden ================================================ FILE: ui-discover/src/main/res/values-es/strings.xml ================================================ Feed: Ocultar series anticipadas Ocultar Colección ================================================ FILE: ui-discover/src/main/res/values-fi/strings.xml ================================================ Syöte: Piilota odotetut sarjat Piilota kokoelma ================================================ FILE: ui-discover/src/main/res/values-fr/strings.xml ================================================ Fil : Cacher les séries attendues Cacher la collection ================================================ FILE: ui-discover/src/main/res/values-it/strings.xml ================================================ Riepilogo: Nascondi gli show attesi Nascondi Raccolta ================================================ FILE: ui-discover/src/main/res/values-pl/strings.xml ================================================ Strumień: Ukryj nadchodzące Ukryj kolekcję ================================================ FILE: ui-discover/src/main/res/values-pt/strings.xml ================================================ Feed: Ocultar séries aguardadas Ocultar assistidos ================================================ FILE: ui-discover/src/main/res/values-ru/strings.xml ================================================ Сортировать по: Скрыть ожидаемые сериалы Скрыть коллекцию ================================================ FILE: ui-discover/src/main/res/values-tr/strings.xml ================================================ Akış: Beklenen Dizileri Gizle Koleksiyonu Gizle ================================================ FILE: ui-discover/src/main/res/values-uk/strings.xml ================================================ Стрічка: Приховати очікувані серіали Приховати колекцію ================================================ FILE: ui-discover/src/main/res/values-vi/strings.xml ================================================ Nguồn cấp: Ẩn các chương trình được mong đợi Ẩn bộ sưu tập Showly có trên Twitter! Theo dõi @AppShowly để nhận tin tức và thông tin mới nhất về ứng dụng. ================================================ FILE: ui-discover/src/main/res/values-zh/strings.xml ================================================ 信息流: 隐藏备受期待的剧集 隐藏合集中的项目 ================================================ FILE: ui-discover/src/test/java/BaseMockTest.kt ================================================ import com.michaldrabik.common_test.MainDispatcherRule import io.mockk.MockKAnnotations import io.mockk.mockkStatic import org.junit.Before import org.junit.Rule @Suppress("EXPERIMENTAL_API_USAGE") abstract class BaseMockTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() @Before open fun setUp() { MockKAnnotations.init(this) mockkStatic("androidx.room.RoomDatabaseKt") } } ================================================ FILE: ui-discover/src/test/java/TestData.kt ================================================ import com.michaldrabik.ui_discover.recycler.DiscoverListItem import com.michaldrabik.ui_model.AirTime import com.michaldrabik.ui_model.IdImdb import com.michaldrabik.ui_model.IdSlug import com.michaldrabik.ui_model.IdTmdb import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.IdTvRage import com.michaldrabik.ui_model.IdTvdb import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageFamily import com.michaldrabik.ui_model.ImageSource import com.michaldrabik.ui_model.ImageStatus import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_model.ShowStatus object TestData { val DISCOVER_LIST_ITEM = DiscoverListItem( show = Show( ids = Ids( trakt = IdTrakt(id = 0), slug = IdSlug(id = ""), tvdb = IdTvdb(id = 0), imdb = IdImdb(id = ""), tmdb = IdTmdb(id = 0), tvrage = IdTvRage(id = 0) ), title = "DISCOVER_LIST_ITEM", year = 0, overview = "", firstAired = "", runtime = 0, airTime = AirTime(day = "", time = "", timezone = ""), certification = "", network = "", country = "", trailer = "", homepage = "", status = ShowStatus.UNKNOWN, rating = 0.0f, votes = 0, commentCount = 0, genres = listOf(), airedEpisodes = 0, createdAt = 0, updatedAt = 0 ), image = Image( id = 0, idTvdb = IdTvdb(id = 0), idTmdb = IdTmdb(id = 0), type = ImageType.POSTER, family = ImageFamily.SHOW, fileUrl = "", thumbnailUrl = "", status = ImageStatus.UNKNOWN, source = ImageSource.TVDB ), isLoading = false, isFollowed = false, isWatchlist = false ) } ================================================ FILE: ui-discover/src/test/java/com/michaldrabik/ui_discover/DiscoverViewModelTest.kt ================================================ package com.michaldrabik.ui_discover import BaseMockTest import TestData import androidx.lifecycle.viewModelScope import androidx.work.WorkManager import com.google.common.truth.Truth.assertThat import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.repository.images.ShowImagesProvider import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_discover.cases.DiscoverFiltersCase import com.michaldrabik.ui_discover.cases.DiscoverShowsCase import com.michaldrabik.ui_discover.cases.DiscoverTwitterCase import com.michaldrabik.ui_model.DiscoverFilters import io.mockk.coEvery import io.mockk.coVerify import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.RelaxedMockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test import java.util.concurrent.TimeUnit @OptIn(ExperimentalCoroutinesApi::class) @Suppress("EXPERIMENTAL_API_USAGE") class DiscoverViewModelTest : BaseMockTest() { @MockK internal lateinit var showsCase: DiscoverShowsCase @MockK lateinit var filtersCase: DiscoverFiltersCase @MockK lateinit var twitterCase: DiscoverTwitterCase @MockK lateinit var imagesProvider: ShowImagesProvider @RelaxedMockK lateinit var workManager: WorkManager private lateinit var SUT: DiscoverViewModel @Before override fun setUp() { super.setUp() coEvery { filtersCase.loadFilters() } returns DiscoverFilters() coEvery { showsCase.loadCachedShows(any()) } returns emptyList() coEvery { showsCase.loadRemoteShows(any()) } returns emptyList() SUT = DiscoverViewModel(showsCase, filtersCase, twitterCase, imagesProvider, workManager) } @After fun tearDown() { SUT.viewModelScope.cancel() } @Test fun `Should not pull to refresh data too often`() = runTest { SUT.lastPullToRefreshMs = nowUtcMillis() - TimeUnit.SECONDS.toMillis(5) SUT.loadShows(pullToRefresh = true) coVerify(exactly = 0) { showsCase.loadCachedShows(any()) } coVerify(exactly = 0) { showsCase.loadRemoteShows(any()) } } @Test fun `Should load cached data and not load remote data if cache is valid`() { coEvery { showsCase.isCacheValid() } returns true SUT.loadShows() coVerify(exactly = 1) { showsCase.loadCachedShows(any()) } coVerify(exactly = 0) { showsCase.loadRemoteShows(any()) } } @Test fun `Should load cached data and load remote data if cache is no longer valid`() { coEvery { showsCase.isCacheValid() } returns false SUT.loadShows() coVerify(exactly = 1) { showsCase.loadCachedShows(any()) } coVerify(exactly = 1) { showsCase.loadRemoteShows(any()) } } @Test fun `Should load remote data only if pull to refresh`() { coEvery { showsCase.isCacheValid() } returns true SUT.loadShows(pullToRefresh = true) coVerify(exactly = 0) { showsCase.loadCachedShows(any()) } coVerify(exactly = 1) { showsCase.loadRemoteShows(any()) } } @Test fun `Should load remote data only if skipping cache`() { coEvery { showsCase.isCacheValid() } returns true SUT.loadShows(skipCache = true) coVerify(exactly = 0) { showsCase.loadCachedShows(any()) } coVerify(exactly = 1) { showsCase.loadRemoteShows(any()) } } @Test fun `Should not load cached data if skipping cache`() { SUT.loadShows(skipCache = true) coVerify(exactly = 0) { showsCase.loadCachedShows(any()) } } @Test fun `Should update last PTR stamp if PTR`() = runTest { coEvery { showsCase.isCacheValid() } returns false SUT.loadShows(pullToRefresh = true) assertThat(SUT.lastPullToRefreshMs).isGreaterThan(nowUtcMillis() - TimeUnit.MINUTES.toMillis(1)) } @Test fun `Should not update last PTR stamp if was not PTR`() { coEvery { showsCase.isCacheValid() } returns false SUT.loadShows(pullToRefresh = false) assertThat(SUT.lastPullToRefreshMs).isEqualTo(0) } @Test fun `Should hide loading state when PTR is run too often`() = runTest { val stateResult = mutableListOf() val messagesResult = mutableListOf() val job = launch(UnconfinedTestDispatcher()) { SUT.uiState.toList(stateResult) } val job2 = launch(UnconfinedTestDispatcher()) { SUT.messageFlow.toList(messagesResult) } SUT.lastPullToRefreshMs = nowUtcMillis() - TimeUnit.SECONDS.toMillis(5) SUT.loadShows(pullToRefresh = true) assertThat(stateResult[0].isLoading).isNull() assertThat(stateResult[1].isLoading).isFalse() assertThat(stateResult[2].isLoading).isTrue() assertThat(stateResult[3].isLoading).isFalse() assertThat(messagesResult).isEmpty() job.cancel() job2.cancel() } @Test fun `Should show loading state instantly if pull to refresh`() = runTest { val stateResult = mutableListOf() val messagesResult = mutableListOf() val job = launch(UnconfinedTestDispatcher()) { SUT.uiState.toList(stateResult) } val job2 = launch(UnconfinedTestDispatcher()) { SUT.messageFlow.toList(messagesResult) } SUT.loadShows(pullToRefresh = true) assertThat(stateResult[0].isLoading).isNull() assertThat(stateResult[1].isLoading).isFalse() assertThat(stateResult[2].isLoading).isTrue() assertThat(messagesResult).isEmpty() job.cancel() job2.cancel() } @Test fun `Should not emit cached results if pull to refresh`() = runTest { val stateResult = mutableListOf() val messagesResult = mutableListOf() val job = launch { SUT.uiState.toList(stateResult) } val job2 = launch { SUT.messageFlow.toList(messagesResult) } val cachedItem = TestData.DISCOVER_LIST_ITEM coEvery { showsCase.loadCachedShows(any()) } returns listOf(cachedItem) SUT.loadShows(pullToRefresh = true) stateResult.forEach { assertThat(it.items.isNullOrEmpty()).isTrue() } assertThat(messagesResult).isEmpty() job.cancel() job2.cancel() } @Test fun `Should not post cached results if skipping cache`() = runTest { val stateResult = mutableListOf() val messagesResult = mutableListOf() val job = launch { SUT.uiState.toList(stateResult) } val job2 = launch { SUT.messageFlow.toList(messagesResult) } val cachedItem = TestData.DISCOVER_LIST_ITEM coEvery { showsCase.loadCachedShows(any()) } returns listOf(cachedItem) SUT.loadShows(skipCache = true) stateResult.forEach { assertThat(it.items.isNullOrEmpty()).isTrue() } assertThat(messagesResult).isEmpty() job.cancel() job2.cancel() } @Test fun `Should post cached results and then fresh remote results`() = runTest { val stateResult = mutableListOf() val messagesResult = mutableListOf() val job = launch(UnconfinedTestDispatcher()) { SUT.uiState.toList(stateResult) } val job2 = launch(UnconfinedTestDispatcher()) { SUT.messageFlow.toList(messagesResult) } val cachedItem = TestData.DISCOVER_LIST_ITEM val remoteItem = cachedItem.copy(isFollowed = true) coEvery { showsCase.loadCachedShows(any()) } returns listOf(cachedItem) coEvery { showsCase.loadRemoteShows(any()) } coAnswers { delay(1000) listOf(remoteItem) } coEvery { showsCase.isCacheValid() } returns false SUT.loadShows() advanceUntilIdle() assertThat(stateResult.any { it.items?.contains(cachedItem) == true }).isTrue() assertThat(stateResult.last().items?.contains(remoteItem)).isTrue() assertThat(messagesResult).isEmpty() job.cancel() job2.cancel() } @Test fun `Should post error message on error`() = runTest { val stateResult = mutableListOf() val messagesResult = mutableListOf() val job = launch(UnconfinedTestDispatcher()) { SUT.uiState.toList(stateResult) } val job2 = launch(UnconfinedTestDispatcher()) { SUT.messageFlow.toList(messagesResult) } coEvery { showsCase.loadCachedShows(any()) } throws Error() SUT.loadShows() assertThat(messagesResult.last().consume()).isEqualTo(com.michaldrabik.ui_base.R.string.errorCouldNotLoadDiscover) job.cancel() job2.cancel() } } ================================================ FILE: ui-discover-movies/.gitignore ================================================ /build ================================================ FILE: ui-discover-movies/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'dagger.hilt.android.plugin' apply from: '../versions.gradle' android { kotlinOptions { jvmTarget = "17" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } buildFeatures { viewBinding true } compileSdkVersion versions.compileSdk defaultConfig { minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk compileSdkVersion versions.compileSdk testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false } } namespace 'com.michaldrabik.ui_discover_movies' } dependencies { implementation project(':common') implementation project(':data-local') implementation project(':ui-base') implementation project(':repository') implementation project(':ui-model') implementation project(':ui-navigation') implementation libs.hilt.android ksp libs.hilt.compiler coreLibraryDesugaring libs.android.desugar } ================================================ FILE: ui-discover-movies/src/main/AndroidManifest.xml ================================================ ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/DiscoverMoviesFragment.kt ================================================ package com.michaldrabik.ui_discover_movies import android.os.Bundle import android.view.View import android.view.View.VISIBLE import android.view.ViewGroup import androidx.activity.addCallback import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateMargins import androidx.core.view.updatePadding import androidx.fragment.app.clearFragmentResultListener import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import com.michaldrabik.ui_base.BaseFragment import com.michaldrabik.ui_base.common.OnTabReselectedListener import com.michaldrabik.ui_base.common.sheets.context_menu.ContextMenuBottomSheet import com.michaldrabik.ui_base.utilities.extensions.add import com.michaldrabik.ui_base.utilities.extensions.colorFromAttr import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.disableUi import com.michaldrabik.ui_base.utilities.extensions.doOnApplyWindowInsets import com.michaldrabik.ui_base.utilities.extensions.enableUi import com.michaldrabik.ui_base.utilities.extensions.fadeIn import com.michaldrabik.ui_base.utilities.extensions.fadeOut import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.navigateToSafe import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.withSpanSizeLookup import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_discover_movies.databinding.FragmentDiscoverMoviesBinding import com.michaldrabik.ui_discover_movies.helpers.DiscoverMoviesLayoutManagerProvider import com.michaldrabik.ui_discover_movies.recycler.DiscoverMovieListItem import com.michaldrabik.ui_discover_movies.recycler.DiscoverMoviesAdapter import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_navigation.java.NavigationArgs import dagger.hilt.android.AndroidEntryPoint import kotlin.random.Random @AndroidEntryPoint internal class DiscoverMoviesFragment : BaseFragment(R.layout.fragment_discover_movies), OnTabReselectedListener { companion object { const val REQUEST_DISCOVER_FILTERS = "REQUEST_DISCOVER_FILTERS" } private val binding by viewBinding(FragmentDiscoverMoviesBinding::bind) override val viewModel by viewModels() override val navigationId = R.id.discoverMoviesFragment private val swipeRefreshStartOffset by lazy { requireContext().dimenToPx(R.dimen.swipeRefreshStartOffset) } private val swipeRefreshEndOffset by lazy { requireContext().dimenToPx(R.dimen.swipeRefreshEndOffset) } private var adapter: DiscoverMoviesAdapter? = null private var layoutManager: GridLayoutManager? = null private var searchViewPosition = 0F private var tabsViewPosition = 0F private var filtersViewPosition = 0F override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) savedInstanceState?.let { searchViewPosition = it.getFloat("ARG_SEARCH_POS", 0F) tabsViewPosition = it.getFloat("ARG_TABS_POS", 0F) filtersViewPosition = it.getFloat("ARG_FILTERS_POS", 0F) } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putFloat("ARG_SEARCH_POS", searchViewPosition) outState.putFloat("ARG_TABS_POS", tabsViewPosition) outState.putFloat("ARG_FILTERS_POS", filtersViewPosition) } override fun onResume() { super.onResume() showNavigation() } override fun onPause() { enableUi() with(binding) { searchViewPosition = discoverMoviesSearchView.translationY tabsViewPosition = discoverMoviesTabsView.translationY filtersViewPosition = discoverMoviesFiltersView.translationY } super.onPause() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() setupStatusBar() setupRecycler() setupSwipeRefresh() launchAndRepeatStarted( { viewModel.uiState.collect { render(it) } }, { viewModel.messageFlow.collect { showSnack(it) } }, doAfterLaunch = { viewModel.loadMovies() } ) setFragmentResultListener(REQUEST_DISCOVER_FILTERS) { _, _ -> viewModel.loadMovies(resetScroll = true, skipCache = true, instantProgress = true) } } private fun setupView() { with(binding) { discoverMoviesSearchView.run { translationY = searchViewPosition settingsIconVisible = true isEnabled = false onClick { openSearch() } onSettingsClickListener = { hideNavigation() navigateToSafe(R.id.actionDiscoverMoviesFragmentToSettingsFragment) } } discoverMoviesTabsView.run { translationY = tabsViewPosition onModeSelected = { mode = it } selectMovies() } discoverMoviesFiltersView.run { translationY = filtersViewPosition onGenresChipClick = { navigateToSafe(R.id.actionDiscoverMoviesFragmentToFiltersGenres) } onFeedChipClick = { navigateToSafe(R.id.actionDiscoverMoviesFragmentToFiltersFeed) } onHideAnticipatedChipClick = { viewModel.toggleAnticipated() } onHideCollectionChipClick = { viewModel.toggleCollection() } } } } private fun setupStatusBar() { with(binding) { discoverMoviesRoot.doOnApplyWindowInsets { _, insets, _, _ -> val tabletOffset = if (isTablet) dimenToPx(R.dimen.spaceMedium) else 0 val statusBarSize = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top + tabletOffset discoverMoviesRecycler .updatePadding(top = statusBarSize + dimenToPx(R.dimen.discoverRecyclerPadding)) (discoverMoviesSearchView.layoutParams as ViewGroup.MarginLayoutParams) .updateMargins(top = statusBarSize + dimenToPx(R.dimen.spaceMedium)) (discoverMoviesTabsView.layoutParams as ViewGroup.MarginLayoutParams) .updateMargins(top = statusBarSize + dimenToPx(R.dimen.collectionTabsMargin)) (discoverMoviesFiltersView.layoutParams as ViewGroup.MarginLayoutParams) .updateMargins(top = statusBarSize + dimenToPx(R.dimen.collectionFiltersMargin)) discoverMoviesSwipeRefresh.setProgressViewOffset( true, swipeRefreshStartOffset + statusBarSize, swipeRefreshEndOffset ) } } } private fun setupRecycler() { layoutManager = DiscoverMoviesLayoutManagerProvider.provideLayoutManager(requireContext()) adapter = DiscoverMoviesAdapter( itemClickListener = { when (it.image.type) { ImageType.PREMIUM -> openPremium() else -> openDetails(it) } }, itemLongClickListener = { openMovieMenu(it.movie) }, missingImageListener = { ids, force -> viewModel.loadMissingImage(ids, force) }, listChangeListener = { binding.discoverMoviesRecycler.scrollToPosition(0) } ) binding.discoverMoviesRecycler.apply { adapter = this@DiscoverMoviesFragment.adapter layoutManager = this@DiscoverMoviesFragment.layoutManager (itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false setHasFixedSize(true) } } private fun setupSwipeRefresh() { binding.discoverMoviesSwipeRefresh.apply { val color = requireContext().colorFromAttr(R.attr.colorAccent) setProgressBackgroundColorSchemeColor(requireContext().colorFromAttr(R.attr.colorSearchViewBackground)) setColorSchemeColors(color, color, color) setOnRefreshListener { searchViewPosition = 0F tabsViewPosition = 0F viewModel.loadMovies(pullToRefresh = true) } } } override fun setupBackPressed() { val dispatcher = requireActivity().onBackPressedDispatcher dispatcher.addCallback(viewLifecycleOwner) { isEnabled = false activity?.onBackPressed() } } private fun openSearch() { disableUi() hideNavigation() with(binding) { discoverMoviesTabsView.fadeOut(duration = 200).add(animations) discoverMoviesFiltersView.fadeOut(duration = 200).add(animations) discoverMoviesRecycler.fadeOut(duration = 200) { navigateToSafe(R.id.actionDiscoverMoviesFragmentToSearchFragment) }.add(animations) } } private fun openDetails(item: DiscoverMovieListItem) { if (!binding.discoverMoviesRecycler.isEnabled) return disableUi() hideNavigation() animateItemsExit(item) } private fun openMovieMenu(movie: Movie) { if (!binding.discoverMoviesRecycler.isEnabled) return setFragmentResultListener(NavigationArgs.REQUEST_ITEM_MENU) { requestKey, _ -> if (requestKey == NavigationArgs.REQUEST_ITEM_MENU) { viewModel.loadMovies() } clearFragmentResultListener(NavigationArgs.REQUEST_ITEM_MENU) } val bundle = ContextMenuBottomSheet.createBundle(movie.ids.trakt) navigateToSafe(R.id.actionDiscoverMoviesFragmentToItemMenu, bundle) } private fun openPremium() { if (!binding.discoverMoviesRecycler.isEnabled) return disableUi() hideNavigation() navigateToSafe(R.id.actionDiscoverMoviesFragmentToPremium, Bundle.EMPTY) } private fun animateItemsExit(item: DiscoverMovieListItem) { with(binding) { discoverMoviesSearchView.fadeOut().add(animations) discoverMoviesTabsView.fadeOut().add(animations) discoverMoviesFiltersView.fadeOut().add(animations) val clickedIndex = adapter?.indexOf(item) ?: 0 val itemCount = adapter?.itemCount ?: 0 (0..itemCount).forEach { if (it != clickedIndex) { val view = discoverMoviesRecycler.findViewHolderForAdapterPosition(it) view?.let { v -> val randomDelay = Random.nextLong(50, 200) v.itemView.fadeOut(duration = 150, startDelay = randomDelay).add(animations) } } } val clickedView = discoverMoviesRecycler.findViewHolderForAdapterPosition(clickedIndex) clickedView?.itemView?.fadeOut( duration = 150, startDelay = 350, endAction = { if (!isResumed) return@fadeOut val bundle = Bundle().apply { putLong(NavigationArgs.ARG_MOVIE_ID, item.movie.traktId) } navigateToSafe(R.id.actionDiscoverMoviesFragmentToMovieDetailsFragment, bundle) } ).add(animations) } } private fun render(uiState: DiscoverMoviesUiState) { uiState.run { with(binding) { items?.let { val resetScroll = resetScroll?.consume() == true adapter?.setItems(it, resetScroll) layoutManager?.withSpanSizeLookup { pos -> adapter?.getItems()?.get(pos)?.image?.type?.getSpan(isTablet)!! } discoverMoviesRecycler.fadeIn(200, withHardware = true) } isSyncing?.let { discoverMoviesSearchView.setTraktProgress(it) discoverMoviesSearchView.isEnabled = !it } isLoading?.let { discoverMoviesSwipeRefresh.isRefreshing = it discoverMoviesSearchView.isEnabled = !it discoverMoviesTabsView.isEnabled = !it discoverMoviesFiltersView.isEnabled = !it discoverMoviesRecycler.isEnabled = !it } filters?.let { if (discoverMoviesFiltersView.visibility != VISIBLE) { discoverMoviesFiltersView.visible() } discoverMoviesFiltersView.bind(it) } } } } override fun onTabReselected() = openSearch() override fun onDestroyView() { adapter = null layoutManager = null super.onDestroyView() } } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/DiscoverMoviesUiState.kt ================================================ package com.michaldrabik.ui_discover_movies import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_discover_movies.recycler.DiscoverMovieListItem import com.michaldrabik.ui_model.DiscoverFilters data class DiscoverMoviesUiState( val items: List? = null, val isLoading: Boolean? = null, val isSyncing: Boolean? = null, var filters: DiscoverFilters? = null, var resetScroll: Event? = null, ) ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/DiscoverMoviesViewModel.kt ================================================ package com.michaldrabik.ui_discover_movies import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting.Companion.PRIVATE import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.WorkInfo import androidx.work.WorkManager import com.michaldrabik.common.Config import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.repository.images.MovieImagesProvider import com.michaldrabik.ui_base.trakt.TraktSyncWorker import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.utilities.extensions.findReplace import com.michaldrabik.ui_base.utilities.extensions.rethrowCancellation import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_discover_movies.cases.DiscoverFiltersCase import com.michaldrabik.ui_discover_movies.cases.DiscoverMoviesCase import com.michaldrabik.ui_discover_movies.recycler.DiscoverMovieListItem import com.michaldrabik.ui_model.DiscoverFilters import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageFamily.MOVIE import com.michaldrabik.ui_model.ImageSource.TMDB import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @HiltViewModel internal class DiscoverMoviesViewModel @Inject constructor( private val moviesCase: DiscoverMoviesCase, private val filtersCase: DiscoverFiltersCase, private val imagesProvider: MovieImagesProvider, workManager: WorkManager, ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val itemsState = MutableStateFlow?>(null) private val loadingState = MutableStateFlow(false) private val syncingState = MutableStateFlow(false) private val filtersState = MutableStateFlow(null) private val scrollState = MutableStateFlow(Event(false)) @VisibleForTesting(otherwise = PRIVATE) var lastPullToRefreshMs = 0L private var initialFilters: DiscoverFilters? = null init { workManager.getWorkInfosByTagLiveData(TraktSyncWorker.TAG_ID).observeForever { work -> syncingState.value = work.any { it.state == WorkInfo.State.RUNNING } } viewModelScope.launch { initialFilters = filtersCase.loadFilters() } } fun loadMovies( pullToRefresh: Boolean = false, resetScroll: Boolean = false, skipCache: Boolean = false, instantProgress: Boolean = false, ) { loadingState.value = true if (pullToRefresh && nowUtcMillis() - lastPullToRefreshMs < Config.PULL_TO_REFRESH_COOLDOWN_MS) { loadingState.value = false return } loadingState.value = pullToRefresh viewModelScope.launch { val progressJob = launch { delay(if (pullToRefresh || instantProgress) 0 else 750) loadingState.value = true } try { val filters = filtersCase.loadFilters() filtersState.value = filters if (!pullToRefresh && !skipCache) { val movies = moviesCase.loadCachedMovies(filters) itemsState.value = movies scrollState.value = Event(resetScroll) } if (pullToRefresh || skipCache || !moviesCase.isCacheValid()) { val movies = moviesCase.loadRemoteMovies(filters) itemsState.value = movies initialFilters = filters scrollState.value = Event(resetScroll) } if (pullToRefresh) { lastPullToRefreshMs = nowUtcMillis() } } catch (error: Throwable) { onError(error) } finally { loadingState.value = false progressJob.cancel() } } } fun loadMissingImage(item: DiscoverMovieListItem, force: Boolean) { fun updateItem(newItem: DiscoverMovieListItem) { val currentItems = uiState.value.items?.toMutableList() currentItems?.findReplace(newItem) { it isSameAs newItem } itemsState.value = currentItems scrollState.value = Event(false) } viewModelScope.launch { val loadingJob = launch { delay(750) updateItem(item.copy(isLoading = true)) } try { val image = imagesProvider.loadRemoteImage(item.movie, item.image.type, force) updateItem(item.copy(isLoading = false, image = image)) } catch (t: Throwable) { updateItem(item.copy(isLoading = false, image = Image.createUnavailable(item.image.type, MOVIE, TMDB))) rethrowCancellation(t) } finally { loadingJob.cancel() } } } fun toggleAnticipated() { viewModelScope.launch { filtersCase.toggleAnticipated() loadMovies(resetScroll = true, skipCache = true, instantProgress = true) } } fun toggleCollection() { viewModelScope.launch { filtersCase.toggleCollection() loadMovies(resetScroll = true, skipCache = true, instantProgress = true) } } private suspend fun onError(error: Throwable) { if (error !is CancellationException) { messageChannel.send(MessageEvent.Error(R.string.errorCouldNotLoadDiscover)) Timber.e(error) } rethrowCancellation(error) } override fun onCleared() { filtersCase.revertFilters( initialFilters = initialFilters, currentFilters = filtersState.value ) super.onCleared() } val uiState = combine( itemsState, loadingState, syncingState, filtersState, scrollState ) { s1, s2, s3, s4, s5 -> DiscoverMoviesUiState( items = s1, isLoading = s2, isSyncing = s3, filters = s4, resetScroll = s5 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = DiscoverMoviesUiState() ) } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/cases/DiscoverFiltersCase.kt ================================================ package com.michaldrabik.ui_discover_movies.cases import android.content.Context import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.common.AppScopeProvider import com.michaldrabik.ui_base.utilities.extensions.rethrowCancellation import com.michaldrabik.ui_model.DiscoverFilters import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class DiscoverFiltersCase @Inject constructor( @ApplicationContext private val context: Context, private val dispatchers: CoroutineDispatchers, private val settingsRepository: SettingsRepository, ) { suspend fun loadFilters(): DiscoverFilters = withContext(dispatchers.IO) { val settings = settingsRepository.load() DiscoverFilters( feedOrder = settings.discoverMoviesFilterFeed, hideAnticipated = !settings.showAnticipatedMovies, hideCollection = !settings.showCollectionMovies, genres = settings.discoverMoviesFilterGenres.toList() ) } suspend fun toggleAnticipated() { withContext(dispatchers.IO) { val settings = settingsRepository.load() settingsRepository.update( settings.copy(showAnticipatedMovies = !settings.showAnticipatedMovies) ) } } suspend fun toggleCollection() { withContext(dispatchers.IO) { val settings = settingsRepository.load() settingsRepository.update( settings.copy(showCollectionMovies = !settings.showCollectionMovies) ) } } fun revertFilters( initialFilters: DiscoverFilters?, currentFilters: DiscoverFilters?, ) { (context as AppScopeProvider).appScope.launch { try { if (initialFilters != currentFilters) { initialFilters?.let { initial -> val settings = settingsRepository.load() settingsRepository.update( settings.copy( discoverMoviesFilterFeed = initial.feedOrder, discoverMoviesFilterGenres = initial.genres, showAnticipatedMovies = !initial.hideAnticipated, showCollectionMovies = !initial.hideCollection ) ) } } } catch (error: Throwable) { rethrowCancellation(error) } } } } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/cases/DiscoverMoviesCase.kt ================================================ package com.michaldrabik.ui_discover_movies.cases import com.michaldrabik.common.Config import com.michaldrabik.common.ConfigVariant import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.repository.TranslationsRepository import com.michaldrabik.repository.images.MovieImagesProvider import com.michaldrabik.repository.movies.MoviesRepository import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_discover_movies.helpers.itemtype.ImageTypeProvider import com.michaldrabik.ui_discover_movies.recycler.DiscoverMovieListItem import com.michaldrabik.ui_model.DiscoverFilters import com.michaldrabik.ui_model.DiscoverSortOrder import com.michaldrabik.ui_model.DiscoverSortOrder.HOT import com.michaldrabik.ui_model.DiscoverSortOrder.NEWEST import com.michaldrabik.ui_model.DiscoverSortOrder.RATING import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.ImageType.POSTER import com.michaldrabik.ui_model.ImageType.PREMIUM import com.michaldrabik.ui_model.Movie import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped internal class DiscoverMoviesCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val moviesRepository: MoviesRepository, private val imagesProvider: MovieImagesProvider, private val imageTypeProvider: ImageTypeProvider, private val translationsRepository: TranslationsRepository, private val settingsRepository: SettingsRepository ) { suspend fun isCacheValid() = withContext(dispatchers.IO) { moviesRepository.discoverMovies.isCacheValid() } suspend fun loadCachedMovies(filters: DiscoverFilters) = withContext(dispatchers.IO) { val myIds = async { moviesRepository.myMovies.loadAllIds() } val watchlistIds = async { moviesRepository.watchlistMovies.loadAllIds() } val hiddenIds = async { moviesRepository.hiddenMovies.loadAllIds() } val cachedMovies = async { moviesRepository.discoverMovies.loadAllCached() } val language = translationsRepository.getLanguage() prepareItems( cachedMovies.await(), myIds.await(), watchlistIds.await(), hiddenIds.await(), filters, language ) } suspend fun loadRemoteMovies(filters: DiscoverFilters) = withContext(dispatchers.IO) { val showAnticipated = !filters.hideAnticipated val showCollection = !filters.hideCollection val genres = filters.genres.toList() val myAsync = async { moviesRepository.myMovies.loadAllIds() } val watchlistSync = async { moviesRepository.watchlistMovies.loadAllIds() } val hiddenAsync = async { moviesRepository.hiddenMovies.loadAllIds() } val (myIds, watchlistIds, hiddenIds) = awaitAll(myAsync, watchlistSync, hiddenAsync) val collectionSize = myIds.size + watchlistIds.size + hiddenIds.size val remoteMovies = moviesRepository.discoverMovies.loadAllRemote(showAnticipated, showCollection, collectionSize, genres) val language = translationsRepository.getLanguage() moviesRepository.discoverMovies.cacheDiscoverMovies(remoteMovies) prepareItems(remoteMovies, myIds, watchlistIds, hiddenIds, filters, language) } private suspend fun prepareItems( movies: List, myMoviesIds: List, watchlistMoviesIds: List, hiddenMoviesIds: List, filters: DiscoverFilters?, language: String ) = coroutineScope { val collectionIds = myMoviesIds + watchlistMoviesIds movies .filter { !hiddenMoviesIds.contains(it.traktId) } .filter { if (filters?.hideCollection == false) true else !collectionIds.contains(it.traktId) } .sortedBy(filters?.feedOrder ?: HOT) .mapIndexed { index, movie -> async { val itemType = imageTypeProvider.getImageType(index) val image = imagesProvider.findCachedImage(movie, itemType) val translation = loadTranslation(language, itemType, movie) DiscoverMovieListItem( movie, image, isCollected = movie.ids.trakt.id in myMoviesIds, isWatchlist = movie.ids.trakt.id in watchlistMoviesIds, translation = translation ) } } .awaitAll() .toMutableList() //.apply { insertPremiumAdItem(this) } .toList() } private fun insertPremiumAdItem(items: MutableList) { val isPremium = settingsRepository.isPremium val isTimePassed = (nowUtcMillis() - settingsRepository.installTimestamp) > ConfigVariant.PREMIUM_AD_DELAY if (isPremium || !isTimePassed) return val premiumAd = DiscoverMovieListItem(Movie.EMPTY, Image.createUnknown(PREMIUM)) if (items.size >= imageTypeProvider.premiumAdPosition) { items.add(imageTypeProvider.premiumAdPosition, premiumAd) } else if (items.isNotEmpty()) { items.add(premiumAd) } } private suspend fun loadTranslation(language: String, itemType: ImageType, movie: Movie) = if (language == Config.DEFAULT_LANGUAGE || itemType == POSTER) null else translationsRepository.loadTranslation(movie, language, true) private fun List.sortedBy(order: DiscoverSortOrder) = when (order) { HOT -> this RATING -> this.sortedWith(compareByDescending { it.votes }.thenBy { it.rating }) NEWEST -> this.sortedWith(compareByDescending { it.year }.thenByDescending { it.released }) } } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/di/DiscoverMoviesModule.kt ================================================ package com.michaldrabik.ui_discover_movies.di import android.content.Context import com.michaldrabik.ui_base.utilities.extensions.isTablet import com.michaldrabik.ui_discover_movies.helpers.itemtype.ImageTypeProvider import com.michaldrabik.ui_discover_movies.helpers.itemtype.PhoneImageTypeProvider import com.michaldrabik.ui_discover_movies.helpers.itemtype.TabletImageTypeProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) class DiscoverMoviesModule { @Provides internal fun providesItemTypeProvider(@ApplicationContext context: Context): ImageTypeProvider { return if (context.isTablet()) { TabletImageTypeProvider() } else { PhoneImageTypeProvider() } } } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/filters/feed/DiscoverMoviesFiltersFeedBottomSheet.kt ================================================ package com.michaldrabik.ui_discover_movies.filters.feed import android.annotation.SuppressLint import android.os.Bundle import android.view.View import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.screenHeight import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_discover_movies.DiscoverMoviesFragment.Companion.REQUEST_DISCOVER_FILTERS import com.michaldrabik.ui_discover_movies.R import com.michaldrabik.ui_discover_movies.databinding.ViewDiscoverMoviesFiltersFeedBinding import com.michaldrabik.ui_discover_movies.filters.feed.DiscoverMoviesFiltersFeedUiEvent.ApplyFilters import com.michaldrabik.ui_discover_movies.filters.feed.DiscoverMoviesFiltersFeedUiEvent.CloseFilters import com.michaldrabik.ui_model.DiscoverSortOrder import com.michaldrabik.ui_model.DiscoverSortOrder.HOT import com.michaldrabik.ui_model.DiscoverSortOrder.NEWEST import com.michaldrabik.ui_model.DiscoverSortOrder.RATING import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint internal class DiscoverMoviesFiltersFeedBottomSheet : BaseBottomSheetFragment(R.layout.view_discover_movies_filters_feed) { private val viewModel by viewModels() private val binding by viewBinding(ViewDiscoverMoviesFiltersFeedBinding::bind) override fun getTheme(): Int = R.style.CustomBottomSheetDialog override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() launchAndRepeatStarted( { viewModel.uiState.collect { render(it) } }, { viewModel.eventFlow.collect { handleEvent(it) } } ) } @SuppressLint("SetTextI18n") private fun setupView() { val behavior: BottomSheetBehavior<*> = (dialog as BottomSheetDialog).behavior behavior.skipCollapsed = true behavior.maxHeight = (screenHeight() * 0.9).toInt() with(binding) { applyButton.onClick { saveFeedOrder() } } } private fun saveFeedOrder() { with(binding) { val feedOrder = when { feedChipHot.isChecked -> HOT feedChipTopRated.isChecked -> RATING feedChipRecent.isChecked -> NEWEST else -> throw IllegalStateException() } viewModel.saveFeedOrder(feedOrder) } } private fun render(uiState: DiscoverMoviesFiltersFeedUiState) { with(uiState) { feedOrder?.let { renderFilters(it) } } } private fun renderFilters(feedOrder: DiscoverSortOrder) { with(binding) { feedChipHot.isChecked = feedOrder == HOT feedChipTopRated.isChecked = feedOrder == RATING feedChipRecent.isChecked = feedOrder == NEWEST } } private fun handleEvent(event: Event<*>) { when (event) { is ApplyFilters -> { setFragmentResult(REQUEST_DISCOVER_FILTERS, Bundle.EMPTY) closeSheet() } is CloseFilters -> closeSheet() } } } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/filters/feed/DiscoverMoviesFiltersFeedUiEvent.kt ================================================ // ktlint-disable filename package com.michaldrabik.ui_discover_movies.filters.feed import com.michaldrabik.ui_base.utilities.events.Event internal sealed class DiscoverMoviesFiltersFeedUiEvent(action: T) : Event(action) { object ApplyFilters : DiscoverMoviesFiltersFeedUiEvent(Unit) object CloseFilters : DiscoverMoviesFiltersFeedUiEvent(Unit) } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/filters/feed/DiscoverMoviesFiltersFeedUiState.kt ================================================ package com.michaldrabik.ui_discover_movies.filters.feed import com.michaldrabik.ui_model.DiscoverSortOrder internal data class DiscoverMoviesFiltersFeedUiState( val feedOrder: DiscoverSortOrder? = null, val isLoading: Boolean? = null, ) ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/filters/feed/DiscoverMoviesFiltersFeedViewModel.kt ================================================ package com.michaldrabik.ui_discover_movies.filters.feed import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_discover_movies.filters.feed.DiscoverMoviesFiltersFeedUiEvent.ApplyFilters import com.michaldrabik.ui_discover_movies.filters.feed.DiscoverMoviesFiltersFeedUiEvent.CloseFilters import com.michaldrabik.ui_model.DiscoverSortOrder import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel internal class DiscoverMoviesFiltersFeedViewModel @Inject constructor( private val settingsRepository: SettingsRepository, ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val feedOrderState = MutableStateFlow(null) private val loadingState = MutableStateFlow(false) init { loadFilters() } private fun loadFilters() { viewModelScope.launch { val settings = settingsRepository.load() feedOrderState.value = settings.discoverMoviesFilterFeed } } fun saveFeedOrder(feedOrder: DiscoverSortOrder) { viewModelScope.launch { if (feedOrder == feedOrderState.value) { eventChannel.send(CloseFilters) return@launch } val settings = settingsRepository.load() settingsRepository.update( settings.copy(discoverMoviesFilterFeed = feedOrder) ) eventChannel.send(ApplyFilters) } } val uiState = combine( feedOrderState, loadingState, ) { s1, s2 -> DiscoverMoviesFiltersFeedUiState( feedOrder = s1, isLoading = s2, ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = DiscoverMoviesFiltersFeedUiState() ) } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/filters/genres/DiscoverMoviesFiltersGenresBottomSheet.kt ================================================ package com.michaldrabik.ui_discover_movies.filters.genres import android.annotation.SuppressLint import android.os.Bundle import android.view.View import androidx.core.content.ContextCompat import androidx.core.view.forEach import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.chip.Chip import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.screenHeight import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_discover_movies.DiscoverMoviesFragment.Companion.REQUEST_DISCOVER_FILTERS import com.michaldrabik.ui_discover_movies.R import com.michaldrabik.ui_discover_movies.databinding.ViewDiscoverMoviesFiltersGenresBinding import com.michaldrabik.ui_discover_movies.filters.genres.DiscoverMoviesFiltersGenresUiEvent.ApplyFilters import com.michaldrabik.ui_discover_movies.filters.genres.DiscoverMoviesFiltersGenresUiEvent.CloseFilters import com.michaldrabik.ui_model.Genre import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint internal class DiscoverMoviesFiltersGenresBottomSheet : BaseBottomSheetFragment(R.layout.view_discover_movies_filters_genres) { private val viewModel by viewModels() private val binding by viewBinding(ViewDiscoverMoviesFiltersGenresBinding::bind) override fun getTheme(): Int = R.style.CustomBottomSheetDialog override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() launchAndRepeatStarted( { viewModel.uiState.collect { render(it) } }, { viewModel.eventFlow.collect { handleEvent(it) } } ) } @SuppressLint("SetTextI18n") private fun setupView() { val behavior: BottomSheetBehavior<*> = (dialog as BottomSheetDialog).behavior behavior.skipCollapsed = true behavior.maxHeight = (screenHeight() * 0.9).toInt() with(binding) { applyButton.onClick { saveGenres() } clearButton.onClick { renderGenres(emptyList()) } } } private fun saveGenres() { with(binding) { val genres = mutableListOf().apply { genresChipGroup.forEach { chip -> if ((chip as Chip).isChecked) { add(Genre.valueOf(chip.tag.toString())) } } } viewModel.saveGenres(genres) } } private fun render(uiState: DiscoverMoviesFiltersGenresUiState) { with(uiState) { genres?.let { renderGenres(it) } } } private fun renderGenres(genres: List) { binding.genresChipGroup.removeAllViews() binding.clearButton.visibleIf(genres.isNotEmpty()) val genresNames = genres.map { it.name } Genre.values() .sortedBy { requireContext().getString(it.displayName) } .forEach { genre -> val chip = Chip(requireContext()).apply { tag = genre.name text = requireContext().getString(genre.displayName) isCheckable = true isCheckedIconVisible = false setEnsureMinTouchTargetSize(false) shapeAppearanceModel = shapeAppearanceModel.toBuilder() .setAllCornerSizes(100f) .build() chipBackgroundColor = ContextCompat.getColorStateList(context, R.color.selector_discover_chip_background) setChipStrokeColorResource(R.color.selector_discover_chip_text) setChipStrokeWidthResource(R.dimen.discoverFilterChipStroke) setTextColor(ContextCompat.getColorStateList(context, R.color.selector_discover_chip_text)) isChecked = genre.name in genresNames } binding.genresChipGroup.addView(chip) } } private fun handleEvent(event: Event<*>) { when (event) { is ApplyFilters -> { setFragmentResult(REQUEST_DISCOVER_FILTERS, Bundle.EMPTY) closeSheet() } is CloseFilters -> closeSheet() } } } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/filters/genres/DiscoverMoviesFiltersGenresUiEvent.kt ================================================ // ktlint-disable filename package com.michaldrabik.ui_discover_movies.filters.genres import com.michaldrabik.ui_base.utilities.events.Event internal sealed class DiscoverMoviesFiltersGenresUiEvent(action: T) : Event(action) { object ApplyFilters : DiscoverMoviesFiltersGenresUiEvent(Unit) object CloseFilters : DiscoverMoviesFiltersGenresUiEvent(Unit) } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/filters/genres/DiscoverMoviesFiltersGenresUiState.kt ================================================ package com.michaldrabik.ui_discover_movies.filters.genres import com.michaldrabik.ui_model.Genre internal data class DiscoverMoviesFiltersGenresUiState( val genres: List? = null, val isLoading: Boolean? = null, ) ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/filters/genres/DiscoverMoviesFiltersGenresViewModel.kt ================================================ package com.michaldrabik.ui_discover_movies.filters.genres import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_discover_movies.filters.genres.DiscoverMoviesFiltersGenresUiEvent.ApplyFilters import com.michaldrabik.ui_discover_movies.filters.genres.DiscoverMoviesFiltersGenresUiEvent.CloseFilters import com.michaldrabik.ui_model.Genre import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel internal class DiscoverMoviesFiltersGenresViewModel @Inject constructor( private val settingsRepository: SettingsRepository, ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val genresState = MutableStateFlow?>(null) private val loadingState = MutableStateFlow(false) init { loadGenres() } private fun loadGenres() { viewModelScope.launch { val settings = settingsRepository.load() genresState.value = settings.discoverMoviesFilterGenres.toList() } } fun saveGenres(genres: List) { viewModelScope.launch { if (genres == genresState.value) { eventChannel.send(CloseFilters) return@launch } val settings = settingsRepository.load() settingsRepository.update( settings.copy( discoverMoviesFilterGenres = genres.toList(), ) ) eventChannel.send(ApplyFilters) } } val uiState = combine( genresState, loadingState, ) { s1, s2 -> DiscoverMoviesFiltersGenresUiState( genres = s1, isLoading = s2, ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = DiscoverMoviesFiltersGenresUiState() ) } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/filters/views/DiscoverMoviesFiltersView.kt ================================================ package com.michaldrabik.ui_discover_movies.filters.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import androidx.core.view.children import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_discover_movies.R import com.michaldrabik.ui_discover_movies.databinding.ViewDiscoverMoviesFiltersBinding import com.michaldrabik.ui_model.DiscoverFilters import com.michaldrabik.ui_model.DiscoverSortOrder import com.michaldrabik.ui_model.DiscoverSortOrder.HOT import com.michaldrabik.ui_model.DiscoverSortOrder.NEWEST import com.michaldrabik.ui_model.DiscoverSortOrder.RATING import com.michaldrabik.ui_model.Genre class DiscoverMoviesFiltersView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewDiscoverMoviesFiltersBinding.inflate(LayoutInflater.from(context), this) var onFeedChipClick: (() -> Unit)? = null var onGenresChipClick: (() -> Unit)? = null var onHideCollectionChipClick: (() -> Unit)? = null var onHideAnticipatedChipClick: (() -> Unit)? = null private lateinit var filters: DiscoverFilters init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) with(binding) { discoverMoviesGenresChip.text = discoverMoviesGenresChip.text.toString().filter { it.isLetter() } discoverMoviesGenresChip.onClick { onGenresChipClick?.invoke() } discoverMoviesFeedChip.isSelected = true discoverMoviesFeedChip.onClick { onFeedChipClick?.invoke() } discoverMoviesCollectionChip.onClick { onHideCollectionChipClick?.invoke() } discoverMoviesAnticipatedChip.onClick { onHideAnticipatedChipClick?.invoke() } } } fun bind(filters: DiscoverFilters) { this.filters = filters bindFeed(filters.feedOrder) bindGenres(filters.genres) with(binding) { discoverMoviesCollectionChip.isChecked = filters.hideCollection discoverMoviesAnticipatedChip.isChecked = filters.hideAnticipated } } private fun bindFeed(feed: DiscoverSortOrder) { with(binding) { discoverMoviesFeedChip.text = when (feed) { HOT -> context.getString(R.string.textHot) RATING -> context.getString(R.string.textSortRated) NEWEST -> context.getString(R.string.textSortNewest) } } } private fun bindGenres(genres: List) { with(binding) { discoverMoviesGenresChip.isSelected = genres.isNotEmpty() discoverMoviesGenresChip.text = when { genres.isEmpty() -> context.getString(R.string.textGenres).filter { it.isLetter() } genres.size == 1 -> context.getString(genres.first().displayName) genres.size == 2 -> "${context.getString(genres[0].displayName)}, ${context.getString(genres[1].displayName)}" else -> "${context.getString(genres[0].displayName)}, ${context.getString(genres[1].displayName)} + ${genres.size - 2}" } } } override fun setEnabled(enabled: Boolean) { binding.discoverMoviesChips.children.forEach { it.isEnabled = enabled } } } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/helpers/DiscoverMoviesLayoutManagerProvider.kt ================================================ package com.michaldrabik.ui_discover_movies.helpers import android.content.Context import androidx.recyclerview.widget.GridLayoutManager import com.michaldrabik.common.Config.MAIN_GRID_SPAN import com.michaldrabik.common.Config.MAIN_GRID_SPAN_TABLET import com.michaldrabik.ui_base.utilities.extensions.isTablet internal object DiscoverMoviesLayoutManagerProvider { fun provideLayoutManager(context: Context): GridLayoutManager { val span = if (context.isTablet()) MAIN_GRID_SPAN_TABLET else MAIN_GRID_SPAN return GridLayoutManager(context, span) } } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/helpers/itemtype/ImageTypeProvider.kt ================================================ package com.michaldrabik.ui_discover_movies.helpers.itemtype import com.michaldrabik.ui_model.ImageType internal interface ImageTypeProvider { val premiumAdPosition: Int fun getImageType(position: Int): ImageType } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/helpers/itemtype/PhoneImageTypeProvider.kt ================================================ package com.michaldrabik.ui_discover_movies.helpers.itemtype import com.michaldrabik.ui_model.ImageType private const val BUFFER = 14 internal class PhoneImageTypeProvider : ImageTypeProvider { override val premiumAdPosition = 29 override fun getImageType(position: Int): ImageType { if (position % BUFFER == 0) return ImageType.FANART_WIDE if ((position + (BUFFER - 5)) % BUFFER == 0) return ImageType.FANART if ((position + (BUFFER - 9)) % BUFFER == 0) return ImageType.FANART return ImageType.POSTER } } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/helpers/itemtype/TabletImageTypeProvider.kt ================================================ package com.michaldrabik.ui_discover_movies.helpers.itemtype import com.michaldrabik.ui_model.ImageType private const val BUFFER = 11 internal class TabletImageTypeProvider : ImageTypeProvider { override val premiumAdPosition = 30 override fun getImageType(position: Int): ImageType { if (position % BUFFER == 0) return ImageType.FANART_WIDE if ((position + (BUFFER - 10)) % BUFFER == 0) return ImageType.FANART_WIDE if ((position + (BUFFER - 2)) % BUFFER == 0) return ImageType.FANART if ((position + (BUFFER - 5)) % BUFFER == 0) return ImageType.FANART if ((position + (BUFFER - 8)) % BUFFER == 0) return ImageType.FANART return ImageType.POSTER } } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/recycler/DiscoverMovieItemDiffCallback.kt ================================================ package com.michaldrabik.ui_discover_movies.recycler import androidx.recyclerview.widget.DiffUtil class DiscoverMovieItemDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: DiscoverMovieListItem, newItem: DiscoverMovieListItem) = oldItem.movie.ids.trakt == newItem.movie.ids.trakt override fun areContentsTheSame(oldItem: DiscoverMovieListItem, newItem: DiscoverMovieListItem) = oldItem.image == newItem.image && oldItem.isLoading == newItem.isLoading && oldItem.isCollected == newItem.isCollected && oldItem.isWatchlist == newItem.isWatchlist && oldItem.translation == newItem.translation } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/recycler/DiscoverMovieListItem.kt ================================================ package com.michaldrabik.ui_discover_movies.recycler import com.michaldrabik.ui_base.common.MovieListItem import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Translation data class DiscoverMovieListItem( override val movie: Movie, override val image: Image, override var isLoading: Boolean = false, val isCollected: Boolean = false, val isWatchlist: Boolean = false, val translation: Translation? = null ) : MovieListItem ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/recycler/DiscoverMoviesAdapter.kt ================================================ package com.michaldrabik.ui_discover_movies.recycler import android.view.ViewGroup import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView import com.michaldrabik.ui_base.BaseMovieAdapter import com.michaldrabik.ui_discover_movies.views.MovieFanartView import com.michaldrabik.ui_discover_movies.views.MoviePosterView import com.michaldrabik.ui_discover_movies.views.MoviePremiumView import com.michaldrabik.ui_model.ImageType.FANART import com.michaldrabik.ui_model.ImageType.FANART_WIDE import com.michaldrabik.ui_model.ImageType.POSTER import com.michaldrabik.ui_model.ImageType.PREMIUM class DiscoverMoviesAdapter( private val itemClickListener: (DiscoverMovieListItem) -> Unit, private val itemLongClickListener: (DiscoverMovieListItem) -> Unit, private val missingImageListener: (DiscoverMovieListItem, Boolean) -> Unit, listChangeListener: () -> Unit ) : BaseMovieAdapter( listChangeListener = listChangeListener ) { init { stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY } override val asyncDiffer = AsyncListDiffer(this, DiscoverMovieItemDiffCallback()) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { POSTER.id -> BaseViewHolder( MoviePosterView(parent.context).apply { itemClickListener = this@DiscoverMoviesAdapter.itemClickListener itemLongClickListener = this@DiscoverMoviesAdapter.itemLongClickListener missingImageListener = this@DiscoverMoviesAdapter.missingImageListener } ) FANART.id, FANART_WIDE.id -> BaseViewHolder( MovieFanartView(parent.context).apply { itemClickListener = this@DiscoverMoviesAdapter.itemClickListener itemLongClickListener = this@DiscoverMoviesAdapter.itemLongClickListener missingImageListener = this@DiscoverMoviesAdapter.missingImageListener } ) PREMIUM.id -> BaseViewHolder( MoviePremiumView(parent.context).apply { itemClickListener = this@DiscoverMoviesAdapter.itemClickListener } ) else -> throw IllegalStateException("Unknown view type.") } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val item = asyncDiffer.currentList[position] when (holder.itemViewType) { POSTER.id -> (holder.itemView as MoviePosterView).bind(item) FANART.id, FANART_WIDE.id -> (holder.itemView as MovieFanartView).bind(item) PREMIUM.id -> (holder.itemView as MoviePremiumView).bind(item) } } override fun getItemViewType(position: Int) = asyncDiffer.currentList[position].image.type.id } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/views/MovieFanartView.kt ================================================ package com.michaldrabik.ui_discover_movies.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.widget.ImageView import com.bumptech.glide.Glide import com.michaldrabik.ui_base.common.views.MovieView import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.onLongClick import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_discover_movies.R import com.michaldrabik.ui_discover_movies.databinding.ViewMovieFanartBinding import com.michaldrabik.ui_discover_movies.recycler.DiscoverMovieListItem import com.michaldrabik.ui_model.ImageStatus.AVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNAVAILABLE class MovieFanartView : MovieView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewMovieFanartBinding.inflate(LayoutInflater.from(context), this) init { with(binding.movieFanartRoot) { onClick { itemClickListener?.invoke(item) } onLongClick { itemLongClickListener?.invoke(item) } } } override val imageView: ImageView = binding.movieFanartImage override val placeholderView: ImageView = binding.movieFanartPlaceholder private lateinit var item: DiscoverMovieListItem override fun bind(item: DiscoverMovieListItem) { super.bind(item) clear() this.item = item with(binding) { movieFanartTitle.text = if (item.translation?.title.isNullOrBlank()) item.movie.title else item.translation?.title movieFanartProgress.visibleIf(item.isLoading) movieFanartBadge.visibleIf(item.isCollected) movieFanartBadgeLater.visibleIf(item.isWatchlist) } loadImage(item) } override fun loadImage(item: DiscoverMovieListItem) { super.loadImage(item) if (item.image.status == UNAVAILABLE) { binding.movieFanartRoot.setBackgroundResource(R.drawable.bg_media_view_placeholder) } } override fun onImageLoadFail(item: DiscoverMovieListItem) { super.onImageLoadFail(item) if (item.image.status == AVAILABLE) { binding.movieFanartRoot.setBackgroundResource(R.drawable.bg_media_view_placeholder) } } private fun clear() { with(binding) { movieFanartTitle.text = "" movieFanartProgress.gone() movieFanartPlaceholder.gone() movieFanartRoot.setBackgroundResource(R.drawable.bg_media_view_elevation) movieFanartBadge.gone() Glide.with(this@MovieFanartView).clear(movieFanartImage) } } } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/views/MoviePosterView.kt ================================================ package com.michaldrabik.ui_discover_movies.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.widget.ImageView import com.bumptech.glide.Glide import com.michaldrabik.ui_base.common.views.MovieView import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.onLongClick import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_discover_movies.R import com.michaldrabik.ui_discover_movies.databinding.ViewMoviePosterBinding import com.michaldrabik.ui_discover_movies.recycler.DiscoverMovieListItem import com.michaldrabik.ui_model.ImageStatus.AVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNAVAILABLE class MoviePosterView : MovieView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewMoviePosterBinding.inflate(LayoutInflater.from(context), this) init { with(binding.moviePosterRoot) { onClick { itemClickListener?.invoke(item) } onLongClick { itemLongClickListener?.invoke(item) } } } override val imageView: ImageView = binding.moviePosterImage override val placeholderView: ImageView = binding.moviePosterPlaceholder private lateinit var item: DiscoverMovieListItem override fun bind(item: DiscoverMovieListItem) { super.bind(item) clear() this.item = item with(binding) { moviePosterTitle.text = item.movie.title moviePosterProgress.visibleIf(item.isLoading) moviePosterBadge.visibleIf(item.isCollected) moviePosterLaterBadge.visibleIf(item.isWatchlist) } loadImage(item) } override fun loadImage(item: DiscoverMovieListItem) { if (item.image.status == UNAVAILABLE) { with(binding) { moviePosterTitle.visible() moviePosterRoot.setBackgroundResource(R.drawable.bg_media_view_placeholder) } } super.loadImage(item) } override fun onImageLoadFail(item: DiscoverMovieListItem) { super.onImageLoadFail(item) if (item.image.status == AVAILABLE) { with(binding) { moviePosterTitle.visible() moviePosterRoot.setBackgroundResource(R.drawable.bg_media_view_placeholder) } } } private fun clear() { with(binding) { moviePosterTitle.text = "" moviePosterTitle.gone() moviePosterRoot.setBackgroundResource(R.drawable.bg_media_view_elevation) moviePosterPlaceholder.gone() moviePosterProgress.gone() moviePosterBadge.gone() Glide.with(this@MoviePosterView).clear(moviePosterImage) } } } ================================================ FILE: ui-discover-movies/src/main/java/com/michaldrabik/ui_discover_movies/views/MoviePremiumView.kt ================================================ package com.michaldrabik.ui_discover_movies.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.widget.ImageView import com.michaldrabik.ui_base.common.views.MovieView import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_discover_movies.databinding.ViewMoviePremiumBinding import com.michaldrabik.ui_discover_movies.recycler.DiscoverMovieListItem class MoviePremiumView : MovieView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewMoviePremiumBinding.inflate(LayoutInflater.from(context), this, true) init { binding.viewMoviePremiumRoot.onClick { itemClickListener?.invoke(item) } } override val imageView: ImageView = binding.viewMoviePremiumImageStub override val placeholderView: ImageView = binding.viewMoviePremiumImageStub private lateinit var item: DiscoverMovieListItem override fun bind(item: DiscoverMovieListItem) { super.bind(item) this.item = item } } ================================================ FILE: ui-discover-movies/src/main/res/layout/fragment_discover_movies.xml ================================================ ================================================ FILE: ui-discover-movies/src/main/res/layout/view_discover_movies_filters.xml ================================================ ================================================ FILE: ui-discover-movies/src/main/res/layout/view_discover_movies_filters_feed.xml ================================================ ================================================ FILE: ui-discover-movies/src/main/res/layout/view_discover_movies_filters_genres.xml ================================================ ================================================ FILE: ui-discover-movies/src/main/res/layout/view_movie_fanart.xml ================================================ ================================================ FILE: ui-discover-movies/src/main/res/layout/view_movie_poster.xml ================================================ ================================================ FILE: ui-discover-movies/src/main/res/layout/view_movie_premium.xml ================================================ ================================================ FILE: ui-discover-movies/src/main/res/values/strings.xml ================================================ Feed: Hide Anticipated Movies Hide Collection ================================================ FILE: ui-discover-movies/src/main/res/values-ar/strings.xml ================================================ ترتيب حسب: إخفاء الأفلام المرتقبة إخفاء محتويات المجموعة ================================================ FILE: ui-discover-movies/src/main/res/values-de/strings.xml ================================================ Feed: Bald erscheinende Filme ausblenden Sammlung ausblenden ================================================ FILE: ui-discover-movies/src/main/res/values-es/strings.xml ================================================ Feed: Ocultar Películas Anticipadas Ocultar Colección ================================================ FILE: ui-discover-movies/src/main/res/values-fi/strings.xml ================================================ Syöte: Piilota odotetut elokuvat Piilota kokoelma ================================================ FILE: ui-discover-movies/src/main/res/values-fr/strings.xml ================================================ Fil : Cacher les films attendus Cacher la collection ================================================ FILE: ui-discover-movies/src/main/res/values-it/strings.xml ================================================ Riepilogo: Nascondi film attesi Nascondi raccolta ================================================ FILE: ui-discover-movies/src/main/res/values-pl/strings.xml ================================================ Strumień: Ukryj nadchodzące Ukryj kolekcję ================================================ FILE: ui-discover-movies/src/main/res/values-pt/strings.xml ================================================ Feed: Ocultar filmes aguardados Ocultar assistidos ================================================ FILE: ui-discover-movies/src/main/res/values-ru/strings.xml ================================================ Сортировать по: Скрыть ожидаемые фильмы Скрыть коллекцию ================================================ FILE: ui-discover-movies/src/main/res/values-tr/strings.xml ================================================ Akış: Beklenen Filmleri Gizle Koleksiyonu Gizle ================================================ FILE: ui-discover-movies/src/main/res/values-uk/strings.xml ================================================ Стрічка: Приховати очікувані фільми Приховати колекцію ================================================ FILE: ui-discover-movies/src/main/res/values-vi/strings.xml ================================================ Nguồn cấp: Ẩn phim được mong đợi Ẩn bộ sưu tập ================================================ FILE: ui-discover-movies/src/main/res/values-zh/strings.xml ================================================ 信息流: 隐藏备受期待的电影 隐藏合集中的项目 ================================================ FILE: ui-episodes/.gitignore ================================================ /build ================================================ FILE: ui-episodes/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'kotlin-parcelize' apply plugin: 'dagger.hilt.android.plugin' apply from: '../versions.gradle' android { kotlinOptions { jvmTarget = "17" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } buildFeatures { viewBinding true } compileSdkVersion versions.compileSdk defaultConfig { minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk compileSdkVersion versions.compileSdk testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false } } namespace 'com.michaldrabik.ui_episodes' } dependencies { implementation project(':common') implementation project(':data-local') implementation project(':data-remote') implementation project(':ui-base') implementation project(':ui-navigation') implementation project(':repository') implementation project(':ui-model') implementation project(':ui-comments') implementation libs.hilt.android ksp libs.hilt.compiler coreLibraryDesugaring libs.android.desugar } ================================================ FILE: ui-episodes/src/main/AndroidManifest.xml ================================================ ================================================ FILE: ui-episodes/src/main/java/com/michaldrabik/ui_episodes/details/EpisodeDetailsBottomSheet.kt ================================================ package com.michaldrabik.ui_episodes.details import android.annotation.SuppressLint import android.graphics.Typeface.BOLD import android.graphics.Typeface.NORMAL import android.os.Bundle import android.os.Parcelable import android.view.View import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.tabs.TabLayout import com.michaldrabik.common.Config import com.michaldrabik.common.Config.IMAGE_FADE_DURATION_MS import com.michaldrabik.common.Config.SPOILERS_HIDE_SYMBOL import com.michaldrabik.common.Config.SPOILERS_REGEX import com.michaldrabik.common.extensions.dateFromMillis import com.michaldrabik.common.extensions.toLocalZone import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.common.sheets.ratings.RatingsBottomSheet import com.michaldrabik.ui_base.common.sheets.ratings.RatingsBottomSheet.Options.Operation import com.michaldrabik.ui_base.common.sheets.ratings.RatingsBottomSheet.Options.Type import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.capitalizeWords import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.fadeIf import com.michaldrabik.ui_base.utilities.extensions.fadeIn import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.invisible import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.requireParcelable import com.michaldrabik.ui_base.utilities.extensions.setTextFade import com.michaldrabik.ui_base.utilities.extensions.showErrorSnackbar import com.michaldrabik.ui_base.utilities.extensions.showInfoSnackbar import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.extensions.withFailListener import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_comments.CommentView import com.michaldrabik.ui_episodes.R import com.michaldrabik.ui_episodes.databinding.ViewEpisodeDetailsBinding import com.michaldrabik.ui_model.Comment import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.SpoilersSettings import com.michaldrabik.ui_model.Translation import com.michaldrabik.ui_navigation.java.NavigationArgs import com.michaldrabik.ui_navigation.java.NavigationArgs.ACTION_EPISODE_TAB_SELECTED import com.michaldrabik.ui_navigation.java.NavigationArgs.ACTION_EPISODE_WATCHED import com.michaldrabik.ui_navigation.java.NavigationArgs.ACTION_NEW_COMMENT import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_COMMENT import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_COMMENT_ACTION import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_COMMENT_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_EPISODE_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_OPTIONS import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_REPLY_USER import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_COMMENT import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_EPISODE_DETAILS import dagger.hilt.android.AndroidEntryPoint import kotlinx.parcelize.Parcelize import timber.log.Timber import java.util.Locale.ENGLISH @AndroidEntryPoint class EpisodeDetailsBottomSheet : BaseBottomSheetFragment(R.layout.view_episode_details) { companion object { fun createBundle( ids: Ids, episode: Episode, seasonEpisodesIds: List?, isWatched: Boolean, showButton: Boolean, showTabs: Boolean, ): Bundle { val options = Options( ids = ids, episode = episode, seasonEpisodesIds = seasonEpisodesIds, isWatched = isWatched, showButton = showButton, showTabs = showTabs ) return bundleOf(ARG_OPTIONS to options) } } private val viewModel by viewModels() private val binding by viewBinding(ViewEpisodeDetailsBinding::bind) private val options by lazy { requireParcelable(ARG_OPTIONS) } private val cornerRadius by lazy { dimenToPx(R.dimen.bottomSheetCorner).toFloat() } private var spoilerTitle: String? = null private var spoilerDescription: String? = null private var spoilerRating: String? = null override fun getTheme(): Int = R.style.CustomBottomSheetDialog override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() with(viewModel) { launchAndRepeatStarted( { uiState.collect { render(it) } }, { messageFlow.collect { renderSnackbar(it) } }, doAfterLaunch = { val (ids, episode, seasonEpisodes) = options loadSeason(ids.trakt, episode, seasonEpisodes?.toIntArray()) loadTranslation(ids.trakt, episode) loadImage(ids.tmdb, episode) loadRatings(episode) } ) } } private fun setupView() { binding.run { val (ids, episode, _, isWatched, showButton, showTabs) = options episodeDetailsTitle.text = when (episode.title) { "Episode ${episode.number}" -> String.format(ENGLISH, requireContext().getString(R.string.textEpisode), episode.number) else -> episode.title } episodeDetailsOverview.text = episode.overview.ifBlank { getString(R.string.textNoDescription) } episodeDetailsButton.run { visibleIf(showButton && !isWatched) onClick { setFragmentResult(REQUEST_EPISODE_DETAILS, bundleOf(ACTION_EPISODE_WATCHED to !isWatched)) closeSheet() } } episodeDetailsRatingLayout.visibleIf(episode.votes > 0) if (!showTabs) episodeDetailsTabs.gone() episodeDetailsRating.text = String.format(ENGLISH, getString(R.string.textVotes), episode.rating, episode.votes) episodeDetailsCommentsButton.text = String.format(ENGLISH, getString(R.string.textLoadCommentsCount), episode.commentCount) episodeDetailsCommentsButton.onClick { viewModel.loadComments(ids.trakt, episode.season, episode.number) } episodeDetailsPostCommentButton.onClick { openPostCommentSheet() } } } private fun openRateDialog() { setFragmentResultListener(NavigationArgs.REQUEST_RATING) { _, bundle -> when (bundle.getParcelable(NavigationArgs.RESULT)) { Operation.SAVE -> renderSnackbar(MessageEvent.Info(R.string.textRateSaved)) Operation.REMOVE -> renderSnackbar(MessageEvent.Info(R.string.textRateRemoved)) else -> Timber.w("Unknown result.") } viewModel.loadRatings(options.episode) setFragmentResult(REQUEST_EPISODE_DETAILS, bundleOf(NavigationArgs.ACTION_RATING_CHANGED to true)) } val bundle = RatingsBottomSheet.createBundle(options.episode.ids.trakt, Type.EPISODE) navigateTo(R.id.actionEpisodeDetailsDialogToRate, bundle) } private fun openPostCommentSheet(comment: Comment? = null) { setFragmentResultListener(REQUEST_COMMENT) { _, bundle -> renderSnackbar(MessageEvent.Info(R.string.textCommentPosted)) when (bundle.getString(ARG_COMMENT_ACTION)) { ACTION_NEW_COMMENT -> { val newComment = bundle.getParcelable(ARG_COMMENT)!! viewModel.addNewComment(newComment) } } } val bundle = when { comment != null -> bundleOf( ARG_COMMENT_ID to comment.getReplyId(), ARG_REPLY_USER to comment.user.username ) else -> bundleOf(ARG_EPISODE_ID to options.episode.ids.trakt.id) } navigateTo(R.id.actionEpisodeDetailsDialogToPostComment, bundle) } @SuppressLint("SetTextI18n") private fun render(uiState: EpisodeDetailsUiState) { uiState.run { with(binding) { val episode = options.episode dateFormat?.let { val millis = episode.firstAired?.toInstant()?.toEpochMilli() ?: -1 val date = if (millis == -1L) { getString(R.string.textTba) } else { it.format(dateFromMillis(millis).toLocalZone()).capitalizeWords() } val name = String.format(ENGLISH, getString(R.string.textSeasonEpisodeDate), episode.season, episode.number, date) val runtime = "${episode.runtime} ${getString(R.string.textMinutesShort)}" episodeDetailsName.text = if (episode.runtime > 0) "$name | $runtime" else name } isImageLoading.let { episodeDetailsProgress.visibleIf(it) } image?.let { renderImage(it, spoilers) } isCommentsLoading.let { episodeDetailsButtons.visibleIf(!it) episodeDetailsCommentsProgress.visibleIf(it) } episodes?.let { renderEpisodes(it) } comments?.let { comments -> episodeDetailsComments.removeAllViews() comments.forEach { val view = CommentView(requireContext()).apply { bind(it, commentsDateFormat) if (it.replies > 0) { onRepliesClickListener = { comment -> viewModel.loadCommentReplies(comment) } } if (it.isSignedIn) { onReplyClickListener = { comment -> openPostCommentSheet(comment) } } if (it.replies == 0L && it.isMe && it.isSignedIn) { onDeleteClickListener = { comment -> openDeleteCommentDialog(comment) } } } episodeDetailsComments.addView(view) } episodeDetailsComments.fadeIf(comments.isNotEmpty()) episodeDetailsCommentsEmpty.fadeIf(comments.isEmpty()) episodeDetailsPostCommentButton.fadeIf(isSignedIn) episodeDetailsCommentsButton.isEnabled = false episodeDetailsCommentsButton.text = String.format(ENGLISH, getString(R.string.textLoadCommentsCount), comments.size) } rating?.let { state -> episodeDetailsRateProgress.visibleIf(state.rateLoading == true) episodeDetailsRateButton.visibleIf(state.rateLoading == false) episodeDetailsRateButton.onClick { if (state.rateAllowed == true) { openRateDialog() } else { renderSnackbar(MessageEvent.Info(R.string.textSignBeforeRate)) } } if (state.hasRating()) { episodeDetailsRateButton.setTypeface(null, BOLD) episodeDetailsRateButton.text = "${state.userRating?.rating}/10" } else { episodeDetailsRateButton.setTypeface(null, NORMAL) episodeDetailsRateButton.setText(R.string.textRate) } } spoilers?.let { renderRating(it) } renderTitle(translation, spoilers) renderDescription(translation, spoilers) } } } private fun renderTitle( translation: Translation?, spoilersSettings: SpoilersSettings?, ) { with(binding) { var title = if (translation?.title?.isNotBlank() == true) { translation.title } else if (episodeDetailsTitle.text.isBlank()) { when (options.episode.title) { "Episode ${options.episode.number}" -> String.format(ENGLISH, requireContext().getString(R.string.textEpisode), options.episode.number) else -> options.episode.title } } else { episodeDetailsTitle.text.toString() } val isEpisodeTitleHidden = !options.isWatched && spoilersSettings?.isEpisodeTitleHidden == true if (isEpisodeTitleHidden) { if (spoilerTitle == null) { spoilerTitle = String(title.toCharArray()) } title = SPOILERS_REGEX.replace(title, SPOILERS_HIDE_SYMBOL) } if (title.isNotBlank()) { episodeDetailsTitle.setTextFade(title, duration = 0) } if (spoilersSettings?.isTapToReveal == true) { episodeDetailsTitle.onClick { spoilerTitle?.let { episodeDetailsTitle.setTextFade(it, duration = 0) } } } } } private fun renderDescription( translation: Translation?, spoilersSettings: SpoilersSettings?, ) { with(binding) { var description = if (translation?.overview?.isNotBlank() == true) { translation.overview } else if (episodeDetailsOverview.text.isBlank()) { options.episode.overview.ifBlank { getString(R.string.textNoDescription) } } else { episodeDetailsOverview.text.toString() } if (!options.isWatched && spoilersSettings?.isEpisodeDescriptionHidden == true) { if (spoilerDescription == null) { spoilerDescription = String(description.toCharArray()) } description = SPOILERS_REGEX.replace(description, SPOILERS_HIDE_SYMBOL) } if (description.isNotBlank()) { episodeDetailsOverview.setTextFade(description, duration = 0) } if (spoilersSettings?.isTapToReveal == true) { episodeDetailsOverview.onClick { spoilerDescription?.let { episodeDetailsOverview.setTextFade(it, duration = 0) } } } } } private fun renderRating(spoilersSettings: SpoilersSettings) { with(binding) { val isSpoilerHidden = !options.isWatched && spoilersSettings.isEpisodeRatingHidden if (isSpoilerHidden) { if (spoilerRating == null) { spoilerRating = episodeDetailsRating.text.toString() } episodeDetailsRating.text = Config.SPOILERS_RATINGS_VOTES_HIDE_SYMBOL } if (spoilersSettings.isTapToReveal) { episodeDetailsRating.onClick { spoilerRating?.let { episodeDetailsRating.text = it } } } } } private fun renderImage( image: Image, spoilers: SpoilersSettings?, tapToReveal: Boolean = false, ) { with(binding) { if (!options.isWatched && spoilers?.isEpisodeImageHidden == true && !tapToReveal) { episodeDetailsImage.invisible() episodeDetailsImagePlaceholder.visible() episodeDetailsImagePlaceholder.setImageResource(R.drawable.ic_eye_no) if (spoilers.isTapToReveal) { episodeDetailsImagePlaceholder.onClick { renderImage(image, spoilers, tapToReveal = true) } } return } episodeDetailsImage.visible() episodeDetailsImagePlaceholder.invisible() Glide.with(this@EpisodeDetailsBottomSheet) .load("${Config.TMDB_IMAGE_BASE_STILL_URL}${image.fileUrl}") .transform(CenterCrop(), GranularRoundedCorners(cornerRadius, cornerRadius, 0F, 0F)) .transition(DrawableTransitionOptions.withCrossFade(IMAGE_FADE_DURATION_MS)) .withFailListener { episodeDetailsImagePlaceholder.visible() episodeDetailsImagePlaceholder.setImageResource(R.drawable.ic_television) episodeDetailsImagePlaceholder.setOnClickListener(null) } .into(episodeDetailsImage) } } private fun renderEpisodes(episodes: List) { with(binding.episodeDetailsTabs) { removeAllTabs() removeOnTabSelectedListener(tabSelectedListener) episodes.forEach { addTab( newTab() .setText("${options.episode.season}x${it.number.toString().padStart(2, '0')}") .setTag(it) ) } val index = episodes.indexOfFirst { it.number == options.episode.number } // Small trick to avoid UI tab change flick getTabAt(index)?.select() post { getTabAt(index)?.select() addOnTabSelectedListener(tabSelectedListener) } if (options.showTabs && episodes.isNotEmpty()) { fadeIn(duration = 200, startDelay = 100, withHardware = true) } else { gone() } } } private fun renderSnackbar(message: MessageEvent) { when (message) { is MessageEvent.Info -> binding.episodeDetailsSnackbarHost.showInfoSnackbar(getString(message.textRestId)) is MessageEvent.Error -> binding.episodeDetailsSnackbarHost.showErrorSnackbar(getString(message.textRestId)) } } private fun openDeleteCommentDialog(comment: Comment) { MaterialAlertDialogBuilder(requireContext(), R.style.AlertDialog) .setBackground(ContextCompat.getDrawable(requireContext(), R.drawable.bg_dialog)) .setTitle(R.string.textCommentConfirmDeleteTitle) .setMessage(R.string.textCommentConfirmDelete) .setPositiveButton(R.string.textYes) { _, _ -> viewModel.deleteComment(comment) } .setNegativeButton(R.string.textNo) { _, _ -> } .show() } private val tabSelectedListener = object : TabLayout.OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab?) { binding.episodeDetailsTabs.removeOnTabSelectedListener(this) closeSheet() setFragmentResult(REQUEST_EPISODE_DETAILS, bundleOf(ACTION_EPISODE_TAB_SELECTED to tab?.tag)) } override fun onTabUnselected(tab: TabLayout.Tab?) = Unit override fun onTabReselected(tab: TabLayout.Tab?) = Unit } @Parcelize private data class Options( val ids: Ids, val episode: Episode, val seasonEpisodesIds: List?, val isWatched: Boolean, val showButton: Boolean, val showTabs: Boolean, ) : Parcelable } ================================================ FILE: ui-episodes/src/main/java/com/michaldrabik/ui_episodes/details/EpisodeDetailsUiState.kt ================================================ package com.michaldrabik.ui_episodes.details import com.michaldrabik.ui_model.Comment import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.RatingState import com.michaldrabik.ui_model.SpoilersSettings import com.michaldrabik.ui_model.Translation import java.time.format.DateTimeFormatter data class EpisodeDetailsUiState( val image: Image? = null, val isImageLoading: Boolean = false, val episodes: List? = null, val comments: List? = null, val isCommentsLoading: Boolean = false, val isSignedIn: Boolean = false, val rating: RatingState? = null, val translation: Translation? = null, val dateFormat: DateTimeFormatter? = null, val commentsDateFormat: DateTimeFormatter? = null, val spoilers: SpoilersSettings? = null ) ================================================ FILE: ui-episodes/src/main/java/com/michaldrabik/ui_episodes/details/EpisodeDetailsViewModel.kt ================================================ package com.michaldrabik.ui_episodes.details import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.common.Config import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError.CoroutineCancellation import com.michaldrabik.common.errors.ShowlyError.ResourceConflictError import com.michaldrabik.repository.CommentsRepository import com.michaldrabik.repository.RatingsRepository import com.michaldrabik.repository.TranslationsRepository import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.images.EpisodeImagesProvider import com.michaldrabik.repository.settings.SettingsSpoilersRepository import com.michaldrabik.ui_base.dates.DateFormatProvider import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.utilities.extensions.combine import com.michaldrabik.ui_base.utilities.extensions.findReplace import com.michaldrabik.ui_base.utilities.extensions.rethrowCancellation import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_episodes.R import com.michaldrabik.ui_episodes.details.cases.EpisodeDetailsSeasonCase import com.michaldrabik.ui_model.Comment import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.IdTmdb import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.RatingState import com.michaldrabik.ui_model.SpoilersSettings import com.michaldrabik.ui_model.Translation import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber import java.time.format.DateTimeFormatter import javax.inject.Inject @HiltViewModel class EpisodeDetailsViewModel @Inject constructor( settingsSpoilersRepository: SettingsSpoilersRepository, private val seasonsCase: EpisodeDetailsSeasonCase, private val imagesProvider: EpisodeImagesProvider, private val dateFormatProvider: DateFormatProvider, private val ratingsRepository: RatingsRepository, private val translationsRepository: TranslationsRepository, private val commentsRepository: CommentsRepository, private val userTraktManager: UserTraktManager, ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val imageState = MutableStateFlow(null) private val imageLoadingState = MutableStateFlow(false) private val episodesState = MutableStateFlow?>(null) private val commentsState = MutableStateFlow?>(null) private val commentsLoadingState = MutableStateFlow(false) private val signedInState = MutableStateFlow(false) private val ratingState = MutableStateFlow(null) private val translationState = MutableStateFlow(null) private val dateFormatState = MutableStateFlow(null) private val commentsDateFormatState = MutableStateFlow(null) private val spoilersState = MutableStateFlow(null) init { dateFormatState.value = dateFormatProvider.loadFullHourFormat() spoilersState.value = settingsSpoilersRepository.getAll() } fun loadImage(showId: IdTmdb, episode: Episode) { viewModelScope.launch { try { imageLoadingState.value = true val episodeImage = imagesProvider.loadRemoteImage(showId, episode) imageState.value = episodeImage imageLoadingState.value = false } catch (t: Throwable) { imageLoadingState.value = false } } } fun loadSeason(showTraktId: IdTrakt, episode: Episode, seasonEpisodes: IntArray?) { viewModelScope.launch { val episodes = seasonsCase.loadSeason(showTraktId, episode, seasonEpisodes) if (episodes.isNotEmpty()) { delay(100) } episodesState.value = episodes } } fun loadTranslation(showTraktId: IdTrakt, episode: Episode) { viewModelScope.launch { try { val language = translationsRepository.getLanguage() if (language == Config.DEFAULT_LANGUAGE) { return@launch } val translation = translationsRepository.loadTranslation(episode, showTraktId, language) translation?.let { translationState.value = it } } catch (error: Throwable) { Timber.e(error) } } } fun loadComments(idTrakt: IdTrakt, season: Int, episode: Int) { viewModelScope.launch { try { commentsLoadingState.value = true val isSignedIn = userTraktManager.isAuthorized() val username = userTraktManager.getUsername() val comments = commentsRepository.loadEpisodeComments(idTrakt, season, episode) .map { it.copy( isMe = it.user.username == username, isSignedIn = isSignedIn ) } .partition { it.isMe } signedInState.value = isSignedIn commentsState.value = comments.first + comments.second commentsLoadingState.value = false commentsDateFormatState.value = dateFormatProvider.loadFullHourFormat() } catch (t: Throwable) { Timber.w("Failed to load comments. ${t.message}") commentsLoadingState.value = false } } } fun loadCommentReplies(comment: Comment) { var current = uiState.value.comments?.toMutableList() ?: mutableListOf() if (current.any { it.parentId == comment.id }) return viewModelScope.launch { try { val parent = current.find { it.id == comment.id } parent?.let { p -> val copy = p.copy(isLoading = true) current.findReplace(copy) { it.id == p.id } commentsState.value = current } val isSignedIn = userTraktManager.isAuthorized() val username = userTraktManager.getUsername() val replies = commentsRepository.loadReplies(comment.id) .map { it.copy( isSignedIn = isSignedIn, isMe = it.user.username == username ) } current = uiState.value.comments?.toMutableList() ?: mutableListOf() val parentIndex = current.indexOfFirst { it.id == comment.id } if (parentIndex > -1) current.addAll(parentIndex + 1, replies) parent?.let { current.findReplace(parent.copy(isLoading = false, replies = 0)) { it.id == comment.id } } commentsState.value = current } catch (t: Throwable) { commentsState.value = current } } } fun addNewComment(comment: Comment) { val current = uiState.value.comments?.toMutableList() ?: mutableListOf() if (!comment.isReply()) { current.add(0, comment) } else { val parentIndex = current.indexOfLast { it.id == comment.parentId } if (parentIndex > -1) { val parent = current[parentIndex] current.add(parentIndex + 1, comment) val repliesCount = current.count { it.parentId == parent.id }.toLong() current.findReplace(parent.copy(replies = repliesCount)) { it.id == comment.parentId } } } commentsState.value = current } fun deleteComment(comment: Comment) { var current = uiState.value.comments?.toMutableList() ?: mutableListOf() val target = current.find { it.id == comment.id } ?: return viewModelScope.launch { try { val copy = target.copy(isLoading = true) current.findReplace(copy) { it.id == target.id } commentsState.value = current commentsRepository.deleteComment(target.id) current = uiState.value.comments?.toMutableList() ?: mutableListOf() val targetIndex = current.indexOfFirst { it.id == target.id } if (targetIndex > -1) { current.removeAt(targetIndex) if (target.isReply()) { val parent = current.first { it.id == target.parentId } val repliesCount = current.count { it.parentId == parent.id }.toLong() current.findReplace(parent.copy(replies = repliesCount)) { it.id == target.parentId } } } commentsState.value = current messageChannel.send(MessageEvent.Info(R.string.textCommentDeleted)) } catch (t: Throwable) { when (ErrorHelper.parse(t)) { is CoroutineCancellation -> rethrowCancellation(t) is ResourceConflictError -> messageChannel.send(MessageEvent.Error(R.string.errorCommentDelete)) else -> messageChannel.send(MessageEvent.Error(R.string.errorGeneral)) } commentsState.value = current } } } fun loadRatings(episode: Episode) { viewModelScope.launch { try { if (!userTraktManager.isAuthorized()) { ratingState.value = RatingState(rateAllowed = false, rateLoading = false) return@launch } ratingState.value = RatingState(rateAllowed = true, rateLoading = true) val rating = ratingsRepository.shows.loadRating(episode) ratingState.value = RatingState(rateAllowed = true, rateLoading = false, userRating = rating) } catch (error: Throwable) { ratingState.value = RatingState(rateAllowed = false, rateLoading = false) } } } val uiState = combine( imageState, imageLoadingState, episodesState, commentsState, commentsLoadingState, signedInState, ratingState, translationState, dateFormatState, commentsDateFormatState, spoilersState ) { s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11 -> EpisodeDetailsUiState( image = s1, isImageLoading = s2, episodes = s3, comments = s4, isCommentsLoading = s5, isSignedIn = s6, rating = s7, translation = s8, dateFormat = s9, commentsDateFormat = s10, spoilers = s11 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = EpisodeDetailsUiState() ) } ================================================ FILE: ui-episodes/src/main/java/com/michaldrabik/ui_episodes/details/cases/EpisodeDetailsSeasonCase.kt ================================================ package com.michaldrabik.ui_episodes.details.cases import com.michaldrabik.data_local.sources.EpisodesLocalDataSource import com.michaldrabik.data_local.sources.MyShowsLocalDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.IdTrakt import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class EpisodeDetailsSeasonCase @Inject constructor( private val myShowsDataSource: MyShowsLocalDataSource, private val episodesDataSource: EpisodesLocalDataSource, private val mappers: Mappers, ) { suspend fun loadSeason(showId: IdTrakt, episode: Episode, seasonEpisodes: IntArray?): List { val isMyShow = myShowsDataSource.checkExists(showId.id) if (!isMyShow) { return seasonEpisodes?.map { Episode.EMPTY.copy(season = episode.season, number = it) } ?: emptyList() } val episodes = episodesDataSource.getAllByShowId(showId.id, episode.season) .map { mappers.episode.fromDatabase(it) } .sortedBy { it.number } if (episodes.isNotEmpty()) return episodes return seasonEpisodes?.map { Episode.EMPTY.copy(season = episode.season, number = it) } ?: emptyList() } } ================================================ FILE: ui-episodes/src/main/java/com/michaldrabik/ui_episodes/details/cases/EpisodeDetailsWatchedCase.kt ================================================ package com.michaldrabik.ui_episodes.details.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.data_local.sources.EpisodesLocalDataSource import com.michaldrabik.ui_model.Episode import com.michaldrabik.ui_model.IdTrakt import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class EpisodeDetailsWatchedCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val episodesDataSource: EpisodesLocalDataSource, ) { suspend fun isWatched(showId: IdTrakt, episode: Episode): Boolean { return withContext(dispatchers.IO) { val episodes = episodesDataSource.getAllByShowId(showId.id, episode.season) episodes.find { it.idShowTrakt == showId.id && it.idTrakt == episode.ids.trakt.id }?.isWatched == true } } } ================================================ FILE: ui-episodes/src/main/res/color/selector_comment_button.xml ================================================ ================================================ FILE: ui-episodes/src/main/res/drawable/bg_bottom_sheet_placeholder.xml ================================================ ================================================ FILE: ui-episodes/src/main/res/drawable/divider_comments_list.xml ================================================ ================================================ FILE: ui-episodes/src/main/res/layout/view_episode_details.xml ================================================ ================================================ FILE: ui-episodes/src/main/res/values/dimens.xml ================================================ 220dp ================================================ FILE: ui-episodes/src/main/res/values/integers.xml ================================================ 4 ================================================ FILE: ui-episodes/src/main/res/values/strings.xml ================================================ S.%1$02d E.%2$02d | %3$s Comments (%d) %.1f (%d votes) ================================================ FILE: ui-episodes/src/main/res/values-ar/strings.xml ================================================ الموسم %02d الحلقة %02d | %s تعليقات (%d) %.1f (%d تقييم) ================================================ FILE: ui-episodes/src/main/res/values-de/integers.xml ================================================ 2 ================================================ FILE: ui-episodes/src/main/res/values-de/strings.xml ================================================ S.%1$02d E.%2$02d | %3$s Kommentare (%d) %.1f (%d Bewertungen) ================================================ FILE: ui-episodes/src/main/res/values-es/integers.xml ================================================ 2 ================================================ FILE: ui-episodes/src/main/res/values-es/strings.xml ================================================ T.%1$02d E.%2$02d | %3$s Comentarios (%d) %.1f (%d votos) ================================================ FILE: ui-episodes/src/main/res/values-fi/strings.xml ================================================ K.%1$02d J.%2$02d | %3$s Kommentit (%d) %.1f (%d ääntä) ================================================ FILE: ui-episodes/src/main/res/values-fr/integers.xml ================================================ 2 ================================================ FILE: ui-episodes/src/main/res/values-fr/strings.xml ================================================ S.%1$02d E.%2$02d | %3$s Commentaires (%d) %.1f (%d votes) ================================================ FILE: ui-episodes/src/main/res/values-it/integers.xml ================================================ 2 ================================================ FILE: ui-episodes/src/main/res/values-it/strings.xml ================================================ S.%1$02d E.%2$02d | %3$s Commenti (%d) %.1f (%d voti) ================================================ FILE: ui-episodes/src/main/res/values-pl/strings.xml ================================================ S.%1$02d E.%2$02d | %3$s Komentarze (%d) %.1f (%d ocen) ================================================ FILE: ui-episodes/src/main/res/values-pt/integers.xml ================================================ 2 ================================================ FILE: ui-episodes/src/main/res/values-pt/strings.xml ================================================ S.%1$02d E.%2$02d | %3$s Comentários (%d) %.1f (%d votos) ================================================ FILE: ui-episodes/src/main/res/values-ru/integers.xml ================================================ 2 ================================================ FILE: ui-episodes/src/main/res/values-ru/strings.xml ================================================ S.%1$02d Е.%2$02d | %3$s Мнения (%d) %.1f (%d голосов) ================================================ FILE: ui-episodes/src/main/res/values-sw600dp/dimens.xml ================================================ 320dp ================================================ FILE: ui-episodes/src/main/res/values-tr/integers.xml ================================================ 3 ================================================ FILE: ui-episodes/src/main/res/values-tr/strings.xml ================================================ S.%1$02d E.%2$02d | %3$s Yorumlar (%d) %.1f (%d oy) ================================================ FILE: ui-episodes/src/main/res/values-uk/strings.xml ================================================ S.%1$02d E.%2$02d | %3$s Коментарі (%d) %.1f (%d оцінок) ================================================ FILE: ui-episodes/src/main/res/values-vi/strings.xml ================================================ M.%1$02d T.%2$02d | %3$s Bình luận (%d) %.1f (%d phiếu) ================================================ FILE: ui-episodes/src/main/res/values-zh/strings.xml ================================================ %1$02d 季 %2$02d 集 | %3$s 评论 (%d) %.1f (%d 个评分 ) ================================================ FILE: ui-gallery/.gitignore ================================================ /build ================================================ FILE: ui-gallery/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'dagger.hilt.android.plugin' apply from: '../versions.gradle' android { kotlinOptions { jvmTarget = "17" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } buildFeatures { viewBinding true } compileSdkVersion versions.compileSdk defaultConfig { minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk compileSdkVersion versions.compileSdk testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false } } namespace 'com.michaldrabik.ui_gallery' } dependencies { implementation project(':common') implementation project(':ui-base') implementation project(':repository') implementation project(':ui-model') implementation project(':ui-navigation') implementation libs.hilt.android ksp libs.hilt.compiler implementation libs.circleIndicator coreLibraryDesugaring libs.android.desugar } ================================================ FILE: ui-gallery/src/main/AndroidManifest.xml ================================================ ================================================ FILE: ui-gallery/src/main/java/com/michaldrabik/ui_gallery/custom/CustomImagesBottomSheet.kt ================================================ package com.michaldrabik.ui_gallery.custom import android.annotation.SuppressLint import android.os.Bundle import android.view.View import android.widget.ImageView import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.requireLong import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.withFailListener import com.michaldrabik.ui_base.utilities.extensions.withSuccessListener import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_gallery.R import com.michaldrabik.ui_gallery.databinding.ViewCustomImagesBinding import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.ImageFamily import com.michaldrabik.ui_model.ImageStatus import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.ImageType.FANART import com.michaldrabik.ui_model.ImageType.POSTER import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_CUSTOM_IMAGE_CLEARED import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_FAMILY import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_MOVIE_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_PICK_MODE import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_SHOW_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_TYPE import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_CUSTOM_IMAGE import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class CustomImagesBottomSheet : BaseBottomSheetFragment(R.layout.view_custom_images) { private val viewModel by viewModels() private val binding by viewBinding(ViewCustomImagesBinding::bind) private val family by lazy { arguments?.getSerializable(ARG_FAMILY) as ImageFamily } private val showTraktId by lazy { IdTrakt(requireLong(ARG_SHOW_ID)) } private val movieTraktId by lazy { IdTrakt(requireLong(ARG_MOVIE_ID)) } private val cornerRadius by lazy { requireContext().dimenToPx(R.dimen.customImagesCorner) } override fun getTheme(): Int = R.style.CustomBottomSheetDialog override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() launchAndRepeatStarted( { viewModel.uiState.collect { render(it) } }, doAfterLaunch = { viewModel.loadPoster(showTraktId, movieTraktId, family) viewModel.loadFanart(showTraktId, movieTraktId, family) } ) } private fun setupView() { with(binding) { viewCustomImagesPosterLayout.onClick { showGallery(POSTER) } viewCustomImagesFanartLayout.onClick { showGallery(FANART) } viewCustomImagesPosterDelete.onClick { viewModel.deletePoster(showTraktId, movieTraktId, family) } viewCustomImagesFanartDelete.onClick { viewModel.deleteFanart(showTraktId, movieTraktId, family) } viewCustomImagesCloseButton.onClick { closeSheet() } } } private fun showGallery(type: ImageType) { val bundle = bundleOf( ARG_SHOW_ID to showTraktId.id, ARG_MOVIE_ID to movieTraktId.id, ARG_FAMILY to family, ARG_TYPE to type, ARG_PICK_MODE to true ) navigateTo(R.id.actionCustomImagesDialogToArtGallery, bundle) } @SuppressLint("SetTextI18n") private fun render(uiState: CustomImagesUiState) { fun loadImage(url: String, imageView: ImageView, progressView: View, deleteView: View) { progressView.visible() deleteView.gone() Glide.with(requireContext()) .load(url) .transform(CenterCrop(), RoundedCorners(cornerRadius)) .withSuccessListener { progressView.gone() deleteView.visible() } .withFailListener { progressView.gone() deleteView.gone() } .into(imageView) } uiState.run { with(binding) { posterImage?.let { if (it.status == ImageStatus.UNAVAILABLE) { Glide.with(requireContext()).clear(viewCustomImagesPosterImage) viewCustomImagesPosterAddButton.visible() viewCustomImagesPosterDelete.gone() return@let } viewCustomImagesPosterAddButton.gone() loadImage( it.fullFileUrl, viewCustomImagesPosterImage, viewCustomImagesPosterProgress, viewCustomImagesPosterDelete ) } fanartImage?.let { if (it.status == ImageStatus.UNAVAILABLE) { Glide.with(requireContext()).clear(viewCustomImagesFanartImage) viewCustomImagesFanartAddButton.visible() viewCustomImagesFanartDelete.gone() setFragmentResult(REQUEST_CUSTOM_IMAGE, bundleOf(ARG_CUSTOM_IMAGE_CLEARED to true)) return@let } viewCustomImagesFanartAddButton.gone() loadImage( it.fullFileUrl, viewCustomImagesFanartImage, viewCustomImagesFanartProgress, viewCustomImagesFanartDelete ) } } } } } ================================================ FILE: ui-gallery/src/main/java/com/michaldrabik/ui_gallery/custom/CustomImagesUiState.kt ================================================ package com.michaldrabik.ui_gallery.custom import com.michaldrabik.ui_model.Image data class CustomImagesUiState( val posterImage: Image? = null, val fanartImage: Image? = null ) ================================================ FILE: ui-gallery/src/main/java/com/michaldrabik/ui_gallery/custom/CustomImagesViewModel.kt ================================================ package com.michaldrabik.ui_gallery.custom import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.repository.images.MovieImagesProvider import com.michaldrabik.repository.images.ShowImagesProvider import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageFamily import com.michaldrabik.ui_model.ImageFamily.MOVIE import com.michaldrabik.ui_model.ImageFamily.SHOW import com.michaldrabik.ui_model.ImageType.FANART import com.michaldrabik.ui_model.ImageType.POSTER import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class CustomImagesViewModel @Inject constructor( private val showImagesProvider: ShowImagesProvider, private val movieImagesProvider: MovieImagesProvider, ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val posterImageState = MutableStateFlow(null) private val fanartImageState = MutableStateFlow(null) fun loadPoster( showTraktId: IdTrakt, movieTraktId: IdTrakt, family: ImageFamily, ) { viewModelScope.launch { val image = when (family) { SHOW -> showImagesProvider.findCustomImage(showTraktId.id, POSTER) MOVIE -> movieImagesProvider.findCustomImage(movieTraktId.id, POSTER) else -> error("Invalid type") } posterImageState.value = image } } fun loadFanart( showTraktId: IdTrakt, movieTraktId: IdTrakt, family: ImageFamily, ) { viewModelScope.launch { val image = when (family) { SHOW -> showImagesProvider.findCustomImage(showTraktId.id, FANART) MOVIE -> movieImagesProvider.findCustomImage(movieTraktId.id, FANART) else -> error("Invalid type") } fanartImageState.value = image } } fun deletePoster( showTraktId: IdTrakt, movieTraktId: IdTrakt, family: ImageFamily, ) { viewModelScope.launch { when (family) { SHOW -> showImagesProvider.deleteCustomImage(showTraktId, family, POSTER) MOVIE -> movieImagesProvider.deleteCustomImage(movieTraktId, family, POSTER) else -> error("Invalid type") } posterImageState.value = Image.createUnavailable(POSTER, family) } } fun deleteFanart( showTraktId: IdTrakt, movieTraktId: IdTrakt, family: ImageFamily, ) { viewModelScope.launch { when (family) { SHOW -> showImagesProvider.deleteCustomImage(showTraktId, family, FANART) MOVIE -> movieImagesProvider.deleteCustomImage(movieTraktId, family, FANART) else -> error("Invalid type") } fanartImageState.value = Image.createUnavailable(FANART, family) } } val uiState = combine( posterImageState, fanartImageState ) { posterImageState, fanartImageState -> CustomImagesUiState( posterImage = posterImageState, fanartImage = fanartImageState ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = CustomImagesUiState() ) } ================================================ FILE: ui-gallery/src/main/java/com/michaldrabik/ui_gallery/fanart/ArtGalleryFragment.kt ================================================ package com.michaldrabik.ui_gallery.fanart import android.annotation.SuppressLint import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_FULL_USER import android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT import android.content.res.Configuration import android.content.res.Configuration.ORIENTATION_LANDSCAPE import android.content.res.Configuration.ORIENTATION_PORTRAIT import android.os.Bundle import android.view.LayoutInflater import android.view.View import androidx.activity.addCallback import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.bumptech.glide.Glide import com.michaldrabik.ui_base.BaseFragment import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.colorStateListFromAttr import com.michaldrabik.ui_base.utilities.extensions.doOnApplyWindowInsets import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.nextPage import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.openWebUrl import com.michaldrabik.ui_base.utilities.extensions.updateTopMargin import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.extensions.withFailListener import com.michaldrabik.ui_base.utilities.extensions.withSuccessListener import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_gallery.R import com.michaldrabik.ui_gallery.databinding.FragmentArtGalleryBinding import com.michaldrabik.ui_gallery.databinding.ViewGalleryUrlDialogBinding import com.michaldrabik.ui_gallery.fanart.recycler.ArtGalleryAdapter import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.ImageFamily import com.michaldrabik.ui_model.ImageFamily.SHOW import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.ImageType.POSTER import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_FAMILY import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_MOVIE_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_PICK_MODE import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_SHOW_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_TYPE import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_CUSTOM_IMAGE import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import timber.log.Timber @SuppressLint("SetTextI18n", "DefaultLocale", "SourceLockedOrientationActivity") @AndroidEntryPoint class ArtGalleryFragment : BaseFragment(R.layout.fragment_art_gallery) { companion object { private const val IMAGE_URL_PATTERN = "(http)?s?:?(//[^\"']*\\.(?:jpg|jpeg|png))" } override val viewModel by viewModels() private val binding by viewBinding(FragmentArtGalleryBinding::bind) private val showId by lazy { IdTrakt(arguments?.getLong(ARG_SHOW_ID, -1) ?: -1) } private val movieId by lazy { IdTrakt(arguments?.getLong(ARG_MOVIE_ID, -1) ?: -1) } private val family by lazy { arguments?.getSerializable(ARG_FAMILY) as ImageFamily } private val type by lazy { arguments?.getSerializable(ARG_TYPE) as ImageType } private val isPickMode by lazy { arguments?.getBoolean(ARG_PICK_MODE, false) } private var galleryAdapter: ArtGalleryAdapter? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) if (type != POSTER) { requireActivity().requestedOrientation = SCREEN_ORIENTATION_FULL_USER } setupView() setupStatusBar() viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { with(viewModel) { launch { uiState.collect { render(it) } } val id = if (family == SHOW) showId else movieId loadImages(id, family, type) } } } } override fun onDestroyView() { galleryAdapter = null requireActivity().requestedOrientation = SCREEN_ORIENTATION_PORTRAIT super.onDestroyView() } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) with(binding) { when (newConfig.orientation) { ORIENTATION_LANDSCAPE -> { val color = requireContext().colorStateListFromAttr(R.attr.textColorOnSurface) artGalleryBackArrow.imageTintList = color artGalleryPagerIndicatorWhite.visible() artGalleryPagerIndicator.gone() artGalleryPagerIndicatorWhite.setViewPager(artGalleryPager) } ORIENTATION_PORTRAIT -> { val color = requireContext().colorStateListFromAttr(android.R.attr.textColorPrimary) artGalleryBackArrow.imageTintList = color artGalleryPagerIndicatorWhite.gone() artGalleryPagerIndicator.visible() artGalleryPagerIndicator.setViewPager(artGalleryPager) } else -> Timber.d("Unused orientation") } } } private fun setupView() { with(binding) { artGalleryBackArrow.onClick { if (isPickMode == true) setFragmentResult(REQUEST_CUSTOM_IMAGE, bundleOf()) requireActivity().onBackPressed() } artGalleryBrowserIcon.onClick { val currentIndex = artGalleryPager.currentItem val image = galleryAdapter?.getItem(currentIndex) image?.fullFileUrl?.let { openWebUrl(it) } } galleryAdapter = ArtGalleryAdapter( onItemClickListener = { artGalleryPager.nextPage() } ) artGalleryPager.run { adapter = galleryAdapter offscreenPageLimit = 2 artGalleryPagerIndicator.setViewPager(this) adapter?.registerAdapterDataObserver(artGalleryPagerIndicator.adapterDataObserver) } artGallerySelectButton.run { onClick { val id = if (family == SHOW) showId else movieId val currentImage = galleryAdapter?.getItem(artGalleryPager.currentItem) currentImage?.let { viewModel.saveCustomImage(id, it, family, type) } } } artGalleryUrlButton.onClick { showUrlInput() } } } private fun setupStatusBar() { requireView().doOnApplyWindowInsets { _, insets, _, _ -> val inset = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top with(binding) { artGalleryBackArrow.updateTopMargin(inset) artGalleryBrowserIcon.updateTopMargin(inset) artGallerySelectButton.updateTopMargin(inset) } } } private fun showUrlInput() { fun onUrlInput(input: String) { with(binding) { if (input.matches(IMAGE_URL_PATTERN.toRegex())) { artGalleryUrlProgress.visible() artGalleryUrlButton.gone() artGallerySelectButton.gone() Glide.with(requireContext()) .load(input) .withSuccessListener { viewModel.addImageFromUrl(input, family, type) artGalleryUrlProgress.gone() artGalleryUrlButton.visible() artGallerySelectButton.visible() } .withFailListener { showSnack(MessageEvent.Error(R.string.textUrlDialogInvalidImage)) artGalleryUrlProgress.gone() artGalleryUrlButton.visible() artGallerySelectButton.visible() } .preload(100, 100) } else { showSnack(MessageEvent.Error(R.string.textUrlDialogInvalidUrl)) } } } AlertDialog.Builder(requireContext(), R.style.UrlInputDialog).apply { val view = ViewGalleryUrlDialogBinding.inflate(LayoutInflater.from(context), binding.fanartGalleryRoot, false) setView(view.root) setTitle(R.string.textUrlDialogTitle) setPositiveButton(R.string.textOk) { _, _ -> onUrlInput(view.urlDialogInput.text.toString()) } setNegativeButton(R.string.textCancel) { dialog, _ -> dialog.dismiss() } show() } } private fun render(uiState: ArtGalleryUiState) { uiState.run { with(binding) { images?.let { galleryAdapter?.setItems(it, type) artGalleryEmptyView.visibleIf(it.isEmpty()) artGallerySelectButton.visibleIf(it.isNotEmpty() && isPickMode == true) artGalleryBrowserIcon.visibleIf(it.isNotEmpty() && isPickMode == false) artGalleryUrlButton.visibleIf(isPickMode == true) } pickedImage?.let { it.consume()?.let { setFragmentResult(REQUEST_CUSTOM_IMAGE, bundleOf()) requireActivity().onBackPressed() } } artGalleryImagesProgress.visibleIf(isLoading) } } } override fun setupBackPressed() { val dispatcher = requireActivity().onBackPressedDispatcher dispatcher.addCallback(viewLifecycleOwner) { if (isPickMode == true) { setFragmentResult(REQUEST_CUSTOM_IMAGE, bundleOf()) } isEnabled = false findNavControl()?.popBackStack() } } } ================================================ FILE: ui-gallery/src/main/java/com/michaldrabik/ui_gallery/fanart/ArtGalleryUiState.kt ================================================ package com.michaldrabik.ui_gallery.fanart import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageType data class ArtGalleryUiState( val images: List? = null, val type: ImageType = ImageType.FANART, val pickedImage: Event? = null, val isLoading: Boolean = false, ) ================================================ FILE: ui-gallery/src/main/java/com/michaldrabik/ui_gallery/fanart/ArtGalleryViewModel.kt ================================================ package com.michaldrabik.ui_gallery.fanart import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_gallery.fanart.cases.ArtLoadImagesCase import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageFamily import com.michaldrabik.ui_model.ImageSource.CUSTOM import com.michaldrabik.ui_model.ImageType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class ArtGalleryViewModel @Inject constructor( private val imagesCase: ArtLoadImagesCase, ) : ViewModel() { private val imagesState = MutableStateFlow?>(null) private val typeState = MutableStateFlow(ImageType.FANART) private val pickedImageState = MutableStateFlow?>(null) private val loadingState = MutableStateFlow(false) fun loadImages(id: IdTrakt, family: ImageFamily, type: ImageType) { viewModelScope.launch { try { loadingState.value = true val allImages = imagesCase.loadImages(id, family, type) imagesState.value = allImages typeState.value = type loadingState.value = false } catch (t: Throwable) { loadingState.value = false } } } fun saveCustomImage(id: IdTrakt, image: Image, family: ImageFamily, type: ImageType) { viewModelScope.launch { imagesCase.saveCustomImage(id, image, family, type) pickedImageState.value = Event(image) } } fun addImageFromUrl(imageUrl: String, family: ImageFamily, type: ImageType) { if (imageUrl.isBlank()) return val currentImages = uiState.value.images?.toMutableList() ?: mutableListOf() val image = Image.createAvailable(Ids.EMPTY, type, family, imageUrl.trim(), CUSTOM) currentImages.add(0, image) imagesState.value = currentImages } val uiState = combine( imagesState, typeState, pickedImageState, loadingState ) { s1, s2, s3, s4 -> ArtGalleryUiState( images = s1, type = s2, pickedImage = s3, isLoading = s4 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = ArtGalleryUiState() ) } ================================================ FILE: ui-gallery/src/main/java/com/michaldrabik/ui_gallery/fanart/cases/ArtLoadImagesCase.kt ================================================ package com.michaldrabik.ui_gallery.fanart.cases import com.michaldrabik.common.Config.FANART_GALLERY_IMAGES_LIMIT import com.michaldrabik.repository.images.MovieImagesProvider import com.michaldrabik.repository.images.ShowImagesProvider import com.michaldrabik.repository.movies.MoviesRepository import com.michaldrabik.repository.shows.ShowsRepository import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageFamily import com.michaldrabik.ui_model.ImageFamily.MOVIE import com.michaldrabik.ui_model.ImageFamily.SHOW import com.michaldrabik.ui_model.ImageStatus.AVAILABLE import com.michaldrabik.ui_model.ImageType import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class ArtLoadImagesCase @Inject constructor( private val showsRepository: ShowsRepository, private val moviesRepository: MoviesRepository, private val showImagesProvider: ShowImagesProvider, private val movieImagesProvider: MovieImagesProvider, ) { suspend fun loadImages( id: IdTrakt, family: ImageFamily, type: ImageType ): List { val images = mutableListOf() val initialImage = loadInitialImage(id, family, type) if (initialImage.status == AVAILABLE) { images.add(initialImage) } var remoteImages: List = emptyList() if (family == SHOW) { val show = showsRepository.detailsShow.load(id) remoteImages = showImagesProvider.loadRemoteImages(show, type) } else if (family == MOVIE) { val movie = moviesRepository.movieDetails.load(id) remoteImages = movieImagesProvider.loadRemoteImages(movie, type) } images.addAll(remoteImages.filter { it.fullFileUrl != initialImage.fullFileUrl }) return images.take(FANART_GALLERY_IMAGES_LIMIT) } private suspend fun loadInitialImage(id: IdTrakt, family: ImageFamily, type: ImageType) = when (family) { SHOW -> { val show = showsRepository.detailsShow.load(id) showImagesProvider.findCachedImage(show, type) } MOVIE -> { val movie = moviesRepository.movieDetails.load(id) movieImagesProvider.findCachedImage(movie, type) } else -> throw IllegalStateException() } suspend fun saveCustomImage(id: IdTrakt, image: Image, family: ImageFamily, type: ImageType) { when (family) { SHOW -> showImagesProvider.saveCustomImage(id, image, family, type) MOVIE -> movieImagesProvider.saveCustomImage(id, image, family, type) else -> error("Invalid image family") } } } ================================================ FILE: ui-gallery/src/main/java/com/michaldrabik/ui_gallery/fanart/recycler/ArtGalleryAdapter.kt ================================================ package com.michaldrabik.ui_gallery.fanart.recycler import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView import com.michaldrabik.ui_gallery.fanart.recycler.views.ArtGalleryFanartView import com.michaldrabik.ui_gallery.fanart.recycler.views.ArtGalleryPosterView import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.ImageType.FANART import com.michaldrabik.ui_model.ImageType.FANART_WIDE import com.michaldrabik.ui_model.ImageType.POSTER class ArtGalleryAdapter( val onItemClickListener: (() -> Unit) ) : RecyclerView.Adapter() { companion object { private const val VIEW_TYPE_POSTER = 0 private const val VIEW_TYPE_FANART = 1 } private lateinit var type: ImageType private val asyncDiffer = AsyncListDiffer(this, ImageItemDiffCallback()) fun setItems(items: List, type: ImageType) { this.type = type asyncDiffer.submitList(items) } fun getItem(index: Int) = asyncDiffer.currentList.getOrNull(index) override fun getItemViewType(position: Int) = when (type) { POSTER -> VIEW_TYPE_POSTER FANART, FANART_WIDE -> VIEW_TYPE_FANART else -> throw Error("Invalid type") } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { VIEW_TYPE_POSTER -> ViewHolderShow( ArtGalleryPosterView(parent.context).apply { onItemClickListener = this@ArtGalleryAdapter.onItemClickListener } ) VIEW_TYPE_FANART -> ViewHolderShow( ArtGalleryFanartView(parent.context).apply { onItemClickListener = this@ArtGalleryAdapter.onItemClickListener } ) else -> error("Invalid type") } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (val itemView = holder.itemView) { is ArtGalleryPosterView -> itemView.bind(asyncDiffer.currentList[position]) is ArtGalleryFanartView -> itemView.bind(asyncDiffer.currentList[position]) } } class ViewHolderShow(itemView: View) : RecyclerView.ViewHolder(itemView) override fun getItemCount() = asyncDiffer.currentList.size } ================================================ FILE: ui-gallery/src/main/java/com/michaldrabik/ui_gallery/fanart/recycler/ImageItemDiffCallback.kt ================================================ package com.michaldrabik.ui_gallery.fanart.recycler import androidx.recyclerview.widget.DiffUtil import com.michaldrabik.ui_model.Image class ImageItemDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Image, newItem: Image) = oldItem.id == newItem.id override fun areContentsTheSame(oldItem: Image, newItem: Image) = oldItem == newItem } ================================================ FILE: ui-gallery/src/main/java/com/michaldrabik/ui_gallery/fanart/recycler/views/ArtGalleryFanartView.kt ================================================ package com.michaldrabik.ui_gallery.fanart.recycler.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import com.bumptech.glide.Glide import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.withFailListener import com.michaldrabik.ui_base.utilities.extensions.withSuccessListener import com.michaldrabik.ui_gallery.databinding.ViewGalleryFanartImageBinding import com.michaldrabik.ui_model.Image class ArtGalleryFanartView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewGalleryFanartImageBinding.inflate(LayoutInflater.from(context), this) init { layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) } var onItemClickListener: (() -> Unit)? = null fun bind(image: Image) { clear() with(binding) { viewGalleryFanarImage.onClick { onItemClickListener?.invoke() } viewGalleryFanarImageProgress.visible() } loadImage(image) } private fun loadImage(image: Image) { with(binding) { Glide.with(this@ArtGalleryFanartView) .load(image.fullFileUrl) .withFailListener { viewGalleryFanarImageProgress.gone() } .withSuccessListener { viewGalleryFanarImageProgress.gone() } .into(viewGalleryFanarImage) } } private fun clear() { binding.viewGalleryFanarImageProgress.gone() Glide.with(this) } } ================================================ FILE: ui-gallery/src/main/java/com/michaldrabik/ui_gallery/fanart/recycler/views/ArtGalleryPosterView.kt ================================================ package com.michaldrabik.ui_gallery.fanart.recycler.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.withFailListener import com.michaldrabik.ui_base.utilities.extensions.withSuccessListener import com.michaldrabik.ui_gallery.R import com.michaldrabik.ui_gallery.databinding.ViewGalleryPosterImageBinding import com.michaldrabik.ui_model.Image class ArtGalleryPosterView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewGalleryPosterImageBinding.inflate(LayoutInflater.from(context), this) init { layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) } private val cornerRadius by lazy { context.dimenToPx(R.dimen.mediaTileCorner) } var onItemClickListener: (() -> Unit)? = null fun bind(image: Image) { clear() with(binding) { viewGalleryPosterImage.onClick { onItemClickListener?.invoke() } viewGalleryPosterImageProgress.visible() } loadImage(image) } private fun loadImage(image: Image) { with(binding) { Glide.with(this@ArtGalleryPosterView) .load(image.fullFileUrl) .transform(CenterCrop(), RoundedCorners(cornerRadius)) .withFailListener { viewGalleryPosterImageProgress.gone() } .withSuccessListener { viewGalleryPosterImageProgress.gone() } .into(viewGalleryPosterImage) } } private fun clear() { binding.viewGalleryPosterImageProgress.gone() Glide.with(this) } } ================================================ FILE: ui-gallery/src/main/java/com/michaldrabik/ui_gallery/fanart/recycler/views/ArtGalleryUrlView.kt ================================================ package com.michaldrabik.ui_gallery.fanart.recycler.views import android.content.Context import android.util.AttributeSet import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import com.michaldrabik.ui_gallery.R class ArtGalleryUrlView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) init { inflate(context, R.layout.view_gallery_url_dialog, this) layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) } } ================================================ FILE: ui-gallery/src/main/res/color/selector_url_input_layout.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/drawable/bg_custom_image_frame.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/drawable/bg_delete_circle.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/drawable/bg_indicator_circle.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/drawable/ic_custom_image2x.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/drawable/ic_delete.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/drawable/ic_download.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/drawable-notnight/bg_indicator_circle.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/layout/fragment_art_gallery.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/layout/view_custom_images.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/layout/view_gallery_fanart_image.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/layout/view_gallery_poster_image.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/layout/view_gallery_url_dialog.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/values/dimens.xml ================================================ 150dp 100dp 150dp 225dp 200dp 133dp 0dp 6dp ================================================ FILE: ui-gallery/src/main/res/values/strings.xml ================================================ Poster Fanart No images available Download Image Image\'s URL Invalid image URL format.\nExample: https://test.com/image.jpg Sorry. This image could not be loaded. ================================================ FILE: ui-gallery/src/main/res/values/styles.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/values-ar/strings.xml ================================================ ملصق فان أرت لا توجد صور تحميل صورة رابط الصورة رابط الصورة غير صالح.\nتأكد أنه بالصيغة التالية: https://test.com/image.jpg عفوًا، لا يمكن تحميل الصورة. ================================================ FILE: ui-gallery/src/main/res/values-de/strings.xml ================================================ Poster Fanart Keine Bilder verfügbar Bild herunterladen Bildadresse Ungültiges Bild URL-Format.\nBeispiel: https://test.com/image.jpg Entschuldigung. Das Bild konnte nicht geladen werden. ================================================ FILE: ui-gallery/src/main/res/values-es/strings.xml ================================================ Póster Fanart No hay imágenes disponibles Descargar Imagen URL de la imagen Formato de URL de imagen no válido.\nEjemplo: https://test.com/image.jpg Lo sentimos. Esta imagen no se ha podido cargar. ================================================ FILE: ui-gallery/src/main/res/values-fi/strings.xml ================================================ Juliste Fanitaide Kuvia ei ole saatavilla Lataa kuva Kuvan URL-osoite Kuvan URL-osoitteen muoto on virheellinen.\nEsimerkki: https://esimerkki.fi/kuva.jpg Valitettavasti kuvan lataus ei onnistunut. ================================================ FILE: ui-gallery/src/main/res/values-fr/strings.xml ================================================ Affiche Fanart Aucune image disponible Télécharger l\'image URL de l’image URL d\'image invalide.\nExemple : https://test.com/image.jpg Désolé, cette image n\'a pu être chargée. ================================================ FILE: ui-gallery/src/main/res/values-it/strings.xml ================================================ Poster Fanart Nessuna immagine disponibile Scarica immagine URL dell\'immagine Formato URL immagine non valido.\nEsempio: https://test.com/image.jpg Siamo spiacenti. Questa immagine non può essere caricata. ================================================ FILE: ui-gallery/src/main/res/values-notnight/styles.xml ================================================ ================================================ FILE: ui-gallery/src/main/res/values-pl/strings.xml ================================================ Plakat Fanart Brak dostępnych obrazów Pobierz Grafikę URL Grafiki Nieprawidłowy format URL grafiki.\nPrzykład: https://test.com/image.jpg Ten obrazek nie mógł zostać załadowany. ================================================ FILE: ui-gallery/src/main/res/values-pt/strings.xml ================================================ Pôster Fanart Nenhuma imagem disponível Baixar imagem URL da imagem Formato URL da imagem inválido.\nExemplo: https://teste.com/imagem.jpg Infelizmente, esta imagem não pôde ser carregada. ================================================ FILE: ui-gallery/src/main/res/values-ru/strings.xml ================================================ Постер Фан-арт Нет доступных изображений Скачать изображение Ссылка на изображение Неверный формат URL изображения.\nПример: https://test.com/image.jpg Извините, данное изображение не может быть загружено. ================================================ FILE: ui-gallery/src/main/res/values-sw600dp/dimens.xml ================================================ 8dp ================================================ FILE: ui-gallery/src/main/res/values-tr/strings.xml ================================================ Afiş Hayran Çalışması Kullanılabilir fotoğraf yok Fotoğraf İndir Fotoğrafın URL\'si Geçersiz URL formatı.\nÖrnek: https://test.com/image.jpg Üzgünüz. Bu fotoğraf yüklenemedi. ================================================ FILE: ui-gallery/src/main/res/values-uk/strings.xml ================================================ Постер Фанарт Немає доступних зображень Завантажити зображення Посилання на зображення Невірний формат посилання на зображення.\nПриклад: https://test.com/image.jpg Вибачте, це зображення не вдалося завантажити. ================================================ FILE: ui-gallery/src/main/res/values-vi/strings.xml ================================================ Áp phích Fanart Không có hình ảnh nào Tải hình ảnh URL của hình ảnh Định dạng URL hình ảnh không hợp lệ.\Ví dụ: https://test.com/image.jpg Lấy làm tiếc. Không thể tải hình ảnh này. ================================================ FILE: ui-gallery/src/main/res/values-zh/strings.xml ================================================ 海报 粉丝作品 暂未能获取图片 下载图片 图片 URL 图片 URL 格式无效。\n正确示例:https://test.com/image.jpg 抱歉。无法加载此图片。 ================================================ FILE: ui-lists/.gitignore ================================================ /build ================================================ FILE: ui-lists/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'dagger.hilt.android.plugin' apply from: '../versions.gradle' android { kotlinOptions { jvmTarget = "17" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } buildFeatures { viewBinding true } compileSdkVersion versions.compileSdk defaultConfig { minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk compileSdkVersion versions.compileSdk testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false } } namespace 'com.michaldrabik.ui_lists' } dependencies { implementation project(':common') implementation project(':data-local') implementation project(':data-remote') implementation project(':ui-base') implementation project(':repository') implementation project(':ui-model') implementation project(':ui-navigation') implementation libs.hilt.android ksp libs.hilt.compiler coreLibraryDesugaring libs.android.desugar } ================================================ FILE: ui-lists/src/main/AndroidManifest.xml ================================================ ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/create/CreateListBottomSheet.kt ================================================ package com.michaldrabik.ui_lists.create import android.annotation.SuppressLint import android.os.Bundle import android.view.View import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.optionalParcelable import com.michaldrabik.ui_base.utilities.extensions.shake import com.michaldrabik.ui_base.utilities.extensions.showErrorSnackbar import com.michaldrabik.ui_base.utilities.extensions.showInfoSnackbar import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_lists.R import com.michaldrabik.ui_lists.databinding.ViewCreateListBinding import com.michaldrabik.ui_model.CustomList import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_LIST import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_CREATE_LIST import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint class CreateListBottomSheet : BaseBottomSheetFragment(R.layout.view_create_list) { private val viewModel by viewModels() private val binding by viewBinding(ViewCreateListBinding::bind) private val list: CustomList? by lazy { optionalParcelable(ARG_LIST) } override fun getTheme(): Int = R.style.CustomBottomSheetDialog override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { with(viewModel) { launch { uiState.collect { render(it) } } launch { messageFlow.collect { renderSnackbar(it) } } if (isEditMode()) { viewModel.loadDetails(list?.id!!) } } } } } @SuppressLint("SetTextI18n") private fun setupView() { with(binding) { viewCreateListButton.onClick { onCreateListClick() } if (isEditMode()) { viewCreateListTitle.setText(R.string.textEditList) viewCreateListSubtitle.setText(R.string.textEditListDescription) viewCreateListButton.setText(R.string.textApply) } } } private fun onCreateListClick() { val name = binding.viewCreateListNameValue.text?.toString() ?: "" val description = binding.viewCreateListDescriptionValue.text?.toString() if (name.trim().isBlank()) { binding.viewCreateListNameInput.shake() return } if (isEditMode()) { viewModel.updateList(list!!.copy(name = name, description = description)) } else { viewModel.createList(name, description) } } @SuppressLint("SetTextI18n") private fun render(uiState: CreateListUiState) { uiState.run { listDetails?.let { binding.viewCreateListNameValue.setText(it.name) binding.viewCreateListDescriptionValue.setText(it.description) } isLoading?.let { with(binding) { viewCreateListNameInput.isEnabled = !it viewCreateListDescriptionInput.isEnabled = !it viewCreateListButton.isEnabled = !it viewCreateListButton.setText( when { it -> R.string.textPleaseWait !it && isEditMode() -> R.string.textEditList else -> R.string.textCreateList } ) } } onListUpdated?.let { it.consume()?.let { setFragmentResult(REQUEST_CREATE_LIST, bundleOf()) closeSheet() } } } } private fun renderSnackbar(message: MessageEvent) { when (message) { is MessageEvent.Info -> binding.viewCreateListSnackHost.showInfoSnackbar(getString(message.textRestId)) is MessageEvent.Error -> binding.viewCreateListSnackHost.showErrorSnackbar(getString(message.textRestId)) } } private fun isEditMode() = list != null } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/create/CreateListUiState.kt ================================================ package com.michaldrabik.ui_lists.create import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_model.CustomList data class CreateListUiState( val listDetails: CustomList? = null, val isLoading: Boolean? = null, val onListUpdated: Event? = null, ) ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/create/CreateListViewModel.kt ================================================ package com.michaldrabik.ui_lists.create import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError.AccountLimitsError import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_lists.R import com.michaldrabik.ui_lists.create.cases.CreateListCase import com.michaldrabik.ui_lists.create.cases.ListDetailsCase import com.michaldrabik.ui_model.CustomList import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class CreateListViewModel @Inject constructor( private val createListCase: CreateListCase, private val listDetailsCase: ListDetailsCase, ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val detailsState = MutableStateFlow(null) private val loadingState = MutableStateFlow(false) private val listUpdateState = MutableStateFlow?>(null) fun loadDetails(id: Long) { viewModelScope.launch { loadingState.value = true detailsState.value = listDetailsCase.loadDetails(id) loadingState.value = false } } fun createList(name: String, description: String?) { if (name.trim().isBlank()) return viewModelScope.launch { try { loadingState.value = true val list = createListCase.createList(name, description) listUpdateState.value = Event(list) } catch (error: Throwable) { loadingState.value = false handleError(error, R.string.errorCouldNotCreateList) } } } fun updateList(list: CustomList) { if (list.name.trim().isBlank()) return viewModelScope.launch { try { loadingState.value = true detailsState.value = list val updatedList = createListCase.updateList(list) listUpdateState.value = Event(updatedList) } catch (error: Throwable) { detailsState.value = list loadingState.value = false handleError(error, R.string.errorCouldNotUpdateList) } } } private suspend fun handleError(error: Throwable, defaultErrorMessage: Int) { when (ErrorHelper.parse(error)) { AccountLimitsError -> messageChannel.send(MessageEvent.Error(R.string.errorAccountListsLimitsReached)) else -> messageChannel.send(MessageEvent.Error(defaultErrorMessage)) } } val uiState = combine( detailsState, loadingState, listUpdateState ) { s1, s2, s3 -> CreateListUiState( listDetails = s1, isLoading = s2, onListUpdated = s3 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = CreateListUiState() ) } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/create/cases/CreateListCase.kt ================================================ package com.michaldrabik.ui_lists.create.cases import com.michaldrabik.common.Mode import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError.ResourceNotFoundError import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.ListsRepository import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.Logger import com.michaldrabik.ui_base.events.EventsManager import com.michaldrabik.ui_base.events.TraktListQuickSyncSuccess import com.michaldrabik.ui_base.events.TraktQuickSyncSuccess import com.michaldrabik.ui_model.CustomList import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.delay import javax.inject.Inject @ViewModelScoped class CreateListCase @Inject constructor( private val remoteSource: RemoteDataSource, private val mappers: Mappers, private val listsRepository: ListsRepository, private val settingsRepository: SettingsRepository, private val userTraktManager: UserTraktManager, private val eventsManager: EventsManager, ) { suspend fun createList(name: String, description: String?): CustomList { val isAuthorized = userTraktManager.isAuthorized() val isQuickSyncEnabled = settingsRepository.load().traktQuickSyncEnabled if (isAuthorized && isQuickSyncEnabled) { userTraktManager.checkAuthorization() val list = remoteSource.trakt.postCreateList(name, description).run { mappers.customList.fromNetwork(this) } return listsRepository.createList(name, description, list.idTrakt, list.idSlug) .also { eventsManager.sendEvent(TraktListQuickSyncSuccess) } } return listsRepository.createList(name, description, null, null) } suspend fun updateList(list: CustomList): CustomList { val isAuthorized = userTraktManager.isAuthorized() val isQuickSyncEnabled = settingsRepository.load().traktQuickSyncEnabled if (isAuthorized && isQuickSyncEnabled) { userTraktManager.checkAuthorization() val updateList = mappers.customList.toNetwork(list) return try { val result = remoteSource.trakt.postUpdateList(updateList).run { mappers.customList.fromNetwork(this) } listsRepository.updateList(list.id, result.idTrakt, result.idSlug, result.name, result.description) .also { eventsManager.sendEvent(TraktQuickSyncSuccess(1)) } } catch (error: Throwable) { if (ErrorHelper.parse(error) is ResourceNotFoundError) { // If list does not exist in Trakt account we need to create it and upload items as well. delay(1000) val result = remoteSource.trakt.postCreateList(updateList.name, updateList.description) .run { mappers.customList.fromNetwork(this) } listsRepository.updateList(list.id, result.idTrakt, result.idSlug, result.name, result.description) val localItems = listsRepository.loadListItemsForId(list.id) if (localItems.isNotEmpty()) { val showsIds = localItems.filter { it.type == Mode.SHOWS.type }.map { it.idTrakt } val moviesIds = localItems.filter { it.type == Mode.MOVIES.type }.map { it.idTrakt } delay(1000) remoteSource.trakt.postAddListItems(result.idTrakt!!, showsIds, moviesIds) } listsRepository.updateList(list.id, result.idTrakt, result.idSlug, result.name, result.description) .also { eventsManager.sendEvent(TraktQuickSyncSuccess(1)) } } else { Logger.record(error, "CreateListCase::updateList()") throw error } } } return listsRepository.updateList(list.id, null, null, list.name, list.description) } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/create/cases/ListDetailsCase.kt ================================================ package com.michaldrabik.ui_lists.create.cases import com.michaldrabik.repository.ListsRepository import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class ListDetailsCase @Inject constructor( private val listsRepository: ListsRepository, ) { suspend fun loadDetails(id: Long) = listsRepository.loadById(id) } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/ListDetailsFragment.kt ================================================ package com.michaldrabik.ui_lists.details import android.os.Bundle import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.addCallback import androidx.appcompat.widget.PopupMenu import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy import androidx.recyclerview.widget.RecyclerView.LayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.michaldrabik.common.Mode import com.michaldrabik.repository.settings.SettingsViewModeRepository import com.michaldrabik.ui_base.BaseFragment import com.michaldrabik.ui_base.common.ListViewMode.GRID import com.michaldrabik.ui_base.common.ListViewMode.GRID_TITLE import com.michaldrabik.ui_base.common.ListViewMode.LIST_COMPACT import com.michaldrabik.ui_base.common.ListViewMode.LIST_NORMAL import com.michaldrabik.ui_base.common.sheets.sort_order.SortOrderBottomSheet import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.extensions.add import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.disableUi import com.michaldrabik.ui_base.utilities.extensions.doOnApplyWindowInsets import com.michaldrabik.ui_base.utilities.extensions.enableUi import com.michaldrabik.ui_base.utilities.extensions.fadeIf import com.michaldrabik.ui_base.utilities.extensions.fadeOut import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.navigateToSafe import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.requireParcelable import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.extensions.withSpanSizeLookup import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_lists.R import com.michaldrabik.ui_lists.databinding.FragmentListDetailsBinding import com.michaldrabik.ui_lists.details.ListDetailsUiEvent.OpenPremium import com.michaldrabik.ui_lists.details.helpers.ListItemDragListener import com.michaldrabik.ui_lists.details.helpers.ListItemSwipeListener import com.michaldrabik.ui_lists.details.helpers.ReorderListCallback import com.michaldrabik.ui_lists.details.helpers.ReorderListCallbackAdapter import com.michaldrabik.ui_lists.details.recycler.ListDetailsAdapter import com.michaldrabik.ui_lists.details.recycler.ListDetailsItem import com.michaldrabik.ui_lists.details.recycler.helpers.ListDetailsGridItemDecoration import com.michaldrabik.ui_lists.details.recycler.helpers.ListDetailsLayoutManagerProvider import com.michaldrabik.ui_lists.details.recycler.helpers.ListDetailsListItemDecoration import com.michaldrabik.ui_lists.details.views.ListDetailsDeleteConfirmView import com.michaldrabik.ui_model.CustomList import com.michaldrabik.ui_model.PremiumFeature import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortOrder.DATE_ADDED import com.michaldrabik.ui_model.SortOrder.NAME import com.michaldrabik.ui_model.SortOrder.NEWEST import com.michaldrabik.ui_model.SortOrder.RANK import com.michaldrabik.ui_model.SortOrder.RATING import com.michaldrabik.ui_model.SortOrder.USER_RATING import com.michaldrabik.ui_model.SortType import com.michaldrabik.ui_navigation.java.NavigationArgs import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_ITEM import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_LIST import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_MOVIE_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_SELECTED_SORT_ORDER import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_SELECTED_SORT_TYPE import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_SHOW_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_SORT_ORDER import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint class ListDetailsFragment : BaseFragment(R.layout.fragment_list_details), ListItemDragListener, ListItemSwipeListener { companion object { private const val ARG_HEADER_TRANSLATION = "ARG_HEADER_TRANSLATION" } @Inject lateinit var settings: SettingsViewModeRepository override val navigationId = R.id.listDetailsFragment override val viewModel by viewModels() private val binding by viewBinding(FragmentListDetailsBinding::bind) private val list by lazy { requireParcelable(ARG_LIST) } private val recyclerPaddingBottom by lazy { requireContext().dimenToPx(R.dimen.spaceSmall) } private val recyclerPaddingTop by lazy { requireContext().dimenToPx(R.dimen.listDetailsRecyclerTopPadding) } private val recyclerPaddingGridTop by lazy { requireContext().dimenToPx(R.dimen.listDetailsRecyclerTopGridPadding) } private val tabletGridSpanSize by lazy { settings.tabletGridSpanSize } private var adapter: ListDetailsAdapter? = null private var touchHelper: ItemTouchHelper? = null private var layoutManager: LayoutManager? = null private var headerTranslation = 0F private var isReorderMode = false override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { savedInstanceState?.let { headerTranslation = it.getFloat(ARG_HEADER_TRANSLATION) } return super.onCreateView(inflater, container, savedInstanceState) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() setupRecycler() launchAndRepeatStarted( { viewModel.uiState.collect { render(it) } }, { viewModel.messageFlow.collect { showSnack(it) } }, { viewModel.eventFlow.collect { handleEvent(it) } }, doAfterLaunch = { viewModel.loadDetails(list.id) } ) } override fun onResume() { super.onResume() hideNavigation() } override fun onPause() { enableUi() headerTranslation = binding.fragmentListDetailsFiltersView.translationY super.onPause() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putFloat(ARG_HEADER_TRANSLATION, headerTranslation) } private fun setupView() { with(binding) { fragmentListDetailsRoot.doOnApplyWindowInsets { view, insets, padding, _ -> val inset = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top view.updatePadding(top = padding.top + inset) } with(fragmentListDetailsToolbar) { title = list.name subtitle = list.description setNavigationOnClickListener { if (isReorderMode) toggleReorderMode() else activity?.onBackPressed() } } with(fragmentListDetailsFiltersView) { onTypesChangeListener = { viewModel.setFilterTypes(list.id, it) } onSortClickListener = { order, type -> openSortOrderDialog(order, type) } translationY = headerTranslation } fragmentListDetailsManageButton.onClick { toggleReorderMode() } fragmentListDetailsViewModeButton.onClick(safe = false) { viewModel.toggleViewMode() } } } private fun setupRecycler() { layoutManager = ListDetailsLayoutManagerProvider.provideLayoutManger(requireContext(), LIST_NORMAL, tabletGridSpanSize) adapter = ListDetailsAdapter( itemClickListener = { openItemDetails(it) }, missingImageListener = { item: ListDetailsItem, force: Boolean -> viewModel.loadMissingImage(item, force) }, missingTranslationListener = { viewModel.loadMissingTranslation(it) }, itemsChangedListener = { with(binding) { fragmentListDetailsRecycler.scrollToPosition(0) fragmentListDetailsFiltersView.translationY = 0F } }, itemsClearedListener = { if (isReorderMode) viewModel.updateRanks(list.id, it) }, itemsSwipedListener = { viewModel.deleteListItem(list.id, it) }, itemDragStartListener = this, itemSwipeStartListener = this ).apply { stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY } binding.fragmentListDetailsRecycler.apply { adapter = this@ListDetailsFragment.adapter layoutManager = this@ListDetailsFragment.layoutManager (itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false setHasFixedSize(true) addItemDecoration(ListDetailsGridItemDecoration(requireContext(), R.dimen.spaceSmall)) addItemDecoration(ListDetailsListItemDecoration(requireContext(), R.dimen.spaceSmall)) } val touchCallback = ReorderListCallback(adapter as ReorderListCallbackAdapter) touchHelper = ItemTouchHelper(touchCallback) touchHelper?.attachToRecyclerView(binding.fragmentListDetailsRecycler) } override fun setupBackPressed() { val dispatcher = requireActivity().onBackPressedDispatcher dispatcher.addCallback(viewLifecycleOwner) { if (isReorderMode) { toggleReorderMode() } else { isEnabled = false findNavControl()?.popBackStack() } } } private fun openSortOrderDialog(order: SortOrder, type: SortType) { val options = listOf(RANK, NAME, RATING, USER_RATING, NEWEST, DATE_ADDED) val args = SortOrderBottomSheet.createBundle(options, order, type) setFragmentResultListener(REQUEST_SORT_ORDER) { _, bundle -> val sortOrder = bundle.getSerializable(ARG_SELECTED_SORT_ORDER) as SortOrder val sortType = bundle.getSerializable(ARG_SELECTED_SORT_TYPE) as SortType viewModel.setSortOrder(list.id, sortOrder, sortType) } navigateTo(R.id.actionListDetailsFragmentToSortOrder, args) } private fun openDeleteDialog(quickRemoveEnabled: Boolean) { val view = ListDetailsDeleteConfirmView(requireContext()) MaterialAlertDialogBuilder(requireContext(), R.style.AlertDialog) .apply { if (quickRemoveEnabled) setView(view) } .setBackground(ContextCompat.getDrawable(requireContext(), R.drawable.bg_dialog)) .setTitle(R.string.textConfirmDeleteListTitle) .setMessage(R.string.textConfirmDeleteListSubtitle) .setPositiveButton(R.string.textYes) { _, _ -> val removeFromTrakt = view.binding.viewListDeleteConfirmCheckbox?.isChecked viewModel.deleteList(list.id, removeFromTrakt == true) } .setNegativeButton(R.string.textNo) { _, _ -> } .show() } private fun openEditDialog() { setFragmentResultListener(NavigationArgs.REQUEST_CREATE_LIST) { _, _ -> viewModel.loadDetails(list.id) } val bundle = bundleOf(ARG_LIST to list) navigateTo(R.id.actionListDetailsFragmentToEditListDialog, bundle) } private fun openItemDetails(listItem: ListDetailsItem) { disableUi() binding.fragmentListDetailsRoot.fadeOut(150) { val bundle = bundleOf( ARG_SHOW_ID to listItem.show?.traktId, ARG_MOVIE_ID to listItem.movie?.traktId ) val destination = when { listItem.isShow() -> R.id.actionListDetailsFragmentToShowDetailsFragment listItem.isMovie() -> R.id.actionListDetailsFragmentToMovieDetailsFragment else -> throw IllegalStateException() } navigateTo(destination, bundle) }.add(animations) } private fun openPopupMenu(quickRemoveEnabled: Boolean) { PopupMenu(requireContext(), binding.fragmentListDetailsMoreButton, Gravity.CENTER).apply { inflate(R.menu.menu_list_details) setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { R.id.menuListDetailsEdit -> openEditDialog() R.id.menuListDetailsDelete -> openDeleteDialog(quickRemoveEnabled) } true } show() } } private fun toggleReorderMode() { isReorderMode = !isReorderMode viewModel.setReorderMode(list.id, isReorderMode) } private fun render(uiState: ListDetailsUiState) { fun renderTitle(name: String?, itemsCount: Int? = null) { if (name.isNullOrBlank()) return binding.fragmentListDetailsToolbar.title = when { itemsCount != null && itemsCount > 0 -> "$name ($itemsCount)" else -> name } } uiState.run { renderTitle(listDetails?.name, listItems?.size) with(binding) { viewMode.let { if (adapter?.listViewMode != it) { layoutManager = ListDetailsLayoutManagerProvider.provideLayoutManger(requireContext(), it, tabletGridSpanSize) adapter?.listViewMode = it fragmentListDetailsRecycler?.let { recycler -> recycler.layoutManager = layoutManager recycler.adapter = adapter } fragmentListDetailsViewModeButton.setImageResource( when (it) { LIST_NORMAL, LIST_COMPACT -> R.drawable.ic_view_list GRID, GRID_TITLE -> R.drawable.ic_view_grid } ) } } listDetails?.let { details -> val isQuickRemoveEnabled = isQuickRemoveEnabled fragmentListDetailsToolbar.subtitle = details.description fragmentListDetailsMoreButton.onClick { openPopupMenu(isQuickRemoveEnabled) } fragmentListDetailsFiltersView.setFilters(details.filterTypeLocal, details.sortByLocal, details.sortHowLocal) } listItems?.let { val isRealEmpty = it.isEmpty() && listDetails?.filterTypeLocal?.containsAll(Mode.getAll()) == true fragmentListDetailsEmptyView.root.fadeIf(it.isEmpty()) fragmentListDetailsManageButton.visibleIf(!isRealEmpty) fragmentListDetailsViewModeButton.visibleIf(!isRealEmpty) val scrollTop = resetScroll?.consume() == true adapter?.setItems(it, scrollTop) (layoutManager as? GridLayoutManager)?.withSpanSizeLookup { pos -> adapter?.items?.get(pos)?.image?.type?.getSpan(isTablet)!! } } isManageMode.let { isManageMode -> if (listItems?.isEmpty() == true && listDetails?.filterTypeLocal?.containsAll(Mode.getAll()) == true) { return@let } fragmentListDetailsManageButton.visibleIf(!isManageMode) fragmentListDetailsMoreButton.visibleIf(!isManageMode) fragmentListDetailsViewModeButton.visibleIf(!isManageMode) if (isManageMode) { fragmentListDetailsToolbar.title = getString(R.string.textChangeRanks) fragmentListDetailsToolbar.subtitle = getString(R.string.textChangeRanksSubtitle) fragmentListDetailsRecycler.updatePadding( top = if (layoutManager is GridLayoutManager) dimenToPx(R.dimen.spaceTiny) else 0, bottom = recyclerPaddingBottom ) } else { renderTitle(listDetails?.name ?: list.name, listItems?.size) fragmentListDetailsToolbar.subtitle = listDetails?.description fragmentListDetailsRecycler.updatePadding( top = if (layoutManager is GridLayoutManager) recyclerPaddingGridTop else recyclerPaddingTop, bottom = recyclerPaddingBottom ) } if (resetScroll?.consume() == true) { fragmentListDetailsRecycler.scrollToPosition(0) fragmentListDetailsFiltersView.translationY = 0F } } isFiltersVisible.let { fragmentListDetailsFiltersView.visibleIf(it) } isLoading.let { fragmentListDetailsLoadingView.visibleIf(it) if (it) disableUi() else enableUi() } deleteEvent?.let { event -> event.consume()?.let { activity?.onBackPressed() } } } } } private fun handleEvent(event: Event<*>) { when (event) { is OpenPremium -> { val args = bundleOf(ARG_ITEM to PremiumFeature.VIEW_TYPES) navigateToSafe(R.id.actionListDetailsFragmentToPremium, args) } } } override fun onListItemDragStarted(viewHolder: RecyclerView.ViewHolder) { touchHelper?.startDrag(viewHolder) } override fun onListItemSwipeStarted(viewHolder: RecyclerView.ViewHolder) { touchHelper?.startSwipe(viewHolder) } override fun onDestroyView() { adapter = null touchHelper = null layoutManager = null super.onDestroyView() } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/ListDetailsUiEvents.kt ================================================ // ktlint-disable filename package com.michaldrabik.ui_lists.details import com.michaldrabik.ui_base.utilities.events.Event sealed class ListDetailsUiEvent(action: T) : Event(action) { object OpenPremium : ListDetailsUiEvent(Unit) } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/ListDetailsUiState.kt ================================================ package com.michaldrabik.ui_lists.details import com.michaldrabik.ui_base.common.ListViewMode import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_lists.details.recycler.ListDetailsItem import com.michaldrabik.ui_model.CustomList data class ListDetailsUiState( val listDetails: CustomList? = null, val listItems: List? = null, val resetScroll: Event? = null, val deleteEvent: Event? = null, val isFiltersVisible: Boolean = false, val isManageMode: Boolean = false, val isQuickRemoveEnabled: Boolean = false, val isLoading: Boolean = false, val viewMode: ListViewMode = ListViewMode.LIST_NORMAL ) ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/ListDetailsViewModel.kt ================================================ package com.michaldrabik.ui_lists.details import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.common.Config import com.michaldrabik.common.Mode import com.michaldrabik.common.Mode.MOVIES import com.michaldrabik.common.Mode.SHOWS import com.michaldrabik.repository.images.MovieImagesProvider import com.michaldrabik.repository.images.ShowImagesProvider import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.common.ListViewMode import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.events.MessageEvent import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.utilities.extensions.combine import com.michaldrabik.ui_base.utilities.extensions.findReplace import com.michaldrabik.ui_base.viewmodel.ChannelsDelegate import com.michaldrabik.ui_base.viewmodel.DefaultChannelsDelegate import com.michaldrabik.ui_lists.R import com.michaldrabik.ui_lists.details.ListDetailsUiEvent.OpenPremium import com.michaldrabik.ui_lists.details.cases.ListDetailsItemsCase import com.michaldrabik.ui_lists.details.cases.ListDetailsMainCase import com.michaldrabik.ui_lists.details.cases.ListDetailsSortCase import com.michaldrabik.ui_lists.details.cases.ListDetailsTipsCase import com.michaldrabik.ui_lists.details.cases.ListDetailsTranslationsCase import com.michaldrabik.ui_lists.details.cases.ListDetailsViewModeCase import com.michaldrabik.ui_lists.details.recycler.ListDetailsItem import com.michaldrabik.ui_model.CustomList import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortType import com.michaldrabik.ui_model.Tip import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @HiltViewModel class ListDetailsViewModel @Inject constructor( private val mainCase: ListDetailsMainCase, private val itemsCase: ListDetailsItemsCase, private val translationsCase: ListDetailsTranslationsCase, private val sortCase: ListDetailsSortCase, private val tipsCase: ListDetailsTipsCase, private val viewModeCase: ListDetailsViewModeCase, private val showImagesProvider: ShowImagesProvider, private val movieImagesProvider: MovieImagesProvider, private val settingsRepository: SettingsRepository ) : ViewModel(), ChannelsDelegate by DefaultChannelsDelegate() { private val listDetailsState = MutableStateFlow(null) private val listItemsState = MutableStateFlow?>(null) private val listDeleteState = MutableStateFlow?>(null) private val manageModeState = MutableStateFlow(false) private val quickRemoveState = MutableStateFlow(false) private val scrollState = MutableStateFlow?>(null) private val loadingState = MutableStateFlow(false) private val filtersVisibleState = MutableStateFlow(false) private val viewModeState = MutableStateFlow(ListViewMode.LIST_NORMAL) fun loadDetails(id: Long) { viewModelScope.launch { val list = mainCase.loadDetails(id) val (listItems, totalCount) = itemsCase.loadItems(list) viewModeState.value = viewModeCase.getListViewMode() listDetailsState.value = list listItemsState.value = listItems manageModeState.value = false filtersVisibleState.value = totalCount > 0 quickRemoveState.value = mainCase.isQuickRemoveEnabled(list) val tip = Tip.LIST_ITEM_SWIPE_DELETE if (listItems.isNotEmpty() && !tipsCase.isTipShown(tip)) { messageChannel.send(MessageEvent.Info(tip.textResId, isIndefinite = true)) tipsCase.setTipShown(tip) } } } fun loadMissingImage(item: ListDetailsItem, force: Boolean) { viewModelScope.launch { updateItem(item.copy(isLoading = true)) try { val image = when { item.isShow() -> showImagesProvider.loadRemoteImage(item.requireShow(), item.image.type, force) item.isMovie() -> movieImagesProvider.loadRemoteImage(item.requireMovie(), item.image.type, force) else -> throw IllegalStateException() } updateItem(item.copy(isLoading = false, image = image)) } catch (t: Throwable) { updateItem(item.copy(isLoading = false, image = Image.createUnavailable(item.image.type))) } } } fun loadMissingTranslation(item: ListDetailsItem) { if (item.translation != null || translationsCase.getLanguage() == Config.DEFAULT_LANGUAGE) return viewModelScope.launch { try { val translation = translationsCase.loadTranslation(item, false) updateItem(item.copy(translation = translation)) } catch (error: Throwable) { Timber.e(error) } } } fun toggleViewMode() { if (settingsRepository.isPremium) { viewModeState.value = viewModeCase.setNextViewMode() return } viewModelScope.launch { eventChannel.send(OpenPremium) } } fun setReorderMode(listId: Long, isReorderMode: Boolean) { viewModelScope.launch { if (isReorderMode) { val list = mainCase.loadDetails(listId).copy( sortByLocal = SortOrder.RANK, sortHowLocal = SortType.ASCENDING, filterTypeLocal = Mode.getAll() ) val listItems = itemsCase.loadItems(list).first.map { it.copy(isManageMode = true) } listItemsState.value = listItems manageModeState.value = true filtersVisibleState.value = false scrollState.value = Event(false) } else { val list = mainCase.loadDetails(listId) val listItems = itemsCase.loadItems(list).first.map { it.copy(isManageMode = false) } listItemsState.value = listItems manageModeState.value = false filtersVisibleState.value = true scrollState.value = Event(true) } } } fun updateRanks(listId: Long, items: List) { viewModelScope.launch { val updatedItems = mainCase.updateRanks(listId, items) listItemsState.value = updatedItems } } fun setSortOrder( id: Long, sortOrder: SortOrder, sortType: SortType ) { viewModelScope.launch { val list = sortCase.setSortOrder(id, sortOrder, sortType) val currentItems = uiState.value.listItems?.toList() ?: emptyList() val sortedItems = itemsCase.sortItems( currentItems, list.sortByLocal, list.sortHowLocal, list.filterTypeLocal ) listDetailsState.value = list listItemsState.value = sortedItems scrollState.value = Event(true) } } fun setFilterTypes(listId: Long, types: List) { viewModelScope.launch { val list = sortCase.setFilterTypes(listId, types) val (sortedItems, _) = itemsCase.loadItems(list) listDetailsState.value = list listItemsState.value = sortedItems filtersVisibleState.value = true scrollState.value = Event(true) } } fun deleteList(listId: Long, removeFromTrakt: Boolean) { viewModelScope.launch { try { if (removeFromTrakt) { loadingState.value = true } mainCase.deleteList(listId, removeFromTrakt) loadingState.value = false listDeleteState.value = Event(true) } catch (error: Throwable) { loadingState.value = false messageChannel.send(MessageEvent.Error(R.string.errorCouldNotDeleteList)) } } } fun deleteListItem(listId: Long, item: ListDetailsItem) { viewModelScope.launch { val type = when { item.isShow() -> SHOWS item.isMovie() -> MOVIES else -> throw IllegalStateException() } itemsCase.deleteListItem(listId, item.getTraktId(), type) loadDetails(listId) } } private fun updateItem(newItem: ListDetailsItem) { val currentItems = uiState.value.listItems?.toMutableList() ?: mutableListOf() currentItems.findReplace(newItem) { it.id == newItem.id } listItemsState.value = currentItems } val uiState = combine( listDetailsState, listItemsState, manageModeState, quickRemoveState, loadingState, listDeleteState, scrollState, filtersVisibleState, viewModeState ) { s1, s2, s3, s4, s5, s6, s7, s8, s9 -> ListDetailsUiState( listDetails = s1, listItems = s2, isManageMode = s3, isQuickRemoveEnabled = s4, isLoading = s5, deleteEvent = s6, resetScroll = s7, isFiltersVisible = s8, viewMode = s9 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = ListDetailsUiState() ) } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/cases/ListDetailsItemsCase.kt ================================================ package com.michaldrabik.ui_lists.details.cases import com.michaldrabik.common.Config import com.michaldrabik.common.Mode import com.michaldrabik.common.Mode.MOVIES import com.michaldrabik.common.Mode.SHOWS import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.CustomListItem import com.michaldrabik.repository.ListsRepository import com.michaldrabik.repository.RatingsRepository import com.michaldrabik.repository.TranslationsRepository import com.michaldrabik.repository.images.MovieImagesProvider import com.michaldrabik.repository.images.ShowImagesProvider import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.movies.MoviesRepository import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.repository.shows.ShowsRepository import com.michaldrabik.ui_base.trakt.quicksync.QuickSyncManager import com.michaldrabik.ui_lists.details.helpers.ListDetailsSorter import com.michaldrabik.ui_lists.details.recycler.ListDetailsItem import com.michaldrabik.ui_model.CustomList import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.ImageType import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortType import com.michaldrabik.ui_model.SpoilersSettings import com.michaldrabik.ui_model.TraktRating import com.michaldrabik.ui_model.Translation import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime import java.util.Collections import javax.inject.Inject @ViewModelScoped class ListDetailsItemsCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val localSource: LocalDataSource, private val mappers: Mappers, private val showsRepository: ShowsRepository, private val moviesRepository: MoviesRepository, private val listsRepository: ListsRepository, private val showImagesProvider: ShowImagesProvider, private val movieImagesProvider: MovieImagesProvider, private val translationsRepository: TranslationsRepository, private val ratingsRepository: RatingsRepository, private val settingsRepository: SettingsRepository, private val quickSyncManager: QuickSyncManager, private val sorter: ListDetailsSorter, ) { suspend fun loadItems(list: CustomList): Pair, Int> = withContext(dispatchers.IO) { val moviesEnabled = settingsRepository.isMoviesEnabled val language = translationsRepository.getLanguage() val listItems = listsRepository.loadItemsById(list.id) val spoilers = settingsRepository.spoilers.getAll() val showsAsync = async { val ids = listItems.filter { it.type == SHOWS.type }.map { it.idTrakt } localSource.shows.getAllChunked(ids) } val moviesAsync = async { val ids = listItems.filter { it.type == MOVIES.type }.map { it.idTrakt } localSource.movies.getAllChunked(ids) } val showsTranslationsAsync = async { if (language == Config.DEFAULT_LANGUAGE) emptyMap() else translationsRepository.loadAllShowsLocal(language) } val moviesTranslationsAsync = async { if (language == Config.DEFAULT_LANGUAGE) emptyMap() else translationsRepository.loadAllMoviesLocal(language) } val showsRatingsAsync = async { ratingsRepository.shows.loadShowsRatings() } val moviesRatingsAsync = async { ratingsRepository.movies.loadMoviesRatings() } val (shows, movies) = Pair(showsAsync.await(), moviesAsync.await()) val (showsTranslations, moviesTranslations) = Pair(showsTranslationsAsync.await(), moviesTranslationsAsync.await()) val (showsRatings, moviesRatings) = Pair(showsRatingsAsync.await(), moviesRatingsAsync.await()) val isRankSort = list.sortByLocal == SortOrder.RANK val itemsToDelete = Collections.synchronizedList(mutableListOf()) val items = listItems.map { listItem -> async { val listedAt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(listItem.listedAt), ZoneId.of("UTC")) when (listItem.type) { SHOWS.type -> { val listShow = shows.firstOrNull { it.idTrakt == listItem.idTrakt } if (listShow == null) { itemsToDelete.add(listItem) return@async null } val show = mappers.show.fromDatabase(listShow) val translation = showsTranslations[show.traktId] val rating = showsRatings.find { it.idTrakt == show.ids.trakt } createListDetailsItem( show = show, listItem = listItem, translation = translation, userRating = rating, isRankSort = isRankSort, listedAt = listedAt, sortOrder = list.sortByLocal, spoilers = spoilers ) } MOVIES.type -> { val listMovie = movies.firstOrNull { it.idTrakt == listItem.idTrakt } if (listMovie == null) { itemsToDelete.add(listItem) return@async null } val movie = mappers.movie.fromDatabase(listMovie) val translation = moviesTranslations[movie.traktId] val rating = moviesRatings.find { it.idTrakt == movie.ids.trakt } createListDetailsItem( movie = movie, listItem = listItem, translation = translation, userRating = rating, isRankSort = isRankSort, listedAt = listedAt, moviesEnabled = moviesEnabled, sortOrder = list.sortByLocal, spoilers = spoilers ) } else -> throw IllegalStateException("Unsupported list item type.") } } }.awaitAll() itemsToDelete.forEach { listsRepository.removeFromList(list.id, IdTrakt(it.idTrakt), it.type) } val sortedItems = sortItems( items = items.filterNotNull(), sort = list.sortByLocal, sortHow = list.sortHowLocal, typeFilters = list.filterTypeLocal ) Pair(sortedItems, listItems.count()) } private suspend fun createListDetailsItem( movie: Movie, listItem: CustomListItem, translation: Translation?, userRating: TraktRating?, isRankSort: Boolean, listedAt: ZonedDateTime, moviesEnabled: Boolean, sortOrder: SortOrder, spoilers: SpoilersSettings ): ListDetailsItem { val image = movieImagesProvider.findCachedImage(movie, ImageType.POSTER) return ListDetailsItem( id = listItem.id, rank = listItem.rank, rankDisplay = listItem.rank.toInt(), show = null, movie = movie, image = image, translation = translation, userRating = userRating?.rating, isLoading = false, isRankDisplayed = isRankSort, isManageMode = false, isEnabled = moviesEnabled, isWatched = moviesRepository.myMovies.exists(movie.ids.trakt), isWatchlist = moviesRepository.watchlistMovies.exists(movie.ids.trakt), listedAt = listedAt, sortOrder = sortOrder, spoilers = spoilers ) } private suspend fun createListDetailsItem( show: Show, listItem: CustomListItem, translation: Translation?, userRating: TraktRating?, isRankSort: Boolean, listedAt: ZonedDateTime, sortOrder: SortOrder, spoilers: SpoilersSettings ): ListDetailsItem { val image = showImagesProvider.findCachedImage(show, ImageType.POSTER) return ListDetailsItem( id = listItem.id, rank = listItem.rank, rankDisplay = listItem.rank.toInt(), show = show, movie = null, image = image, translation = translation, userRating = userRating?.rating, isLoading = false, isRankDisplayed = isRankSort, isManageMode = false, isEnabled = true, isWatched = showsRepository.myShows.exists(show.ids.trakt), isWatchlist = showsRepository.watchlistShows.exists(show.ids.trakt), listedAt = listedAt, sortOrder = sortOrder, spoilers = spoilers ) } fun sortItems( items: List, sort: SortOrder, sortHow: SortType, typeFilters: List, ) = items .filter { if (typeFilters.isEmpty()) { return@filter true } when { it.isShow() -> typeFilters.contains(SHOWS) it.isMovie() -> typeFilters.contains(MOVIES) else -> throw IllegalStateException() } } .sortedWith(sorter.sort(sort, sortHow)) .mapIndexed { index, item -> val rankDisplay = if (sortHow == SortType.ASCENDING) index + 1 else items.size - index item.copy( isRankDisplayed = sort == SortOrder.RANK, rankDisplay = rankDisplay, sortOrder = sort ) } suspend fun deleteListItem( listId: Long, itemTraktId: IdTrakt, itemType: Mode, ) = withContext(dispatchers.IO) { listsRepository.removeFromList(listId, itemTraktId, itemType.type) val isQuickRemoveEnabled = settingsRepository.load().traktQuickRemoveEnabled if (isQuickRemoveEnabled) { quickSyncManager.scheduleRemoveFromList(itemTraktId.id, listId, itemType) } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/cases/ListDetailsMainCase.kt ================================================ package com.michaldrabik.ui_lists.details.cases import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.common.errors.ErrorHelper import com.michaldrabik.common.errors.ShowlyError import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.CustomListItem import com.michaldrabik.data_local.utilities.TransactionsProvider import com.michaldrabik.data_remote.RemoteDataSource import com.michaldrabik.repository.ListsRepository import com.michaldrabik.repository.UserTraktManager import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_lists.details.recycler.ListDetailsItem import com.michaldrabik.ui_model.CustomList import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class ListDetailsMainCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val localSource: LocalDataSource, private val remoteSource: RemoteDataSource, private val transactions: TransactionsProvider, private val listsRepository: ListsRepository, private val settingsRepository: SettingsRepository, private val userTraktManager: UserTraktManager, ) { suspend fun loadDetails(id: Long) = withContext(dispatchers.IO) { listsRepository.loadById(id) } suspend fun updateRanks(listId: Long, items: List): List = withContext(dispatchers.IO) { val now = nowUtcMillis() val listItems = listsRepository.loadItemsById(listId) val updateItems = mutableListOf() val updateItemsDb = mutableListOf() items.forEachIndexed { index, item -> val dbItem = listItems.first { it.id == item.id }.copy(rank = index + 1L, updatedAt = now) val updatedItem = item.copy(rank = index + 1L) updateItems.add(updatedItem) updateItemsDb.add(dbItem) } transactions.withTransaction { localSource.customListsItems.update(updateItemsDb) localSource.customLists.updateTimestamp(listId, now) } updateItems } suspend fun deleteList(listId: Long, removeFromTrakt: Boolean) = withContext(dispatchers.IO) { val isAuthorized = userTraktManager.isAuthorized() val isQuickRemove = settingsRepository.load().traktQuickRemoveEnabled val list = listsRepository.loadById(listId) val listIdTrakt = list.idTrakt if (isQuickRemove && isAuthorized && removeFromTrakt && listIdTrakt != null) { userTraktManager.checkAuthorization() try { remoteSource.trakt.deleteList(listIdTrakt) } catch (error: Throwable) { when (ErrorHelper.parse(error)) { is ShowlyError.ResourceNotFoundError -> Unit // NOOP List does not exist in Trakt. else -> throw error } } } listsRepository.deleteList(listId) } suspend fun isQuickRemoveEnabled(list: CustomList) = withContext(dispatchers.IO) { list.idTrakt != null && settingsRepository.load().traktQuickRemoveEnabled } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/cases/ListDetailsSortCase.kt ================================================ package com.michaldrabik.ui_lists.details.cases import com.michaldrabik.common.Mode import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.common.extensions.nowUtcMillis import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.ui_model.CustomList import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortType import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class ListDetailsSortCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val localSource: LocalDataSource, private val mappers: Mappers ) { suspend fun setSortOrder(listId: Long, sortOrder: SortOrder, sortType: SortType): CustomList = withContext(dispatchers.IO) { localSource.customLists.updateSortByLocal( listId, sortOrder.slug, sortType.slug, nowUtcMillis() ) val list = localSource.customLists.getById(listId)!! mappers.customList.fromDatabase(list) } suspend fun setFilterTypes(listId: Long, types: List): CustomList = withContext(dispatchers.IO) { localSource.customLists.updateFilterTypeLocal( listId, types.joinToString(",") { it.type }, nowUtcMillis() ) val list = localSource.customLists.getById(listId)!! mappers.customList.fromDatabase(list) } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/cases/ListDetailsTipsCase.kt ================================================ package com.michaldrabik.ui_lists.details.cases import android.content.SharedPreferences import com.michaldrabik.ui_model.Tip import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject import javax.inject.Named @ViewModelScoped class ListDetailsTipsCase @Inject constructor( @Named("tipsPreferences") private val sharedPreferences: SharedPreferences ) { fun isTipShown(tip: Tip) = sharedPreferences.getBoolean(tip.name, false) fun setTipShown(tip: Tip) { sharedPreferences.edit().putBoolean(tip.name, true).apply() } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/cases/ListDetailsTranslationsCase.kt ================================================ package com.michaldrabik.ui_lists.details.cases import com.michaldrabik.common.Config import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.repository.TranslationsRepository import com.michaldrabik.ui_lists.details.recycler.ListDetailsItem import com.michaldrabik.ui_model.Translation import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class ListDetailsTranslationsCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val translationsRepository: TranslationsRepository, ) { fun getLanguage() = translationsRepository.getLanguage() suspend fun loadTranslation(item: ListDetailsItem, onlyLocal: Boolean): Translation? = withContext(dispatchers.IO) { val language = getLanguage() if (language == Config.DEFAULT_LANGUAGE) { return@withContext Translation.EMPTY } when { item.isShow() -> translationsRepository.loadTranslation( show = item.requireShow(), language = language, onlyLocal = onlyLocal ) item.isMovie() -> translationsRepository.loadTranslation( movie = item.requireMovie(), language = language, onlyLocal = onlyLocal ) else -> throw IllegalStateException() } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/cases/ListDetailsViewModeCase.kt ================================================ package com.michaldrabik.ui_lists.details.cases import com.michaldrabik.common.Config import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.common.ListViewMode import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class ListDetailsViewModeCase @Inject constructor( private val settingsRepository: SettingsRepository, ) { fun setNextViewMode(): ListViewMode { if (!settingsRepository.isPremium) { return ListViewMode.valueOf(Config.DEFAULT_LIST_VIEW_MODE) } val viewModes = ListViewMode.values() val index = viewModes.indexOf(getListViewMode()) + 1 val nextIndex = if (index >= viewModes.size) 0 else index settingsRepository.viewMode.customListsViewMode = viewModes[nextIndex].name return viewModes[nextIndex] } fun getListViewMode(): ListViewMode { if (!settingsRepository.isPremium) { return ListViewMode.valueOf(Config.DEFAULT_LIST_VIEW_MODE) } val viewMode = settingsRepository.viewMode.customListsViewMode return ListViewMode.valueOf(viewMode) } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/helpers/ListDetailsSorter.kt ================================================ package com.michaldrabik.ui_lists.details.helpers import com.michaldrabik.ui_lists.details.recycler.ListDetailsItem import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortOrder.DATE_ADDED import com.michaldrabik.ui_model.SortOrder.NAME import com.michaldrabik.ui_model.SortOrder.NEWEST import com.michaldrabik.ui_model.SortOrder.RANK import com.michaldrabik.ui_model.SortOrder.RATING import com.michaldrabik.ui_model.SortOrder.USER_RATING import com.michaldrabik.ui_model.SortType import com.michaldrabik.ui_model.SortType.ASCENDING import com.michaldrabik.ui_model.SortType.DESCENDING import javax.inject.Inject class ListDetailsSorter @Inject constructor() { fun sort(sortOrder: SortOrder, sortType: SortType) = when (sortType) { ASCENDING -> sortAscending(sortOrder) DESCENDING -> sortDescending(sortOrder) } private fun sortAscending(sortOrder: SortOrder): Comparator = when (sortOrder) { RANK -> compareBy { it.rank } NAME -> compareBy { getTitle(it) } NEWEST -> compareBy { it.getYear() }.thenBy { it.getDate() } RATING -> compareBy { it.getRating() } USER_RATING -> compareByDescending { it.userRating != null } .thenBy { it.userRating } .thenBy { getTitle(it) } DATE_ADDED -> compareBy { it.listedAt } else -> throw IllegalStateException("Invalid sort order") } private fun sortDescending(sortOrder: SortOrder): Comparator = when (sortOrder) { RANK -> compareByDescending { it.rank } NAME -> compareByDescending { getTitle(it) } NEWEST -> compareByDescending { it.getYear() }.thenByDescending { it.getDate() } RATING -> compareByDescending { it.getRating() } USER_RATING -> compareByDescending { it.userRating != null } .thenByDescending { it.userRating } .thenBy { getTitle(it) } DATE_ADDED -> compareByDescending { it.listedAt } else -> throw IllegalStateException("Invalid sort order") } private fun getTitle(item: ListDetailsItem): String { return if (item.translation?.hasTitle == true) item.translation.title else item.getTitleNoThe().uppercase() } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/helpers/ListItemDragListener.kt ================================================ package com.michaldrabik.ui_lists.details.helpers import androidx.recyclerview.widget.RecyclerView interface ListItemDragListener { fun onListItemDragStarted(viewHolder: RecyclerView.ViewHolder) } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/helpers/ListItemSwipeListener.kt ================================================ package com.michaldrabik.ui_lists.details.helpers import androidx.recyclerview.widget.RecyclerView interface ListItemSwipeListener { fun onListItemSwipeStarted(viewHolder: RecyclerView.ViewHolder) } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/helpers/ReorderListCallback.kt ================================================ package com.michaldrabik.ui_lists.details.helpers import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper.DOWN import androidx.recyclerview.widget.ItemTouchHelper.END import androidx.recyclerview.widget.ItemTouchHelper.START import androidx.recyclerview.widget.ItemTouchHelper.UP import androidx.recyclerview.widget.RecyclerView class ReorderListCallback( private val adapter: ReorderListCallbackAdapter ) : ItemTouchHelper.SimpleCallback(UP or DOWN or START or END, START) { override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { adapter.onItemMove(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) return true } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { adapter.onItemSwiped(viewHolder) } override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { super.clearView(recyclerView, viewHolder) adapter.onItemCleared() } override fun isItemViewSwipeEnabled() = false override fun isLongPressDragEnabled() = false } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/helpers/ReorderListCallbackAdapter.kt ================================================ package com.michaldrabik.ui_lists.details.helpers import androidx.recyclerview.widget.RecyclerView interface ReorderListCallbackAdapter { fun onItemSwiped(viewHolder: RecyclerView.ViewHolder) fun onItemMove(fromPosition: Int, toPosition: Int): Boolean fun onItemCleared() } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/recycler/ListDetailsAdapter.kt ================================================ package com.michaldrabik.ui_lists.details.recycler import android.annotation.SuppressLint import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.michaldrabik.ui_base.common.ListViewMode import com.michaldrabik.ui_base.common.ListViewMode.GRID import com.michaldrabik.ui_base.common.ListViewMode.GRID_TITLE import com.michaldrabik.ui_base.common.ListViewMode.LIST_COMPACT import com.michaldrabik.ui_base.common.ListViewMode.LIST_NORMAL import com.michaldrabik.ui_lists.details.helpers.ListItemDragListener import com.michaldrabik.ui_lists.details.helpers.ListItemSwipeListener import com.michaldrabik.ui_lists.details.helpers.ReorderListCallbackAdapter import com.michaldrabik.ui_lists.details.views.ListDetailsItemView import com.michaldrabik.ui_lists.details.views.ListDetailsMovieItemView import com.michaldrabik.ui_lists.details.views.ListDetailsShowItemView import com.michaldrabik.ui_lists.details.views.compact.ListDetailsCompactMovieItemView import com.michaldrabik.ui_lists.details.views.compact.ListDetailsCompactShowItemView import com.michaldrabik.ui_lists.details.views.grid.ListDetailsGridItemView import com.michaldrabik.ui_lists.details.views.grid.ListDetailsGridTitleItemView import java.util.Collections class ListDetailsAdapter( val itemClickListener: (ListDetailsItem) -> Unit, val missingImageListener: (ListDetailsItem, Boolean) -> Unit, val missingTranslationListener: (ListDetailsItem) -> Unit, val itemsChangedListener: () -> Unit, val itemsClearedListener: (List) -> Unit, val itemsSwipedListener: (ListDetailsItem) -> Unit, val itemDragStartListener: ListItemDragListener, val itemSwipeStartListener: ListItemSwipeListener, ) : RecyclerView.Adapter(), ReorderListCallbackAdapter { companion object { private const val VIEW_TYPE_SHOW = 1 private const val VIEW_TYPE_MOVIE = 2 } var items = listOf() var listViewMode: ListViewMode = LIST_NORMAL set(value) { field = value notifyItemRangeChanged(0, items.size) } fun setItems(newItems: List, notifyItemsChange: Boolean) { // Using old DiffUtil method here because of drag and drop issues with asyncDiff. val diff = DiffUtil.calculateDiff(ListDetailsDiffCallback(items, newItems)) diff.dispatchUpdatesTo(this) items = newItems if (notifyItemsChange) itemsChangedListener.invoke() } override fun getItemViewType(position: Int): Int { val item = items[position] return when { item.isShow() -> VIEW_TYPE_SHOW item.isMovie() -> VIEW_TYPE_MOVIE else -> throw IllegalStateException() } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { VIEW_TYPE_SHOW -> { val view = when (listViewMode) { LIST_NORMAL -> ListDetailsShowItemView(parent.context) LIST_COMPACT -> ListDetailsCompactShowItemView(parent.context) GRID -> ListDetailsGridItemView(parent.context) GRID_TITLE -> ListDetailsGridTitleItemView(parent.context) }.apply { itemClickListener = { item -> this@ListDetailsAdapter.itemClickListener(item) } missingImageListener = { item, force -> this@ListDetailsAdapter.missingImageListener(item, force) } missingTranslationListener = { item -> this@ListDetailsAdapter.missingTranslationListener(item) } } ListDetailsItemViewHolder( view, itemDragStartListener, itemSwipeStartListener ) } VIEW_TYPE_MOVIE -> { val view = when (listViewMode) { LIST_NORMAL -> ListDetailsMovieItemView(parent.context) LIST_COMPACT -> ListDetailsCompactMovieItemView(parent.context) GRID -> ListDetailsGridItemView(parent.context) GRID_TITLE -> ListDetailsGridTitleItemView(parent.context) }.apply { itemClickListener = { item -> this@ListDetailsAdapter.itemClickListener(item) } missingImageListener = { item, force -> this@ListDetailsAdapter.missingImageListener(item, force) } missingTranslationListener = { item -> this@ListDetailsAdapter.missingTranslationListener(item) } } ListDetailsItemViewHolder( view, itemDragStartListener, itemSwipeStartListener ) } else -> throw IllegalStateException() } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val item = items[position] when (holder.itemViewType) { VIEW_TYPE_SHOW -> when (listViewMode) { LIST_NORMAL -> (holder.itemView as ListDetailsShowItemView).bind(item) LIST_COMPACT -> (holder.itemView as ListDetailsCompactShowItemView).bind(item) GRID -> (holder.itemView as ListDetailsGridItemView).bind(item) GRID_TITLE -> (holder.itemView as ListDetailsGridTitleItemView).bind(item) } VIEW_TYPE_MOVIE -> when (listViewMode) { LIST_NORMAL -> (holder.itemView as ListDetailsMovieItemView).bind(item) LIST_COMPACT -> (holder.itemView as ListDetailsCompactMovieItemView).bind(item) GRID -> (holder.itemView as ListDetailsGridItemView).bind(item) GRID_TITLE -> (holder.itemView as ListDetailsGridTitleItemView).bind(item) } else -> throw IllegalStateException() } } override fun getItemCount() = items.size override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean { if (fromPosition < toPosition) { for (i in fromPosition until toPosition) { Collections.swap(items, i, i + 1) } } else { for (i in fromPosition downTo toPosition + 1) { Collections.swap(items, i, i - 1) } } notifyItemMoved(fromPosition, toPosition) return true } override fun onItemCleared() = itemsClearedListener(items) override fun onItemSwiped(viewHolder: RecyclerView.ViewHolder) { val item = ((viewHolder as ListDetailsItemViewHolder).itemView as ListDetailsItemView).item itemsSwipedListener(item) } @SuppressLint("ClickableViewAccessibility") class ListDetailsItemViewHolder( itemView: ListDetailsItemView, dragStartListener: ListItemDragListener, swipeStartListener: ListItemSwipeListener ) : RecyclerView.ViewHolder(itemView) { init { itemView.itemDragStartListener = { dragStartListener.onListItemDragStarted(this) } itemView.itemSwipeStartListener = { swipeStartListener.onListItemSwipeStarted(this) } } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/recycler/ListDetailsDiffCallback.kt ================================================ package com.michaldrabik.ui_lists.details.recycler import androidx.recyclerview.widget.DiffUtil class ListDetailsDiffCallback( private val oldItems: List, private val newItems: List, ) : DiffUtil.Callback() { override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean { if (oldItems[oldPos].isMovie() && newItems[newPos].isShow()) return false if (oldItems[oldPos].isShow() && newItems[newPos].isMovie()) return false return oldItems[oldPos].id == newItems[newPos].id } override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean { val oldItem = oldItems[oldPos] val newItem = newItems[newPos] return when { oldItem.isShow() -> { oldItem.show == newItem.show && oldItem.isLoading == newItem.isLoading && oldItem.isRankDisplayed == newItem.isRankDisplayed && oldItem.isEnabled == newItem.isEnabled && oldItem.isWatchlist == newItem.isWatchlist && oldItem.isWatched == newItem.isWatched && oldItem.isManageMode == newItem.isManageMode && oldItem.translation == newItem.translation && oldItem.userRating == newItem.userRating && oldItem.image == newItem.image && oldItem.listedAt == newItem.listedAt && oldItem.sortOrder == newItem.sortOrder && oldItem.rankDisplay == newItem.rankDisplay && oldItem.spoilers == newItem.spoilers && oldItem.rank == newItem.rank } oldItem.isMovie() -> { oldItem.movie == newItem.movie && oldItem.isLoading == newItem.isLoading && oldItem.isRankDisplayed == newItem.isRankDisplayed && oldItem.isManageMode == newItem.isManageMode && oldItem.isWatchlist == newItem.isWatchlist && oldItem.isWatched == newItem.isWatched && oldItem.isEnabled == newItem.isEnabled && oldItem.translation == newItem.translation && oldItem.userRating == newItem.userRating && oldItem.image == newItem.image && oldItem.listedAt == newItem.listedAt && oldItem.sortOrder == newItem.sortOrder && oldItem.rankDisplay == newItem.rankDisplay && oldItem.spoilers == newItem.spoilers && oldItem.rank == newItem.rank } else -> throw IllegalStateException() } } override fun getOldListSize() = oldItems.size override fun getNewListSize() = newItems.size } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/recycler/ListDetailsItem.kt ================================================ package com.michaldrabik.ui_lists.details.recycler import com.michaldrabik.common.extensions.toMillis import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Show import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SpoilersSettings import com.michaldrabik.ui_model.Translation import java.time.ZoneOffset import java.time.ZonedDateTime data class ListDetailsItem( val id: Long, val rank: Long, val rankDisplay: Int, val show: Show?, val movie: Movie?, val image: Image, val translation: Translation?, val userRating: Int?, val isLoading: Boolean, val isRankDisplayed: Boolean, val isManageMode: Boolean, val isEnabled: Boolean, val isWatched: Boolean, val isWatchlist: Boolean, val listedAt: ZonedDateTime, val sortOrder: SortOrder, val spoilers: SpoilersSettings ) { fun getTitleNoThe(): String { if (isShow()) return requireShow().titleNoThe if (isMovie()) return requireMovie().titleNoThe throw IllegalStateException() } fun getYear(): Int { if (isShow()) return requireShow().year if (isMovie()) return requireMovie().year throw IllegalStateException() } fun getDate(): Long { if (isShow()) { return if (requireShow().firstAired.isBlank()) 0 else ZonedDateTime.parse(requireShow().firstAired).toMillis() } if (isMovie()) { return requireMovie().released?.atStartOfDay()?.toInstant(ZoneOffset.UTC)?.toEpochMilli() ?: 0 } throw IllegalStateException() } fun getRating(): Float { if (isShow()) return requireShow().rating if (isMovie()) return requireMovie().rating throw IllegalStateException() } fun getTraktId(): IdTrakt { if (isShow()) return IdTrakt(requireShow().traktId) if (isMovie()) return IdTrakt(requireMovie().traktId) throw IllegalStateException() } fun isShow() = show != null fun isMovie() = movie != null fun requireShow() = show!! fun requireMovie() = movie!! } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/recycler/helpers/ListDetailsGridItemDecoration.kt ================================================ package com.michaldrabik.ui_lists.details.recycler.helpers import android.content.Context import android.graphics.Rect import android.view.View import androidx.annotation.DimenRes import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ItemDecoration import com.michaldrabik.ui_lists.details.views.grid.ListDetailsGridItemView import com.michaldrabik.ui_lists.details.views.grid.ListDetailsGridTitleItemView class ListDetailsGridItemDecoration : ItemDecoration { private var spacing: Int private var halfSpacing: Int constructor( context: Context, @DimenRes spacingDimen: Int, ) { this.spacing = context.resources.getDimensionPixelSize(spacingDimen) this.halfSpacing = spacing / 2 } override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State, ) { if (parent.layoutManager !is GridLayoutManager) return val totalSpan = (parent.layoutManager as GridLayoutManager).spanCount if (view is ListDetailsGridItemView || view is ListDetailsGridTitleItemView) { outRect.top = halfSpacing outRect.bottom = halfSpacing val position = parent.getChildAdapterPosition(view) val column = position % totalSpan outRect.left = spacing * column / totalSpan outRect.right = spacing * ((totalSpan - 1) - column) / totalSpan } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/recycler/helpers/ListDetailsLayoutManagerProvider.kt ================================================ package com.michaldrabik.ui_lists.details.recycler.helpers import android.content.Context import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL import androidx.recyclerview.widget.RecyclerView import com.michaldrabik.common.Config.LISTS_GRID_SPAN import com.michaldrabik.common.Config.LISTS_GRID_SPAN_TABLET import com.michaldrabik.ui_base.common.ListViewMode import com.michaldrabik.ui_base.common.ListViewMode.GRID import com.michaldrabik.ui_base.common.ListViewMode.GRID_TITLE import com.michaldrabik.ui_base.common.ListViewMode.LIST_COMPACT import com.michaldrabik.ui_base.common.ListViewMode.LIST_NORMAL import com.michaldrabik.ui_base.utilities.extensions.isTablet internal object ListDetailsLayoutManagerProvider { fun provideLayoutManger( context: Context, viewMode: ListViewMode, gridSpanSize: Int, ): RecyclerView.LayoutManager { return if (context.isTablet()) { provideTabletLayout(context, viewMode, gridSpanSize) } else { providePhoneLayout(context, viewMode) } } private fun provideTabletLayout( context: Context, viewMode: ListViewMode, gridSpanSize: Int, ): RecyclerView.LayoutManager { return when (viewMode) { LIST_NORMAL, LIST_COMPACT -> GridLayoutManager(context, gridSpanSize) GRID, GRID_TITLE -> GridLayoutManager(context, LISTS_GRID_SPAN_TABLET) } } private fun providePhoneLayout( context: Context, viewMode: ListViewMode, ): RecyclerView.LayoutManager { return when (viewMode) { LIST_NORMAL, LIST_COMPACT -> LinearLayoutManager(context, VERTICAL, false) GRID, GRID_TITLE -> GridLayoutManager(context, LISTS_GRID_SPAN) } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/recycler/helpers/ListDetailsListItemDecoration.kt ================================================ package com.michaldrabik.ui_lists.details.recycler.helpers import android.content.Context import android.graphics.Rect import android.view.View import androidx.annotation.DimenRes import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ItemDecoration import com.michaldrabik.ui_base.utilities.extensions.isTablet import com.michaldrabik.ui_lists.details.views.ListDetailsMovieItemView import com.michaldrabik.ui_lists.details.views.ListDetailsShowItemView import com.michaldrabik.ui_lists.details.views.compact.ListDetailsCompactMovieItemView import com.michaldrabik.ui_lists.details.views.compact.ListDetailsCompactShowItemView class ListDetailsListItemDecoration : ItemDecoration { private var spacing: Int private var halfSpacing: Int private val isTablet: Boolean constructor( context: Context, @DimenRes spacingDimen: Int, ) { this.spacing = context.resources.getDimensionPixelSize(spacingDimen) this.halfSpacing = spacing / 2 this.isTablet = context.isTablet() } override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State, ) { if (view !is ListDetailsShowItemView && view !is ListDetailsCompactShowItemView && view !is ListDetailsMovieItemView && view !is ListDetailsCompactMovieItemView ) { return } if (!isTablet && (parent.layoutManager is LinearLayoutManager)) { getItemOffsetsPhone(outRect, view) return } if (isTablet && (parent.layoutManager is GridLayoutManager)) { getItemOffsetsTablet(outRect, view, parent) return } } private fun getItemOffsetsTablet( outRect: Rect, view: View, parent: RecyclerView, ) { if (view is ListDetailsShowItemView || view is ListDetailsMovieItemView) { outRect.top = spacing outRect.bottom = spacing } else if (view is ListDetailsCompactShowItemView || view is ListDetailsCompactMovieItemView) { outRect.top = halfSpacing outRect.bottom = halfSpacing } val totalSpan = (parent.layoutManager as GridLayoutManager).spanCount val column = getPosition(parent, view) % totalSpan outRect.left = (spacing * 2) * column / totalSpan outRect.right = (spacing * 2) * ((totalSpan - 1) - column) / totalSpan } private fun getItemOffsetsPhone(outRect: Rect, view: View) { if (view is ListDetailsShowItemView || view is ListDetailsMovieItemView) { outRect.top = spacing outRect.bottom = spacing } else if (view is ListDetailsCompactShowItemView || view is ListDetailsCompactMovieItemView) { outRect.top = halfSpacing outRect.bottom = halfSpacing } outRect.left = 0 outRect.right = 0 } private fun getPosition(parent: RecyclerView, view: View): Int = parent.getChildAdapterPosition(view) } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/views/ListDetailsDeleteConfirmView.kt ================================================ package com.michaldrabik.ui_lists.details.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import com.michaldrabik.ui_lists.databinding.ViewListDeleteConfirmBinding class ListDetailsDeleteConfirmView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) val binding = ViewListDeleteConfirmBinding.inflate(LayoutInflater.from(context), this) init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/views/ListDetailsFilterView.kt ================================================ package com.michaldrabik.ui_lists.details.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.widget.FrameLayout import androidx.core.content.ContextCompat import androidx.core.view.forEach import com.michaldrabik.common.Mode import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_lists.R import com.michaldrabik.ui_lists.databinding.ViewListDetailsFiltersBinding import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortType class ListDetailsFilterView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewListDetailsFiltersBinding.inflate(LayoutInflater.from(context), this) var onSortClickListener: ((SortOrder, SortType) -> Unit)? = null var onTypesChangeListener: ((List) -> Unit)? = null init { with(binding) { showsChip.onClick { showsChip.isSelected = !showsChip.isSelected onTypeClick() } moviesChip.onClick { moviesChip.isSelected = !moviesChip.isSelected onTypeClick() } } } override fun setEnabled(enabled: Boolean) { binding.chipsGroup.forEach { it.isEnabled = enabled } } fun setFilters( types: List, sortOrder: SortOrder, sortType: SortType, ) { with(binding) { showsChip.isSelected = Mode.SHOWS in types moviesChip.isSelected = Mode.MOVIES in types sortingChip.text = context.getString(sortOrder.displayString) sortingChip.onClick { onSortClickListener?.invoke(sortOrder, sortType) } val sortIcon = when (sortType) { SortType.ASCENDING -> R.drawable.ic_arrow_alt_up SortType.DESCENDING -> R.drawable.ic_arrow_alt_down } sortingChip.closeIcon = ContextCompat.getDrawable(context, sortIcon) } } private fun onTypeClick() { val types = mutableListOf().apply { if (binding.showsChip.isSelected) add(Mode.SHOWS) if (binding.moviesChip.isSelected) add(Mode.MOVIES) } onTypesChangeListener?.invoke(types) } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/views/ListDetailsItemView.kt ================================================ package com.michaldrabik.ui_lists.details.views import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.widget.FrameLayout import android.widget.ImageView import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade import com.michaldrabik.common.Config.IMAGE_FADE_DURATION_MS import com.michaldrabik.common.Config.TVDB_IMAGE_BASE_FANART_URL import com.michaldrabik.common.Config.TVDB_IMAGE_BASE_POSTER_URL import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.withFailListener import com.michaldrabik.ui_base.utilities.extensions.withSuccessListener import com.michaldrabik.ui_lists.details.recycler.ListDetailsItem import com.michaldrabik.ui_model.ImageStatus.AVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNAVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNKNOWN import com.michaldrabik.ui_model.ImageType.POSTER @SuppressLint("ClickableViewAccessibility") abstract class ListDetailsItemView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val cornerRadius by lazy { context.dimenToPx(R.dimen.mediaTileCorner) } private val centerCropTransformation by lazy { CenterCrop() } private val cornersTransformation by lazy { RoundedCorners(cornerRadius) } protected abstract val imageView: ImageView protected abstract val placeholderView: ImageView var itemClickListener: ((ListDetailsItem) -> Unit)? = null var imageLoadCompleteListener: (() -> Unit)? = null var missingImageListener: ((ListDetailsItem, Boolean) -> Unit)? = null var missingTranslationListener: ((ListDetailsItem) -> Unit)? = null var itemDragStartListener: (() -> Unit)? = null var itemSwipeStartListener: (() -> Unit)? = null lateinit var item: ListDetailsItem open fun bind(item: ListDetailsItem) { this.item = item } protected open fun loadImage(item: ListDetailsItem) { if (item.isLoading) return if (item.image.status == UNAVAILABLE) { placeholderView.visible() return } val unknownBase = when (item.image.type) { POSTER -> TVDB_IMAGE_BASE_POSTER_URL else -> TVDB_IMAGE_BASE_FANART_URL } val url = when (item.image.status) { UNKNOWN -> { when { item.isShow() -> "${unknownBase}${item.show?.ids?.tvdb?.id}-1.jpg" item.isMovie() -> "${unknownBase}${item.movie?.ids?.tvdb?.id}-1.jpg" else -> throw IllegalStateException() } } AVAILABLE -> item.image.fullFileUrl else -> error("Should not handle other statuses.") } Glide.with(this) .load(url) .transform(centerCropTransformation, cornersTransformation) .transition(withCrossFade(IMAGE_FADE_DURATION_MS)) .withSuccessListener { onImageLoadSuccess() } .withFailListener { onImageLoadFail(item) } .into(imageView) } protected open fun onImageLoadSuccess() { placeholderView.gone() imageLoadCompleteListener?.invoke() } protected open fun onImageLoadFail(item: ListDetailsItem) { if (item.image.status == AVAILABLE) { placeholderView.visible() imageLoadCompleteListener?.invoke() return } val force = (item.image.status == UNKNOWN) missingImageListener?.invoke(item, force) } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/views/ListDetailsMovieItemView.kt ================================================ package com.michaldrabik.ui_lists.details.views import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.util.AttributeSet import android.view.LayoutInflater import android.view.MotionEvent.ACTION_DOWN import android.view.MotionEvent.ACTION_MOVE import android.view.MotionEvent.ACTION_UP import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.ImageView import androidx.core.content.ContextCompat import com.bumptech.glide.Glide import com.michaldrabik.common.Config import com.michaldrabik.common.Config.SPOILERS_RATINGS_HIDE_SYMBOL import com.michaldrabik.common.Config.SPOILERS_REGEX import com.michaldrabik.ui_base.utilities.extensions.colorFromAttr import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.expandTouch import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.setOutboundRipple import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_lists.R import com.michaldrabik.ui_lists.databinding.ViewListDetailsMovieItemBinding import com.michaldrabik.ui_lists.details.recycler.ListDetailsItem import com.michaldrabik.ui_model.Movie import java.util.Locale.ENGLISH import kotlin.math.abs @SuppressLint("ClickableViewAccessibility") class ListDetailsMovieItemView : ListDetailsItemView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewListDetailsMovieItemBinding.inflate(LayoutInflater.from(context), this) init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) setBackgroundColor(context.colorFromAttr(android.R.attr.windowBackground)) clipChildren = false clipToPadding = false imageLoadCompleteListener = { if (item.translation == null) { missingTranslationListener?.invoke(item) } } with(binding) { listDetailsMovieHandle.expandTouch(100) listDetailsMovieHandle.setOnTouchListener { _, event -> if (item.isManageMode && event.action == ACTION_DOWN) { itemDragStartListener?.invoke() } false } var x = 0F listDetailsMovieRoot.setOnTouchListener { _, event -> if (item.isManageMode) { return@setOnTouchListener false } if (event.action == ACTION_DOWN) x = event.x if (event.action == ACTION_UP) x = 0F if (event.action == ACTION_MOVE && abs(x - event.x) > 50F) { itemSwipeStartListener?.invoke() return@setOnTouchListener true } false } listDetailsMovieRoot.onClick { if (item.isEnabled && !item.isManageMode) itemClickListener?.invoke(item) } listDetailsMovieRoot.setOutboundRipple( size = (context.dimenToPx(R.dimen.collectionItemRippleSpace)).toFloat(), corner = context.dimenToPx(R.dimen.mediaTileCorner).toFloat() ) } } override val imageView: ImageView = binding.listDetailsMovieImage override val placeholderView: ImageView = binding.listDetailsMoviePlaceholder override fun bind(item: ListDetailsItem) { super.bind(item) with(binding) { Glide.with(this@ListDetailsMovieItemView).clear(listDetailsMovieImage) val movie = item.requireMovie() listDetailsMovieProgress.visibleIf(item.isLoading) listDetailsMovieTitle.text = if (item.translation?.title.isNullOrBlank()) movie.title else item.translation?.title bindDescription(item, movie) bindRating(item, movie) listDetailsMovieHeader.text = String.format(ENGLISH, "%d", movie.year) listDetailsMovieUserRating.text = String.format(ENGLISH, "%d", item.userRating) listDetailsMovieRank.visibleIf(item.isRankDisplayed) listDetailsMovieRank.text = String.format(ENGLISH, "%d", item.rankDisplay) listDetailsMovieHandle.visibleIf(item.isManageMode) listDetailsMovieStarIcon.visibleIf(!item.isManageMode) listDetailsMovieUserStarIcon.visibleIf(!item.isManageMode && item.userRating != null) listDetailsMovieUserRating.visibleIf(!item.isManageMode && item.userRating != null) with(listDetailsMovieHeaderBadge) { val inCollection = item.isWatched || item.isWatchlist visibleIf(inCollection) if (inCollection) { val color = if (item.isWatched) R.color.colorAccent else R.color.colorGrayLight imageTintList = ColorStateList.valueOf(ContextCompat.getColor(context, color)) } } listDetailsMovieRoot.alpha = if (item.isEnabled) 1F else 0.45F } loadImage(item) } private fun bindDescription( item: ListDetailsItem, movie: Movie, ) { var description = when { item.translation?.overview.isNullOrBlank() -> movie.overview.ifBlank { context.getString(R.string.textNoDescription) } else -> item.translation?.overview } val isMyHidden = item.spoilers.isMyMoviesHidden && item.isWatched val isWatchlistHidden = item.spoilers.isWatchlistMoviesHidden && item.isWatchlist val isNotCollectedHidden = item.spoilers.isNotCollectedMoviesHidden && (!item.isWatched && !item.isWatchlist) if (isMyHidden || isWatchlistHidden || isNotCollectedHidden) { binding.listDetailsMovieDescription.tag = description description = SPOILERS_REGEX.replace(description.toString(), Config.SPOILERS_HIDE_SYMBOL) } binding.listDetailsMovieDescription.text = description if (item.spoilers.isTapToReveal) { with(binding.listDetailsMovieDescription) { onClick { tag?.let { text = it.toString() } isClickable = false } } } } private fun bindRating( item: ListDetailsItem, movie: Movie, ) { var rating = String.format(ENGLISH, "%.1f", movie.rating) val isMyHidden = item.spoilers.isMyMoviesRatingsHidden && item.isWatched val isWatchlistHidden = item.spoilers.isWatchlistMoviesRatingsHidden && item.isWatchlist val isNotCollectedHidden = item.spoilers.isNotCollectedMoviesRatingsHidden && (!item.isWatched && !item.isWatchlist) if (isMyHidden || isWatchlistHidden || isNotCollectedHidden) { binding.listDetailsMovieRating.tag = rating rating = SPOILERS_RATINGS_HIDE_SYMBOL } binding.listDetailsMovieRating.visibleIf(!item.isManageMode) binding.listDetailsMovieRating.text = rating if (item.spoilers.isTapToReveal) { with(binding.listDetailsMovieRating) { onClick { tag?.let { text = it.toString() } isClickable = false } } } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/views/ListDetailsShowItemView.kt ================================================ package com.michaldrabik.ui_lists.details.views import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.util.AttributeSet import android.view.LayoutInflater import android.view.MotionEvent.ACTION_DOWN import android.view.MotionEvent.ACTION_MOVE import android.view.MotionEvent.ACTION_UP import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.ImageView import androidx.core.content.ContextCompat import com.bumptech.glide.Glide import com.michaldrabik.common.Config.SPOILERS_HIDE_SYMBOL import com.michaldrabik.common.Config.SPOILERS_RATINGS_HIDE_SYMBOL import com.michaldrabik.common.Config.SPOILERS_REGEX import com.michaldrabik.ui_base.utilities.extensions.colorFromAttr import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.expandTouch import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.setOutboundRipple import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_lists.R import com.michaldrabik.ui_lists.databinding.ViewListDetailsShowItemBinding import com.michaldrabik.ui_lists.details.recycler.ListDetailsItem import com.michaldrabik.ui_model.Show import java.util.Locale.ENGLISH import kotlin.math.abs @SuppressLint("ClickableViewAccessibility") class ListDetailsShowItemView : ListDetailsItemView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewListDetailsShowItemBinding.inflate(LayoutInflater.from(context), this) init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) setBackgroundColor(context.colorFromAttr(android.R.attr.windowBackground)) clipChildren = false clipToPadding = false imageLoadCompleteListener = { if (item.translation == null) { missingTranslationListener?.invoke(item) } } with(binding) { listDetailsShowHandle.expandTouch(100) listDetailsShowHandle.setOnTouchListener { _, event -> if (item.isManageMode && event.action == ACTION_DOWN) { itemDragStartListener?.invoke() } false } var x = 0F listDetailsShowRoot.setOnTouchListener { _, event -> if (item.isManageMode) { return@setOnTouchListener false } if (event.action == ACTION_DOWN) x = event.x if (event.action == ACTION_UP) x = 0F if (event.action == ACTION_MOVE && abs(x - event.x) > 50F) { itemSwipeStartListener?.invoke() return@setOnTouchListener true } false } listDetailsShowRoot.onClick { if (!item.isManageMode) itemClickListener?.invoke(item) } listDetailsShowRoot.setOutboundRipple( size = (context.dimenToPx(R.dimen.collectionItemRippleSpace)).toFloat(), corner = context.dimenToPx(R.dimen.mediaTileCorner).toFloat() ) } } override val imageView: ImageView = binding.listDetailsShowImage override val placeholderView: ImageView = binding.listDetailsShowPlaceholder override fun bind(item: ListDetailsItem) { super.bind(item) with(binding) { Glide.with(this@ListDetailsShowItemView).clear(listDetailsShowImage) val show = item.requireShow() listDetailsShowProgress.visibleIf(item.isLoading) listDetailsShowTitle.text = if (item.translation?.title.isNullOrBlank()) show.title else item.translation?.title bindDescription(item, show) bindRating(item, show) listDetailsShowHeader.text = if (show.year > 0) context.getString(R.string.textNetwork, show.year.toString(), show.network) else String.format("%s", show.network) listDetailsShowUserRating.text = String.format(ENGLISH, "%d", item.userRating) listDetailsShowRank.visibleIf(item.isRankDisplayed) listDetailsShowRank.text = String.format(ENGLISH, "%d", item.rankDisplay) listDetailsShowHandle.visibleIf(item.isManageMode) listDetailsShowStarIcon.visibleIf(!item.isManageMode) listDetailsShowUserStarIcon.visibleIf(!item.isManageMode && item.userRating != null) listDetailsShowUserRating.visibleIf(!item.isManageMode && item.userRating != null) with(listDetailsShowHeaderBadge) { val inCollection = item.isWatched || item.isWatchlist visibleIf(inCollection) if (inCollection) { val color = if (item.isWatched) R.color.colorAccent else R.color.colorGrayLight imageTintList = ColorStateList.valueOf(ContextCompat.getColor(context, color)) } } } loadImage(item) } private fun bindDescription( item: ListDetailsItem, show: Show, ) { var description = when { item.translation?.overview.isNullOrBlank() -> show.overview.ifBlank { context.getString(R.string.textNoDescription) } else -> item.translation?.overview } val isMyHidden = item.spoilers.isMyShowsHidden && item.isWatched val isWatchlistHidden = item.spoilers.isWatchlistShowsHidden && item.isWatchlist val isNotCollectedHidden = item.spoilers.isNotCollectedShowsHidden && (!item.isWatched && !item.isWatchlist) if (isMyHidden || isWatchlistHidden || isNotCollectedHidden) { binding.listDetailsShowDescription.tag = description description = SPOILERS_REGEX.replace(description.toString(), SPOILERS_HIDE_SYMBOL) } binding.listDetailsShowDescription.text = description if (item.spoilers.isTapToReveal) { with(binding.listDetailsShowDescription) { onClick { tag?.let { text = it.toString() } isClickable = false } } } } private fun bindRating( item: ListDetailsItem, show: Show, ) { var rating = String.format(ENGLISH, "%.1f", show.rating) val isMyHidden = item.spoilers.isMyShowsRatingsHidden && item.isWatched val isWatchlistHidden = item.spoilers.isWatchlistShowsRatingsHidden && item.isWatchlist val isNotCollectedHidden = item.spoilers.isNotCollectedShowsRatingsHidden && (!item.isWatched && !item.isWatchlist) if (isMyHidden || isWatchlistHidden || isNotCollectedHidden) { binding.listDetailsShowRating.tag = rating rating = SPOILERS_RATINGS_HIDE_SYMBOL } binding.listDetailsShowRating.visibleIf(!item.isManageMode) binding.listDetailsShowRating.text = rating if (item.spoilers.isTapToReveal) { with(binding.listDetailsShowRating) { onClick { tag?.let { text = it.toString() } isClickable = false } } } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/views/compact/ListDetailsCompactMovieItemView.kt ================================================ package com.michaldrabik.ui_lists.details.views.compact import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.util.AttributeSet import android.view.LayoutInflater import android.view.MotionEvent.ACTION_DOWN import android.view.MotionEvent.ACTION_MOVE import android.view.MotionEvent.ACTION_UP import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.ImageView import androidx.core.content.ContextCompat import com.bumptech.glide.Glide import com.michaldrabik.common.Config.SPOILERS_RATINGS_HIDE_SYMBOL import com.michaldrabik.ui_base.utilities.extensions.colorFromAttr import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.expandTouch import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.setOutboundRipple import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_lists.R import com.michaldrabik.ui_lists.databinding.ViewListDetailsMovieItemCompactBinding import com.michaldrabik.ui_lists.details.recycler.ListDetailsItem import com.michaldrabik.ui_lists.details.views.ListDetailsItemView import com.michaldrabik.ui_model.Movie import java.util.Locale.ENGLISH import kotlin.math.abs @SuppressLint("ClickableViewAccessibility") class ListDetailsCompactMovieItemView : ListDetailsItemView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewListDetailsMovieItemCompactBinding.inflate(LayoutInflater.from(context), this) init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) setBackgroundColor(context.colorFromAttr(android.R.attr.windowBackground)) clipChildren = false clipToPadding = false imageLoadCompleteListener = { if (item.translation == null) { missingTranslationListener?.invoke(item) } } with(binding) { listDetailsMovieHandle.expandTouch(100) listDetailsMovieHandle.setOnTouchListener { _, event -> if (item.isManageMode && event.action == ACTION_DOWN) { itemDragStartListener?.invoke() } false } var x = 0F listDetailsMovieRoot.setOnTouchListener { _, event -> if (item.isManageMode) { return@setOnTouchListener false } if (event.action == ACTION_DOWN) x = event.x if (event.action == ACTION_UP) x = 0F if (event.action == ACTION_MOVE && abs(x - event.x) > 50F) { itemSwipeStartListener?.invoke() return@setOnTouchListener true } false } listDetailsMovieRoot.onClick { if (item.isEnabled && !item.isManageMode) itemClickListener?.invoke(item) } listDetailsMovieRoot.setOutboundRipple( size = (context.dimenToPx(R.dimen.collectionItemRippleSpace)).toFloat(), corner = context.dimenToPx(R.dimen.mediaTileCorner).toFloat() ) } } override val imageView: ImageView = binding.listDetailsMovieImage override val placeholderView: ImageView = binding.listDetailsMoviePlaceholder override fun bind(item: ListDetailsItem) { super.bind(item) with(binding) { Glide.with(this@ListDetailsCompactMovieItemView).clear(listDetailsMovieImage) val movie = item.requireMovie() listDetailsMovieProgress.visibleIf(item.isLoading) listDetailsMovieTitle.text = if (item.translation?.title.isNullOrBlank()) movie.title else item.translation?.title listDetailsMovieHeader.text = String.format(ENGLISH, "%d", movie.year) listDetailsMovieUserRating.text = String.format(ENGLISH, "%d", item.userRating) bindRating(item, movie) listDetailsMovieRank.visibleIf(item.isRankDisplayed) listDetailsMovieRank.text = String.format(ENGLISH, "%d", item.rankDisplay) listDetailsMovieHandle.visibleIf(item.isManageMode) listDetailsMovieStarIcon.visibleIf(!item.isManageMode) listDetailsMovieUserStarIcon.visibleIf(!item.isManageMode && item.userRating != null) listDetailsMovieUserRating.visibleIf(!item.isManageMode && item.userRating != null) with(listDetailsMovieHeaderBadge) { val inCollection = item.isWatched || item.isWatchlist visibleIf(inCollection) if (inCollection) { val color = if (item.isWatched) R.color.colorAccent else R.color.colorGrayLight imageTintList = ColorStateList.valueOf(ContextCompat.getColor(context, color)) } } listDetailsMovieRoot.alpha = if (item.isEnabled) 1F else 0.45F } loadImage(item) } private fun bindRating( item: ListDetailsItem, movie: Movie, ) { with(binding) { var rating = String.format(ENGLISH, "%.1f", movie.rating) val isMyHidden = item.spoilers.isMyMoviesRatingsHidden && item.isWatched val isWatchlistHidden = item.spoilers.isWatchlistMoviesRatingsHidden && item.isWatchlist val isNotCollectedHidden = item.spoilers.isNotCollectedMoviesRatingsHidden && (!item.isWatched && !item.isWatchlist) if (isMyHidden || isWatchlistHidden || isNotCollectedHidden) { listDetailsMovieRating.tag = rating rating = SPOILERS_RATINGS_HIDE_SYMBOL if (item.spoilers.isTapToReveal) { with(listDetailsMovieRating) { onClick { tag?.let { text = it.toString() } isClickable = false } } } } listDetailsMovieRating.visibleIf(!item.isManageMode) listDetailsMovieRating.text = rating } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/views/compact/ListDetailsCompactShowItemView.kt ================================================ package com.michaldrabik.ui_lists.details.views.compact import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.util.AttributeSet import android.view.LayoutInflater import android.view.MotionEvent.ACTION_DOWN import android.view.MotionEvent.ACTION_MOVE import android.view.MotionEvent.ACTION_UP import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.ImageView import androidx.core.content.ContextCompat import com.bumptech.glide.Glide import com.michaldrabik.common.Config.SPOILERS_RATINGS_HIDE_SYMBOL import com.michaldrabik.ui_base.utilities.extensions.colorFromAttr import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.expandTouch import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.setOutboundRipple import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_lists.R import com.michaldrabik.ui_lists.databinding.ViewListDetailsShowItemCompactBinding import com.michaldrabik.ui_lists.details.recycler.ListDetailsItem import com.michaldrabik.ui_lists.details.views.ListDetailsItemView import com.michaldrabik.ui_model.Show import java.util.Locale.ENGLISH import kotlin.math.abs @SuppressLint("ClickableViewAccessibility") class ListDetailsCompactShowItemView : ListDetailsItemView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewListDetailsShowItemCompactBinding.inflate(LayoutInflater.from(context), this) init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) setBackgroundColor(context.colorFromAttr(android.R.attr.windowBackground)) clipChildren = false clipToPadding = false imageLoadCompleteListener = { if (item.translation == null) { missingTranslationListener?.invoke(item) } } with(binding) { listDetailsShowHandle.expandTouch(100) listDetailsShowHandle.setOnTouchListener { _, event -> if (item.isManageMode && event.action == ACTION_DOWN) { itemDragStartListener?.invoke() } false } var x = 0F listDetailsShowRoot.setOnTouchListener { _, event -> if (item.isManageMode) { return@setOnTouchListener false } if (event.action == ACTION_DOWN) x = event.x if (event.action == ACTION_UP) x = 0F if (event.action == ACTION_MOVE && abs(x - event.x) > 50F) { itemSwipeStartListener?.invoke() return@setOnTouchListener true } false } listDetailsShowRoot.onClick { if (!item.isManageMode) itemClickListener?.invoke(item) } listDetailsShowRoot.setOutboundRipple( size = (context.dimenToPx(R.dimen.collectionItemRippleSpace)).toFloat(), corner = context.dimenToPx(R.dimen.mediaTileCorner).toFloat() ) } } override val imageView: ImageView = binding.listDetailsShowImage override val placeholderView: ImageView = binding.listDetailsShowPlaceholder override fun bind(item: ListDetailsItem) { super.bind(item) with(binding) { Glide.with(this@ListDetailsCompactShowItemView).clear(listDetailsShowImage) val show = item.requireShow() listDetailsShowProgress.visibleIf(item.isLoading) listDetailsShowTitle.text = if (item.translation?.title.isNullOrBlank()) show.title else item.translation?.title listDetailsShowHeader.text = if (show.year > 0) context.getString(R.string.textNetwork, show.year.toString(), show.network) else String.format("%s", show.network) bindRating(item, show) listDetailsShowUserRating.text = String.format(ENGLISH, "%d", item.userRating) listDetailsShowRank.visibleIf(item.isRankDisplayed) listDetailsShowRank.text = String.format(ENGLISH, "%d", item.rankDisplay) listDetailsShowHandle.visibleIf(item.isManageMode) listDetailsShowStarIcon.visibleIf(!item.isManageMode) listDetailsShowUserStarIcon.visibleIf(!item.isManageMode && item.userRating != null) listDetailsShowUserRating.visibleIf(!item.isManageMode && item.userRating != null) with(listDetailsShowHeaderBadge) { val inCollection = item.isWatched || item.isWatchlist visibleIf(inCollection) if (inCollection) { val color = if (item.isWatched) R.color.colorAccent else R.color.colorGrayLight imageTintList = ColorStateList.valueOf(ContextCompat.getColor(context, color)) } } } loadImage(item) } private fun bindRating( item: ListDetailsItem, show: Show, ) { with(binding) { var rating = String.format(ENGLISH, "%.1f", show.rating) val isMyHidden = item.spoilers.isMyShowsRatingsHidden && item.isWatched val isWatchlistHidden = item.spoilers.isWatchlistShowsRatingsHidden && item.isWatchlist val isNotCollectedHidden = item.spoilers.isNotCollectedShowsRatingsHidden && (!item.isWatched && !item.isWatchlist) if (isMyHidden || isWatchlistHidden || isNotCollectedHidden) { listDetailsShowRating.tag = rating rating = SPOILERS_RATINGS_HIDE_SYMBOL if (item.spoilers.isTapToReveal) { with(listDetailsShowRating) { onClick { tag?.let { text = it.toString() } isClickable = false } } } } listDetailsShowRating.visibleIf(!item.isManageMode) listDetailsShowRating.text = rating } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/views/grid/ListDetailsGridItemView.kt ================================================ package com.michaldrabik.ui_lists.details.views.grid import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.util.AttributeSet import android.view.LayoutInflater import android.view.MotionEvent import android.widget.ImageView import androidx.core.content.ContextCompat import com.bumptech.glide.Glide import com.michaldrabik.common.Config import com.michaldrabik.common.Config.SPOILERS_RATINGS_HIDE_SYMBOL import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.common.views.ShowView.Companion.ASPECT_RATIO import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.isTablet import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.screenWidth import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_lists.databinding.LayoutListDetailsItemGridBinding import com.michaldrabik.ui_lists.databinding.ViewListDetailsItemGridBinding import com.michaldrabik.ui_lists.details.recycler.ListDetailsItem import com.michaldrabik.ui_lists.details.views.ListDetailsItemView import com.michaldrabik.ui_model.SortOrder.RATING import com.michaldrabik.ui_model.SortOrder.USER_RATING import java.util.Locale.ENGLISH import kotlin.math.abs @SuppressLint("ClickableViewAccessibility") class ListDetailsGridItemView : ListDetailsItemView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewListDetailsItemGridBinding.inflate(LayoutInflater.from(context), this) private val contentBinding = LayoutListDetailsItemGridBinding.bind(binding.listDetailsGridItemRoot) private val width by lazy { val span = if (context.isTablet()) Config.LISTS_GRID_SPAN_TABLET else Config.LISTS_GRID_SPAN val itemSpacing = context.dimenToPx(R.dimen.spaceSmall) val screenMargin = context.dimenToPx(R.dimen.screenMarginHorizontal) val screenWidth = screenWidth().toFloat() ((screenWidth - (screenMargin * 2.0)) - ((span - 1) * itemSpacing)) / span } private val height by lazy { width * ASPECT_RATIO } init { layoutParams = LayoutParams(width.toInt(), height.toInt()) clipChildren = false clipToPadding = false binding.listDetailsGridItemRoot.onClick { if (item.isEnabled && !item.isManageMode) { itemClickListener?.invoke(item) } } imageLoadCompleteListener = { loadTranslation() } initSwipeListener() initDragListener() } override val imageView: ImageView = contentBinding.listDetailsGridItemImage override val placeholderView: ImageView = contentBinding.listDetailsGridItemPlaceholder private fun initSwipeListener() { var x = 0F binding.listDetailsGridItemRoot.setOnTouchListener { _, event -> if (item.isManageMode) { return@setOnTouchListener false } if (event.action == MotionEvent.ACTION_DOWN) x = event.x if (event.action == MotionEvent.ACTION_UP) x = 0F if (event.action == MotionEvent.ACTION_MOVE && abs(x - event.x) > 50F) { itemSwipeStartListener?.invoke() return@setOnTouchListener true } false } } private fun initDragListener() { contentBinding.listDetailsGridItemHandle.setOnTouchListener { _, event -> if (item.isManageMode && event.action == MotionEvent.ACTION_DOWN) { itemDragStartListener?.invoke() } false } } override fun bind(item: ListDetailsItem) { clear() this.item = item with(contentBinding) { listDetailsGridItemProgress.visibleIf(item.isLoading) with(listDetailsGridItemImage) { if (item.isShow()) setImageResource(R.drawable.ic_television) if (item.isMovie()) setImageResource(R.drawable.ic_film) } with(listDetailsGridItemBadge) { val inCollection = item.isWatched || item.isWatchlist visibleIf(inCollection) if (inCollection) { val color = if (item.isWatched) R.color.colorAccent else R.color.colorGrayLight imageTintList = ColorStateList.valueOf(ContextCompat.getColor(context, color)) } } with(listDetailsGridItemRating) { when { item.isRankDisplayed -> gone() item.sortOrder == RATING -> bindRating(item) item.sortOrder == USER_RATING && item.userRating != null -> { visible() text = String.format(ENGLISH, "%d", item.userRating) } else -> gone() } } listDetailsGridItemRank.visibleIf(item.isRankDisplayed) listDetailsGridItemRank.text = String.format(ENGLISH, "%d", item.rankDisplay) listDetailsGridItemHandle.visibleIf(item.isManageMode) } loadImage(item) } private fun bindRating(item: ListDetailsItem) { with(contentBinding) { var rating = String.format(ENGLISH, "%.1f", item.getRating()) if (item.isShow()) { val isMyHidden = item.spoilers.isMyShowsRatingsHidden && item.isWatched val isWatchlistHidden = item.spoilers.isWatchlistShowsRatingsHidden && item.isWatchlist val isNotCollectedHidden = item.spoilers.isNotCollectedShowsRatingsHidden && (!item.isWatched && !item.isWatchlist) if (isMyHidden || isWatchlistHidden || isNotCollectedHidden) { listDetailsGridItemRating.tag = rating rating = SPOILERS_RATINGS_HIDE_SYMBOL } } if (item.isMovie()) { val isMyHidden = item.spoilers.isMyMoviesRatingsHidden && item.isWatched val isWatchlistHidden = item.spoilers.isWatchlistMoviesRatingsHidden && item.isWatchlist val isNotCollectedHidden = item.spoilers.isNotCollectedMoviesRatingsHidden && (!item.isWatched && !item.isWatchlist) if (isMyHidden || isWatchlistHidden || isNotCollectedHidden) { listDetailsGridItemRating.tag = rating rating = SPOILERS_RATINGS_HIDE_SYMBOL } } if (item.spoilers.isTapToReveal) { with(listDetailsGridItemRating) { onClick { tag?.let { text = it.toString() } isClickable = false } } } listDetailsGridItemRating.visible() listDetailsGridItemRating.text = rating } } private fun loadTranslation() { if (item.translation == null) { missingTranslationListener?.invoke(item) } } private fun clear() { with(contentBinding) { listDetailsGridItemPlaceholder.gone() Glide.with(this@ListDetailsGridItemView).clear(listDetailsGridItemImage) } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/details/views/grid/ListDetailsGridTitleItemView.kt ================================================ package com.michaldrabik.ui_lists.details.views.grid import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.util.AttributeSet import android.view.LayoutInflater import android.view.MotionEvent import android.widget.ImageView import androidx.core.content.ContextCompat import com.bumptech.glide.Glide import com.michaldrabik.common.Config import com.michaldrabik.ui_base.R import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.isTablet import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.screenWidth import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_lists.databinding.LayoutListDetailsItemGridBinding import com.michaldrabik.ui_lists.databinding.ViewListDetailsItemGridTitleBinding import com.michaldrabik.ui_lists.details.recycler.ListDetailsItem import com.michaldrabik.ui_lists.details.views.ListDetailsItemView import com.michaldrabik.ui_model.SortOrder.RATING import com.michaldrabik.ui_model.SortOrder.USER_RATING import java.util.Locale import java.util.Locale.ENGLISH import kotlin.math.abs @SuppressLint("ClickableViewAccessibility") class ListDetailsGridTitleItemView : ListDetailsItemView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewListDetailsItemGridTitleBinding.inflate(LayoutInflater.from(context), this) private val contentBinding = LayoutListDetailsItemGridBinding.bind(binding.listDetailsGridItemRoot) private val width by lazy { val span = if (context.isTablet()) Config.LISTS_GRID_SPAN_TABLET else Config.LISTS_GRID_SPAN val itemSpacing = context.dimenToPx(R.dimen.spaceSmall) val screenMargin = context.dimenToPx(R.dimen.screenMarginHorizontal) val screenWidth = screenWidth().toFloat() ((screenWidth - (screenMargin * 2.0)) - ((span - 1) * itemSpacing)) / span } private val height by lazy { width * 1.7305 } init { layoutParams = LayoutParams(width.toInt(), height.toInt()) clipToPadding = false clipChildren = false binding.listDetailsGridItemRoot.onClick { if (item.isEnabled && !item.isManageMode) { itemClickListener?.invoke(item) } } imageLoadCompleteListener = { loadTranslation() } initSwipeListener() initDragListener() } override val imageView: ImageView = contentBinding.listDetailsGridItemImage override val placeholderView: ImageView = contentBinding.listDetailsGridItemPlaceholder private fun initSwipeListener() { var x = 0F binding.listDetailsGridItemRoot.setOnTouchListener { _, event -> if (item.isManageMode) { return@setOnTouchListener false } if (event.action == MotionEvent.ACTION_DOWN) x = event.x if (event.action == MotionEvent.ACTION_UP) x = 0F if (event.action == MotionEvent.ACTION_MOVE && abs(x - event.x) > 50F) { itemSwipeStartListener?.invoke() return@setOnTouchListener true } false } } private fun initDragListener() { contentBinding.listDetailsGridItemHandle.setOnTouchListener { _, event -> if (item.isManageMode && event.action == MotionEvent.ACTION_DOWN) { itemDragStartListener?.invoke() } false } } override fun bind(item: ListDetailsItem) { clear() this.item = item with(contentBinding) { listDetailsGridItemProgress.visibleIf(item.isLoading) with(binding.listDetailsGridItemTitle) { if (item.isShow()) { text = if (item.translation?.title.isNullOrBlank()) item.requireShow().title else item.translation?.title } if (item.isMovie()) { text = if (item.translation?.title.isNullOrBlank()) item.requireMovie().title else item.translation?.title } } with(listDetailsGridItemImage) { if (item.isShow()) setImageResource(R.drawable.ic_television) if (item.isMovie()) setImageResource(R.drawable.ic_film) } with(listDetailsGridItemBadge) { val inCollection = item.isWatched || item.isWatchlist visibleIf(inCollection) if (inCollection) { val color = if (item.isWatched) R.color.colorAccent else R.color.colorGrayLight imageTintList = ColorStateList.valueOf(ContextCompat.getColor(context, color)) } } with(listDetailsGridItemRating) { when { item.isRankDisplayed -> gone() item.sortOrder == RATING -> bindRating(item) item.sortOrder == USER_RATING && item.userRating != null -> { visible() text = String.format(ENGLISH, "%d", item.userRating) } else -> gone() } } listDetailsGridItemRank.visibleIf(item.isRankDisplayed) listDetailsGridItemRank.text = String.format(Locale.ENGLISH, "%d", item.rankDisplay) listDetailsGridItemHandle.visibleIf(item.isManageMode) } loadImage(item) } private fun bindRating(item: ListDetailsItem) { with(contentBinding) { var rating = String.format(ENGLISH, "%.1f", item.getRating()) if (item.isShow()) { val isMyHidden = item.spoilers.isMyShowsRatingsHidden && item.isWatched val isWatchlistHidden = item.spoilers.isWatchlistShowsRatingsHidden && item.isWatchlist val isNotCollectedHidden = item.spoilers.isNotCollectedShowsRatingsHidden && (!item.isWatched && !item.isWatchlist) if (isMyHidden || isWatchlistHidden || isNotCollectedHidden) { listDetailsGridItemRating.tag = rating rating = Config.SPOILERS_RATINGS_HIDE_SYMBOL } } if (item.isMovie()) { val isMyHidden = item.spoilers.isMyMoviesRatingsHidden && item.isWatched val isWatchlistHidden = item.spoilers.isWatchlistMoviesRatingsHidden && item.isWatchlist val isNotCollectedHidden = item.spoilers.isNotCollectedMoviesRatingsHidden && (!item.isWatched && !item.isWatchlist) if (isMyHidden || isWatchlistHidden || isNotCollectedHidden) { listDetailsGridItemRating.tag = rating rating = Config.SPOILERS_RATINGS_HIDE_SYMBOL } } if (item.spoilers.isTapToReveal) { with(listDetailsGridItemRating) { onClick { tag?.let { text = it.toString() } isClickable = false } } } listDetailsGridItemRating.visible() listDetailsGridItemRating.text = rating } } private fun loadTranslation() { if (item.translation == null) { missingTranslationListener?.invoke(item) } } private fun clear() { with(contentBinding) { listDetailsGridItemPlaceholder.gone() Glide.with(this@ListDetailsGridTitleItemView).clear(listDetailsGridItemImage) } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/lists/ListsFragment.kt ================================================ package com.michaldrabik.ui_lists.lists import android.os.Bundle import android.view.View import android.view.View.GONE import android.view.View.VISIBLE import androidx.activity.addCallback import androidx.core.os.bundleOf import androidx.core.view.WindowInsetsCompat import androidx.core.view.postDelayed import androidx.core.view.updatePadding import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy import androidx.recyclerview.widget.RecyclerView.LayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import com.michaldrabik.repository.settings.SettingsViewModeRepository import com.michaldrabik.ui_base.BaseFragment import com.michaldrabik.ui_base.common.OnTabReselectedListener import com.michaldrabik.ui_base.common.sheets.sort_order.SortOrderBottomSheet import com.michaldrabik.ui_base.events.Event import com.michaldrabik.ui_base.events.EventsManager import com.michaldrabik.ui_base.events.TraktListQuickSyncSuccess import com.michaldrabik.ui_base.events.TraktQuickSyncSuccess import com.michaldrabik.ui_base.utilities.ModeHost import com.michaldrabik.ui_base.utilities.extensions.add import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.disableUi import com.michaldrabik.ui_base.utilities.extensions.doOnApplyWindowInsets import com.michaldrabik.ui_base.utilities.extensions.enableUi import com.michaldrabik.ui_base.utilities.extensions.fadeIf import com.michaldrabik.ui_base.utilities.extensions.fadeIn import com.michaldrabik.ui_base.utilities.extensions.fadeOut import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.hideKeyboard import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.showInfoSnackbar import com.michaldrabik.ui_base.utilities.extensions.showKeyboard import com.michaldrabik.ui_base.utilities.extensions.updateTopMargin import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_lists.R import com.michaldrabik.ui_lists.databinding.FragmentListsBinding import com.michaldrabik.ui_lists.lists.helpers.ListsLayoutManagerProvider import com.michaldrabik.ui_lists.lists.recycler.ListsAdapter import com.michaldrabik.ui_lists.lists.recycler.ListsItem import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortOrder.DATE_UPDATED import com.michaldrabik.ui_model.SortOrder.NAME import com.michaldrabik.ui_model.SortOrder.NEWEST import com.michaldrabik.ui_model.SortType import com.michaldrabik.ui_navigation.java.NavigationArgs import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_LIST import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_CREATE_LIST import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint class ListsFragment : BaseFragment(R.layout.fragment_lists), OnTabReselectedListener { companion object { private const val TRANSLATION_DURATION = 225L } override val viewModel by viewModels() private val binding by viewBinding(FragmentListsBinding::bind) @Inject lateinit var eventsManager: EventsManager @Inject lateinit var settings: SettingsViewModeRepository private var adapter: ListsAdapter? = null private var layoutManager: LayoutManager? = null private var searchViewTranslation = 0F private var tabsTranslation = 0F private var isFabHidden = false private var isSearching = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) savedInstanceState?.let { searchViewTranslation = it.getFloat("ARG_SEARCH_POSITION") tabsTranslation = it.getFloat("ARG_TABS_POSITION") isFabHidden = it.getBoolean("ARG_FAB_HIDDEN") } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() setupStatusBar() setupRecycler() launchAndRepeatStarted( { viewModel.uiState.collect { render(it) } }, { eventsManager.events.collect { handleEvent(it) } }, doAfterLaunch = { viewModel.loadItems(resetScroll = false) } ) } override fun onResume() { super.onResume() showNavigation() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putFloat("ARG_SEARCH_POSITION", searchViewTranslation) outState.putFloat("ARG_TABS_POSITION", tabsTranslation) outState.putBoolean("ARG_FAB_HIDDEN", isFabHidden) } override fun onPause() { enableUi() with(binding) { tabsTranslation = fragmentListsModeTabs.translationY searchViewTranslation = fragmentListsSearchView.translationY isFabHidden = fragmentListsCreateListButton.visibility != VISIBLE } super.onPause() } private fun setupView() { with(binding) { fragmentListsSearchView.run { hint = getString(R.string.textSearchFor) onSettingsClickListener = { openSettings() } } with(fragmentListsSearchLocalView) { onCloseClickListener = { exitSearch() } } fragmentListsModeTabs.run { onModeSelected = { (requireActivity() as ModeHost).setMode(it, force = true) } showMovies(moviesEnabled) showLists(true, anchorEnd = moviesEnabled) selectLists() } fragmentListsCreateListButton.run { if (!isFabHidden) fadeIn() onClick { openCreateList() } } fragmentListsFilters.onSortClickListener = { sortOrder, sortType -> showSortOrderDialog(sortOrder, sortType) } fragmentListsSearchButton.run { onClick { if (!isSearching) enterSearch() else exitSearch() } } fragmentListsSearchView.onClick { openMainSearch() } fragmentListsSearchView.translationY = searchViewTranslation fragmentListsModeTabs.translationY = tabsTranslation fragmentListsIcons.translationY = tabsTranslation } } private fun setupStatusBar() { with(binding) { fragmentListsRoot.doOnApplyWindowInsets { _, insets, _, _ -> val tabletOffset = if (isTablet) dimenToPx(R.dimen.spaceMedium) else 0 val statusBarSize = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top + tabletOffset fragmentListsRecycler .updatePadding(top = statusBarSize + dimenToPx(R.dimen.listsRecyclerPaddingTop)) fragmentListsSearchView.applyWindowInsetBehaviour(dimenToPx(R.dimen.spaceNormal) + statusBarSize) fragmentListsSearchView.updateTopMargin(dimenToPx(R.dimen.spaceMedium) + statusBarSize) fragmentListsModeTabs.updateTopMargin(dimenToPx(R.dimen.collectionTabsMargin) + statusBarSize) fragmentListsIcons.updateTopMargin(dimenToPx(R.dimen.listsIconsPadding) + statusBarSize) fragmentListsSearchLocalView.updateTopMargin(dimenToPx(R.dimen.listsSearchLocalViewPadding) + statusBarSize) fragmentListsEmptyView.root.updateTopMargin(statusBarSize) } } } private fun setupRecycler() { layoutManager = ListsLayoutManagerProvider.provideLayoutManger(requireContext(), settings) adapter = ListsAdapter().apply { stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY itemClickListener = { openListDetails(it) } itemsChangedListener = { resetTranslations() layoutManager?.scrollToPosition(0) } missingImageListener = { item, itemImage, force -> viewModel.loadMissingImage(item, itemImage, force) } } with(binding) { fragmentListsRecycler.apply { adapter = this@ListsFragment.adapter layoutManager = this@ListsFragment.layoutManager (itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false setHasFixedSize(true) clearOnScrollListeners() addOnScrollListener(object : RecyclerView.OnScrollListener() { var isFading = false override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { if (isFading) return val position = (this@ListsFragment.layoutManager as? LinearLayoutManager)?.findFirstCompletelyVisibleItemPosition() ?: (this@ListsFragment.layoutManager as? GridLayoutManager)?.findFirstCompletelyVisibleItemPosition() if ((position ?: 0) > 1) { if (fragmentListsCreateListButton.visibility != VISIBLE) return fragmentListsCreateListButton .fadeOut(125, endAction = { isFading = false }) .add(animations) } else { if (fragmentListsCreateListButton.visibility != GONE) return fragmentListsCreateListButton .fadeIn(125, endAction = { isFading = false }) .add(animations) } isFading = true } }) } } } override fun setupBackPressed() { val dispatcher = requireActivity().onBackPressedDispatcher dispatcher.addCallback(viewLifecycleOwner) { if (isSearching) { exitSearch() } else { isEnabled = false activity?.onBackPressed() } } } private fun enterSearch() { resetTranslations() with(binding) { fragmentListsSearchLocalView.fadeIn(150) fragmentListsIcons.gone() fragmentListsRecycler.smoothScrollToPosition(0) with(fragmentListsSearchLocalView.binding.searchViewLocalInput) { setText("") doAfterTextChanged { viewModel.loadItems( searchQuery = it.toString().trim(), resetScroll = true ) } visible() showKeyboard() requestFocus() } } isSearching = true } private fun exitSearch() { with(binding) { isSearching = false resetTranslations() fragmentListsSearchLocalView.gone() fragmentListsIcons.visible() fragmentListsRecycler.translationY = 0F fragmentListsRecycler.postDelayed(200) { layoutManager?.scrollToPosition(0) } with(fragmentListsSearchLocalView.binding.searchViewLocalInput) { setText("") gone() hideKeyboard() clearFocus() } } } private fun showSortOrderDialog(sortOrder: SortOrder, sortType: SortType) { val options = listOf(NAME, NEWEST, DATE_UPDATED) val args = SortOrderBottomSheet.createBundle(options, sortOrder, sortType) setFragmentResultListener(NavigationArgs.REQUEST_SORT_ORDER) { _, bundle -> val order = bundle.getSerializable(NavigationArgs.ARG_SELECTED_SORT_ORDER) as SortOrder val type = bundle.getSerializable(NavigationArgs.ARG_SELECTED_SORT_TYPE) as SortType viewModel.setSortOrder(order, type) } navigateTo(R.id.actionListsFragmentToSortOrder, args) } private fun render(uiState: ListsUiState) { uiState.run { with(binding) { items?.let { fragmentListsEmptyView.root.fadeIf(it.isEmpty() && !isSearching) fragmentListsSearchButton.visibleIf(it.isNotEmpty() || isSearching) val resetScroll = resetScroll.consume() == true adapter?.setItems(it, resetScroll) } sortOrder?.let { fragmentListsFilters.setSorting(it.first, it.second) } isSyncing?.let { fragmentListsSearchView.setTraktProgress(it) fragmentListsSearchView.isEnabled = !it } } } } private fun openMainSearch() { disableUi() hideNavigation() with(binding) { fragmentListsModeTabs.fadeOut(duration = 200).add(animations) fragmentListsIcons.fadeOut(duration = 200).add(animations) fragmentListsRecycler.fadeOut(duration = 200) { super.navigateTo(R.id.actionListsFragmentToSearch, null) }.add(animations) } } private fun openListDetails(listItem: ListsItem) { disableUi() hideNavigation() binding.fragmentListsRoot.fadeOut(150) { val bundle = bundleOf(ARG_LIST to listItem.list) navigateTo(R.id.actionListsFragmentToDetailsFragment, bundle) exitSearch() }.add(animations) } private fun openSettings() { hideNavigation() exitSearch() navigateTo(R.id.actionListsFragmentToSettingsFragment) } private fun openCreateList() { setFragmentResultListener(REQUEST_CREATE_LIST) { _, _ -> viewModel.loadItems(resetScroll = true) } navigateTo(R.id.actionListsFragmentToCreateListDialog, bundleOf()) } private fun resetTranslations(duration: Long = TRANSLATION_DURATION) { if (view == null) return with(binding) { arrayOf( fragmentListsSearchView, fragmentListsModeTabs, fragmentListsIcons, fragmentListsSearchLocalView ).forEach { it.animate().translationY(0F).setDuration(duration).add(animations)?.start() } } } private fun handleEvent(event: Event) { when (event) { is TraktListQuickSyncSuccess -> { val text = resources.getQuantityString(R.plurals.textTraktQuickSyncComplete, 1, 1) binding.fragmentListsSnackHost.showInfoSnackbar(text) } is TraktQuickSyncSuccess -> { val text = resources.getQuantityString(R.plurals.textTraktQuickSyncComplete, event.count, event.count) binding.fragmentListsSnackHost.showInfoSnackbar(text) } else -> Unit } } override fun onTabReselected() { if (view == null) return resetTranslations() binding.fragmentListsRecycler.smoothScrollToPosition(0) } override fun onDestroyView() { adapter = null layoutManager = null super.onDestroyView() } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/lists/ListsUiState.kt ================================================ package com.michaldrabik.ui_lists.lists import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_lists.lists.recycler.ListsItem import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortType data class ListsUiState( val items: List? = null, val resetScroll: Event = Event(false), val sortOrder: Pair? = null, val isSyncing: Boolean? = null, ) ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/lists/ListsViewModel.kt ================================================ package com.michaldrabik.ui_lists.lists import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.WorkInfo import androidx.work.WorkManager import com.michaldrabik.repository.images.MovieImagesProvider import com.michaldrabik.repository.images.ShowImagesProvider import com.michaldrabik.ui_base.events.EventsManager import com.michaldrabik.ui_base.events.TraktSyncAuthError import com.michaldrabik.ui_base.events.TraktSyncError import com.michaldrabik.ui_base.events.TraktSyncSuccess import com.michaldrabik.ui_base.trakt.TraktSyncWorker import com.michaldrabik.ui_base.utilities.events.Event import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.utilities.extensions.findReplace import com.michaldrabik.ui_lists.lists.cases.MainListsCase import com.michaldrabik.ui_lists.lists.cases.SortOrderListsCase import com.michaldrabik.ui_lists.lists.helpers.ListsItemImage import com.michaldrabik.ui_lists.lists.recycler.ListsItem import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject import com.michaldrabik.ui_base.events.Event as EventSync @HiltViewModel class ListsViewModel @Inject constructor( private val mainCase: MainListsCase, private val sortCase: SortOrderListsCase, private val showImagesProvider: ShowImagesProvider, private val movieImagesProvider: MovieImagesProvider, private val eventsManager: EventsManager, workManager: WorkManager, ) : ViewModel() { private var loadItemsJob: Job? = null private val itemsState = MutableStateFlow?>(null) private val scrollState = MutableStateFlow(Event(false)) private val sortOrderState = MutableStateFlow?>(null) private val syncingState = MutableStateFlow(false) init { viewModelScope.launch { eventsManager.events.collect { onEvent(it) } } workManager.getWorkInfosByTagLiveData(TraktSyncWorker.TAG_ID).observeForever { work -> syncingState.value = work.any { it.state == WorkInfo.State.RUNNING } } } fun loadItems( resetScroll: Boolean, searchQuery: String? = null, ) { loadItemsJob?.cancel() loadItemsJob = viewModelScope.launch { sortOrderState.value = sortCase.loadSortOrder() itemsState.value = mainCase.loadLists(searchQuery) scrollState.value = Event(resetScroll) } } fun setSortOrder(sortOrder: SortOrder, sortType: SortType) { viewModelScope.launch { sortCase.setSortOrder(sortOrder, sortType) loadItems(resetScroll = true) } } fun loadMissingImage(item: ListsItem, itemImage: ListsItemImage, force: Boolean) { viewModelScope.launch { try { val imageType = itemImage.image.type val image = when { itemImage.isShow() -> showImagesProvider.loadRemoteImage(itemImage.show!!, imageType, force) itemImage.isMovie() -> movieImagesProvider.loadRemoteImage(itemImage.movie!!, imageType, force) else -> throw IllegalStateException() } val updateItemImage = itemImage.copy(image = image) val updateImages = item.images.toMutableList() updateImages.findReplace(updateItemImage) { it.getIds()?.trakt == updateItemImage.getIds()?.trakt } updateItem(item.copy(images = updateImages)) } catch (t: Throwable) { val updateItemImage = itemImage.copy(image = Image.createUnavailable(itemImage.image.type)) val updateImages = item.images.toMutableList() updateImages.findReplace(updateItemImage) { it.getIds()?.trakt == updateItemImage.getIds()?.trakt } updateItem(item.copy(images = updateImages)) } } } private fun updateItem(newItem: ListsItem) { val currentItems = uiState.value.items?.toMutableList() ?: mutableListOf() currentItems.findReplace(newItem) { it.list.id == newItem.list.id } itemsState.value = currentItems } private fun onEvent(event: EventSync) { if (event in arrayOf(TraktSyncError, TraktSyncAuthError, TraktSyncSuccess)) { loadItems(resetScroll = true) } } val uiState = combine( itemsState, scrollState, sortOrderState, syncingState ) { s1, s2, s3, s4 -> ListsUiState( items = s1, resetScroll = s2, sortOrder = s3, isSyncing = s4 ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = ListsUiState() ) } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/lists/cases/MainListsCase.kt ================================================ package com.michaldrabik.ui_lists.lists.cases import com.michaldrabik.common.Mode.MOVIES import com.michaldrabik.common.Mode.SHOWS import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.data_local.LocalDataSource import com.michaldrabik.data_local.database.model.CustomListItem import com.michaldrabik.repository.ListsRepository import com.michaldrabik.repository.images.MovieImagesProvider import com.michaldrabik.repository.images.ShowImagesProvider import com.michaldrabik.repository.mappers.Mappers import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_base.dates.DateFormatProvider import com.michaldrabik.ui_lists.lists.helpers.ListsItemImage import com.michaldrabik.ui_lists.lists.helpers.ListsSorter import com.michaldrabik.ui_lists.lists.recycler.ListsItem import com.michaldrabik.ui_model.CustomList import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.ImageType.POSTER import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class MainListsCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val localSource: LocalDataSource, private val mappers: Mappers, private val listsRepository: ListsRepository, private val dateProvider: DateFormatProvider, private val settingsRepository: SettingsRepository, private val showImagesProvider: ShowImagesProvider, private val movieImagesProvider: MovieImagesProvider, private val sorter: ListsSorter, ) { companion object { private const val IMAGES_LIMIT = 3 } suspend fun loadLists(searchQuery: String?) = withContext(dispatchers.IO) { val lists = listsRepository.loadAll() val dateFormat = dateProvider.loadFullDayFormat() val sorting = Pair( settingsRepository.sorting.listsAllSortOrder, settingsRepository.sorting.listsAllSortType ) lists .filterByQuery(searchQuery) .sortedWith(sorter.sort(sorting.first, sorting.second)) .map { async { val items = localSource.customListsItems.getItemsForListImages(it.id, IMAGES_LIMIT) val images = mutableListOf() val unavailable = ListsItemImage(Image.createUnavailable(POSTER)) items.forEach { item -> images.add(findImage(item) ?: unavailable) } if (images.size < IMAGES_LIMIT) { (images.size..IMAGES_LIMIT).forEach { _ -> images.add(unavailable) } } ListsItem(it, images, sorting, dateFormat) } }.awaitAll() } private fun List.filterByQuery(query: String?) = when { query.isNullOrBlank() -> this else -> this.filter { it.name.contains(query, ignoreCase = true) || it.description?.contains(query, ignoreCase = true) == true } } private suspend fun findImage(item: CustomListItem) = when (item.type) { SHOWS.type -> { val showDb = localSource.shows.getById(item.idTrakt) showDb?.let { val show = mappers.show.fromDatabase(it) val image = showImagesProvider.findCachedImage(show, POSTER) ListsItemImage(image, show = show) } } MOVIES.type -> { val movieDb = localSource.movies.getById(item.idTrakt) movieDb?.let { val movie = mappers.movie.fromDatabase(movieDb) val image = movieImagesProvider.findCachedImage(movie, POSTER) ListsItemImage(image, movie = movie) } } else -> throw IllegalStateException() } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/lists/cases/SortOrderListsCase.kt ================================================ package com.michaldrabik.ui_lists.lists.cases import com.michaldrabik.repository.settings.SettingsRepository import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortType import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject @ViewModelScoped class SortOrderListsCase @Inject constructor( private val settingsRepository: SettingsRepository ) { fun setSortOrder(sortOrder: SortOrder, sortType: SortType) { settingsRepository.sorting.listsAllSortOrder = sortOrder settingsRepository.sorting.listsAllSortType = sortType } fun loadSortOrder() = Pair( settingsRepository.sorting.listsAllSortOrder, settingsRepository.sorting.listsAllSortType ) } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/lists/helpers/ListsItemImage.kt ================================================ package com.michaldrabik.ui_lists.lists.helpers import com.michaldrabik.ui_model.Ids import com.michaldrabik.ui_model.Image import com.michaldrabik.ui_model.Movie import com.michaldrabik.ui_model.Show data class ListsItemImage( val image: Image, val show: Show? = null, val movie: Movie? = null ) { fun getIds(): Ids? { if (show != null) return show.ids if (movie != null) return movie.ids return null } fun isShow() = show != null fun isMovie() = movie != null } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/lists/helpers/ListsLayoutManagerProvider.kt ================================================ package com.michaldrabik.ui_lists.lists.helpers import android.content.Context import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL import androidx.recyclerview.widget.RecyclerView import com.michaldrabik.repository.settings.SettingsViewModeRepository import com.michaldrabik.ui_base.utilities.extensions.isTablet internal object ListsLayoutManagerProvider { fun provideLayoutManger( context: Context, settings: SettingsViewModeRepository, ): RecyclerView.LayoutManager { return if (context.isTablet()) { GridLayoutManager(context, settings.tabletGridSpanSize) } else { LinearLayoutManager(context, VERTICAL, false) } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/lists/helpers/ListsSorter.kt ================================================ package com.michaldrabik.ui_lists.lists.helpers import com.michaldrabik.ui_model.CustomList import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortOrder.DATE_UPDATED import com.michaldrabik.ui_model.SortOrder.NAME import com.michaldrabik.ui_model.SortOrder.NEWEST import com.michaldrabik.ui_model.SortType import com.michaldrabik.ui_model.SortType.ASCENDING import com.michaldrabik.ui_model.SortType.DESCENDING import javax.inject.Inject import javax.inject.Singleton @Singleton class ListsSorter @Inject constructor() { fun sort(sortOrder: SortOrder, sortType: SortType) = when (sortType) { ASCENDING -> sortAscending(sortOrder) DESCENDING -> sortDescending(sortOrder) } private fun sortAscending(sortOrder: SortOrder): Comparator = when (sortOrder) { NAME -> compareBy { it.name } NEWEST -> compareBy { it.createdAt } DATE_UPDATED -> compareBy { it.updatedAt } else -> throw IllegalStateException("Invalid sort order") } private fun sortDescending(sortOrder: SortOrder): Comparator = when (sortOrder) { NAME -> compareByDescending { it.name } NEWEST -> compareByDescending { it.createdAt } DATE_UPDATED -> compareByDescending { it.updatedAt } else -> throw IllegalStateException("Invalid sort order") } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/lists/recycler/ListsAdapter.kt ================================================ package com.michaldrabik.ui_lists.lists.recycler import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView import com.michaldrabik.ui_lists.lists.helpers.ListsItemImage import com.michaldrabik.ui_lists.lists.views.ListsItemView class ListsAdapter : RecyclerView.Adapter(), AsyncListDiffer.ListListener { private val asyncDiffer = AsyncListDiffer(this, ListsItemDiffCallback()) var itemClickListener: ((ListsItem) -> Unit)? = null var itemsChangedListener: (() -> Unit)? = null var missingImageListener: ((ListsItem, ListsItemImage, Boolean) -> Unit)? = null private var notifyItemsChange = false init { asyncDiffer.addListListener(this) } fun setItems(newItems: List, notifyItemsChange: Boolean = false) { this.notifyItemsChange = notifyItemsChange asyncDiffer.submitList(newItems) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ListsItemViewHolder( ListsItemView(parent.context).apply { itemClickListener = { this@ListsAdapter.itemClickListener?.invoke(it) } missingImageListener = { item, itemImage, force -> this@ListsAdapter.missingImageListener?.invoke(item, itemImage, force) } } ) override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val item = asyncDiffer.currentList[position] (holder.itemView as ListsItemView).bind(item) } override fun getItemCount() = asyncDiffer.currentList.size override fun onCurrentListChanged(oldList: MutableList, newList: MutableList) { if (notifyItemsChange) itemsChangedListener?.invoke() } class ListsItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/lists/recycler/ListsItem.kt ================================================ package com.michaldrabik.ui_lists.lists.recycler import com.michaldrabik.ui_lists.lists.helpers.ListsItemImage import com.michaldrabik.ui_model.CustomList import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortType import java.time.format.DateTimeFormatter data class ListsItem( val list: CustomList, val images: List, val sortOrder: Pair, val dateFormat: DateTimeFormatter? = null ) ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/lists/recycler/ListsItemDiffCallback.kt ================================================ package com.michaldrabik.ui_lists.lists.recycler import androidx.recyclerview.widget.DiffUtil class ListsItemDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ListsItem, newItem: ListsItem) = oldItem.list.id == newItem.list.id override fun areContentsTheSame(oldItem: ListsItem, newItem: ListsItem) = oldItem.list == newItem.list && oldItem.sortOrder == newItem.sortOrder && oldItem.images.size == newItem.images.size && oldItem.images.toTypedArray().contentDeepEquals(newItem.images.toTypedArray()) && oldItem.dateFormat?.toString() == newItem.dateFormat?.toString() } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/lists/views/ListsFiltersView.kt ================================================ package com.michaldrabik.ui_lists.lists.views import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.widget.FrameLayout import androidx.core.content.ContextCompat import androidx.core.view.forEach import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_lists.R import com.michaldrabik.ui_lists.databinding.ViewListsFiltersBinding import com.michaldrabik.ui_model.SortOrder import com.michaldrabik.ui_model.SortType class ListsFiltersView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewListsFiltersBinding.inflate(LayoutInflater.from(context), this) var onSortClickListener: ((SortOrder, SortType) -> Unit)? = null override fun setEnabled(enabled: Boolean) { binding.viewListsFiltersChipGroup.forEach { it.isEnabled = enabled } } fun setSorting(sortOrder: SortOrder, sortType: SortType) { with(binding) { viewListsFilterSortChip.text = context.getString(sortOrder.displayString) viewListsFilterSortChip.onClick { onSortClickListener?.invoke(sortOrder, sortType) } val sortIcon = when (sortType) { SortType.ASCENDING -> R.drawable.ic_arrow_alt_up SortType.DESCENDING -> R.drawable.ic_arrow_alt_down } viewListsFilterSortChip.closeIcon = ContextCompat.getDrawable(context, sortIcon) } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/lists/views/ListsItemView.kt ================================================ package com.michaldrabik.ui_lists.lists.views import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import com.michaldrabik.ui_base.utilities.extensions.capitalizeWords import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_lists.databinding.ViewListsItemBinding import com.michaldrabik.ui_lists.lists.helpers.ListsItemImage import com.michaldrabik.ui_lists.lists.recycler.ListsItem import com.michaldrabik.ui_model.SortOrder.DATE_UPDATED import com.michaldrabik.ui_model.SortOrder.NAME import com.michaldrabik.ui_model.SortOrder.NEWEST @SuppressLint("SetTextI18n") class ListsItemView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewListsItemBinding.inflate(LayoutInflater.from(context), this) var itemClickListener: ((ListsItem) -> Unit)? = null var missingImageListener: ((ListsItem, ListsItemImage, Boolean) -> Unit)? = null init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) with(binding) { listsItemRoot.onClick { itemClickListener?.invoke(item) } listsItemImages.missingImageListener = { itemImage, force -> missingImageListener?.invoke(item, itemImage, force) } } } private lateinit var item: ListsItem fun bind(item: ListsItem) { this.item = item with(binding) { listsItemTitle.text = item.list.name with(listsItemDescription) { text = item.list.description visibleIf(!item.list.description.isNullOrBlank()) } val sortOrder = item.sortOrder.first listsItemHeader.visibleIf(sortOrder != NAME) listsItemHeader.text = when (sortOrder) { NAME -> "" NEWEST -> item.dateFormat?.format(item.list.createdAt)?.capitalizeWords() DATE_UPDATED -> item.dateFormat?.format(item.list.updatedAt)?.capitalizeWords() else -> throw IllegalStateException() } listsItemImages.bind(item.images) } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/lists/views/ListsTripleImageView.kt ================================================ package com.michaldrabik.ui_lists.lists.views import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import android.widget.ImageView import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.michaldrabik.common.Config import com.michaldrabik.ui_base.utilities.extensions.dimenToPx import com.michaldrabik.ui_base.utilities.extensions.gone import com.michaldrabik.ui_base.utilities.extensions.invisible import com.michaldrabik.ui_base.utilities.extensions.visible import com.michaldrabik.ui_base.utilities.extensions.withFailListener import com.michaldrabik.ui_base.utilities.extensions.withSuccessListener import com.michaldrabik.ui_lists.R import com.michaldrabik.ui_lists.databinding.ViewTripleImageBinding import com.michaldrabik.ui_lists.lists.helpers.ListsItemImage import com.michaldrabik.ui_model.ImageStatus @SuppressLint("SetTextI18n") class ListsTripleImageView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewTripleImageBinding.inflate(LayoutInflater.from(context), this) private val cornerRadius by lazy { context.dimenToPx(R.dimen.mediaTileCorner) } var missingImageListener: ((ListsItemImage, Boolean) -> Unit)? = null init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) } fun bind(images: List) { clear() with(binding) { if (images.all { it.image.status == ImageStatus.UNAVAILABLE }) { viewTripleImagePlaceholder1.visible() viewTripleImagePlaceholder1.visible() viewTripleImagePlaceholder2.visible() viewTripleImagePlaceholder3.visible() viewTripleImage1.gone() viewTripleImage2.gone() viewTripleImage3.gone() return } // List is guaranteed to always have exact 3 items. loadImage(images[0], viewTripleImage1, viewTripleImagePlaceholder1) loadImage(images[1], viewTripleImage2, viewTripleImagePlaceholder2) loadImage(images[2], viewTripleImage3, viewTripleImagePlaceholder3) } } private fun loadImage( itemImage: ListsItemImage, imageView: ImageView, placeholderView: ImageView, ) { if (itemImage.image.status == ImageStatus.UNAVAILABLE) { imageView.invisible() placeholderView.visible() return } if (itemImage.image.status == ImageStatus.UNKNOWN) { missingImageListener?.invoke(itemImage, true) return } Glide.with(this) .load(itemImage.image.fullFileUrl) .transform(CenterCrop(), RoundedCorners(cornerRadius)) .transition(DrawableTransitionOptions.withCrossFade(Config.IMAGE_FADE_DURATION_MS)) .withSuccessListener { imageView.visible() placeholderView.invisible() } .withFailListener { if (itemImage.image.status == ImageStatus.AVAILABLE) { imageView.invisible() placeholderView.visible() return@withFailListener } val force = (itemImage.image.status == ImageStatus.UNKNOWN) missingImageListener?.invoke(itemImage, force) } .into(imageView) } private fun clear() { with(binding) { Glide.with(this@ListsTripleImageView).run { clear(viewTripleImage1) clear(viewTripleImage2) clear(viewTripleImage3) } } } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/manage/ManageListsBottomSheet.kt ================================================ package com.michaldrabik.ui_lists.manage import android.annotation.SuppressLint import android.os.Bundle import android.view.View import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL import androidx.recyclerview.widget.SimpleItemAnimator import com.michaldrabik.common.Mode import com.michaldrabik.ui_base.BaseBottomSheetFragment import com.michaldrabik.ui_base.events.Event import com.michaldrabik.ui_base.events.EventsManager import com.michaldrabik.ui_base.events.TraktQuickSyncSuccess import com.michaldrabik.ui_base.utilities.extensions.launchAndRepeatStarted import com.michaldrabik.ui_base.utilities.extensions.onClick import com.michaldrabik.ui_base.utilities.extensions.requireLong import com.michaldrabik.ui_base.utilities.extensions.requireString import com.michaldrabik.ui_base.utilities.extensions.showInfoSnackbar import com.michaldrabik.ui_base.utilities.extensions.visibleIf import com.michaldrabik.ui_base.utilities.viewBinding import com.michaldrabik.ui_lists.R import com.michaldrabik.ui_lists.databinding.ViewManageListsBinding import com.michaldrabik.ui_lists.manage.recycler.ManageListsAdapter import com.michaldrabik.ui_model.IdTrakt import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_ID import com.michaldrabik.ui_navigation.java.NavigationArgs.ARG_TYPE import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_CREATE_LIST import com.michaldrabik.ui_navigation.java.NavigationArgs.REQUEST_MANAGE_LISTS import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint class ManageListsBottomSheet : BaseBottomSheetFragment(R.layout.view_manage_lists) { private val viewModel by viewModels() private val binding by viewBinding(ViewManageListsBinding::bind) private val itemId by lazy { IdTrakt(requireLong(ARG_ID)) } private val itemType by lazy { requireString(ARG_TYPE) } private var adapter: ManageListsAdapter? = null private var layoutManager: LinearLayoutManager? = null @Inject lateinit var eventsManager: EventsManager override fun getTheme(): Int = R.style.CustomBottomSheetDialog override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupView() setupRecycler() launchAndRepeatStarted( { viewModel.uiState.collect { render(it) } }, { eventsManager.events.collect { handleEvent(it) } }, doAfterLaunch = { viewModel.loadLists(itemId, itemType) } ) viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { with(viewModel) { launch { uiState.collect { render(it) } } loadLists(itemId, itemType) } } } } private fun setupRecycler() { layoutManager = LinearLayoutManager(context, VERTICAL, false) adapter = ManageListsAdapter( itemCheckListener = { item, isChecked -> viewModel.onListItemChecked(itemId, itemType, item, isChecked) } ) binding.viewManageListsRecycler.apply { adapter = this@ManageListsBottomSheet.adapter layoutManager = this@ManageListsBottomSheet.layoutManager (itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false } } private fun setupView() { with(binding) { viewManageListsButton.onClick { closeSheet() } viewManageListsCreateButton.onClick { setFragmentResultListener(REQUEST_CREATE_LIST) { _, _ -> viewModel.loadLists(itemId, itemType) } navigateTo(R.id.actionManageListsDialogToCreateListDialog, Bundle.EMPTY) } if (itemType == Mode.MOVIES.type) { viewManageListsSubtitle.setText(R.string.textManageListsMovies) } } } @SuppressLint("SetTextI18n") private fun render(uiState: ManageListsUiState) { uiState.run { items?.let { adapter?.setItems(it) binding.viewManageListsEmptyView.layoutManageListsEmpty.visibleIf(it.isEmpty()) } } } private fun handleEvent(event: Event) { if (event is TraktQuickSyncSuccess) { val text = resources.getQuantityString(R.plurals.textTraktQuickSyncComplete, event.count, event.count) binding.viewManageListsSnackHost.showInfoSnackbar(text) } } override fun onDestroyView() { setFragmentResult(REQUEST_MANAGE_LISTS, bundleOf()) adapter = null layoutManager = null super.onDestroyView() } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/manage/ManageListsUiState.kt ================================================ package com.michaldrabik.ui_lists.manage import com.michaldrabik.ui_lists.manage.recycler.ManageListsItem data class ManageListsUiState( val items: List? = null, ) ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/manage/ManageListsViewModel.kt ================================================ package com.michaldrabik.ui_lists.manage import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.michaldrabik.ui_base.utilities.extensions.SUBSCRIBE_STOP_TIMEOUT import com.michaldrabik.ui_base.utilities.extensions.findReplace import com.michaldrabik.ui_lists.manage.cases.ManageListsCase import com.michaldrabik.ui_lists.manage.recycler.ManageListsItem import com.michaldrabik.ui_model.IdTrakt import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class ManageListsViewModel @Inject constructor( private val manageListsCase: ManageListsCase, ) : ViewModel() { private val loadingState = MutableStateFlow(false) private val itemsState = MutableStateFlow?>(null) fun loadLists(itemId: IdTrakt, itemType: String) { viewModelScope.launch { val loadingJob = launch { delay(500) loadingState.value = true } val items = manageListsCase.loadLists(itemId, itemType) itemsState.value = items loadingState.value = false loadingJob.cancel() } } fun onListItemChecked( itemId: IdTrakt, itemType: String, listItem: ManageListsItem, isChecked: Boolean, ) { viewModelScope.launch { if (isChecked) { updateItem(listItem.copy(isEnabled = false, isChecked = true)) manageListsCase.addToList(itemId, itemType, listItem) updateItem(listItem.copy(isEnabled = true, isChecked = true)) } else { updateItem(listItem.copy(isEnabled = false, isChecked = false)) manageListsCase.removeFromList(itemId, itemType, listItem) updateItem(listItem.copy(isEnabled = true, isChecked = false)) } } } private fun updateItem(listItem: ManageListsItem) { val currentItems = uiState.value.items?.toMutableList() currentItems?.findReplace(listItem) { it.list.id == listItem.list.id } itemsState.value = currentItems } val uiState = combine( loadingState, itemsState ) { _, itemsState -> ManageListsUiState( items = itemsState ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(SUBSCRIBE_STOP_TIMEOUT), initialValue = ManageListsUiState() ) } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/manage/cases/ManageListsCase.kt ================================================ package com.michaldrabik.ui_lists.manage.cases import com.michaldrabik.common.Mode import com.michaldrabik.common.dispatchers.CoroutineDispatchers import com.michaldrabik.repository.ListsRepository import com.michaldrabik.ui_base.trakt.quicksync.QuickSyncManager import com.michaldrabik.ui_lists.manage.recycler.ManageListsItem import com.michaldrabik.ui_model.IdTrakt import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.async import kotlinx.coroutines.withContext import javax.inject.Inject @ViewModelScoped class ManageListsCase @Inject constructor( private val dispatchers: CoroutineDispatchers, private val listsRepository: ListsRepository, private val quickSyncManager: QuickSyncManager, ) { suspend fun loadLists(itemId: IdTrakt, itemType: String) = withContext(dispatchers.IO) { val listsAsync = async { listsRepository.loadAll() } val listsWithItemAsync = async { listsRepository.loadListIdsForItem(itemId, itemType) } val (lists, listsWithItem) = Pair(listsAsync.await(), listsWithItemAsync.await()) lists .sortedBy { it.name } .map { val isChecked = listsWithItem.contains(it.id) ManageListsItem(it, isChecked, true) } } suspend fun addToList( itemId: IdTrakt, itemType: String, listItem: ManageListsItem, ) = withContext(dispatchers.IO) { listsRepository.addToList(listItem.list.id, itemId, itemType) quickSyncManager.scheduleAddToList(itemId.id, listItem.list.id, Mode.fromType(itemType)) } suspend fun removeFromList( itemId: IdTrakt, itemType: String, listItem: ManageListsItem, ) = withContext(dispatchers.IO) { listsRepository.removeFromList(listItem.list.id, itemId, itemType) quickSyncManager.scheduleRemoveFromList(itemId.id, listItem.list.id, Mode.fromType(itemType)) } } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/manage/recycler/ManageListsAdapter.kt ================================================ package com.michaldrabik.ui_lists.manage.recycler import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView import com.michaldrabik.ui_lists.manage.views.ManageListsItemView class ManageListsAdapter( val itemCheckListener: ((ManageListsItem, Boolean) -> Unit) ) : RecyclerView.Adapter() { private val asyncDiffer = AsyncListDiffer(this, ManageListsItemDiffCallback()) fun setItems(newItems: List) { asyncDiffer.submitList(newItems) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ManageListsItemViewHolder( ManageListsItemView(parent.context).apply { itemCheckListener = { item, isChecked -> this@ManageListsAdapter.itemCheckListener.invoke(item, isChecked) } } ) override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val item = asyncDiffer.currentList[position] (holder.itemView as ManageListsItemView).bind(item) } override fun getItemCount() = asyncDiffer.currentList.size class ManageListsItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/manage/recycler/ManageListsItem.kt ================================================ package com.michaldrabik.ui_lists.manage.recycler import com.michaldrabik.ui_model.CustomList data class ManageListsItem( val list: CustomList, val isChecked: Boolean, val isEnabled: Boolean ) ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/manage/recycler/ManageListsItemDiffCallback.kt ================================================ package com.michaldrabik.ui_lists.manage.recycler import androidx.recyclerview.widget.DiffUtil class ManageListsItemDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ManageListsItem, newItem: ManageListsItem) = oldItem.list.id == newItem.list.id override fun areContentsTheSame(oldItem: ManageListsItem, newItem: ManageListsItem) = oldItem.list == newItem.list && oldItem.isChecked == newItem.isChecked && oldItem.isEnabled == newItem.isEnabled } ================================================ FILE: ui-lists/src/main/java/com/michaldrabik/ui_lists/manage/views/ManageListsItemView.kt ================================================ package com.michaldrabik.ui_lists.manage.views import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import com.michaldrabik.ui_lists.databinding.ViewManageListsItemBinding import com.michaldrabik.ui_lists.manage.recycler.ManageListsItem @SuppressLint("SetTextI18n") class ManageListsItemView : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) private val binding = ViewManageListsItemBinding.inflate(LayoutInflater.from(context), this) var itemCheckListener: ((ManageListsItem, Boolean) -> Unit)? = null private var isCheckEnabled = false init { layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT) binding.manageListsItemCheckbox.setOnCheckedChangeListener { _, isChecked -> if (isCheckEnabled) itemCheckListener?.invoke(item, isChecked) } } private lateinit var item: ManageListsItem fun bind(item: ManageListsItem) { this.item = item isCheckEnabled = false with(binding) { manageListsItemCheckbox.text = " ${item.list.name}" manageListsItemCheckbox.isChecked = item.isChecked manageListsItemCheckbox.isEnabled = item.isEnabled } isCheckEnabled = true } } ================================================ FILE: ui-lists/src/main/res/color/selector_create_list_button.xml ================================================ ================================================ FILE: ui-lists/src/main/res/color/selector_create_list_input.xml ================================================ ================================================ FILE: ui-lists/src/main/res/color/selector_list_chip_background.xml ================================================ ================================================ FILE: ui-lists/src/main/res/color/selector_list_chip_text.xml ================================================ ================================================ FILE: ui-lists/src/main/res/color-notnight/selector_list_chip_background.xml ================================================ ================================================ FILE: ui-lists/src/main/res/drawable/bg_rank.xml ================================================ ================================================ FILE: ui-lists/src/main/res/drawable/ic_edit.xml ================================================ ================================================ FILE: ui-lists/src/main/res/drawable/ic_handle.xml ================================================ ================================================ FILE: ui-lists/src/main/res/drawable/ic_list_create.xml ================================================ ================================================ FILE: ui-lists/src/main/res/drawable/ic_more.xml ================================================ ================================================ FILE: ui-lists/src/main/res/drawable/ic_reorder.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/fragment_list_details.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/fragment_lists.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/layout_list_details_empty.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/layout_list_details_item_grid.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/layout_lists_empty.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/layout_manage_lists_empty.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/view_create_list.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/view_list_delete_confirm.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/view_list_details_filters.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/view_list_details_item_grid.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/view_list_details_item_grid_title.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/view_list_details_movie_item.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/view_list_details_movie_item_compact.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/view_list_details_show_item.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/view_list_details_show_item_compact.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/view_lists_filters.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/view_lists_item.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/view_manage_lists.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/view_manage_lists_item.xml ================================================ ================================================ FILE: ui-lists/src/main/res/layout/view_triple_image.xml ================================================ ================================================ FILE: ui-lists/src/main/res/menu/menu_list_details.xml ================================================ ================================================ FILE: ui-lists/src/main/res/values/dimens.xml ================================================ 156dp 0dp 112dp 78dp 68dp 16dp 114dp 120dp 80dp 116dp 12dp 23dp 4dp 12sp 80dp 120dp 43dp 64dp 54dp 56dp 66dp ================================================ FILE: ui-lists/src/main/res/values/strings.xml ================================================ Lists List Filters: Your lists collection is currently empty. Your list is currently empty. Create list Create a new custom list. Name Description (optional) Delete list Edit list Update your custom list. Delete List Are you sure you want to delete this list? Change Ranks Drag and drop list item to change its rank Manage lists Manage your lists for this show. Manage your lists for this movie. We could not create new list in your Trakt account at this moment.\nPlease check internet connection and try again. We could not update this list in your Trakt account at this moment.\nPlease check internet connection and try again. We could not delete this list from your Trakt account at this moment.\nPlease check internet connection and try again. ================================================ FILE: ui-lists/src/main/res/values/styles.xml ================================================ ================================================ FILE: ui-lists/src/main/res/values-ar/strings.xml ================================================ قوائم قائمة الفلاتر: لا توجد قوائم حاليًا. قائمتك فارغة حاليًا. إنشاء قائمة إنشاء قائمة جديدة. اسم القائمة وصف القائمة (اختياري) حذف القائمة تعديل القائمة تحديث قائمتك الخاصة. حذف القائمة أتريد فعلًا حذف القائمة؟ تغيير ترتيب العناصر اِسحب وأسقط أحد العناصر لتغيير ترتيبه إدارة القوائم إدارة قوائمك المتعلقة بهذا المسلسل. إدارة قوائمك المتعلقة بهذا الفيلم. تعذر إنشاء قائمة جديدة في حسابك على موقع Trakt.tv.\nالرجاء التأكد من اتصالك بالإنترنت وحاول مرة أخرى. تعذر تحديث القائمة في حسابك على موقع Trakt.tv.\nالرجاء التأكد من اتصالك بالإنترنت وحاول مرة أخرى. تعذر حذف القائمة من حسابك على موقع Trakt.tv.\nالرجاء التأكد من اتصالك بالإنترنت وحاول مرة أخرى. ================================================ FILE: ui-lists/src/main/res/values-de/strings.xml ================================================ Listen Liste Filter: Ihre Listensammlung ist derzeit leer. Die Liste ist aktuell leer. Liste erstellen Erstelle eine neue Liste. Name Beschreibung (optional) Liste löschen Liste bearbeiten Aktualisiere deine Liste. Liste löschen Bist du sicher, dass du diese Liste löschen möchtest? Ränge ändern Ziehe ein Listenelement um seinen Rang zu ändern Listen verwalten Verwalte deine Listen für diese Serie. Verwalte deine Listen für diesen Film. Wir konnten derzeit keine neue Liste in deinem Trakt Konto erstellen.\nBitte überprüfe deine Internetverbindung und versuche es erneut. Wir konnten diese Liste derzeit nicht in deinem Trakt Konto aktualisieren.\nBitte überprüfe deine Internetverbindung und versuche es erneut. Wir konnten diese Liste momentan nicht von deinem Trakt Konto löschen.\nBitte überprüfe deine Internetverbindung und versuche es erneut. ================================================ FILE: ui-lists/src/main/res/values-es/strings.xml ================================================ Listas Lista Filtros: La colección de tus listas está vacía. Tu lista está vacía. Crear lista Crear una nueva lista personalizada. Nombre Descripción (opcional) Borrar lista Editar lista Actualiza tu lista personalizada. Borrar Lista ¿Seguro que quieres eliminar esta lista? Cambiar Rangos Arrastra y suelta el elemento de la lista para cambiar su rango Gestionar listas Gestiona tus listas para esta serie. Gestiona tus listas para esta película. No pudimos crear una nueva lista en tu cuenta de Trakt en este momento.\nPor favor, comprueba la conexión a Internet e inténtalo de nuevo. No pudimos actualizar esta lista en tu cuenta de Trakt en este momento.\nPor favor, comprueba la conexión a Internet e inténtalo de nuevo. No pudimos eliminar esta lista de tu cuenta de Trakt en este momento.\nPor favor, comprueba la conexión a Internet e inténtalo de nuevo. ================================================ FILE: ui-lists/src/main/res/values-fi/strings.xml ================================================ Listat Lista Suodattimet: Listakokoelmasi on tyhjä. Listasi on tyhjä. Luo lista Luo uusi mukautettu lista. Nimi Kuvaus (valinnainen) Poista lista Muokkaa listaa Päivitä mukautettu listasi. Poista lista Haluatko varmasti poistaa listan? Muuta sijoituksia Muuta listan kohteen sijoitusta vetämällä ja pudottamalla Hallitse listoja Hallitse sarjan listoja. Hallitse elokuvan listoja. Listan luonti Trakt.tv-kokoelmaasi ei juuri nyt onnistunut.\nTarkista Internet-yhteytesi ja yritä uudelleen. Listan päivitys Trakt.tv-kokoelmaasi ei juuri nyt onnistunut.\nTarkista Internet-yhteytesi ja yritä uudelleen. Listan poisto Trakt.tv-kokoelmastasi ei juuri nyt onnistunut.\nTarkista Internet-yhteytesi ja yritä uudelleen. ================================================ FILE: ui-lists/src/main/res/values-fr/strings.xml ================================================ Listes Liste Filtres : Votre collection de listes est vide pour le moment. Votre liste est vide pour le moment. Créer une liste Créer une nouvelle liste personnalisée. Nom Description (optionnel) Supprimer la liste Modifier la liste Mettre à jour votre liste personnalisée. Supprimer la liste Êtes-vous sûr de vouloir supprimer cette liste ? Modifier le classement Glisser-déposer l\'élément de la liste pour changer son rang Gérer les listes Gérer vos listes pour cette série. Gérer vos listes pour ce film. Nous n\'avons pas pu créer une nouvelle liste dans votre compte Trakt pour le moment.\nVeuillez vérifier la connexion Internet et réessayer. Nous n\'avons pas pu mettre à jour cette liste dans votre compte Trakt pour le moment.\nVeuillez vérifier la connexion internet et réessayer. Nous n\'avons pas pu supprimer cette liste de votre compte Trakt pour le moment.\nVeuillez vérifier la connexion internet et réessayer. ================================================ FILE: ui-lists/src/main/res/values-it/strings.xml ================================================ Liste Lista Filtri: La tua raccolta di liste è attualmente vuota. La tua lista è attualmente vuota. Crea lista Crea una nuova lista personalizzata. Nome Descrizione (facoltativa) Elimina lista Modifica lista Aggiorna la tua lista personalizzata. Elimina lista Sei sicuro di voler eliminare questa lista? Cambia posizione Trascina e rilascia l\'elemento per cambiare la sua posizione nella lista Gestisci liste Gestisci le tue liste per questo show. Gestisci le tue liste per questo film. Al momento non è possibile creare una nuova lista nel tuo account Trakt.\nControlla la connessione a internet e riprova. Al momento non è possibile aggiornare questa lista nel tuo account Trakt.\nControlla la connessione internet e riprova. Al momento non è possibile eliminare questa lista dal tuo account Trakt.\nControlla la connessione a internet e riprova. ================================================ FILE: ui-lists/src/main/res/values-notnight/dimens.xml ================================================ 52dp 54dp 66dp ================================================ FILE: ui-lists/src/main/res/values-pl/strings.xml ================================================ Listy Lista Filtry: Twoja kolekcja list jest obecnie pusta. Twoja lista jest obecnie pusta. Utwórz listę Utwórz nową własną listę. Nazwa Opis (opcjonalny) Usuń listę Edytuj listę Aktualizuj swoją listę. Usuń listę Czy na pewno chcesz usunąć tę listę? Zmień rangi Przeciągnij element listy aby zmienić jego rangę Zarządzaj listami Zarządzaj listami dla tego serialu. Zarządzaj listami dla tego filmu. W tej chwili nie mogliśmy utworzyć nowej listy na Twoim koncie Trakt.\nSprawdź połączenie internetowe i spróbuj ponownie. W tej chwili nie udało się zaktualizować tej listy na Twoim koncie Trakt.\nSprawdź połączenie internetowe i spróbuj ponownie. W tej chwili nie można usunąć tej listy z Twojego konta Trakt.\nSprawdź połączenie internetowe i spróbuj ponownie. ================================================ FILE: ui-lists/src/main/res/values-pt/strings.xml ================================================ Listas Lista Filtros: A sua coleção de listas está vazia. Sua lista está vazia no momento. Criar lista Criar uma nova lista personalizada. Nome Descrição (opcional) Excluir lista Editar lista Atualize sua lista personalizada. Excluir Lista Tem certeza que deseja excluir esta lista? Alterar Rank Arraste e solte o item da lista para alterar seu rank Gerenciar listas Gerencie suas listas para esta série. Gerencie suas listas para este filme. Não foi possível criar uma nova lista na sua conta Trakt neste momento.\nPor favor, verifique a conexão com a internet e tente novamente. Não foi possível atualizar esta lista na sua conta Trakt neste momento.\nPor favor, verifique a conexão com a internet e tente novamente. Não foi possível excluir esta lista da sua conta Trakt neste momento.\nPor favor, verifique a conexão com a internet e tente novamente. ================================================ FILE: ui-lists/src/main/res/values-ru/strings.xml ================================================ Списки Список Фильтры: Ваш список коллекций пуст. Ваш список пуст. Создать список Создать новый пользовательский список. Название Описание (необязательно) Удалить список Изменить список Обновить пользовательский список. Удалить список Вы уверены, что вы хотите удалить этот список? Изменить ранг Перетащите элемент списка, чтобы изменить его ранг Управление списками Управление списками для этого шоу. Управление списками для этого фильма. Мы не смогли создать новый список в вашей учетной записи Trakt.\nПожалуйста, проверьте подключение к Интернету и повторите попытку. Мы не смогли обновить список в вашей учетной записи Trakt.\nПожалуйста, проверьте подключение к Интернету и повторите попытку. Мы не смогли удалить список в вашей учетной записи Trakt.\nПожалуйста, проверьте подключение к Интернету и повторите попытку. ================================================ FILE: ui-lists/src/main/res/values-sw600dp/dimens.xml ================================================ 12dp 24dp 29dp 8dp 12sp ================================================ FILE: ui-lists/src/main/res/values-tr/strings.xml ================================================ Listeler Liste Filtreler: Listeler koleksiyonunuz şu anda boş. Listeniz şu anda boş. Liste oluştur Yeni bir özel liste oluştur. İsim Açıklama (isteğe bağlı) Listeyi sil Listeyi düzenle Özel listenizi güncelleyin. Listeyi Sil Bu listeyi silmek istediğinizden emin misiniz? Dereceleri Değiştir Derecesini değiştirmek için liste öğesini sürükleyip bırakın Listeleri yönet Bu dizi için listelerinizi yönetin. Bu film için listelerinizi yönetin. Şu anda Trakt hesabınızda yeni liste oluşturamıyoruz.\nLütfen internet bağlantısını kontrol edin ve tekrar deneyin. Şu anda Trakt hesabınızdaki bu listeyi güncelleyemiyoruz.\nLütfen internet bağlantısını kontrol edin ve tekrar deneyin. Şu anda Trakt hesabınızdaki bu listeyi silemiyoruz.\nLütfen internet bağlantısını kontrol edin ve tekrar deneyin. ================================================ FILE: ui-lists/src/main/res/values-uk/strings.xml ================================================ Списки Список Фільтри: Ваші списки колекцій наразі порожні. Ваш список наразі порожній. Створити список Створити новий користувацький список. Назва Опис (необов\'язково) Видалити список Редагувати список Оновити користувацький список. Видалити список Ви дійсно бажаєте видалити цей список? Змінити ранги Перетягніть елемент списку, щоб змінити його ранг Керування списками Керування списками для цього серіалу. Керування списками для цього фільму. Не вдалося створити новий список у вашому обліковому записі Trakt.\nБудь ласка, перевірте підключення до Інтернету і спробуйте ще раз. Не вдалося оновити цей список у вашому обліковому записі Trakt.\nБудь ласка, перевірте підключення до Інтернету і спробуйте ще раз. Не вдалося видалити цей список з вашого облікового запису Trakt.\nБудь ласка, перевірте підключення до Інтернету і спробуйте ще раз. ================================================ FILE: ui-lists/src/main/res/values-vi/strings.xml ================================================ Các Danh sách Danh sách Bộ lọc: Bộ sưu tập các danh sách của bạn hiện đang trống. Danh sách của bạn hiện đang trống. Tạo danh sách Tạo một danh sách tùy chỉnh mới. Tên Mô tả (tùy chọn) Xóa danh sách Chỉnh sửa danh sách Cập nhật danh sách tùy chỉnh của bạn. Xóa danh sách Bạn có chắc chắn muốn xóa danh sách này? Thay đổi thứ hạng Kéo và thả mục danh sách để thay đổi thứ hạng của nó Quản lý danh sách Quản lý danh sách của bạn cho chương trình này. Quản lý danh sách của bạn cho bộ phim này. Chúng tôi không thể tạo danh sách mới trong tài khoản Trakt của bạn vào lúc này.\nVui lòng kiểm tra kết nối Internet và thử lại. Chúng tôi không thể cập nhật danh sách này trong tài khoản Trakt của bạn vào lúc này.\nVui lòng kiểm tra kết nối Internet và thử lại. Chúng tôi không thể xóa danh sách này khỏi tài khoản Trakt của bạn vào lúc này.\nVui lòng kiểm tra kết nối Internet và thử lại. ================================================ FILE: ui-lists/src/main/res/values-zh/strings.xml ================================================ 列表 列表 筛选: 当前您的列表合集为空。 当前您的列表为空。 创建列表 创建一个新的自定义列表 名称 描述(选填) 删除列表 编辑列表 更新自定义列表 删除列表 您确定要删除此列表吗? 修改排行 拖放列表项以更改排行 管理列表 管理这部剧所在的列表。 管理这部电影所在的列表。 目前无法在您 Trakt 账号中创建新的列表。\n请检查网络连接并重试。 目前无法更新您 Trakt 账号中的此列表。\n请检查网络连接并重试。 目前无法删除您 Trakt 账号中的此列表。\n请检查网络连接并重试。 ================================================ FILE: ui-model/.gitignore ================================================ /build ================================================ FILE: ui-model/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'kotlin-parcelize' apply from: '../versions.gradle' android { kotlinOptions { jvmTarget = "17" } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } compileSdkVersion versions.compileSdk defaultConfig { minSdkVersion versions.minSdk targetSdkVersion versions.targetSdk compileSdkVersion versions.compileSdk testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false } } namespace 'com.michaldrabik.ui_model' } dependencies { implementation project(':common') implementation libs.android.appcompat coreLibraryDesugaring libs.android.desugar } ================================================ FILE: ui-model/src/main/AndroidManifest.xml ================================================ ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/AirTime.kt ================================================ package com.michaldrabik.ui_model data class AirTime( val day: String, val time: String, val timezone: String ) ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/CalendarMode.kt ================================================ package com.michaldrabik.ui_model enum class CalendarMode { PRESENT_FUTURE, RECENTS } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/Comment.kt ================================================ package com.michaldrabik.ui_model import android.os.Parcelable import kotlinx.parcelize.Parcelize import java.time.ZonedDateTime @Parcelize data class Comment( val id: Long, val parentId: Long, val comment: String, val userRating: Int, val spoiler: Boolean, val review: Boolean, val likes: Long, val replies: Long, val createdAt: ZonedDateTime?, val updatedAt: ZonedDateTime?, val user: User, val isMe: Boolean, val isSignedIn: Boolean, val isLoading: Boolean, val hasRepliesLoaded: Boolean, ) : Parcelable { fun hasSpoilers() = spoiler || comment.contains("spoiler", true) fun isReply() = parentId > 0 fun getReplyId() = if (isReply()) parentId else id } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/CustomList.kt ================================================ package com.michaldrabik.ui_model import android.os.Parcelable import com.michaldrabik.common.Mode import com.michaldrabik.common.extensions.nowUtc import kotlinx.parcelize.Parcelize import java.time.ZonedDateTime @Parcelize data class CustomList( val id: Long, val idTrakt: Long?, val idSlug: String, val name: String, val description: String?, val privacy: String, val displayNumbers: Boolean, val allowComments: Boolean, val sortBy: SortOrder, val sortHow: SortType, val sortByLocal: SortOrder, val sortHowLocal: SortType, val filterTypeLocal: List, val itemCount: Long, val commentCount: Long, val likes: Long, val createdAt: ZonedDateTime, val updatedAt: ZonedDateTime ) : Parcelable { companion object { fun create() = CustomList( id = 0, idTrakt = null, idSlug = "", name = "", description = null, privacy = "private", displayNumbers = false, allowComments = true, sortBy = SortOrder.RANK, sortHow = SortType.ASCENDING, sortByLocal = SortOrder.RANK, sortHowLocal = SortType.ASCENDING, filterTypeLocal = Mode.getAll(), itemCount = 0, commentCount = 0, likes = 0, createdAt = nowUtc(), updatedAt = nowUtc() ) } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/DiscoverFilters.kt ================================================ package com.michaldrabik.ui_model data class DiscoverFilters( val feedOrder: DiscoverSortOrder = DiscoverSortOrder.HOT, val hideAnticipated: Boolean = true, val hideCollection: Boolean = false, val genres: List = emptyList(), val networks: List = emptyList(), ) ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/DiscoverSortOrder.kt ================================================ package com.michaldrabik.ui_model enum class DiscoverSortOrder { HOT, RATING, NEWEST } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/Episode.kt ================================================ package com.michaldrabik.ui_model import android.os.Parcelable import com.michaldrabik.common.extensions.nowUtc import kotlinx.parcelize.Parcelize import java.time.ZonedDateTime @Parcelize data class Episode( val season: Int, val number: Int, val title: String, val ids: Ids, val overview: String, val rating: Float, val votes: Int, val commentCount: Int, val firstAired: ZonedDateTime?, val runtime: Int, val numberAbs: Int?, val lastWatchedAt: ZonedDateTime? ) : Parcelable { companion object { val EMPTY = Episode(-1, -1, "", Ids.EMPTY, "", -1F, -1, -1, null, -1, -1, null) } fun hasAired(season: Season): Boolean { val nowUtc = nowUtc() return when (firstAired) { null -> season.episodes.any { it.number > number && (it.firstAired != null && nowUtc.isAfter(it.firstAired)) } else -> nowUtc.isAfter(firstAired) } } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/EpisodeBundle.kt ================================================ package com.michaldrabik.ui_model data class EpisodeBundle( val episode: Episode, val season: Season, val show: Show ) ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/Genre.kt ================================================ package com.michaldrabik.ui_model import androidx.annotation.StringRes enum class Genre( val slug: String, @StringRes val displayName: Int ) { ACTION("action", R.string.textGenreAction), ADVENTURE("adventure", R.string.textGenreAdventure), ANIMATION("animation", R.string.textGenreAnimation), ANIME("anime", R.string.textGenreAnime), COMEDY("comedy", R.string.textGenreComedy), CRIME("crime", R.string.textGenreCrime), DOCUMENTARY("documentary", R.string.textGenreDocumentary), DRAMA("drama", R.string.textGenreDrama), FANTASY("fantasy", R.string.textGenreFantasy), HISTORY("history", R.string.textGenreHistory), HORROR("horror", R.string.textGenreHorror), SF("science-fiction", R.string.textGenreScienceFiction), THRILLER("thriller", R.string.textGenreThriller), WAR("war", R.string.textGenreWar), WESTERN("western", R.string.textGenreWestern); companion object { fun fromSlug(slug: String) = values().find { it.slug.equals(slug, ignoreCase = true) } } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/Ids.kt ================================================ package com.michaldrabik.ui_model import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize data class Ids( val trakt: IdTrakt, val slug: IdSlug, val tvdb: IdTvdb, val imdb: IdImdb, val tmdb: IdTmdb, val tvrage: IdTvRage, ) : Parcelable { companion object { val EMPTY = Ids( IdTrakt(), IdSlug(), IdTvdb(), IdImdb(), IdTmdb(), IdTvRage() ) } } sealed interface Id : Parcelable @JvmInline @Parcelize value class IdTrakt(val id: Long = -1) : Id @JvmInline @Parcelize value class IdTvdb(val id: Long = -1) : Id @JvmInline @Parcelize value class IdImdb(val id: String = "") : Id @JvmInline @Parcelize value class IdTmdb(val id: Long = -1) : Id @JvmInline @Parcelize value class IdTvRage(val id: Long = -1) : Id @JvmInline @Parcelize value class IdSlug(val id: String = "") : Id ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/Image.kt ================================================ package com.michaldrabik.ui_model import com.michaldrabik.common.Config.AWS_IMAGE_BASE_URL import com.michaldrabik.common.Config.TMDB_IMAGE_BASE_FANART_URL import com.michaldrabik.common.Config.TMDB_IMAGE_BASE_POSTER_URL import com.michaldrabik.common.Config.TMDB_IMAGE_BASE_PROFILE_URL import com.michaldrabik.common.Config.TVDB_IMAGE_BASE_BANNERS_URL import com.michaldrabik.ui_model.ImageFamily.SHOW import com.michaldrabik.ui_model.ImageSource.AWS import com.michaldrabik.ui_model.ImageSource.CUSTOM import com.michaldrabik.ui_model.ImageSource.TMDB import com.michaldrabik.ui_model.ImageSource.TVDB import com.michaldrabik.ui_model.ImageStatus.AVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNAVAILABLE import com.michaldrabik.ui_model.ImageStatus.UNKNOWN import com.michaldrabik.ui_model.ImageType.FANART import com.michaldrabik.ui_model.ImageType.FANART_WIDE import com.michaldrabik.ui_model.ImageType.POSTER import com.michaldrabik.ui_model.ImageType.PROFILE data class Image( val id: Long, val idTvdb: IdTvdb, val idTmdb: IdTmdb, val type: ImageType, val family: ImageFamily, val fileUrl: String, val thumbnailUrl: String, val status: ImageStatus, val source: ImageSource ) { val fullFileUrl = when (source) { TVDB -> "$TVDB_IMAGE_BASE_BANNERS_URL$fileUrl" TMDB -> when (type) { POSTER -> "${TMDB_IMAGE_BASE_POSTER_URL}$fileUrl" FANART, FANART_WIDE -> "${TMDB_IMAGE_BASE_FANART_URL}$fileUrl" PROFILE -> "${TMDB_IMAGE_BASE_PROFILE_URL}$fileUrl" else -> "" } AWS -> "$AWS_IMAGE_BASE_URL$fileUrl" CUSTOM -> fileUrl } companion object { fun createUnknown( type: ImageType, family: ImageFamily = SHOW, source: ImageSource = TVDB ) = Image(0, IdTvdb(0), IdTmdb(0), type, family, "", "", UNKNOWN, source) fun createUnavailable( type: ImageType, family: ImageFamily = SHOW, source: ImageSource = TVDB ) = Image(0, IdTvdb(0), IdTmdb(0), type, family, "", "", UNAVAILABLE, source) fun createAvailable( ids: Ids, type: ImageType, family: ImageFamily, path: String, source: ImageSource ) = Image(0, ids.tvdb, ids.tmdb, type, family, path, "", AVAILABLE, source) } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/ImageFamily.kt ================================================ package com.michaldrabik.ui_model enum class ImageFamily(val key: String) { SHOW("show"), MOVIE("movie"), EPISODE("episode"), PROFILE("profile"), } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/ImageSource.kt ================================================ package com.michaldrabik.ui_model enum class ImageSource( val key: String ) { TVDB("tvdb"), TMDB("tmdb"), CUSTOM("custom"), AWS("aws"); companion object { fun fromKey(key: String) = values().first { it.key == key } } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/ImageStatus.kt ================================================ package com.michaldrabik.ui_model /** * AVAILABLE - image's web url is known to be valid when used the last time. * UNKNOWN - image's web url has not been yet checked with remote images service (TVDB). * UNAVAILABLE - remote images service does not contain any valid image url. */ enum class ImageStatus { AVAILABLE, UNKNOWN, UNAVAILABLE } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/ImageType.kt ================================================ package com.michaldrabik.ui_model enum class ImageType( val id: Int, val key: String ) { POSTER(1, "poster"), FANART(2, "fanart"), FANART_WIDE(3, "fanart"), TWITTER(4, "twitterAd"), PREMIUM(5, "premiumAd"), PROFILE(6, "profile"); fun getSpan(isTablet: Boolean): Int { return when (this) { POSTER -> 1 FANART -> 2 FANART_WIDE -> if (isTablet) 3 else 3 TWITTER -> if (isTablet) 6 else 3 PREMIUM -> if (isTablet) 6 else 3 PROFILE -> 1 } } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/Movie.kt ================================================ package com.michaldrabik.ui_model import com.michaldrabik.common.extensions.nowUtcDay import java.time.LocalDate data class Movie( val ids: Ids, val title: String, val year: Int, val overview: String, val released: LocalDate?, val runtime: Int, val country: String, val trailer: String, val homepage: String, val language: String, val status: MovieStatus, val rating: Float, val votes: Long, val commentCount: Long, val genres: List, val updatedAt: Long, val createdAt: Long ) { val traktId = ids.trakt.id val titleNoThe = title.removePrefix("The").trim() fun hasNoDate() = released == null && year <= 0 fun hasAired(): Boolean { if (released == null) return false val now = nowUtcDay() return now.isEqual(released) || now.isAfter(released) } fun isToday(): Boolean { if (released == null) return false val now = nowUtcDay() return now.isEqual(released) } companion object { val EMPTY = Movie( Ids.EMPTY, "", -1, "", null, -1, "", "", "", "", MovieStatus.UNKNOWN, -1F, -1L, -1L, emptyList(), -1L, -1L ) } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/MovieCollection.kt ================================================ package com.michaldrabik.ui_model data class MovieCollection( val id: IdTrakt, val name: String, val description: String, val itemCount: Int, ) { companion object { val EMPTY = MovieCollection(IdTrakt(-1), "", "", -1) } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/MovieStatus.kt ================================================ package com.michaldrabik.ui_model import androidx.annotation.StringRes enum class MovieStatus( val key: String, @StringRes val displayName: Int ) { RELEASED("released", R.string.textMovieStatusReleased), IN_PRODUCTION("in production", R.string.textMovieStatusInProduction), POST_PRODUCTION("post production", R.string.textMovieStatusPostProduction), PLANNED("planned", R.string.textMovieStatusPlanned), RUMORED("rumored", R.string.textMovieStatusRumored), CANCELED("canceled", R.string.textMovieStatusCanceled), UNKNOWN("unknown", R.string.textMovieStatusUnknown); fun isAnticipated() = this in arrayOf(IN_PRODUCTION, POST_PRODUCTION, PLANNED, RUMORED) companion object { fun fromKey(key: String?) = enumValues().firstOrNull { it.key == key } ?: UNKNOWN } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/MyMoviesSection.kt ================================================ package com.michaldrabik.ui_model import androidx.annotation.StringRes enum class MyMoviesSection( @StringRes val displayString: Int ) { RECENTS( displayString = R.string.textHeaderRecentlyAdded ), ALL( displayString = R.string.textHeaderAll ) } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/MyShowsSection.kt ================================================ package com.michaldrabik.ui_model import androidx.annotation.StringRes import com.michaldrabik.ui_model.ShowStatus.CANCELED import com.michaldrabik.ui_model.ShowStatus.ENDED import com.michaldrabik.ui_model.ShowStatus.IN_PRODUCTION import com.michaldrabik.ui_model.ShowStatus.PLANNED import com.michaldrabik.ui_model.ShowStatus.RETURNING enum class MyShowsSection( @StringRes val displayString: Int, val allowedStatuses: List = emptyList(), ) { RECENTS( displayString = R.string.textHeaderRecentlyAdded ), WATCHING( allowedStatuses = listOf(RETURNING), displayString = R.string.textHeaderWatching ), FINISHED( allowedStatuses = listOf(CANCELED, ENDED), displayString = R.string.textHeaderFinished ), UPCOMING( allowedStatuses = listOf(IN_PRODUCTION, PLANNED, ShowStatus.UPCOMING), displayString = R.string.textHeaderReturning ), ALL( displayString = R.string.textHeaderAll ) } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/Network.kt ================================================ package com.michaldrabik.ui_model enum class Network( vararg val channels: String, ) { ABC( "ABC", "ABC (AU)", "ABC (JA)", "ABC (US)", "ABC Comedy", "ABC Family", "ABC KIDS", "ABC Me", "ABC News", "ABC News 24", "ABC Spark", "ABC TV", "ABC TV Plus", ), AMC( "AMC", "AMC+" ), APPLE( "Apple", "Apple TV+", "Apple Music" ), AMAZON( "Amazon", "Amazon (Japan)", "Amazon Freevee", "Amazon Kids+", "Amazon Prime Video", "Amazon Studios", "Amazon miniTV" ), BBC( "BBC", "BBC America", "BBC Canada", "BBC Choice", "BBC Earth", "BBC First", "BBC Four", "BBC HD", "BBC Kids", "BBC Knowledge", "BBC Music", "BBC News", "BBC One", "BBC Prime", "BBC Red Button", "BBC Scotland", "BBC Television", "BBC Three", "BBC Two", "BBC UKTV", "BBC Wales", "BBC World News", "BBC Worldwide", "BBC iPlayer", ), CBS( "CBS", "CBS All Access", "CBS News", "CBS Reality", "CBS Reality (UK)", "CBS.com", ), CW("CW", "The CW"), DISCOVERY( "Discovery", "Discovery (NL)", "Discovery (US)", "Discovery Asia", "Discovery Channel", "Discovery Channel (AU)", "Discovery Channel (Asia)", "Discovery Channel (CA)", "Discovery Channel (Canada)", "Discovery Channel (PL)", "Discovery Channel (SE)", "Discovery Channel (UK)", "Discovery Communications", "Discovery Family", "Discovery GO", "Discovery HD World", "Discovery Health Channel", "Discovery History", "Discovery JEET", "Discovery Kids", "Discovery Life", "Discovery MAX", "Discovery Real Time", "Discovery Science", "Discovery Shed", "Discovery Turbo", "Discovery Turbo UK", "Discovery UK", "Discovery+" ), DISNEY( "Disney", "Disney Channel", "Disney Channel (DE)", "Disney Channel (FR)", "Disney Channel (IT)", "Disney Channel (UK)", "Disney Channel (US)", "Disney Cinemagic", "Disney Junior", "Disney Junior (UK)", "Disney Television Animation", "Disney XD", "Disney XD (Latin America)", "Disney+", "Disney+ Hotstar" ), HBO( "HBO Max", "HBO Asia", "HBO Brasil", "HBO Canada", "HBO España", "HBO Europe", "HBO Family", "HBO Latin", "HBO America", "HBO Magyarország", "HBO Nordic", "HBO", "Max", "MAX", ), FOX( "FOX", "FOX (US)", "FOX España", "FOX Reality", "FOX SPORTS", "FOX Traveller", "FOX Türkiye", "FOX Türkiye (TR)", "FOX+", ), HULU("Hulu", "Hulu Japan"), ITV( "ITV", "ITV Encore", "ITV Granada", "ITV Wales" ), NBC( "NBC", "NBC News Studios", "NBC Universo", "NBCSN", "NBCUniversal" ), NETFLIX("Netflix"), PARAMOUNT( "Paramount", "Paramount (GB)", "Paramount Network", "Paramount+", "Paramount+ (AU)", "Paramount+ (MX)", ), PEACOCK( "Peacock", ), RAKUTEN("Rakuten TV"), SHOWTIME("Showtime"), } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/NewsItem.kt ================================================ package com.michaldrabik.ui_model import java.time.ZonedDateTime data class NewsItem( val id: String, val title: String, val url: String, val type: Type, val image: String?, val score: Long, val datedAt: ZonedDateTime, val createdAt: ZonedDateTime, val updatedAt: ZonedDateTime ) { enum class Type(val slug: String) { SHOW("show"), MOVIE("movie"); companion object { fun fromSlug(slug: String) = Type.values().first { it.slug == slug } } } val isVideo = url.startsWith("https://www.youtu") || url.startsWith("https://youtu") || url.startsWith("www.youtu") val isWebLink = url.startsWith("http") || url.startsWith("www") } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/NotificationDelay.kt ================================================ package com.michaldrabik.ui_model import androidx.annotation.StringRes const val HOUR_MS = 3_600_000L @Suppress("unused") enum class NotificationDelay( @StringRes val stringRes: Int, val delayMs: Long ) { HOURS_12_NEG(R.string.textSettingsShowsNotificationsWhen12HoursBefore, -HOUR_MS * 12), HOURS_6_NEG(R.string.textSettingsShowsNotificationsWhen6HoursBefore, -HOUR_MS * 6), HOURS_3_NEG(R.string.textSettingsShowsNotificationsWhen3HoursBefore, -HOUR_MS * 3), HOURS_1_NEG(R.string.textSettingsShowsNotificationsWhen1HourBefore, -HOUR_MS), WHEN_AVAILABLE(R.string.textSettingsShowsNotificationsWhenAvailable, 0), HOURS_1(R.string.textSettingsShowsNotificationsWhen1Hour, HOUR_MS), HOURS_3(R.string.textSettingsShowsNotificationsWhen3Hours, HOUR_MS * 3), HOURS_6(R.string.textSettingsShowsNotificationsWhen6Hours, HOUR_MS * 6), HOURS_12(R.string.textSettingsShowsNotificationsWhen12Hours, HOUR_MS * 12), HOURS_24(R.string.textSettingsShowsNotificationsWhenNextDay, HOUR_MS * 24); companion object { fun fromDelay(delayMs: Long) = enumValues().first { it.delayMs == delayMs } } fun isBefore() = this in listOf(HOURS_1_NEG, HOURS_3_NEG, HOURS_6_NEG, HOURS_12_NEG) } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/Person.kt ================================================ package com.michaldrabik.ui_model import android.os.Parcelable import com.michaldrabik.common.extensions.nowUtcDay import kotlinx.parcelize.Parcelize import java.time.LocalDate import java.time.Period @Parcelize data class Person( val ids: Ids, val name: String, val department: Department, val bio: String?, val bioTranslation: String?, val characters: List, val jobs: List, val episodesCount: Int, val birthplace: String?, val imagePath: String?, val homepage: String?, val birthday: LocalDate?, val deathday: LocalDate?, ) : Parcelable { fun getAge() = when { birthday != null && deathday != null -> Period.between(birthday, deathday).years birthday != null -> Period.between(birthday, nowUtcDay()).years else -> null } enum class Department(val slug: String) { ACTING("Acting"), DIRECTING("Directing"), WRITING("Writing"), SOUND("Sound"), UNKNOWN("-") } enum class Job(val slug: String) { DIRECTOR("Director"), WRITER("Writer"), STORY("Story"), SCREENPLAY("Screenplay"), MUSIC("Music"), ORIGINAL_MUSIC("Original Music Composer"), UNKNOWN("-"); companion object { fun fromSlug(slug: String?) = values().firstOrNull { it.slug == slug } ?: UNKNOWN } } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/PersonCredit.kt ================================================ package com.michaldrabik.ui_model import com.michaldrabik.ui_model.MovieStatus.IN_PRODUCTION import com.michaldrabik.ui_model.MovieStatus.PLANNED import com.michaldrabik.ui_model.MovieStatus.POST_PRODUCTION import com.michaldrabik.ui_model.MovieStatus.RUMORED import java.time.LocalDate import java.time.ZonedDateTime data class PersonCredit( val show: Show?, val movie: Movie?, val image: Image, val translation: Translation? ) { fun requireShow() = show!! fun requireMovie() = movie!! val releaseDate: LocalDate? get() = when { show != null -> if (show.firstAired.isNotBlank()) { ZonedDateTime.parse(show.firstAired).toLocalDate() } else { null } movie != null -> movie.released else -> null } val isUpcoming: Boolean get() = when { show != null -> { show.status in arrayOf(ShowStatus.IN_PRODUCTION, ShowStatus.PLANNED, ShowStatus.UPCOMING) } movie != null -> { !movie.hasAired() && movie.status in arrayOf(RUMORED, PLANNED, IN_PRODUCTION, POST_PRODUCTION) } else -> false } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/PremiumFeature.kt ================================================ package com.michaldrabik.ui_model import android.content.Context import androidx.annotation.StringRes enum class PremiumFeature(@StringRes val tag: Int) { THEME(R.string.tagTheme), NEWS(R.string.tagNews), WIDGET_TRANSPARENCY(R.string.tagWidgetTransparency), QUICK_RATING(R.string.tagQuickRating), CUSTOM_IMAGES(R.string.tagCustomImages), VIEW_TYPES(R.string.tagViewsTypes); companion object { fun fromTag( context: Context, tag: String ) = values().firstOrNull { context.getString(it.tag) == tag } } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/ProgressNextEpisodeType.kt ================================================ package com.michaldrabik.ui_model enum class ProgressNextEpisodeType { LAST_WATCHED, OLDEST } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/ProgressType.kt ================================================ package com.michaldrabik.ui_model enum class ProgressType { AIRED, ALL } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/RatingState.kt ================================================ package com.michaldrabik.ui_model data class RatingState( val userRating: TraktRating? = null, val rateAllowed: Boolean? = null, val rateLoading: Boolean? = null ) { fun hasRating() = userRating != null && userRating.rating > 0 } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/Ratings.kt ================================================ package com.michaldrabik.ui_model data class Ratings( val trakt: Value? = null, val imdb: Value? = null, val metascore: Value? = null, val rottenTomatoes: Value? = null, val rottenTomatoesUrl: String? = null, val isHidden: Boolean = false, val isTapToReveal: Boolean = false ) { fun isAnyLoading() = trakt?.isLoading == true || imdb?.isLoading == true || metascore?.isLoading == true || rottenTomatoes?.isLoading == true data class Value( val value: String?, val isLoading: Boolean, ) } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/RecentSearch.kt ================================================ package com.michaldrabik.ui_model data class RecentSearch( val text: String ) ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/SearchResult.kt ================================================ package com.michaldrabik.ui_model data class SearchResult( val score: Float, val show: Show, val movie: Movie ) { val isShow = show != Show.EMPTY val traktId = if (show != Show.EMPTY) show.traktId else movie.traktId } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/Season.kt ================================================ package com.michaldrabik.ui_model import java.time.ZonedDateTime data class Season( val ids: Ids, val number: Int, val episodeCount: Int, val airedEpisodes: Int, val title: String, val firstAired: ZonedDateTime?, val overview: String, val rating: Float, val episodes: List ) { companion object { val EMPTY = Season( ids = Ids.EMPTY, number = 0, episodeCount = 0, airedEpisodes = 0, title = "", firstAired = null, overview = "", rating = 0F, episodes = listOf() ) } fun isSpecial() = number == 0 } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/SeasonBundle.kt ================================================ package com.michaldrabik.ui_model data class SeasonBundle( val season: Season, val show: Show ) ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/SeasonTranslation.kt ================================================ package com.michaldrabik.ui_model data class SeasonTranslation( val ids: Ids, val title: String, val seasonNumber: Int, val episodeNumber: Int, val overview: String, val language: String, val isLocal: Boolean = false ) ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/Settings.kt ================================================ package com.michaldrabik.ui_model data class Settings( val isInitialRun: Boolean, val episodesNotificationsEnabled: Boolean, val episodesNotificationsDelay: NotificationDelay, val watchlistShowsSortBy: SortOrder, val archiveShowsSortBy: SortOrder, val myShowsWatchingSortBy: SortOrder, val myShowsUpcomingSortBy: SortOrder, val myShowsFinishedSortBy: SortOrder, val myShowsAllSortBy: SortOrder, val myShowsRunningIsCollapsed: Boolean, val myShowsIncomingIsCollapsed: Boolean, val myShowsEndedIsCollapsed: Boolean, val myRecentsAmount: Int, val showAnticipatedShows: Boolean, val discoverFilterGenres: List, val discoverFilterNetworks: List, val discoverFilterFeed: DiscoverSortOrder, val traktSyncSchedule: TraktSyncSchedule, val traktQuickSyncEnabled: Boolean, val traktQuickRemoveEnabled: Boolean, val progressSortOrder: SortOrder, val archiveIncludeStatistics: Boolean, val specialSeasonsEnabled: Boolean, val showAnticipatedMovies: Boolean, val discoverMoviesFilterGenres: List, val discoverMoviesFilterFeed: DiscoverSortOrder, val watchlistMoviesSortBy: SortOrder, val myMoviesAllSortBy: SortOrder, val progressMoviesSortBy: SortOrder, val showCollectionShows: Boolean, val showCollectionMovies: Boolean, val widgetsShowLabel: Boolean, val traktQuickRateEnabled: Boolean, val listsSortBy: SortOrder, val progressUpcomingEnabled: Boolean, ) { companion object { fun createInitial() = Settings( isInitialRun = true, episodesNotificationsEnabled = true, episodesNotificationsDelay = NotificationDelay.WHEN_AVAILABLE, myShowsFinishedSortBy = SortOrder.NAME, myShowsUpcomingSortBy = SortOrder.NAME, myShowsWatchingSortBy = SortOrder.NAME, myShowsAllSortBy = SortOrder.NAME, myShowsEndedIsCollapsed = true, myShowsIncomingIsCollapsed = true, myShowsRunningIsCollapsed = true, myRecentsAmount = 4, watchlistShowsSortBy = SortOrder.NAME, archiveShowsSortBy = SortOrder.NAME, showAnticipatedShows = true, discoverFilterFeed = DiscoverSortOrder.HOT, discoverFilterGenres = emptyList(), discoverFilterNetworks = emptyList(), traktSyncSchedule = TraktSyncSchedule.OFF, traktQuickSyncEnabled = false, traktQuickRemoveEnabled = false, progressSortOrder = SortOrder.NAME, archiveIncludeStatistics = true, specialSeasonsEnabled = false, discoverMoviesFilterFeed = DiscoverSortOrder.HOT, discoverMoviesFilterGenres = emptyList(), showAnticipatedMovies = true, watchlistMoviesSortBy = SortOrder.NAME, myMoviesAllSortBy = SortOrder.NAME, progressMoviesSortBy = SortOrder.NAME, showCollectionMovies = true, showCollectionShows = true, widgetsShowLabel = true, traktQuickRateEnabled = false, listsSortBy = SortOrder.NEWEST, progressUpcomingEnabled = true ) } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/Show.kt ================================================ package com.michaldrabik.ui_model data class Show( val ids: Ids, val title: String, val year: Int, val overview: String, val firstAired: String, val runtime: Int, val airTime: AirTime, val certification: String, val network: String, val country: String, val trailer: String, val homepage: String, val status: ShowStatus, val rating: Float, val votes: Long, val commentCount: Long, val genres: List, val airedEpisodes: Int, val createdAt: Long, val updatedAt: Long ) { val traktId = ids.trakt.id val titleNoThe = title.removePrefix("The").trim() val isAnime get() = genres.contains(Genre.ANIME.slug) companion object { val EMPTY = Show( ids = Ids( trakt = IdTrakt(id = 0), slug = IdSlug(id = ""), tvdb = IdTvdb(id = 0), imdb = IdImdb(id = ""), tmdb = IdTmdb(id = 0), tvrage = IdTvRage(id = 0) ), title = "", year = 0, overview = "", firstAired = "", runtime = 0, airTime = AirTime(day = "", time = "", timezone = ""), certification = "", network = "", country = "", trailer = "", homepage = "", status = ShowStatus.UNKNOWN, rating = 0.0f, votes = 0, commentCount = 0, genres = listOf(), airedEpisodes = 0, createdAt = 0, updatedAt = 0 ) } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/ShowStatus.kt ================================================ package com.michaldrabik.ui_model import androidx.annotation.StringRes enum class ShowStatus( val key: String, @StringRes val displayName: Int ) { RETURNING("returning series", R.string.textShowStatusReturning), UPCOMING("upcoming", R.string.textShowStatusUpcoming), IN_PRODUCTION("in production", R.string.textShowStatusInProduction), PLANNED("planned", R.string.textShowStatusPlanned), CANCELED("canceled", R.string.textShowStatusCanceled), ENDED("ended", R.string.textShowStatusEnded), UNKNOWN("unknown", R.string.textShowStatusUnknown); fun isAnticipated() = this in arrayOf(UPCOMING, IN_PRODUCTION, PLANNED) companion object { fun fromKey(key: String?) = enumValues().firstOrNull { it.key == key } ?: UNKNOWN } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/SortOrder.kt ================================================ package com.michaldrabik.ui_model enum class SortOrder( val slug: String, val displayString: Int ) { RANK("rank", R.string.textSortRank), NAME("title", R.string.textSortName), NEWEST("released", R.string.textSortNewest), RATING("percentage", R.string.textSortRated), USER_RATING("user_rating", R.string.textSortRatedUser), DATE_ADDED("added", R.string.textSortDateAdded), DATE_UPDATED("updated", R.string.textSortDateUpdated), RECENTLY_WATCHED("recently_watched", R.string.textSortRecentlyWatched), EPISODES_LEFT("episodes_left", R.string.textSortEpisodesLeft); companion object { fun fromSlug(slug: String) = values().firstOrNull { it.slug == slug } } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/SortType.kt ================================================ package com.michaldrabik.ui_model enum class SortType( val slug: String ) { ASCENDING("asc"), DESCENDING("desc"); companion object { fun fromSlug(slug: String) = SortType.values().first { it.slug == slug } } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/SpoilersSettings.kt ================================================ package com.michaldrabik.ui_model data class SpoilersSettings( val isNotCollectedShowsHidden: Boolean, val isNotCollectedShowsRatingsHidden: Boolean, val isMyShowsHidden: Boolean, val isMyShowsRatingsHidden: Boolean, val isWatchlistShowsHidden: Boolean, val isWatchlistShowsRatingsHidden: Boolean, val isHiddenShowsHidden: Boolean, val isHiddenShowsRatingsHidden: Boolean, val isNotCollectedMoviesHidden: Boolean, val isNotCollectedMoviesRatingsHidden: Boolean, val isMyMoviesHidden: Boolean, val isMyMoviesRatingsHidden: Boolean, val isWatchlistMoviesHidden: Boolean, val isWatchlistMoviesRatingsHidden: Boolean, val isHiddenMoviesHidden: Boolean, val isHiddenMoviesRatingsHidden: Boolean, val isEpisodeTitleHidden: Boolean, val isEpisodeDescriptionHidden: Boolean, val isEpisodeRatingHidden: Boolean, val isEpisodeImageHidden: Boolean, val isTapToReveal: Boolean, ) { companion object { val INITIAL = SpoilersSettings( isNotCollectedShowsHidden = false, isNotCollectedShowsRatingsHidden = false, isMyShowsHidden = false, isMyShowsRatingsHidden = false, isWatchlistShowsHidden = false, isWatchlistShowsRatingsHidden = false, isHiddenShowsHidden = false, isHiddenShowsRatingsHidden = false, isNotCollectedMoviesHidden = false, isNotCollectedMoviesRatingsHidden = false, isMyMoviesHidden = false, isMyMoviesRatingsHidden = false, isWatchlistMoviesHidden = false, isWatchlistMoviesRatingsHidden = false, isHiddenMoviesHidden = false, isHiddenMoviesRatingsHidden = false, isEpisodeTitleHidden = false, isEpisodeDescriptionHidden = false, isEpisodeRatingHidden = false, isEpisodeImageHidden = false, isTapToReveal = false, ) } } ================================================ FILE: ui-model/src/main/java/com/michaldrabik/ui_model/StreamingService.kt ================================================ package com.michaldrabik.ui_model import androidx.annotation.StringRes data class StreamingService( val imagePath: String, val name: String, val options: List