From 45a534b360ceb425a70890dcd4a0d7944e5066ec Mon Sep 17 00:00:00 2001 From: Sarath S <53013721+Sharkaboi@users.noreply.github.com> Date: Sat, 14 Aug 2021 00:01:06 +0530 Subject: [PATCH] V1.3 (#32) - Updated CI to run instrumentation tests and have better caching mechanism - Upgraded project to use JDK 11 - Moved all dependency versions to ext variable set - Bumped target and compile SDK to API 31 - Refactored `PresentationExtensions` and Enum classes to use string resources. Closes #13 - Added instrumentation tests for `PresentationExtensions` - Fixed small formatting issues with text - Removed useless GraphQl calls for next episode airing time - Handled invalid deeplinks using regex by querying default browser of phone and force opening url. Closes #30 - Added tests for accepted deeplink regex - Refactored `openAnimeById`, `openMangaById` and `openImageSlider` to global navigation actions - Removed dead code - Refactored standalone companion object classes to objects - Extracted `UIConstants` to reuse chip styles, Image transformations, Corner radius and grid count - Added `printStackTrace()` calls to missed catch blocks - Cleaned up `MPAndroidChart` value formatter to use ktx functions - Refactored `Uri.parse(url)` calls to `url.toUri()` - Moved api constants and `MALExternalLinks` to data package - Added `AnimeAiringStatus`, `AnimeNsfwRating`, `AnimeRating`, `MangaNsfwRating`, `MangaPublishingStatus` enums and tests - Forced all paging data sources to work on `IO` thread - Forced Datastore to use `IO` thread - Refactored `MHError` to have general static error types - Removed magic numbers to enum classes - Fixed bug with 0 `timeToAiring` of episode returning invalid string - Decomposed UI binding functions to smaller functions for readability - Fixed Anime Seasonal screen not loading on navigation with season args - Fixed Anime Ranking screen not loading on navigation with ranking args - Fixed Manga Ranking screen not loading on navigation with ranking args - Fixed incompatible manga ranking type slugs across manga service and manga rank service by mapping - Refactored `OAuthRepository` to use `MHTaskState` - Move fab explode animation to after navigation - Rearrange `strings.xml ` - Cleaned up comments - Updated `Readme` to use latest kotlin version and changed project info section --- .github/workflows/android.yaml | 99 ++- README.md | 36 +- app/build.gradle | 12 +- app/src/androidTest/java/EnumTest.kt | 195 ++++++ .../java/PresentationExtensionsTest.kt | 559 ++++++++++++++++ app/src/main/AndroidManifest.xml | 6 + .../graphql/nextAiringAnimeEpisode.graphql | 4 - .../alarm_manager/NotifyAlarmManager.kt | 12 - .../mediahub/common/constants/AppConstants.kt | 15 +- .../common/constants/MALExternalLinks.kt | 105 --- .../mediahub/common/constants/UIConstants.kt | 52 ++ .../extensions/KotlinResultExtension.kt | 5 +- .../extensions/PresentationExtensions.kt | 478 +++++-------- .../common/extensions/StringExtensions.kt | 14 +- .../common/extensions/UtilExtensions.kt | 4 + .../util/MPAndroidChartValueFormatter.kt | 35 +- .../mediahub/common/util/UrlLauncher.kt | 55 +- .../common/views/CustomProgressScreen.kt | 2 +- .../image_slider/FullScreenImageFragment.kt | 3 +- .../data/api/{ => constants}/ApiConstants.kt | 3 +- .../data/api/constants/MALExternalLinks.kt | 115 ++++ .../data/api/enums/AnimeAiringStatus.kt | 29 + .../data/api/enums/AnimeNsfwRating.kt | 30 + .../data/api/enums/AnimeRankingType.kt | 23 +- .../mediahub/data/api/enums/AnimeRating.kt | 26 + .../mediahub/data/api/enums/AnimeStatus.kt | 27 +- .../data/api/enums/MangaNsfwRating.kt | 20 + .../data/api/enums/MangaPublishingStatus.kt | 19 + .../data/api/enums/MangaRankingType.kt | 40 +- .../mediahub/data/api/enums/MangaStatus.kt | 27 +- .../data/api/enums/UserAnimeSortType.kt | 13 +- .../data/api/enums/UserMangaSortType.kt | 13 +- .../data/api/retrofit/AnimeService.kt | 2 +- .../data/api/retrofit/MangaService.kt | 2 +- .../data/api/retrofit/UserAnimeService.kt | 2 +- .../data/api/retrofit/UserMangaService.kt | 2 +- .../mediahub/data/api/retrofit/UserService.kt | 2 +- .../data/datastore/DataStoreRepositoryImpl.kt | 12 +- .../data/paging/AnimeRankingDataSource.kt | 36 +- .../data/paging/AnimeSearchDataSource.kt | 36 +- .../data/paging/AnimeSeasonalDataSource.kt | 38 +- .../data/paging/AnimeSuggestionsDataSource.kt | 34 +- .../data/paging/MangaRankingDataSource.kt | 36 +- .../data/paging/MangaSearchDataSource.kt | 36 +- .../data/paging/UserAnimeListDataSource.kt | 38 +- .../data/paging/UserMangaListDataSource.kt | 38 +- .../data/sharedpref/SharedPreferencesKeys.kt | 18 +- .../mediahub/data/wrappers/MHError.kt | 17 +- .../data/wrappers/NoTokenFoundError.kt | 9 - .../mediahub/di/AlarmManagerModule.kt | 20 - .../anime/adapters/AnimeListAdapter.kt | 26 +- .../anime/adapters/AnimePagerAdapter.kt | 2 +- .../anime/repository/AnimeRepositoryImpl.kt | 8 +- .../modules/anime/ui/AnimeFragment.kt | 3 +- .../anime/ui/AnimeListByStatusFragment.kt | 13 +- .../adapters/RecommendedAnimeAdapter.kt | 19 +- .../adapters/RelatedAnimeAdapter.kt | 14 +- .../adapters/RelatedMangaAdapter.kt | 14 +- .../repository/AnimeDetailsRepositoryImpl.kt | 78 +-- .../anime_details/ui/AnimeDetailsFragment.kt | 580 ++++++++-------- .../anime_details/vm/AnimeDetailsViewModel.kt | 3 +- .../adapters/AnimeRankingDetailedAdapter.kt | 22 +- .../repository/AnimeRankingRepositoryImpl.kt | 2 +- .../anime_ranking/ui/AnimeRankingFragment.kt | 11 +- .../adapters/AnimeSearchListAdapter.kt | 18 +- .../repository/AnimeSearchRepositoryImpl.kt | 2 +- .../anime_search/ui/AnimeSearchFragment.kt | 9 +- .../adapters/AnimeSeasonalAdapter.kt | 18 +- .../repository/AnimeSeasonalRepositoryImpl.kt | 2 +- .../ui/AnimeSeasonalFragment.kt | 4 +- .../adapters/AnimeSuggestionsAdapter.kt | 18 +- .../AnimeSuggestionsRepositoryImpl.kt | 2 +- .../ui/AnimeSuggestionsFragment.kt | 3 +- .../auth/repository/OAuthRepository.kt | 4 +- .../auth/repository/OAuthRepositoryImpl.kt | 32 +- .../mediahub/modules/auth/ui/OAuthActivity.kt | 6 +- .../modules/auth/vm/OAuthViewModel.kt | 4 +- .../discover/adapters/AiringAnimeAdapter.kt | 18 +- .../discover/adapters/AnimeRankingAdapter.kt | 18 +- .../adapters/AnimeSuggestionsAdapter.kt | 18 +- .../repository/DiscoverRepositoryImpl.kt | 69 +- .../modules/discover/ui/DiscoverFragment.kt | 105 +-- .../mediahub/modules/main/ui/MainActivity.kt | 21 +- .../manga/adapters/MangaListAdapter.kt | 34 +- .../manga/repository/MangaRepositoryImpl.kt | 2 +- .../modules/manga/ui/MangaFragment.kt | 2 +- .../manga/ui/MangaListByStatusFragment.kt | 14 +- .../modules/manga/vm/MangaViewModel.kt | 2 +- .../adapters/RecommendedMangaAdapter.kt | 19 +- .../adapters/RelatedAnimeAdapter.kt | 14 +- .../adapters/RelatedMangaAdapter.kt | 14 +- .../repository/MangaDetailsRepositoryImpl.kt | 73 +- .../manga_details/ui/MangaDetailsFragment.kt | 632 +++++++++--------- .../manga_details/vm/MangaDetailsViewModel.kt | 3 +- .../adapters/MangaRankingDetailedAdapter.kt | 22 +- .../repository/MangaRankingRepositoryImpl.kt | 2 +- .../manga_ranking/ui/MangaRankingFragment.kt | 41 +- .../adapters/MangaSearchListAdapter.kt | 18 +- .../repository/MangaSearchRepositoryImpl.kt | 2 +- .../manga_search/ui/MangaSearchFragment.kt | 9 +- .../repository/ProfileRepositoryImpl.kt | 25 +- .../modules/profile/ui/ProfileFragment.kt | 314 ++++----- .../repository/SettingsRepositoryImpl.kt | 3 +- .../modules/settings/ui/SettingsFragment.kt | 40 +- app/src/main/res/layout/activity_auth.xml | 6 +- .../layout/anime_details_user_list_item.xml | 2 +- app/src/main/res/layout/anime_list_item.xml | 6 +- app/src/main/res/layout/content_profile.xml | 8 +- .../layout/fragment_anime_list_by_status.xml | 2 +- app/src/main/res/layout/manga_list_item.xml | 5 +- .../main/res/layout/profile_details_item.xml | 2 +- .../main/res/navigation/bottom_nav_graph.xml | 145 +--- app/src/main/res/values/colors.xml | 1 - app/src/main/res/values/strings.xml | 178 ++++- app/src/main/res/xml/root_preferences.xml | 2 +- .../mediahub/AppAcceptedDeepLinkRegexTest.kt | 45 ++ .../mediahub/MALExternalLinksTest.kt | 2 +- .../enum_tests/MangaRankingTypeTest.kt | 56 ++ .../mediahub/enum_tests/SortTypeEnumTest.kt | 25 - .../PresentationExtensionsTest.kt | 162 ----- build.gradle | 8 +- gradle.properties | 3 +- 122 files changed, 3207 insertions(+), 2356 deletions(-) create mode 100644 app/src/androidTest/java/EnumTest.kt create mode 100644 app/src/androidTest/java/PresentationExtensionsTest.kt delete mode 100644 app/src/main/java/com/sharkaboi/mediahub/common/alarm_manager/NotifyAlarmManager.kt delete mode 100644 app/src/main/java/com/sharkaboi/mediahub/common/constants/MALExternalLinks.kt create mode 100644 app/src/main/java/com/sharkaboi/mediahub/common/constants/UIConstants.kt rename app/src/main/java/com/sharkaboi/mediahub/data/api/{ => constants}/ApiConstants.kt (89%) create mode 100644 app/src/main/java/com/sharkaboi/mediahub/data/api/constants/MALExternalLinks.kt create mode 100644 app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeAiringStatus.kt create mode 100644 app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeNsfwRating.kt create mode 100644 app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeRating.kt create mode 100644 app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaNsfwRating.kt create mode 100644 app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaPublishingStatus.kt delete mode 100644 app/src/main/java/com/sharkaboi/mediahub/data/wrappers/NoTokenFoundError.kt delete mode 100644 app/src/main/java/com/sharkaboi/mediahub/di/AlarmManagerModule.kt create mode 100644 app/src/test/java/com/sharkaboi/mediahub/AppAcceptedDeepLinkRegexTest.kt create mode 100644 app/src/test/java/com/sharkaboi/mediahub/enum_tests/MangaRankingTypeTest.kt delete mode 100644 app/src/test/java/com/sharkaboi/mediahub/enum_tests/SortTypeEnumTest.kt delete mode 100644 app/src/test/java/com/sharkaboi/mediahub/extension_tests/PresentationExtensionsTest.kt diff --git a/.github/workflows/android.yaml b/.github/workflows/android.yaml index 77e6cfd..340ac91 100644 --- a/.github/workflows/android.yaml +++ b/.github/workflows/android.yaml @@ -3,21 +3,102 @@ name: Android CI on: [ push, pull_request ] jobs: - test: - name: Run Unit Tests + build: + name: Gradle Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - name: Checkout repo + uses: actions/checkout@v1 + + - uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/buildSrc/**/*.kt') }} + - name: set up JDK 11 - uses: actions/setup-java@v1 + uses: actions/setup-java@v2 with: - java-version: 11 + java-version: '11' + distribution: 'adopt' + - name: Setup Gradle run: chmod +x gradlew + - name: Gradle build - run: bash ./gradlew build + run: bash ./gradlew assembleDebug + + test: + name: Run Tests and lint checks + runs-on: macos-latest + timeout-minutes: 60 + needs: build + strategy: + matrix: + api-level: [23, 26, 28] + + steps: + - name: Checkout repo + uses: actions/checkout@v2 + + - uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/buildSrc/**/*.kt') }} + + - uses: actions/cache@v2 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }}-default + + - name: Set up JDK 11 + uses: actions/setup-java@v2 + with: + java-version: '11' + distribution: 'adopt' + + - name: Setup Gradle + run: chmod +x gradlew + + - name: Run kltlint + run: ./gradlew ktlintCheck + - name: Unit tests - run: bash ./gradlew test --stacktrace - - name: kltlint check - run: bash ./gradlew ktlintCheck \ No newline at end of file + run: ./gradlew test --stacktrace + + - name: Create AVD snapshot + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: default + disable-animations: false + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: echo "Generated AVD snapshot." + + - name: Instrumentation Tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86 + target: default + disable-animations: true + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-snapshot-save + emulator-build: 7425822 # https://github.com/ReactiveCircus/android-emulator-runner/issues/160 + script: ./gradlew connectedCheck --stacktrace + + - name: Upload Reports + uses: actions/upload-artifact@v2 + with: + name: Test-Reports + path: app/build/reports + if: always() \ No newline at end of file diff --git a/README.md b/README.md index c0f935c..0e937fa 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@

MediaHub

- - Kotlin + + Kotlin License @@ -25,6 +25,7 @@

## Uses + * [Kotlin](https://kotlinlang.org/) * MVI/MVVM Architecture * [Retrofit](https://square.github.io/retrofit/) @@ -50,9 +51,11 @@ * [OSS licenses plugin](https://developers.google.com/android/guides/opensource) ## Releases + * Check out the latest releases [here](https://github.com/Sharkaboi/MediaHub/releases) ## Screenshots + Anime | Manga :-------------------------:|:-------------------------: ![](assets/screenshots/anime.png) | ![](assets/screenshots/manga.png) @@ -74,30 +77,45 @@ Settings | Update ![](assets/screenshots/settings.png) | ![](assets/screenshots/update.png) ## Build instructions + * Install Gradle and Kotlin. * Clone project. * Register your app with MyAnimeList as show [here](https://myanimelist.net/blog.php?eid=835707) -* In the project root, add `clientId=` to the `secrets.properties` file. Create if not found. -* Open in Android studio or Intellij and build and sync project (Be sure the generated classes of Hilt, ViewBinding and Apollo are generated). +* In the project root, add `clientId=` to the `secrets.properties` file. Create if not + found. +* Open in Android studio or Intellij and build and sync project (Be sure the generated classes of + Hilt, ViewBinding and Apollo are generated). * Run on any device and perform OAuth login to give access to your account. ## Credits + * [Photo by Audrey Mari from Pexels](https://www.pexels.com/photo/photo-of-japanese-lanterns-3421920/) * [Tabler icons by Paweł Kuna](https://tablericons.com/) * [Bubbles icons by Umar Irshad](https://www.iconfinder.com/iconsets/48-bubbles) ## Contributing + PR's are welcome. Please try to follow the template. ## Privacy, Security and other info -* App only acts as intermediate to MyAnimeList and AniList and does not have it's own server or store any data. + +* App only acts as intermediate to MyAnimeList and AniList and does not have it's own server or + store any data. * App only has network permissions, the `WRITE_EXTERNAL_STORAGE`, -`READ_EXTERNAL_STORAGE` & `FOREGROUND_SERVICE` in the merged manifest is from [LeakCanary](https://square.github.io/leakcanary/) which is not included in the release builds. -* App stores the token using [Datastore](https://developer.android.com/topic/libraries/architecture/datastore), which doesn't have an encryption library yet. -This implies anyone with debugging access or root access to your phone can get the your OAuth token. Please keep this in mind. -* The app doesn't fetch "live data" and only shows the snapshot of data of when it was fetched. Please refresh to see any changes made through the MyAnimeList website or other clients. + `READ_EXTERNAL_STORAGE` & `FOREGROUND_SERVICE` in the merged manifest is + from [LeakCanary](https://square.github.io/leakcanary/) which is not included in the release + builds. +* App stores the token + using [Datastore](https://developer.android.com/topic/libraries/architecture/datastore), which + is not encrypted. This implies anyone with debugging access or root access + to your phone can get the your OAuth token if needed. Please keep this in mind. +* The app doesn't fetch "live data" and only shows the snapshot of data of when it was fetched. + Please refresh to see any changes made through the MyAnimeList website or other clients. +* The app queries all browsers in the device to handle unsupported MyAnimeList deeplinks. This is + done as the API does not still support all the features provided in the web interface. ## Licence + ``` MIT License diff --git a/app/build.gradle b/app/build.gradle index e3ff244..9f5d3d9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,14 +11,14 @@ plugins { } android { - compileSdkVersion 30 + compileSdkVersion 31 defaultConfig { applicationId "com.sharkaboi.mediahub" minSdkVersion 23 - targetSdkVersion 30 + targetSdkVersion 31 versionCode 1 - versionName "1.2" + versionName "1.3" multiDexEnabled true testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -34,11 +34,11 @@ android { } compileOptions { coreLibraryDesugaringEnabled true - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '11' } buildFeatures { diff --git a/app/src/androidTest/java/EnumTest.kt b/app/src/androidTest/java/EnumTest.kt new file mode 100644 index 0000000..9eb52e2 --- /dev/null +++ b/app/src/androidTest/java/EnumTest.kt @@ -0,0 +1,195 @@ +import androidx.test.platform.app.InstrumentationRegistry +import com.sharkaboi.mediahub.data.api.enums.* +import com.sharkaboi.mediahub.data.api.enums.AnimeRating.getAnimeRating +import org.junit.Assert +import org.junit.Test + +class EnumTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun formattedStringOfAnimeMustContainSameElementsAsEnumValues() { + Assert.assertEquals( + UserAnimeSortType.values().size, + UserAnimeSortType.getFormattedArray(context).size + ) + } + + @Test + fun formattedStringOfMangaMustContainSameElementsAsEnumValues() { + Assert.assertEquals( + UserMangaSortType.values().size, + UserMangaSortType.getFormattedArray(context).size + ) + } + + @Test + fun getAnimeAiringStatusWithValidStatusReturnsValidString() { + val inputToExpectedMap = mapOf( + "finished_airing" to "Finished airing", + "currently_airing" to "Currently airing", + "not_yet_aired" to "Yet to be aired", + "invalid_status" to "Airing status : N/A" + ) + inputToExpectedMap.forEach { (inputString, expectedString) -> + Assert.assertEquals(context.getAnimeAiringStatus(inputString), expectedString) + } + } + + @Test + fun getAnimeNsfwRatingWithRatingReturnsValidString() { + val inputToExpectedMap = mapOf( + "white" to "SFW", + "gray" to "NSFW?", + "black" to "NSFW", + null to "N/A", + "invalid_rating" to "SFW : N/A" + ) + inputToExpectedMap.forEach { (inputString, expectedString) -> + Assert.assertEquals(context.getAnimeNsfwRating(inputString), expectedString) + } + } + + @Test + fun getMangaNsfwRatingWithRatingReturnsValidString() { + val inputToExpectedMap = mapOf( + "white" to "Safe for work (SFW)", + "gray" to "Maybe not safe for work (NSFW)", + "black" to "Not safe for work (NSFW)", + null to "N/A", + "invalid_rating" to "N/A" + ) + inputToExpectedMap.forEach { (inputString, expectedString) -> + Assert.assertEquals(context.getMangaNsfwRating(inputString), expectedString) + } + } + + @Test + fun getMangaPublishStatusWithStatusReturnsValidString() { + val inputToExpectedMap = mapOf( + "finished" to "Finished publishing", + "currently_publishing" to "Currently publishing", + "not_yet_published" to "Yet to be published", + "invalid_rating" to "Publishing status : N/A" + ) + inputToExpectedMap.forEach { (inputString, expectedString) -> + Assert.assertEquals(context.getMangaPublishStatus(inputString), expectedString) + } + } + + @Test + fun getAnimeRankingWithRankingReturnsValidString() { + val inputToExpectedMap = mapOf( + AnimeRankingType.all to "All", + AnimeRankingType.airing to "Airing", + AnimeRankingType.upcoming to "Upcoming", + AnimeRankingType.tv to "TV", + AnimeRankingType.ova to "OVA", + AnimeRankingType.movie to "Movie", + AnimeRankingType.special to "Specials", + AnimeRankingType.bypopularity to "By popularity", + AnimeRankingType.favorite to "In your list", + ) + inputToExpectedMap.forEach { (input, expectedString) -> + Assert.assertEquals(input.getAnimeRanking(context), expectedString) + } + } + + @Test + fun getFormattedStringOfAnimeStatusWithValidStatusReturnsValidString() { + val inputToExpectedMap = mapOf( + AnimeStatus.all to "All", + AnimeStatus.watching to "Watching", + AnimeStatus.plan_to_watch to "Planned", + AnimeStatus.completed to "Completed", + AnimeStatus.on_hold to "On hold", + AnimeStatus.dropped to "Dropped" + ) + inputToExpectedMap.forEach { (input, expectedString) -> + Assert.assertEquals(input.getFormattedString(context), expectedString) + } + } + + @Test + fun getFormattedStringOfMangaStatusWithValidStatusReturnsValidString() { + val inputToExpectedMap = mapOf( + MangaStatus.all to "All", + MangaStatus.reading to "Reading", + MangaStatus.plan_to_read to "Planned", + MangaStatus.completed to "Completed", + MangaStatus.on_hold to "On hold", + MangaStatus.dropped to "Dropped" + ) + inputToExpectedMap.forEach { (input, expectedString) -> + Assert.assertEquals(input.getFormattedString(context), expectedString) + } + } + + @Test + fun getFormattedStringOfUserAnimeSortTypeWithValidTypeReturnsValidString() { + val inputToExpectedMap = mapOf( + MangaStatus.all to "All", + MangaStatus.reading to "Reading", + MangaStatus.plan_to_read to "Planned", + MangaStatus.completed to "Completed", + MangaStatus.on_hold to "On hold", + MangaStatus.dropped to "Dropped" + ) + inputToExpectedMap.forEach { (input, expectedString) -> + Assert.assertEquals(input.getFormattedString(context), expectedString) + } + } + + @Test + fun getFormattedStringOfMangaRankingWithValidRankingReturnsValidString() { + val inputToExpectedMap = mapOf( + MangaRankingType.all to "All", + MangaRankingType.manga to "Manga", + MangaRankingType.oneshots to "One-shots", + MangaRankingType.doujin to "Doujins", + MangaRankingType.lightnovels to "Light novels", + MangaRankingType.novels to "Novels", + MangaRankingType.manhwa to "Manhwa", + MangaRankingType.manhua to "Manhua", + MangaRankingType.bypopularity to "By popularity", + MangaRankingType.favorite to "In your list", + ) + inputToExpectedMap.forEach { (input, expectedString) -> + Assert.assertEquals(input.getFormattedString(context), expectedString) + } + } + + @Test + fun getAnimeRatingWithRatingReturnsValidString() { + val inputToExpectedMap = mapOf( + null to "N/A", + "g" to "G - All ages", + "pg" to "PG", + "pg_13" to "PG 13", + "r" to "R - 17+", + "r+" to "R+", + "rx" to "Rx - Hentai", + "invalid_rating" to "N/A", + ) + inputToExpectedMap.forEach { (inputString, expectedString) -> + Assert.assertEquals(context.getAnimeRating(inputString), expectedString) + } + } + + @Test + fun getFormattedArrayOfUserAnimeSortTypeHasSameSizeAsEnumValues() { + Assert.assertEquals( + UserAnimeSortType.getFormattedArray(context).size, + UserAnimeSortType.values().size + ) + } + + @Test + fun getFormattedArrayOfUserMangaSortTypeHasSameSizeAsEnumValues() { + Assert.assertEquals( + UserMangaSortType.getFormattedArray(context).size, + UserMangaSortType.values().size + ) + } +} diff --git a/app/src/androidTest/java/PresentationExtensionsTest.kt b/app/src/androidTest/java/PresentationExtensionsTest.kt new file mode 100644 index 0000000..32d8546 --- /dev/null +++ b/app/src/androidTest/java/PresentationExtensionsTest.kt @@ -0,0 +1,559 @@ +import androidx.core.text.toSpanned +import androidx.test.platform.app.InstrumentationRegistry +import com.sharkaboi.mediahub.common.extensions.* +import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse +import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse +import org.junit.Assert +import org.junit.Test +import kotlin.time.ExperimentalTime + +class PresentationExtensionsTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun getProgressStringWithNullProgressAndNullTotalReturnsValidString() { + val progress = null + val total = null + val expectedString = "0/??" + val resultString = context.getProgressStringWith(progress, total) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getProgressStringWithValidProgressAndNullTotalReturnsValidString() { + val progress = 5 + val total = null + val expectedString = "5/??" + val resultString = context.getProgressStringWith(progress, total) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getProgressStringWithNullProgressAndValidTotalReturnsValidString() { + val progress = null + val total = 10 + val expectedString = "0/10" + val resultString = context.getProgressStringWith(progress, total) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getProgressStringWith0ProgressAnd0TotalReturnsValidString() { + val progress = 0 + val total = 0 + val expectedString = "0/??" + val resultString = context.getProgressStringWith(progress, total) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getProgressStringWithNullProgressAnd0TotalReturnsValidString() { + val progress = null + val total = 0 + val expectedString = "0/??" + val resultString = context.getProgressStringWith(progress, total) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getProgressStringWithValidProgressAndValidTotalReturnsValidString() { + val progress = 10 + val total = 10 + val expectedString = "10/10" + val resultString = context.getProgressStringWith(progress, total) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getEpisodesOfAnimeStringWithValidEpisodeCountReturnsValidString() { + val episodeCount = 10 + val expectedString = "10 eps" + val resultString = context.getEpisodesOfAnimeString(episodeCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getEpisodesOfAnimeStringWith1EpisodeCountReturnsValidString() { + val episodeCount = 1 + val expectedString = "1 ep" + val resultString = context.getEpisodesOfAnimeString(episodeCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getEpisodesOfAnimeStringWith0EpisodeCountReturnsValidString() { + val episodeCount = 0 + val expectedString = "N/A eps" + val resultString = context.getEpisodesOfAnimeString(episodeCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getEpisodesOfAnimeFullStringWithValidEpisodeCountReturnsValidString() { + val episodeCount = 10.0 + val expectedString = "10 episodes" + val resultString = context.getEpisodesOfAnimeFullString(episodeCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getEpisodesOfAnimeFullStringWith1EpisodeCountReturnsValidString() { + val episodeCount = 1.0 + val expectedString = "1 episode" + val resultString = context.getEpisodesOfAnimeFullString(episodeCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getEpisodesOfAnimeFullStringWith0EpisodeCountReturnsValidString() { + val episodeCount = 0.0 + val expectedString = "N/A episodes" + val resultString = context.getEpisodesOfAnimeFullString(episodeCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getDaysCountStringWithValidDaysCountReturnsValidString() { + val daysCount = 10L + val expectedString = "10 days" + val resultString = context.getDaysCountString(daysCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getDaysCountStringWith1DayCountReturnsValidString() { + val daysCount = 1L + val expectedString = "1 day" + val resultString = context.getDaysCountString(daysCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getDaysCountStringWith0DaysCountReturnsValidString() { + val daysCount = 0L + val expectedString = "N/A days" + val resultString = context.getDaysCountString(daysCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getVolumesOfMangaStringWithValidVolumeCountReturnsValidString() { + val volCount = 10 + val expectedString = "10 vols" + val resultString = context.getVolumesOfMangaString(volCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getVolumesOfMangaStringWith1VolumeCountReturnsValidString() { + val volCount = 1 + val expectedString = "1 vol" + val resultString = context.getVolumesOfMangaString(volCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getVolumesOfMangaStringWith0VolumeCountReturnsValidString() { + val volCount = 0 + val expectedString = "N/A vols" + val resultString = context.getVolumesOfMangaString(volCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getChaptersOfMangaStringWithValidChapterCountReturnsValidString() { + val chapCount = 10 + val expectedString = "10 chaps" + val resultString = context.getChaptersOfMangaString(chapCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getChaptersOfMangaStringWith1ChapterCountReturnsValidString() { + val volCount = 1 + val expectedString = "1 chap" + val resultString = context.getChaptersOfMangaString(volCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getChaptersOfMangaStringWith0ChapterCountReturnsValidString() { + val volCount = 0 + val expectedString = "N/A chaps" + val resultString = context.getChaptersOfMangaString(volCount) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getAnimeSeasonStringWithNullSeasonAndNullYearReturnsValidString() { + val season = null + val year = null + val expectedString = "Season : N/A" + val resultString = context.getAnimeSeasonString(season, year) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getAnimeSeasonStringWithNullSeasonAnd0YearReturnsValidString() { + val season = null + val year = 0 + val expectedString = "Season : N/A" + val resultString = context.getAnimeSeasonString(season, year) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getAnimeSeasonStringWithValidSeasonAndNullYearReturnsValidString() { + val season = "Summer" + val year = null + val expectedString = "Season : N/A" + val resultString = context.getAnimeSeasonString(season, year) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getAnimeSeasonStringWithValidSeasonAndValidYearReturnsValidString() { + val season = "Summer" + val year = 2020 + val expectedString = "Summer 2020" + val resultString = context.getAnimeSeasonString(season, year) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getAnimeOriginalSourceStringWithNullSourceReturnsValidString() { + val source = null + val expectedString = "From N/A" + val resultString = context.getAnimeOriginalSourceString(source) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getAnimeOriginalSourceStringWithLowerCaseSourceReturnsValidString() { + val source = "manga" + val expectedString = "From Manga" + val resultString = context.getAnimeOriginalSourceString(source) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getAnimeOriginalSourceStringWithUnderscoreSourceReturnsValidString() { + val source = "light_novel" + val expectedString = "From Light novel" + val resultString = context.getAnimeOriginalSourceString(source) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getMediaTypeStringWithWithValidMediaTypeReturnsValidString() { + val mediaType = "Movie" + val expectedString = "Type : MOVIE" + val resultString = context.getMediaTypeStringWith(mediaType) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getRatingStringWithRatingWithWithNullRatingReturnsValidString() { + val rating = null + val expectedString = "★ 0.0" + val resultString = context.getRatingStringWithRating(rating) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getRatingStringWithRatingWithWith0RatingReturnsValidString() { + val rating = 0 + val expectedString = "★ 0.0" + val resultString = context.getRatingStringWithRating(rating) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getRatingStringWithRatingWithWithHalfRatingReturnsValidString() { + val rating = 0.5 + val expectedString = "★ 0.5" + val resultString = context.getRatingStringWithRating(rating) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getRatingStringWithRatingWithWith1RatingReturnsValidString() { + val rating = 1 + val expectedString = "★ 1" + val resultString = context.getRatingStringWithRating(rating) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getRatingStringWithRatingWithWithMore9RatingReturnsValidString() { + val rating = 9.999 + val expectedString = "★ 10.0" + val resultString = context.getRatingStringWithRating(rating) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getRatingStringWithRatingWithWithLess9RatingReturnsValidString() { + val rating = 9.125 + val expectedString = "★ 9.1" + val resultString = context.getRatingStringWithRating(rating) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getAnimeBroadcastTimeWithNullTimeReturnsValidString() { + val time = null + val expectedString = "N/A" + val resultString = context.getAnimeBroadcastTime(time) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getAnimeBroadcastTimeWithNullTimeAndValidDayReturnsValidString() { + val time = AnimeByIDResponse.Broadcast( + startTime = null, + dayOfTheWeek = "sunday" + ) + val expectedString = "On sunday" + val resultString = context.getAnimeBroadcastTime(time) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getFormattedAnimeTitlesStringWithNullReturnsValidString() { + val titles = null + val expectedString = "N/A".toSpanned() + val resultString = context.getFormattedAnimeTitlesString(titles) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getFormattedAnimeTitlesStringWithNullTitlesReturnsValidString() { + val titles = AnimeByIDResponse.AlternativeTitles( + en = null, + ja = null, + synonyms = null + ) + val resultString = context.getFormattedAnimeTitlesString(titles) + Assert.assertTrue(resultString.split("N/A").size == 4) + } + + @Test + fun getFormattedAnimeTitlesStringWithNullTitlesAndNoSynonymsReturnsValidString() { + val titles = AnimeByIDResponse.AlternativeTitles( + en = null, + ja = null, + synonyms = emptyList() + ) + val resultString = context.getFormattedAnimeTitlesString(titles) + Assert.assertTrue(resultString.split("N/A").size == 4) + } + + @Test + fun getFormattedAnimeTitlesStringWithBlankTitlesReturnsValidString() { + val titles = AnimeByIDResponse.AlternativeTitles( + en = " ", + ja = " ", + synonyms = emptyList() + ) + val resultString = context.getFormattedAnimeTitlesString(titles) + Assert.assertTrue(resultString.split("N/A").size == 4) + } + + @Test + fun getFormattedAnimeTitlesStringWithValidTitlesReturnsValidString() { + val titles = AnimeByIDResponse.AlternativeTitles( + en = "english", + ja = "japanese", + synonyms = listOf("synonyms") + ) + val resultString = context.getFormattedAnimeTitlesString(titles) + Assert.assertFalse(resultString.contains("N/A")) + } + + @Test + fun getFormattedMangaTitlesStringWithNullReturnsValidString() { + val titles = null + val expectedString = "N/A".toSpanned() + val resultString = context.getFormattedMangaTitlesString(titles) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getFormattedMangaTitlesStringWithNullTitlesReturnsValidString() { + val titles = MangaByIDResponse.AlternativeTitles( + en = null, + ja = null, + synonyms = null + ) + val resultString = context.getFormattedMangaTitlesString(titles) + Assert.assertTrue(resultString.split("N/A").size == 4) + } + + @Test + fun getFormattedMangaTitlesStringWithNullTitlesAndNoSynonymsReturnsValidString() { + val titles = MangaByIDResponse.AlternativeTitles( + en = null, + ja = null, + synonyms = emptyList() + ) + val resultString = context.getFormattedMangaTitlesString(titles) + Assert.assertTrue(resultString.split("N/A").size == 4) + } + + @Test + fun getFormattedMangaTitlesStringWithBlankTitlesReturnsValidString() { + val titles = MangaByIDResponse.AlternativeTitles( + en = " ", + ja = " ", + synonyms = emptyList() + ) + val resultString = context.getFormattedMangaTitlesString(titles) + Assert.assertTrue(resultString.split("N/A").size == 4) + } + + @Test + fun getFormattedMangaTitlesStringWithValidTitlesReturnsValidString() { + val titles = MangaByIDResponse.AlternativeTitles( + en = "english", + ja = "japanese", + synonyms = listOf("synonyms") + ) + val resultString = context.getFormattedMangaTitlesString(titles) + Assert.assertFalse(resultString.contains("N/A")) + } + + @Test + fun getAnimeStatsWithNullStatsReturnsValidString() { + val stats = null + val expectedString = "N/A".toSpanned() + val resultString = context.getAnimeStats(stats) + Assert.assertEquals(expectedString, resultString) + } + + @Test + fun getAnimeStatsWithValidStatsReturnsValidString() { + val stats = AnimeByIDResponse.Statistics( + numListUsers = 0, + status = AnimeByIDResponse.Statistics.Status( + watching = "0", + completed = "0", + onHold = "0", + dropped = "0", + planToWatch = "0", + ) + ) + val resultString = context.getAnimeStats(stats) + Assert.assertTrue(resultString.isNotBlank()) + } + + @Test + fun getMangaStatsWithNullStatsReturnsValidString() { + val numListUsers = null + val numScoredUsers = null + val resultString = context.getMangaStats(numListUsers, numScoredUsers) + Assert.assertTrue(resultString.split("N/A").size == 3) + } + + @Test + fun getMangaStatsWithValidStatsReturnsValidString() { + val numListUsers = 0 + val numScoredUsers = 0 + val resultString = context.getMangaStats(numListUsers, numScoredUsers) + Assert.assertFalse(resultString.contains("N/A")) + } + + @ExperimentalTime + @Test + fun getEpisodeLengthFromSecondsWithNullSecondsReturnsValidString() { + val seconds = null + val expectedString = "N/A per ep" + val resultString = context.getEpisodeLengthFromSeconds(seconds) + Assert.assertEquals(expectedString, resultString) + } + + @ExperimentalTime + @Test + fun getEpisodeLengthFromSecondsWith0SecondsReturnsValidString() { + val seconds = 0 + val expectedString = "N/A per ep" + val resultString = context.getEpisodeLengthFromSeconds(seconds) + Assert.assertEquals(expectedString, resultString) + } + + @ExperimentalTime + @Test + fun getEpisodeLengthFromSecondsWithNegativeSecondsReturnsValidString() { + val seconds = -1 + val expectedString = "N/A per ep" + val resultString = context.getEpisodeLengthFromSeconds(seconds) + Assert.assertEquals(expectedString, resultString) + } + + @ExperimentalTime + @Test + fun getEpisodeLengthFromSecondsWithValidHourSecondsReturnsValidString() { + val seconds = 90 * 60 + val expectedString = "1h 30m per ep" + val resultString = context.getEpisodeLengthFromSeconds(seconds) + Assert.assertEquals(expectedString, resultString) + } + + @ExperimentalTime + @Test + fun getEpisodeLengthFromSecondsWithValidMinutesSecondsReturnsValidString() { + val seconds = 30 * 60 + val expectedString = "30m per ep" + val resultString = context.getEpisodeLengthFromSeconds(seconds) + Assert.assertEquals(expectedString, resultString) + } + + @ExperimentalTime + @Test + fun getAiringTimeFormattedWith0TimeReturnsValidString() { + val time = GetNextAiringAnimeEpisodeQuery.NextAiringEpisode( + timeUntilAiring = 0, + episode = 7 + ) + val expectedString = "Episode 7 airs in 0h 0m 0s" + val resultString = context.getAiringTimeFormatted(time) + Assert.assertEquals(expectedString, resultString) + } + + @ExperimentalTime + @Test + fun getAiringTimeFormattedWithMinutesTimeReturnsValidString() { + val time = GetNextAiringAnimeEpisodeQuery.NextAiringEpisode( + timeUntilAiring = 30 * 60, + episode = 7 + ) + val expectedString = "Episode 7 airs in 30m" + val resultString = context.getAiringTimeFormatted(time) + Assert.assertEquals(expectedString, resultString) + } + + @ExperimentalTime + @Test + fun getAiringTimeFormattedWithHoursTimeReturnsValidString() { + val time = GetNextAiringAnimeEpisodeQuery.NextAiringEpisode( + timeUntilAiring = 90 * 60, + episode = 7 + ) + val expectedString = "Episode 7 airs in 1h 30m" + val resultString = context.getAiringTimeFormatted(time) + Assert.assertEquals(expectedString, resultString) + } + + @ExperimentalTime + @Test + fun getAiringTimeFormattedWithDaysTimeReturnsValidString() { + val time = GetNextAiringAnimeEpisodeQuery.NextAiringEpisode( + timeUntilAiring = 90 * 24 * 60 * 60, + episode = 7 + ) + val expectedString = "Episode 7 airs in 90d 0h 0m" + val resultString = context.getAiringTimeFormatted(time) + Assert.assertEquals(expectedString, resultString) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 414da35..da2d8e2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -66,4 +66,10 @@ android:name="com.google.android.gms.oss.licenses.OssLicensesActivity" android:theme="@style/Theme.MediaHub.ActionBar" /> + + + + + + \ No newline at end of file diff --git a/app/src/main/graphql/nextAiringAnimeEpisode.graphql b/app/src/main/graphql/nextAiringAnimeEpisode.graphql index fd4f0dc..206968b 100644 --- a/app/src/main/graphql/nextAiringAnimeEpisode.graphql +++ b/app/src/main/graphql/nextAiringAnimeEpisode.graphql @@ -2,13 +2,9 @@ query GetNextAiringAnimeEpisode( $idMal: Int! ) { Media(idMal: $idMal, type: ANIME) { - id - idMal nextAiringEpisode { - id episode timeUntilAiring - airingAt } } } \ No newline at end of file diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/alarm_manager/NotifyAlarmManager.kt b/app/src/main/java/com/sharkaboi/mediahub/common/alarm_manager/NotifyAlarmManager.kt deleted file mode 100644 index 9716242..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/common/alarm_manager/NotifyAlarmManager.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.sharkaboi.mediahub.common.alarm_manager - -import android.app.AlarmManager -import android.content.Context - -class NotifyAlarmManager(context: Context) { - - init { - val alarmManager = - context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager - } -} diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/constants/AppConstants.kt b/app/src/main/java/com/sharkaboi/mediahub/common/constants/AppConstants.kt index ed0a25f..6bfa712 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/constants/AppConstants.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/common/constants/AppConstants.kt @@ -1,11 +1,10 @@ package com.sharkaboi.mediahub.common.constants -class AppConstants { - companion object { - const val githubLink = "https://github.com/Sharkaboi/MediaHub" - const val githubUsername = "Sharkaboi" - const val githubRepoName = "MediaHub" - const val description = "Simple unofficial MyAnimeList client." - const val oAuthDeepLinkUri = "mediahub://callback" - } +object AppConstants { + const val githubLink = "https://github.com/Sharkaboi/MediaHub" + const val githubUsername = "Sharkaboi" + const val githubRepoName = "MediaHub" + const val description = "Simple, unofficial MyAnimeList client." + const val oAuthDeepLinkUri = "mediahub://callback" + const val nonDeepLinkedUrl = "https://sharkaboi.github.io" } diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/constants/MALExternalLinks.kt b/app/src/main/java/com/sharkaboi/mediahub/common/constants/MALExternalLinks.kt deleted file mode 100644 index 6f8124d..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/common/constants/MALExternalLinks.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.sharkaboi.mediahub.common.constants - -import com.sharkaboi.mediahub.common.extensions.replaceWhiteSpaceWithUnderScore -import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse -import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse - -class MALExternalLinks { - companion object { - fun getAnimeGenresLink(genre: AnimeByIDResponse.Genre): String { - return "https://myanimelist.net/anime/genre" + - "/${genre.id}/${genre.name.replaceWhiteSpaceWithUnderScore()}" - } - - fun getAnimeCharactersLink(id: Int, title: String): String { - return "https://myanimelist.net/anime" + - "/$id/${title.replaceWhiteSpaceWithUnderScore()}/characters" - } - - fun getAnimeStaffLink(id: Int, title: String): String { - return "https://myanimelist.net/anime" + - "/$id/${title.replaceWhiteSpaceWithUnderScore()}/characters#staff" - } - - fun getAnimeReviewsLink(id: Int, title: String): String { - return "https://myanimelist.net/anime" + - "/$id/${title.replaceWhiteSpaceWithUnderScore()}/reviews" - } - - fun getAnimeNewsLink(id: Int, title: String): String { - return "https://myanimelist.net/anime" + - "/$id/${title.replaceWhiteSpaceWithUnderScore()}/news" - } - - fun getAnimeVideosLink(id: Int, title: String): String { - return "https://myanimelist.net/anime" + - "/$id/${title.replaceWhiteSpaceWithUnderScore()}/video" - } - - fun getAnimeProducerPageLink(studio: AnimeByIDResponse.Studio): String { - return "https://myanimelist.net/anime/producer" + - "/${studio.id}/${studio.name.replaceWhiteSpaceWithUnderScore()}" - } - - fun getMangaAuthorPageLink(author: MangaByIDResponse.Author): String { - val name = - "${author.node.firstName} ${author.node.lastName}" - .replaceWhiteSpaceWithUnderScore() - return "https://myanimelist.net/people" + - "/${author.node.id}/$name" - } - - fun getMangaGenresLink(genre: MangaByIDResponse.Genre): String { - return "https://myanimelist.net/manga/genre" + - "/${genre.id}/${genre.name.replaceWhiteSpaceWithUnderScore()}" - } - - fun getMangaCharactersLink(id: Int, title: String): String { - return "https://myanimelist.net/manga" + - "/$id/${title.replaceWhiteSpaceWithUnderScore()}/characters" - } - - fun getMangaReviewsLink(id: Int, title: String): String { - return "https://myanimelist.net/manga" + - "/$id/${title.replaceWhiteSpaceWithUnderScore()}/reviews" - } - - fun getMangaNewsLink(id: Int, title: String): String { - return "https://myanimelist.net/manga" + - "/$id/${title.replaceWhiteSpaceWithUnderScore()}/news" - } - - fun getMangaSerializationPageLink(magazine: MangaByIDResponse.Serialization): String { - return "https://myanimelist.net/manga/magazine" + - "/${magazine.node.id}/${magazine.node.name.replaceWhiteSpaceWithUnderScore()}" - } - - fun getBlogsLink(name: String): String { - return "https://myanimelist.net/blog/$name" - } - - fun getClubsLink(name: String): String { - return "https://myanimelist.net/profile/$name/clubs" - } - - fun getForumTopicsLink(name: String): String { - return "https://myanimelist.net/forum/search?u=$name&q=&uloc=1&loc=-1" - } - - fun getFriendsLink(name: String): String { - return "https://myanimelist.net/profile/$name/friends" - } - - fun getHistoryLink(name: String): String { - return "https://myanimelist.net/history/$name" - } - - fun getRecommendationsLink(name: String): String { - return "https://myanimelist.net/profile/$name/recommendations" - } - - fun getReviewsLink(name: String): String { - return "https://myanimelist.net/profile/$name/reviews" - } - } -} diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/constants/UIConstants.kt b/app/src/main/java/com/sharkaboi/mediahub/common/constants/UIConstants.kt new file mode 100644 index 0000000..c694055 --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/common/constants/UIConstants.kt @@ -0,0 +1,52 @@ +package com.sharkaboi.mediahub.common.constants + +import coil.request.ImageRequest +import coil.transform.RoundedCornersTransformation +import com.google.android.material.chip.Chip +import com.google.android.material.shape.ShapeAppearanceModel +import com.sharkaboi.mediahub.R + +object UIConstants { + const val AnimeAndMangaGridSpanCount = 3 + private const val AnimeAndMangaImageCornerRadius = 8f + private const val ProfileImageCornerRadius = 10f + private val ChipShapeAppearanceModel = ShapeAppearanceModel().withCornerSize(8f) + val AnimeImageBuilder: ImageRequest.Builder.() -> Unit = { + crossfade(true) + placeholder(R.drawable.ic_anime_placeholder) + error(R.drawable.ic_anime_placeholder) + fallback(R.drawable.ic_anime_placeholder) + transformations( + RoundedCornersTransformation( + topLeft = AnimeAndMangaImageCornerRadius, + topRight = AnimeAndMangaImageCornerRadius + ) + ) + } + val MangaImageBuilder: ImageRequest.Builder.() -> Unit = { + crossfade(true) + placeholder(R.drawable.ic_manga_placeholder) + error(R.drawable.ic_manga_placeholder) + fallback(R.drawable.ic_manga_placeholder) + transformations( + RoundedCornersTransformation( + topLeft = AnimeAndMangaImageCornerRadius, + topRight = AnimeAndMangaImageCornerRadius + ) + ) + } + val ProfileImageBuilder: ImageRequest.Builder.() -> Unit = { + crossfade(true) + placeholder(R.drawable.ic_profile_placeholder) + error(R.drawable.ic_profile_placeholder) + fallback(R.drawable.ic_profile_placeholder) + transformations(RoundedCornersTransformation(ProfileImageCornerRadius)) + } + + fun Chip.setMediaHubChipStyle(): Chip { + return this.apply { + setEnsureMinTouchTargetSize(false) + shapeAppearanceModel = ChipShapeAppearanceModel + } + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/extensions/KotlinResultExtension.kt b/app/src/main/java/com/sharkaboi/mediahub/common/extensions/KotlinResultExtension.kt index cc5c4d3..d737773 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/extensions/KotlinResultExtension.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/common/extensions/KotlinResultExtension.kt @@ -2,7 +2,10 @@ package com.sharkaboi.mediahub.common.extensions internal fun Result.getOrNullWithStackTrace(): T? { return when { - isFailure -> null + isFailure -> { + exceptionOrNull()?.printStackTrace() + null + } else -> this.getOrDefault(null) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/extensions/PresentationExtensions.kt b/app/src/main/java/com/sharkaboi/mediahub/common/extensions/PresentationExtensions.kt index 130f41d..8b771f6 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/extensions/PresentationExtensions.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/common/extensions/PresentationExtensions.kt @@ -1,356 +1,228 @@ package com.sharkaboi.mediahub.common.extensions -import android.text.Html +import GetNextAiringAnimeEpisodeQuery +import android.content.Context import android.text.Spanned +import androidx.annotation.StringRes +import androidx.core.text.HtmlCompat +import androidx.core.text.toSpanned +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.sharkaboi.mediahub.R import com.sharkaboi.mediahub.common.util.getLocalDateFromDayAndTime import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse -import java.text.DecimalFormat import java.time.format.DateTimeFormatter import kotlin.time.Duration import kotlin.time.ExperimentalTime -internal fun Double.roundOfString(): String { - if (this == 0.0) { - return "0" - } - val format = DecimalFormat("#.##") - return format.format(this) +internal fun Context.getProgressStringWith(progress: Int?, total: Int?): String { + val totalCountString = if (total == null || total == 0) + getString(R.string.no_count_fount_marker) + else + total.toString() + val progressCount = progress ?: 0 + return getString( + R.string.media_progress_template, + progressCount, + totalCountString + ) } -internal fun String.getAnimeNsfwRating(): String { - return when { - this.trim() == "white" -> { - "SFW" - } - this.trim() == "gray" -> { - "NSFW?" - } - this.trim() == "black" -> { - "NSFW" - } - else -> { - "SFW : N/A" - } - } +internal fun Context.getEpisodesOfAnimeString(episodes: Int): String { + return resources.getQuantityString( + R.plurals.episode_count_template, + episodes, + if (episodes == 0) getString(R.string.n_a) else episodes.toString() + ) } -internal fun String.getMangaNsfwRating(): String { - return when { - this.trim() == "white" -> { - "Safe for work (SFW)" - } - this.trim() == "gray" -> { - "Maybe not safe for work (NSFW)" - } - this.trim() == "black" -> { - "Not safe for work (NSFW)" - } - else -> { - "N/A" - } - } +internal fun Context.getEpisodesOfAnimeFullString(episodes: Double): String { + return resources.getQuantityString( + R.plurals.episode_count_full_template, + episodes.toInt(), + if (episodes == 0.0) getString(R.string.n_a) else episodes.toInt().toString() + ) } -internal fun String.getRating(): String { - return when { - this.trim() == "g" -> { - "G - All ages" - } - this.trim() == "pg" -> { - "PG" - } - this.trim() == "pg_13" -> { - "PG 13" - } - this.trim() == "r" -> { - "R - 17+" - } - this.trim() == "r+" -> { - "R+" - } - this.trim() == "rx" -> { - "Rx - Hentai" - } - else -> { - "N/A" - } - } +internal fun Context.getDaysCountString(days: Long): String { + return resources.getQuantityString( + R.plurals.days_count_template, + days.toInt(), + if (days == 0L) getString(R.string.n_a) else days.toString() + ) } -internal fun String.getAnimeAiringStatus(): String { - return when { - this.trim() == "finished_airing" -> { - "Finished airing" - } - this.trim() == "currently_airing" -> { - "Currently airing" - } - this.trim() == "not_yet_aired" -> { - "Yet to be aired" - } - else -> { - "Airing status : N/A" - } - } +internal fun Context.getVolumesOfMangaString(volumes: Int): String { + return resources.getQuantityString( + R.plurals.volume_count_template, + volumes, + if (volumes == 0) getString(R.string.n_a) else volumes.toString() + ) } -internal fun String.getMangaPublishStatus(): String { - return when { - this.trim() == "finished" -> { - "Finished publishing" - } - this.trim() == "currently_publishing" -> { - "Currently publishing" - } - this.trim() == "not_yet_published" -> { - "Yet to be published" - } - else -> { - "Publishing status : N/A" - } +internal fun Context.getChaptersOfMangaString(chapters: Int): String { + return resources.getQuantityString( + R.plurals.chapter_count_template, + chapters, + if (chapters == 0) getString(R.string.n_a) else chapters.toString() + ) +} + +internal fun Context.getAnimeSeasonString(season: String?, year: Int?): String { + return if (season == null || year == null) { + getString(R.string.anime_season_unknown_template, getString(R.string.n_a)) + } else { + getString(R.string.anime_season_template, season.capitalizeFirst(), year) } } -internal fun AnimeByIDResponse.Broadcast.getBroadcastTime(): String { - try { - if (this.startTime == null) { - return "On ${this.dayOfTheWeek}" - } - val localTime = getLocalDateFromDayAndTime(this.dayOfTheWeek, this.startTime) - return localTime?.format(DateTimeFormatter.ofPattern("EEEE h:mm a zzzz")) ?: "N/A" - } catch (e: Exception) { - e.printStackTrace() - return "N/A" +internal fun Context.getAnimeOriginalSourceString(source: String?): String { + val sourceValue = source?.replaceUnderScoreWithWhiteSpace() + ?.capitalizeFirst() + ?: getString(R.string.n_a) + return getString(R.string.anime_original_source_template, sourceValue) +} + +internal fun Context.getMediaTypeStringWith(type: String): String { + return getString( + R.string.media_type_template, + type.uppercase() + ) +} + +internal fun Context.getRatingStringWithRating(rating: Number?): String { + return resources.getQuantityString( + R.plurals.rating_hint, + rating?.toInt() ?: 0, + rating?.toFloat() ?: 0F + ) +} + +internal fun Context.getAnimeBroadcastTime(broadcast: AnimeByIDResponse.Broadcast?): String { + runCatching { + if (broadcast == null) { + return getString(R.string.n_a) + } else if (broadcast.startTime == null) { + return getString(R.string.anime_broadcast_on_day, broadcast.dayOfTheWeek) + } + val localTime = getLocalDateFromDayAndTime(broadcast.dayOfTheWeek, broadcast.startTime) + return localTime?.format(DateTimeFormatter.ofPattern("EEEE h:mm a zzzz")) + ?: getString(R.string.n_a) + }.getOrElse { + it.printStackTrace() + return getString(R.string.n_a) } } -internal fun AnimeByIDResponse.AlternativeTitles.getFormattedString(): Spanned { - return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { - Html.fromHtml( - """ - English title : ${ - this.en?.let { - if (it.isBlank()) { - "N/A" - } else { - it - } - } ?: "N/A" - }
- Japanese title : ${ - this.ja?.let { - if (it.isBlank()) { - "N/A" - } else { - it - } - } ?: "N/A" - }
- Synonyms : ${ - this.synonyms?.let { - if (it.isEmpty()) { - "N/A" - } else { - it.joinToString().ifBlank { "N/A" } - } - } ?: "N/A" - } - """.trimIndent(), - Html.FROM_HTML_MODE_COMPACT - ) - } else { - Html.fromHtml( - """ - English title : ${ - this.en?.let { - if (it.isBlank()) { - "N/A" - } else { - it - } - } ?: "N/A" - }
- Japanese title : ${ - this.ja?.let { - if (it.isBlank()) { - "N/A" - } else { - it - } - } ?: "N/A" - }
- Synonyms : ${ - this.synonyms?.let { - if (it.isEmpty()) { - "N/A" - } else { - it.joinToString().ifBlank { "N/A" } - } - } ?: "N/A" - } - """.trimIndent() - ) +internal fun Context.getFormattedAnimeTitlesString(titles: AnimeByIDResponse.AlternativeTitles?): Spanned { + if (titles == null) { + return getString(R.string.n_a).toSpanned() } + val synonyms = titles.synonyms?.joinToString().ifNullOrBlank { getString(R.string.n_a) } + val englishTitle = titles.en.ifNullOrBlank { getString(R.string.n_a) } + val japaneseTitle = titles.ja.ifNullOrBlank { getString(R.string.n_a) } + val html = getString(R.string.alternate_titles_html, englishTitle, japaneseTitle, synonyms) + return HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT) } -internal fun MangaByIDResponse.AlternativeTitles.getFormattedString(): Spanned { - return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { - Html.fromHtml( - """ - English title : ${ - this.en?.let { - if (it.isBlank()) { - "N/A" - } else { - it - } - } ?: "N/A" - }
- Japanese title : ${ - this.ja?.let { - if (it.isBlank()) { - "N/A" - } else { - it - } - } ?: "N/A" - }
- Synonyms : ${ - this.synonyms?.let { - if (it.isEmpty()) { - "N/A" - } else { - it.joinToString().ifBlank { "N/A" } - } - } ?: "N/A" - } - """.trimIndent(), - Html.FROM_HTML_MODE_COMPACT - ) - } else { - Html.fromHtml( - """ - English title : ${ - this.en?.let { - if (it.isBlank()) { - "N/A" - } else { - it - } - } ?: "N/A" - }
- Japanese title : ${ - this.ja?.let { - if (it.isBlank()) { - "N/A" - } else { - it - } - } ?: "N/A" - }
- Synonyms : ${ - this.synonyms?.let { - if (it.isEmpty()) { - "N/A" - } else { - it.joinToString().ifBlank { "N/A" } - } - } ?: "N/A" - } - """.trimIndent() - ) +internal fun Context.getFormattedMangaTitlesString(titles: MangaByIDResponse.AlternativeTitles?): Spanned { + if (titles == null) { + return getString(R.string.n_a).toSpanned() } + val synonyms = titles.synonyms?.joinToString().ifNullOrBlank { getString(R.string.n_a) } + val englishTitle = titles.en.ifNullOrBlank { getString(R.string.n_a) } + val japaneseTitle = titles.ja.ifNullOrBlank { getString(R.string.n_a) } + val html = getString(R.string.alternate_titles_html, englishTitle, japaneseTitle, synonyms) + return HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT) } -internal fun AnimeByIDResponse.Statistics.getStats(): Spanned { - return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { - Html.fromHtml( - """ - Number of users with this anime in list : ${ - this.numListUsers - }
- Watching count : ${ - this.status.watching - }
- Planned count : ${ - this.status.planToWatch - }
- Completed count : ${ - this.status.completed - }
- Dropped count : ${ - this.status.dropped - }
- On hold count : ${ - this.status.onHold - } - """.trimIndent(), - Html.FROM_HTML_MODE_COMPACT - ) - } else { - Html.fromHtml( - """ - Number of users with this anime in list : ${ - this.numListUsers - }
- Watching count : ${ - this.status.watching - }
- Planned count : ${ - this.status.planToWatch - }
- Completed count : ${ - this.status.completed - }
- Dropped count : ${ - this.status.dropped - }
- On hold count : ${ - this.status.onHold - } - """.trimIndent() - ) +internal fun Context.getAnimeStats(stats: AnimeByIDResponse.Statistics?): Spanned { + if (stats == null) { + return getString(R.string.n_a).toSpanned() } + val html = getString( + R.string.anime_stats_html, + stats.numListUsers.toLong(), + stats.status.watching.toLong(), + stats.status.planToWatch.toLong(), + stats.status.completed.toLong(), + stats.status.dropped.toLong(), + stats.status.onHold.toLong() + ) + return HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT) +} + +internal fun Context.getMangaStats(numListUsers: Int?, numScoredUsers: Int?): Spanned { + val listUsers = numListUsers?.toString() ?: getString(R.string.n_a) + val scoredUsers = numScoredUsers?.toString() ?: getString(R.string.n_a) + val html = getString( + R.string.manga_stats_html, + listUsers, + scoredUsers + ) + return HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT) } @ExperimentalTime -internal fun Int?.getEpisodeLengthFromSeconds(): String { - try { - if (this == null || this <= 0) { - return "N/A per ep" +internal fun Context.getEpisodeLengthFromSeconds(seconds: Int?): String { + runCatching { + if (seconds == null || seconds <= 0) { + return getString(R.string.anime_episode_length_n_a) } - val duration = Duration.seconds(this.toLong()) + val duration = Duration.seconds(seconds.toLong()) val hours = duration.inWholeHours.toInt() - val minutes = duration.minus(Duration.Companion.hours(hours)).inWholeMinutes - return if (hours <= 0) "${minutes}m per ep" else "${hours}h ${minutes}m per ep" - } catch (e: Exception) { - e.printStackTrace() - return "N/A per ep" + val minutes = duration.minus(Duration.hours(hours)).inWholeMinutes + return if (hours <= 0) getString( + R.string.anime_episode_length_mins, + minutes + ) else getString( + R.string.anime_episode_length_hours, + hours, + minutes + ) + }.getOrElse { + it.printStackTrace() + return getString(R.string.anime_episode_length_n_a) } } @ExperimentalTime -internal fun Int.getAiringTimeFormatted(): String { - try { - if (this <= 0) { - return "0h 0m 0s" +internal fun Context.getAiringTimeFormatted(nextEp: GetNextAiringAnimeEpisodeQuery.NextAiringEpisode): String { + val timeFromNow = runCatching { + if (nextEp.timeUntilAiring <= 0) { + return@runCatching getString(R.string.anime_next_episode_airing_n_a) } - var currentDuration = Duration.seconds(this.toLong()) + var currentDuration = Duration.seconds(nextEp.timeUntilAiring.toLong()) val days = currentDuration.inWholeDays.toInt() currentDuration = currentDuration.minus(Duration.days(days)) val hours = currentDuration.inWholeHours.toInt() currentDuration = currentDuration.minus(Duration.hours(hours)) val minutes = currentDuration.inWholeMinutes.toInt() - return if (days <= 0 && hours <= 0) { - "${minutes}m" + if (days <= 0 && hours <= 0) { + getString(R.string.anime_next_episode_airing_minutes, minutes) } else if (days <= 0) { - "${hours}h ${minutes}m" + getString(R.string.anime_next_episode_airing_hours, hours, minutes) } else { - "${days}d ${hours}h ${minutes}m" + getString(R.string.anime_next_episode_airing_days, days, hours, minutes) } - } catch (e: Exception) { - e.printStackTrace() - return "0h 0m 0s" + }.getOrElse { + it.printStackTrace() + getString(R.string.anime_next_episode_airing_n_a) } + return buildString { + append(getString(R.string.anime_next_episode_prefix)) + append(nextEp.episode) + append(getString(R.string.anime_next_episode_suffix)) + append(timeFromNow) + } +} + +internal fun Context.showNoActionOkDialog(@StringRes title: Int, content: CharSequence?) { + MaterialAlertDialogBuilder(this) + .setTitle(title) + .setMessage( + content.ifNullOrBlank { getString(R.string.n_a) } + ).setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + }.show() } diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/extensions/StringExtensions.kt b/app/src/main/java/com/sharkaboi/mediahub/common/extensions/StringExtensions.kt index f61a03d..20f9fc7 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/extensions/StringExtensions.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/common/extensions/StringExtensions.kt @@ -9,6 +9,13 @@ internal inline var String.Companion.emptyString: String get() = "" private set(_) {} +internal fun CharSequence?.ifNullOrBlank(block: () -> String): CharSequence { + if (this == null || this.isBlank()) { + return block() + } + return this +} + internal fun String?.ifNullOrBlank(block: () -> String): String { if (this == null || this.isBlank()) { return block() @@ -17,13 +24,10 @@ internal fun String?.ifNullOrBlank(block: () -> String): String { } internal fun String.tryParseDateTime(): LocalDateTime? { - return try { + return runCatching { val format = DateTimeFormatter.ISO_DATE_TIME LocalDateTime.parse(this, format) - } catch (e: Exception) { - e.printStackTrace() - null - } + }.getOrNullWithStackTrace() } internal fun String.replaceWhiteSpaceWithUnderScore(): String { diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/extensions/UtilExtensions.kt b/app/src/main/java/com/sharkaboi/mediahub/common/extensions/UtilExtensions.kt index ad3bae6..5c13aee 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/extensions/UtilExtensions.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/common/extensions/UtilExtensions.kt @@ -2,6 +2,7 @@ package com.sharkaboi.mediahub.common.extensions import android.content.Context import android.widget.Toast +import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment @@ -11,5 +12,8 @@ internal fun AppCompatActivity.showToast(message: String?, length: Int = Toast.L internal fun Fragment.showToast(message: String?, length: Int = Toast.LENGTH_SHORT) = Toast.makeText(context, message ?: String.emptyString, length).show() +internal fun Fragment.showToast(@StringRes id: Int, length: Int = Toast.LENGTH_SHORT) = + Toast.makeText(context, id, length).show() + internal fun Context.showToast(message: String?, length: Int = Toast.LENGTH_SHORT) = Toast.makeText(this, message ?: String.emptyString, length).show() diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/util/MPAndroidChartValueFormatter.kt b/app/src/main/java/com/sharkaboi/mediahub/common/util/MPAndroidChartValueFormatter.kt index a221d80..a0c12b0 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/util/MPAndroidChartValueFormatter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/common/util/MPAndroidChartValueFormatter.kt @@ -1,32 +1,33 @@ package com.sharkaboi.mediahub.common.util import com.github.mikephil.charting.formatter.ValueFormatter +import com.sharkaboi.mediahub.common.extensions.emptyString import java.text.DecimalFormat class MPAndroidChartValueFormatter : ValueFormatter() { - private var mSuffix = arrayOf( - "", "k", "m", "b", "t" - ) - private var mMaxLength = 5 - private var mFormat: DecimalFormat = DecimalFormat("###E00") + /** + * Blank -> Less than 1000 (Exact) + * k -> 1k, 5.5k (Round off) + * m -> 1m, 1.8m (Round off) + * b -> 1b, 1.8b (Round off) + * t -> 1t, 1.8t (Round off) + */ + private val suffixes = listOf("", "k", "m", "b", "t") override fun getFormattedValue(value: Float): String { - return makePretty(value.toDouble()) + return format(value.toDouble()) } - private fun makePretty(number: Double): String { + private fun format(number: Double): String { if (number == 0.0) { - return "" + return String.emptyString } - var r = mFormat.format(number) - val numericValue1 = Character.getNumericValue(r[r.length - 1]) - val numericValue2 = Character.getNumericValue(r[r.length - 2]) - val combined = Integer.valueOf(numericValue2.toString() + "" + numericValue1) - r = r.replace("E[0-9][0-9]".toRegex(), mSuffix[combined / 3]) - while (r.length > mMaxLength || r.matches(Regex("[0-9]+\\.[a-z]"))) { - r = r.substring(0, r.length - 2) + r.substring(r.length - 1) - } - return r + val exponentFormatter = DecimalFormat("###E00") + val formattedValue = exponentFormatter.format(number) + val base = formattedValue.dropLast(3).toLong() + val exponent = formattedValue.takeLast(2).toInt() + val currentSuffix = suffixes[exponent / 3] + return "$base$currentSuffix" } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/util/UrlLauncher.kt b/app/src/main/java/com/sharkaboi/mediahub/common/util/UrlLauncher.kt index 11cdbc3..ebd3bd1 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/util/UrlLauncher.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/common/util/UrlLauncher.kt @@ -3,17 +3,20 @@ package com.sharkaboi.mediahub.common.util import android.app.Activity import android.content.ActivityNotFoundException import android.content.Intent -import android.net.Uri +import android.content.pm.PackageManager +import androidx.core.net.toUri import androidx.fragment.app.Fragment import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.constants.AppConstants import com.sharkaboi.mediahub.common.extensions.showToast +import timber.log.Timber fun Activity.openUrl(url: String) { try { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) overridePendingTransition(R.anim.fade_in, R.anim.fade_out) } catch (e: ActivityNotFoundException) { - showToast("No browser found in the phone") + showToast(getString(R.string.no_browser_found_hint)) } catch (e: Exception) { showToast(e.message) } @@ -21,45 +24,61 @@ fun Activity.openUrl(url: String) { fun Fragment.openUrl(url: String) { try { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) activity?.overridePendingTransition(R.anim.fade_in, R.anim.fade_out) } catch (e: ActivityNotFoundException) { - showToast("No browser found in the phone") + showToast(getString(R.string.no_browser_found_hint)) } catch (e: Exception) { showToast(e.message) } } -fun Activity.openShareChooser(content: String, shareHint: String = "Share here") { +fun Fragment.openShareChooser(content: String, shareHint: String = getString(R.string.share_hint)) { try { val sendIntent: Intent = Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_TEXT, content) - type = "text/plain" + setTypeAndNormalize("text/plain") } val shareIntent = Intent.createChooser(sendIntent, shareHint) startActivity(shareIntent) - overridePendingTransition(R.anim.fade_in, R.anim.fade_out) + activity?.overridePendingTransition(R.anim.fade_in, R.anim.fade_out) } catch (e: ActivityNotFoundException) { - showToast("No browser found in the phone") + showToast(getString(R.string.no_browser_found_hint)) } catch (e: Exception) { showToast(e.message) } } -fun Fragment.openShareChooser(content: String, shareHint: String = "Share here") { +/** + * Uses package manager to get all activities that can open a non-deeplinked https uri. + * Then forces the url specified to be opened by the first activity among the possible activities. + * Excludes Mediahub's package name and sorts by default to get most optimal browser. + */ +fun Activity.forceLaunchInBrowser(url: String) { try { - val sendIntent: Intent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, content) - type = "text/plain" + val browserIntent = Intent( + Intent.ACTION_VIEW, + AppConstants.nonDeepLinkedUrl.toUri() + ) + val browsersList = packageManager.queryIntentActivities( + browserIntent, PackageManager.MATCH_DEFAULT_ONLY + ) + Timber.d(browsersList.toString()) + val filteredList = browsersList.filter { + it.activityInfo.packageName != this.packageName } - val shareIntent = Intent.createChooser(sendIntent, shareHint) - startActivity(shareIntent) - activity?.overridePendingTransition(R.anim.fade_in, R.anim.fade_out) + val targetedBrowserIntent = Intent(Intent.ACTION_VIEW, url.toUri()).apply { + setPackage(filteredList.first().activityInfo.packageName) + } + startActivity(targetedBrowserIntent) + overridePendingTransition(R.anim.fade_in, R.anim.fade_out) + } catch (e: NoSuchElementException) { + showToast(getString(R.string.no_browser_found_hint)) } catch (e: ActivityNotFoundException) { - showToast("No browser found in the phone") + showToast(getString(R.string.no_browser_found_hint)) } catch (e: Exception) { + e.printStackTrace() showToast(e.message) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/views/CustomProgressScreen.kt b/app/src/main/java/com/sharkaboi/mediahub/common/views/CustomProgressScreen.kt index 71cb411..527e8da 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/views/CustomProgressScreen.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/common/views/CustomProgressScreen.kt @@ -10,7 +10,7 @@ import androidx.core.view.isVisible import com.sharkaboi.mediahub.R /** - * Progress view with dimmed bg + * Progress view with dimmed background * that shows only if hide isn't called within set time * and hides only if set delay is over */ diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/views/image_slider/FullScreenImageFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/common/views/image_slider/FullScreenImageFragment.kt index 519207d..f4b44af 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/views/image_slider/FullScreenImageFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/common/views/image_slider/FullScreenImageFragment.kt @@ -7,6 +7,7 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import coil.load import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.extensions.emptyString import com.sharkaboi.mediahub.databinding.FragmentFullScreenImageBinding class FullScreenImageFragment : Fragment() { @@ -17,7 +18,7 @@ class FullScreenImageFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { - imageUrl = it.getString(IMAGE_URL_KEY) ?: "" + imageUrl = it.getString(IMAGE_URL_KEY) ?: String.emptyString } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/ApiConstants.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/constants/ApiConstants.kt similarity index 89% rename from app/src/main/java/com/sharkaboi/mediahub/data/api/ApiConstants.kt rename to app/src/main/java/com/sharkaboi/mediahub/data/api/constants/ApiConstants.kt index 5e0097a..14950f2 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/ApiConstants.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/constants/ApiConstants.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.data.api +package com.sharkaboi.mediahub.data.api.constants object ApiConstants { const val BEARER_SEPARATOR = "Bearer " @@ -16,4 +16,5 @@ object ApiConstants { const val MANGA_ALL_FIELDS = "id,title,main_picture,alternative_titles,start_date,end_date,synopsis,mean,rank,popularity,num_list_users,num_scoring_users,nsfw,created_at,updated_at,media_type,status,genres,my_list_status,num_volumes,num_chapters,authors{first_name,last_name},pictures,background,related_anime,related_manga,recommendations,serialization{name}" const val PROFILE_FIELDS = "anime_statistics" + val appAcceptedDeepLinkRegex = """^(https://myanimelist.net/(anime|manga)/(\d+)/([^/])+)$""".toRegex() } diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/constants/MALExternalLinks.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/constants/MALExternalLinks.kt new file mode 100644 index 0000000..9fd641e --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/constants/MALExternalLinks.kt @@ -0,0 +1,115 @@ +package com.sharkaboi.mediahub.data.api.constants + +import com.sharkaboi.mediahub.common.extensions.replaceWhiteSpaceWithUnderScore +import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse +import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse + +object MALExternalLinks { + fun getAnimeGenresLink(genre: AnimeByIDResponse.Genre): String { + return "https://myanimelist.net/anime/genre" + + "/${genre.id}/${genre.name.replaceWhiteSpaceWithUnderScore()}" + } + + fun getAnimeCharactersLink(id: Int, title: String): String { + return "https://myanimelist.net/anime" + + "/$id/${title.replaceWhiteSpaceWithUnderScore()}/characters" + } + + fun getAnimeStaffLink(id: Int, title: String): String { + return "https://myanimelist.net/anime" + + "/$id/${title.replaceWhiteSpaceWithUnderScore()}/characters#staff" + } + + fun getAnimeReviewsLink(id: Int, title: String): String { + return "https://myanimelist.net/anime" + + "/$id/${title.replaceWhiteSpaceWithUnderScore()}/reviews" + } + + fun getAnimeNewsLink(id: Int, title: String): String { + return "https://myanimelist.net/anime" + + "/$id/${title.replaceWhiteSpaceWithUnderScore()}/news" + } + + fun getAnimeVideosLink(id: Int, title: String): String { + return "https://myanimelist.net/anime" + + "/$id/${title.replaceWhiteSpaceWithUnderScore()}/video" + } + + fun getAnimeProducerPageLink(studio: AnimeByIDResponse.Studio): String { + return "https://myanimelist.net/anime/producer" + + "/${studio.id}/${studio.name.replaceWhiteSpaceWithUnderScore()}" + } + + fun getMangaAuthorPageLink(author: MangaByIDResponse.Author): String { + val name = + "${author.node.firstName} ${author.node.lastName}" + .replaceWhiteSpaceWithUnderScore() + return "https://myanimelist.net/people" + + "/${author.node.id}/$name" + } + + fun getMangaGenresLink(genre: MangaByIDResponse.Genre): String { + return "https://myanimelist.net/manga/genre" + + "/${genre.id}/${genre.name.replaceWhiteSpaceWithUnderScore()}" + } + + fun getMangaCharactersLink(id: Int, title: String): String { + return "https://myanimelist.net/manga" + + "/$id/${title.replaceWhiteSpaceWithUnderScore()}/characters" + } + + fun getMangaReviewsLink(id: Int, title: String): String { + return "https://myanimelist.net/manga" + + "/$id/${title.replaceWhiteSpaceWithUnderScore()}/reviews" + } + + fun getMangaNewsLink(id: Int, title: String): String { + return "https://myanimelist.net/manga" + + "/$id/${title.replaceWhiteSpaceWithUnderScore()}/news" + } + + fun getMangaSerializationPageLink(magazine: MangaByIDResponse.Serialization): String { + return "https://myanimelist.net/manga/magazine" + + "/${magazine.node.id}/${magazine.node.name.replaceWhiteSpaceWithUnderScore()}" + } + + fun getBlogsLink(name: String): String { + return "https://myanimelist.net/blog/$name" + } + + fun getClubsLink(name: String): String { + return "https://myanimelist.net/profile/$name/clubs" + } + + fun getForumTopicsLink(name: String): String { + return "https://myanimelist.net/forum/search?u=$name&q=&uloc=1&loc=-1" + } + + fun getFriendsLink(name: String): String { + return "https://myanimelist.net/profile/$name/friends" + } + + fun getHistoryLink(name: String): String { + return "https://myanimelist.net/history/$name" + } + + fun getRecommendationsLink(name: String): String { + return "https://myanimelist.net/profile/$name/recommendations" + } + + fun getReviewsLink(name: String): String { + return "https://myanimelist.net/profile/$name/reviews" + } + + fun getProfileLink(name: String): String { + return "https://myanimelist.net/profile/$name" + } + + fun getUserAnimeListLink(name: String): String { + return "https://myanimelist.net/animelist/$name" + } + + fun getUserMangaListLink(name: String): String { + return "https://myanimelist.net/mangalist/$name" + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeAiringStatus.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeAiringStatus.kt new file mode 100644 index 0000000..27ab6ce --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeAiringStatus.kt @@ -0,0 +1,29 @@ +@file:Suppress("EnumEntryName") + +package com.sharkaboi.mediahub.data.api.enums + +import android.content.Context +import com.sharkaboi.mediahub.R + +enum class AnimeAiringStatus { + finished_airing, + currently_airing, + not_yet_aired +} + +internal fun Context.getAnimeAiringStatus(status: String): String { + return when { + status.trim() == AnimeAiringStatus.finished_airing.name -> { + getString(R.string.anime_airing_finished) + } + status.trim() == AnimeAiringStatus.currently_airing.name -> { + getString(R.string.anime_airing_ongoing) + } + status.trim() == AnimeAiringStatus.not_yet_aired.name -> { + getString(R.string.anime_airing_not_yet_aired) + } + else -> { + getString(R.string.anime_airing_unknown) + } + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeNsfwRating.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeNsfwRating.kt new file mode 100644 index 0000000..acadfd2 --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeNsfwRating.kt @@ -0,0 +1,30 @@ +package com.sharkaboi.mediahub.data.api.enums + +import android.content.Context +import com.sharkaboi.mediahub.R + +enum class AnimeNsfwRating { + white, + gray, + black +} + +internal fun Context.getAnimeNsfwRating(rating: String?): String { + return when { + rating == null -> { + getString(R.string.n_a) + } + rating.trim() == AnimeNsfwRating.white.name -> { + getString(R.string.anime_nsfw_rating_white) + } + rating.trim() == AnimeNsfwRating.gray.name -> { + getString(R.string.anime_nsfw_rating_gray) + } + rating.trim() == AnimeNsfwRating.black.name -> { + getString(R.string.anime_nsfw_rating_black) + } + else -> { + getString(R.string.anime_nsfw_rating_unknown) + } + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeRankingType.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeRankingType.kt index f33c9bc..ea02b15 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeRankingType.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeRankingType.kt @@ -1,5 +1,8 @@ package com.sharkaboi.mediahub.data.api.enums +import android.content.Context +import com.sharkaboi.mediahub.R + @Suppress("EnumEntryName") enum class AnimeRankingType { all, // Top Anime Series @@ -12,17 +15,17 @@ enum class AnimeRankingType { bypopularity, // Top Anime by Popularity favorite; // Top Favorited Anime - fun getAnimeRanking(): String { + fun getAnimeRanking(context: Context): String { return when (this) { - all -> "All" - airing -> "Airing" - upcoming -> "Upcoming" - tv -> "TV" - ova -> "OVA" - movie -> "Movie" - special -> "Specials" - bypopularity -> "By popularity" - favorite -> "In your list" + all -> context.getString(R.string.anime_ranking_all) + airing -> context.getString(R.string.anime_ranking_airing) + upcoming -> context.getString(R.string.anime_ranking_upcoming) + tv -> context.getString(R.string.anime_ranking_tv) + ova -> context.getString(R.string.anime_ranking_ova) + movie -> context.getString(R.string.anime_ranking_movie) + special -> context.getString(R.string.anime_ranking_specials) + bypopularity -> context.getString(R.string.anime_ranking_by_popularity) + favorite -> context.getString(R.string.anime_ranking_in_your_list) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeRating.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeRating.kt new file mode 100644 index 0000000..d1d7eef --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeRating.kt @@ -0,0 +1,26 @@ +package com.sharkaboi.mediahub.data.api.enums + +import android.content.Context +import com.sharkaboi.mediahub.R + +object AnimeRating { + private const val G = "g" + private const val PG = "pg" + private const val PG13 = "pg_13" + private const val rRating = "r" + private const val rPlusRating = "r+" + private const val RX = "rx" + + fun Context.getAnimeRating(rating: String?): String { + return when { + rating == null -> getString(R.string.n_a) + rating.trim() == G -> getString(R.string.anime_rating_g) + rating.trim() == PG -> getString(R.string.anime_rating_pg) + rating.trim() == PG13 -> getString(R.string.anime_rating_pg13) + rating.trim() == rRating -> getString(R.string.anime_rating_r) + rating.trim() == rPlusRating -> getString(R.string.anime_rating_r_plus) + rating.trim() == RX -> getString(R.string.anime_rating_rx) + else -> getString(R.string.n_a) + } + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeStatus.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeStatus.kt index fe5feb5..4f667d0 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeStatus.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/AnimeStatus.kt @@ -1,5 +1,8 @@ package com.sharkaboi.mediahub.data.api.enums +import android.content.Context +import com.sharkaboi.mediahub.R + @Suppress("EnumEntryName") enum class AnimeStatus { watching, @@ -9,14 +12,14 @@ enum class AnimeStatus { dropped, all; - fun getFormattedString(): String { + fun getFormattedString(context: Context): String { return when (this) { - watching -> "Watching" - plan_to_watch -> "Planned" - completed -> "Completed" - on_hold -> "On hold" - dropped -> "Dropped" - all -> "All" + watching -> context.getString(R.string.anime_status_watching) + plan_to_watch -> context.getString(R.string.anime_status_planned) + completed -> context.getString(R.string.anime_status_completed) + on_hold -> context.getString(R.string.anime_status_on_hold) + dropped -> context.getString(R.string.anime_status_dropped) + all -> context.getString(R.string.anime_status_all) } } @@ -27,11 +30,11 @@ enum class AnimeStatus { fun String.animeStatusFromString(): AnimeStatus? { return when (this) { - "watching" -> AnimeStatus.watching - "plan_to_watch" -> AnimeStatus.plan_to_watch - "completed" -> AnimeStatus.completed - "on_hold" -> AnimeStatus.on_hold - "dropped" -> AnimeStatus.dropped + AnimeStatus.watching.name -> AnimeStatus.watching + AnimeStatus.plan_to_watch.name -> AnimeStatus.plan_to_watch + AnimeStatus.completed.name -> AnimeStatus.completed + AnimeStatus.on_hold.name -> AnimeStatus.on_hold + AnimeStatus.dropped.name -> AnimeStatus.dropped else -> null // AnimeStatus.all } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaNsfwRating.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaNsfwRating.kt new file mode 100644 index 0000000..098cb7c --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaNsfwRating.kt @@ -0,0 +1,20 @@ +package com.sharkaboi.mediahub.data.api.enums + +import android.content.Context +import com.sharkaboi.mediahub.R + +enum class MangaNsfwRating { + white, + gray, + black +} + +internal fun Context.getMangaNsfwRating(rating: String?): String { + return when { + rating == null -> getString(R.string.n_a) + rating.trim() == MangaNsfwRating.white.name -> getString(R.string.manga_nsfw_rating_white) + rating.trim() == MangaNsfwRating.gray.name -> getString(R.string.manga_nsfw_rating_gray) + rating.trim() == MangaNsfwRating.black.name -> getString(R.string.manga_nsfw_rating_black) + else -> getString(R.string.n_a) + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaPublishingStatus.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaPublishingStatus.kt new file mode 100644 index 0000000..bbe4736 --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaPublishingStatus.kt @@ -0,0 +1,19 @@ +package com.sharkaboi.mediahub.data.api.enums + +import android.content.Context +import com.sharkaboi.mediahub.R + +enum class MangaPublishingStatus { + finished, + currently_publishing, + not_yet_published +} + +internal fun Context.getMangaPublishStatus(status: String): String { + return when { + status.trim() == MangaPublishingStatus.finished.name -> getString(R.string.manga_publishing_finished) + status.trim() == MangaPublishingStatus.currently_publishing.name -> getString(R.string.manga_publishing_ongoing) + status.trim() == MangaPublishingStatus.not_yet_published.name -> getString(R.string.manga_publishing_not_yet_aired) + else -> getString(R.string.manga_publishing_unknown) + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaRankingType.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaRankingType.kt index 5e9addf..9191cb7 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaRankingType.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaRankingType.kt @@ -1,5 +1,8 @@ package com.sharkaboi.mediahub.data.api.enums +import android.content.Context +import com.sharkaboi.mediahub.R + @Suppress("EnumEntryName") enum class MangaRankingType { all, // All @@ -13,18 +16,33 @@ enum class MangaRankingType { bypopularity, // Most Popular favorite; // Most Favorited - fun getFormattedString(): String { + fun getFormattedString(context: Context): String { return when (this) { - all -> "All" - manga -> "Manga" - oneshots -> "One-shots" - doujin -> "Doujins" - lightnovels -> "Light novels" - novels -> "Novels" - manhwa -> "Manhwa" - manhua -> "Manhua" - bypopularity -> "By popularity" - favorite -> "In your list" + all -> context.getString(R.string.manga_ranking_all) + manga -> context.getString(R.string.manga_ranking_manga) + oneshots -> context.getString(R.string.manga_ranking_one_shot) + doujin -> context.getString(R.string.manga_ranking_doujins) + lightnovels -> context.getString(R.string.manga_ranking_light_novels) + novels -> context.getString(R.string.manga_ranking_novels) + manhwa -> context.getString(R.string.manga_ranking_manhwa) + manhua -> context.getString(R.string.manga_ranking_manhua) + bypopularity -> context.getString(R.string.manga_ranking_by_popularity) + favorite -> context.getString(R.string.manga_ranking_in_your_list) + } + } + + companion object { + private const val lightNovelSlug = "light_novel" + private const val oneShotSlug = "one_shot" + private const val doujinshiSlug = "doujinshi" + fun getMangaRankingFromString(ranking: String?): MangaRankingType { + return when { + ranking == null -> all + ranking.lowercase() == lightNovelSlug -> lightnovels + ranking.lowercase() == oneShotSlug -> oneshots + ranking.lowercase() == doujinshiSlug -> doujin + else -> runCatching { valueOf(ranking.lowercase()) }.getOrElse { all } + } } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaStatus.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaStatus.kt index ea873cf..dfdc990 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaStatus.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/MangaStatus.kt @@ -1,5 +1,8 @@ package com.sharkaboi.mediahub.data.api.enums +import android.content.Context +import com.sharkaboi.mediahub.R + @Suppress("EnumEntryName") enum class MangaStatus { reading, @@ -9,14 +12,14 @@ enum class MangaStatus { dropped, all; - fun getFormattedString(): String { + fun getFormattedString(context: Context): String { return when (this) { - reading -> "Reading" - plan_to_read -> "Planned" - completed -> "Completed" - on_hold -> "On hold" - dropped -> "Dropped" - all -> "All" + reading -> context.getString(R.string.manga_status_reading) + plan_to_read -> context.getString(R.string.manga_status_planned) + completed -> context.getString(R.string.manga_status_completed) + on_hold -> context.getString(R.string.manga_status_on_hold) + dropped -> context.getString(R.string.manga_status_dropped) + all -> context.getString(R.string.manga_status_all) } } @@ -27,11 +30,11 @@ enum class MangaStatus { fun String.mangaStatusFromString(): MangaStatus? { return when (this) { - "reading" -> MangaStatus.reading - "plan_to_read" -> MangaStatus.plan_to_read - "completed" -> MangaStatus.completed - "on_hold" -> MangaStatus.on_hold - "dropped" -> MangaStatus.dropped + MangaStatus.reading.name -> MangaStatus.reading + MangaStatus.plan_to_read.name -> MangaStatus.plan_to_read + MangaStatus.completed.name -> MangaStatus.completed + MangaStatus.on_hold.name -> MangaStatus.on_hold + MangaStatus.dropped.name -> MangaStatus.dropped else -> null // MangaStatus.all } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/UserAnimeSortType.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/UserAnimeSortType.kt index cec7276..7f7a51e 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/UserAnimeSortType.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/UserAnimeSortType.kt @@ -1,5 +1,8 @@ package com.sharkaboi.mediahub.data.api.enums +import android.content.Context +import com.sharkaboi.mediahub.R + @Suppress("EnumEntryName") enum class UserAnimeSortType { list_score, // Descending @@ -8,11 +11,11 @@ enum class UserAnimeSortType { anime_start_date; // Descending companion object { - fun getFormattedArray() = arrayOf( - "Highest rating", - "Last updated", - "Alphabetical order", - "Newest addition" + fun getFormattedArray(context: Context) = arrayOf( + context.getString(R.string.user_anime_sort_by_rating), + context.getString(R.string.user_anime_sort_by_updated), + context.getString(R.string.user_anime_sort_by_alphabetical), + context.getString(R.string.user_anime_sort_by_newest) ) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/UserMangaSortType.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/UserMangaSortType.kt index 6fa5d35..d1f03f1 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/UserMangaSortType.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/enums/UserMangaSortType.kt @@ -1,5 +1,8 @@ package com.sharkaboi.mediahub.data.api.enums +import android.content.Context +import com.sharkaboi.mediahub.R + @Suppress("EnumEntryName") enum class UserMangaSortType { list_score, // Descending @@ -8,11 +11,11 @@ enum class UserMangaSortType { manga_start_date; // Descending companion object { - fun getFormattedArray() = arrayOf( - "Highest rating", - "Last updated", - "Alphabetical order", - "Newest addition" + fun getFormattedArray(context: Context) = arrayOf( + context.getString(R.string.user_manga_sort_by_rating), + context.getString(R.string.user_manga_sort_by_updated), + context.getString(R.string.user_manga_sort_by_alphabetical), + context.getString(R.string.user_manga_sort_by_newest) ) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/AnimeService.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/AnimeService.kt index ef4d84f..d0b76a7 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/AnimeService.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/AnimeService.kt @@ -1,7 +1,7 @@ package com.sharkaboi.mediahub.data.api.retrofit import com.haroldadmin.cnradapter.NetworkResponse -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.AnimeRankingType import com.sharkaboi.mediahub.data.api.enums.AnimeSortType import com.sharkaboi.mediahub.data.api.models.ApiError diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/MangaService.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/MangaService.kt index 2fd9a2d..c5ec2aa 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/MangaService.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/MangaService.kt @@ -1,7 +1,7 @@ package com.sharkaboi.mediahub.data.api.retrofit import com.haroldadmin.cnradapter.NetworkResponse -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.MangaRankingType import com.sharkaboi.mediahub.data.api.models.ApiError import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/UserAnimeService.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/UserAnimeService.kt index bc5f5ad..85d2a31 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/UserAnimeService.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/UserAnimeService.kt @@ -1,7 +1,7 @@ package com.sharkaboi.mediahub.data.api.retrofit import com.haroldadmin.cnradapter.NetworkResponse -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.UserAnimeSortType import com.sharkaboi.mediahub.data.api.models.ApiError import com.sharkaboi.mediahub.data.api.models.useranime.UserAnimeListResponse diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/UserMangaService.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/UserMangaService.kt index 26ee684..95c9979 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/UserMangaService.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/UserMangaService.kt @@ -1,7 +1,7 @@ package com.sharkaboi.mediahub.data.api.retrofit import com.haroldadmin.cnradapter.NetworkResponse -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.UserMangaSortType import com.sharkaboi.mediahub.data.api.models.ApiError import com.sharkaboi.mediahub.data.api.models.usermanga.UserMangaListResponse diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/UserService.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/UserService.kt index cd207bd..b3c2aba 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/UserService.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/retrofit/UserService.kt @@ -1,7 +1,7 @@ package com.sharkaboi.mediahub.data.api.retrofit import com.haroldadmin.cnradapter.NetworkResponse -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.models.ApiError import com.sharkaboi.mediahub.data.api.models.user.UserDetailsResponse import kotlinx.coroutines.Deferred diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/datastore/DataStoreRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/data/datastore/DataStoreRepositoryImpl.kt index 11a2740..5124ce5 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/datastore/DataStoreRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/datastore/DataStoreRepositoryImpl.kt @@ -11,10 +11,13 @@ import com.sharkaboi.mediahub.data.datastore.DataStoreConstants.REFRESH_TOKEN import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext +import timber.log.Timber import java.io.IOException import java.util.* +import kotlin.coroutines.coroutineContext internal val Context.dataStore by preferencesDataStore( name = DataStoreConstants.PREFERENCES_NAME @@ -30,8 +33,9 @@ class DataStoreRepositoryImpl( throw exception } }.map { preferences -> + Timber.d("Collected access token on $coroutineContext") preferences[ACCESS_TOKEN] - } + }.flowOn(Dispatchers.IO) override val refreshTokenFlow: Flow = dataStore.data.catch { exception -> if (exception is IOException) { @@ -45,7 +49,7 @@ class DataStoreRepositoryImpl( } ?: run { return@map String.emptyString } - } + }.flowOn(Dispatchers.IO) override val expiresInFlow: Flow = dataStore.data.catch { exception -> if (exception is IOException) { @@ -59,7 +63,7 @@ class DataStoreRepositoryImpl( timeInMillis = expiredIn } date.time - } + }.flowOn(Dispatchers.IO) override suspend fun setAccessToken(token: String): Unit = withContext(Dispatchers.IO) { dataStore.edit { preferences -> @@ -82,7 +86,7 @@ class DataStoreRepositoryImpl( } } - override suspend fun clearDataStore() { + override suspend fun clearDataStore(): Unit = withContext(Dispatchers.IO) { dataStore.edit { preferences -> preferences.clear() } diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeRankingDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeRankingDataSource.kt index 7689364..c4efe16 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeRankingDataSource.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeRankingDataSource.kt @@ -4,12 +4,14 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import com.haroldadmin.cnradapter.NetworkResponse import com.sharkaboi.mediahub.common.extensions.emptyString -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.AnimeRankingType import com.sharkaboi.mediahub.data.api.models.ApiError import com.sharkaboi.mediahub.data.api.models.anime.AnimeRankingResponse import com.sharkaboi.mediahub.data.api.retrofit.AnimeService -import com.sharkaboi.mediahub.data.wrappers.NoTokenFoundError +import com.sharkaboi.mediahub.data.wrappers.MHError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber class AnimeRankingDataSource( @@ -19,14 +21,12 @@ class AnimeRankingDataSource( private val showNsfw: Boolean = false ) : PagingSource() { + /** + * prevKey == null -> first page + * nextKey == null -> last page + * both prevKey and nextKey null -> only one page + */ override fun getRefreshKey(state: PagingState): Int? { - // Try to find the page key of the closest page to anchorPosition, from - // either the prevKey or the nextKey, but you need to handle nullability - // here: - // * prevKey == null -> anchorPage is the first page. - // * nextKey == null -> anchorPage is the last page. - // * both prevKey and nextKey null -> anchorPage is the initial page, so - // just return null. return state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( @@ -42,16 +42,18 @@ class AnimeRankingDataSource( val limit = ApiConstants.API_PAGE_LIMIT if (accessToken == null) { return LoadResult.Error( - NoTokenFoundError.getThrowable() + MHError.LoginExpiredError.getThrowable() ) } else { - val response = animeService.getAnimeRankingAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - offset = offset, - limit = limit, - rankingType = animeRankingType.name, - nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY - ).await() + val response = withContext(Dispatchers.IO) { + animeService.getAnimeRankingAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + offset = offset, + limit = limit, + rankingType = animeRankingType.name, + nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY + ).await() + } when (response) { is NetworkResponse.Success -> { val nextOffset = if (response.body.data.isEmpty()) null else offset + limit diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSearchDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSearchDataSource.kt index 3bad4b9..2977caf 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSearchDataSource.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSearchDataSource.kt @@ -4,11 +4,13 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import com.haroldadmin.cnradapter.NetworkResponse import com.sharkaboi.mediahub.common.extensions.emptyString -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.models.ApiError import com.sharkaboi.mediahub.data.api.models.anime.AnimeSearchResponse import com.sharkaboi.mediahub.data.api.retrofit.AnimeService -import com.sharkaboi.mediahub.data.wrappers.NoTokenFoundError +import com.sharkaboi.mediahub.data.wrappers.MHError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber class AnimeSearchDataSource( @@ -18,14 +20,12 @@ class AnimeSearchDataSource( private val showNsfw: Boolean = false ) : PagingSource() { + /** + * prevKey == null -> first page + * nextKey == null -> last page + * both prevKey and nextKey null -> only one page + */ override fun getRefreshKey(state: PagingState): Int? { - // Try to find the page key of the closest page to anchorPosition, from - // either the prevKey or the nextKey, but you need to handle nullability - // here: - // * prevKey == null -> anchorPage is the first page. - // * nextKey == null -> anchorPage is the last page. - // * both prevKey and nextKey null -> anchorPage is the initial page, so - // just return null. return state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( @@ -41,16 +41,18 @@ class AnimeSearchDataSource( val limit = ApiConstants.API_PAGE_LIMIT if (accessToken == null) { return LoadResult.Error( - NoTokenFoundError.getThrowable() + MHError.LoginExpiredError.getThrowable() ) } else { - val response = animeService.getAnimeAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - offset = offset, - limit = limit, - searchQuery = query, - nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY - ).await() + val response = withContext(Dispatchers.IO) { + animeService.getAnimeAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + offset = offset, + limit = limit, + searchQuery = query, + nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY + ).await() + } when (response) { is NetworkResponse.Success -> { val nextOffset = if (response.body.data.isEmpty()) null else offset + limit diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSeasonalDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSeasonalDataSource.kt index 58c222a..2ce278f 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSeasonalDataSource.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSeasonalDataSource.kt @@ -4,12 +4,14 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import com.haroldadmin.cnradapter.NetworkResponse import com.sharkaboi.mediahub.common.extensions.emptyString -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.AnimeSeason import com.sharkaboi.mediahub.data.api.models.ApiError import com.sharkaboi.mediahub.data.api.models.anime.AnimeSeasonalResponse import com.sharkaboi.mediahub.data.api.retrofit.AnimeService -import com.sharkaboi.mediahub.data.wrappers.NoTokenFoundError +import com.sharkaboi.mediahub.data.wrappers.MHError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber class AnimeSeasonalDataSource( @@ -20,14 +22,12 @@ class AnimeSeasonalDataSource( private val showNsfw: Boolean = false ) : PagingSource() { + /** + * prevKey == null -> first page + * nextKey == null -> last page + * both prevKey and nextKey null -> only one page + */ override fun getRefreshKey(state: PagingState): Int? { - // Try to find the page key of the closest page to anchorPosition, from - // either the prevKey or the nextKey, but you need to handle nullability - // here: - // * prevKey == null -> anchorPage is the first page. - // * nextKey == null -> anchorPage is the last page. - // * both prevKey and nextKey null -> anchorPage is the initial page, so - // just return null. return state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( @@ -43,17 +43,19 @@ class AnimeSeasonalDataSource( val limit = ApiConstants.API_PAGE_LIMIT if (accessToken == null) { return LoadResult.Error( - NoTokenFoundError.getThrowable() + MHError.LoginExpiredError.getThrowable() ) } else { - val response = animeService.getAnimeBySeasonAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - offset = offset, - limit = limit, - season = animeSeason.name, - year = year, - nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY - ).await() + val response = withContext(Dispatchers.IO) { + animeService.getAnimeBySeasonAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + offset = offset, + limit = limit, + season = animeSeason.name, + year = year, + nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY + ).await() + } when (response) { is NetworkResponse.Success -> { val nextOffset = if (response.body.data.isEmpty()) null else offset + limit diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSuggestionsDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSuggestionsDataSource.kt index a1710cc..ecdb4b5 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSuggestionsDataSource.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSuggestionsDataSource.kt @@ -4,11 +4,13 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import com.haroldadmin.cnradapter.NetworkResponse import com.sharkaboi.mediahub.common.extensions.emptyString -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.models.ApiError import com.sharkaboi.mediahub.data.api.models.anime.AnimeSuggestionsResponse import com.sharkaboi.mediahub.data.api.retrofit.AnimeService -import com.sharkaboi.mediahub.data.wrappers.NoTokenFoundError +import com.sharkaboi.mediahub.data.wrappers.MHError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber class AnimeSuggestionsDataSource( @@ -17,14 +19,12 @@ class AnimeSuggestionsDataSource( private val showNsfw: Boolean = false ) : PagingSource() { + /** + * prevKey == null -> first page + * nextKey == null -> last page + * both prevKey and nextKey null -> only one page + */ override fun getRefreshKey(state: PagingState): Int? { - // Try to find the page key of the closest page to anchorPosition, from - // either the prevKey or the nextKey, but you need to handle nullability - // here: - // * prevKey == null -> anchorPage is the first page. - // * nextKey == null -> anchorPage is the last page. - // * both prevKey and nextKey null -> anchorPage is the initial page, so - // just return null. return state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( @@ -40,15 +40,17 @@ class AnimeSuggestionsDataSource( val limit = ApiConstants.API_PAGE_LIMIT if (accessToken == null) { return LoadResult.Error( - NoTokenFoundError.getThrowable() + MHError.LoginExpiredError.getThrowable() ) } else { - val response = animeService.getAnimeSuggestionsAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - offset = offset, - limit = limit, - nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY - ).await() + val response = withContext(Dispatchers.IO) { + animeService.getAnimeSuggestionsAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + offset = offset, + limit = limit, + nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY + ).await() + } when (response) { is NetworkResponse.Success -> { val nextOffset = if (response.body.data.isEmpty()) null else offset + limit diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/paging/MangaRankingDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/data/paging/MangaRankingDataSource.kt index cd14ef0..eb15a5e 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/MangaRankingDataSource.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/paging/MangaRankingDataSource.kt @@ -4,12 +4,14 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import com.haroldadmin.cnradapter.NetworkResponse import com.sharkaboi.mediahub.common.extensions.emptyString -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.MangaRankingType import com.sharkaboi.mediahub.data.api.models.ApiError import com.sharkaboi.mediahub.data.api.models.manga.MangaRankingResponse import com.sharkaboi.mediahub.data.api.retrofit.MangaService -import com.sharkaboi.mediahub.data.wrappers.NoTokenFoundError +import com.sharkaboi.mediahub.data.wrappers.MHError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber class MangaRankingDataSource( @@ -19,14 +21,12 @@ class MangaRankingDataSource( private val showNsfw: Boolean = false ) : PagingSource() { + /** + * prevKey == null -> first page + * nextKey == null -> last page + * both prevKey and nextKey null -> only one page + */ override fun getRefreshKey(state: PagingState): Int? { - // Try to find the page key of the closest page to anchorPosition, from - // either the prevKey or the nextKey, but you need to handle nullability - // here: - // * prevKey == null -> anchorPage is the first page. - // * nextKey == null -> anchorPage is the last page. - // * both prevKey and nextKey null -> anchorPage is the initial page, so - // just return null. return state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( @@ -42,16 +42,18 @@ class MangaRankingDataSource( val limit = ApiConstants.API_PAGE_LIMIT if (accessToken == null) { return LoadResult.Error( - NoTokenFoundError.getThrowable() + MHError.LoginExpiredError.getThrowable() ) } else { - val response = mangaService.getMangaRankingAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - offset = offset, - limit = limit, - rankingType = mangaRankingType.name, - nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY - ).await() + val response = withContext(Dispatchers.IO) { + mangaService.getMangaRankingAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + offset = offset, + limit = limit, + rankingType = mangaRankingType.name, + nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY + ).await() + } when (response) { is NetworkResponse.Success -> { val nextOffset = if (response.body.data.isEmpty()) null else offset + limit diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/paging/MangaSearchDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/data/paging/MangaSearchDataSource.kt index 06c5971..7a12d61 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/MangaSearchDataSource.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/paging/MangaSearchDataSource.kt @@ -4,11 +4,13 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import com.haroldadmin.cnradapter.NetworkResponse import com.sharkaboi.mediahub.common.extensions.emptyString -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.models.ApiError import com.sharkaboi.mediahub.data.api.models.manga.MangaSearchResponse import com.sharkaboi.mediahub.data.api.retrofit.MangaService -import com.sharkaboi.mediahub.data.wrappers.NoTokenFoundError +import com.sharkaboi.mediahub.data.wrappers.MHError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber class MangaSearchDataSource( @@ -18,14 +20,12 @@ class MangaSearchDataSource( private val showNsfw: Boolean = false ) : PagingSource() { + /** + * prevKey == null -> first page + * nextKey == null -> last page + * both prevKey and nextKey null -> only one page + */ override fun getRefreshKey(state: PagingState): Int? { - // Try to find the page key of the closest page to anchorPosition, from - // either the prevKey or the nextKey, but you need to handle nullability - // here: - // * prevKey == null -> anchorPage is the first page. - // * nextKey == null -> anchorPage is the last page. - // * both prevKey and nextKey null -> anchorPage is the initial page, so - // just return null. return state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( @@ -41,16 +41,18 @@ class MangaSearchDataSource( val limit = ApiConstants.API_PAGE_LIMIT if (accessToken == null) { return LoadResult.Error( - NoTokenFoundError.getThrowable() + MHError.LoginExpiredError.getThrowable() ) } else { - val response = mangaService.getMangaAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - offset = offset, - limit = limit, - searchQuery = query, - nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY - ).await() + val response = withContext(Dispatchers.IO) { + mangaService.getMangaAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + offset = offset, + limit = limit, + searchQuery = query, + nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY + ).await() + } when (response) { is NetworkResponse.Success -> { val nextOffset = if (response.body.data.isEmpty()) null else offset + limit diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/paging/UserAnimeListDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/data/paging/UserAnimeListDataSource.kt index 30ee39f..1eb2a47 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/UserAnimeListDataSource.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/paging/UserAnimeListDataSource.kt @@ -4,13 +4,15 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import com.haroldadmin.cnradapter.NetworkResponse import com.sharkaboi.mediahub.common.extensions.emptyString -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.AnimeStatus import com.sharkaboi.mediahub.data.api.enums.UserAnimeSortType import com.sharkaboi.mediahub.data.api.models.ApiError import com.sharkaboi.mediahub.data.api.models.useranime.UserAnimeListResponse import com.sharkaboi.mediahub.data.api.retrofit.UserAnimeService -import com.sharkaboi.mediahub.data.wrappers.NoTokenFoundError +import com.sharkaboi.mediahub.data.wrappers.MHError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber class UserAnimeListDataSource( @@ -21,14 +23,12 @@ class UserAnimeListDataSource( private val showNsfw: Boolean = false ) : PagingSource() { + /** + * prevKey == null -> first page + * nextKey == null -> last page + * both prevKey and nextKey null -> only one page + */ override fun getRefreshKey(state: PagingState): Int? { - // Try to find the page key of the closest page to anchorPosition, from - // either the prevKey or the nextKey, but you need to handle nullability - // here: - // * prevKey == null -> anchorPage is the first page. - // * nextKey == null -> anchorPage is the last page. - // * both prevKey and nextKey null -> anchorPage is the initial page, so - // just return null. return state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( @@ -44,17 +44,19 @@ class UserAnimeListDataSource( val limit = ApiConstants.API_PAGE_LIMIT if (accessToken == null) { return LoadResult.Error( - NoTokenFoundError.getThrowable() + MHError.LoginExpiredError.getThrowable() ) } else { - val response = userAnimeService.getAnimeListOfUserAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - status = getStatus(), - offset = offset, - limit = limit, - sort = animeSortType.name, - nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY - ).await() + val response = withContext(Dispatchers.IO) { + userAnimeService.getAnimeListOfUserAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + status = getStatus(), + offset = offset, + limit = limit, + sort = animeSortType.name, + nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY + ).await() + } when (response) { is NetworkResponse.Success -> { val nextOffset = if (response.body.data.isEmpty()) null else offset + limit diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/paging/UserMangaListDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/data/paging/UserMangaListDataSource.kt index 39176d2..1a1d0be 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/UserMangaListDataSource.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/paging/UserMangaListDataSource.kt @@ -4,13 +4,15 @@ import androidx.paging.PagingSource import androidx.paging.PagingState import com.haroldadmin.cnradapter.NetworkResponse import com.sharkaboi.mediahub.common.extensions.emptyString -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.MangaStatus import com.sharkaboi.mediahub.data.api.enums.UserMangaSortType import com.sharkaboi.mediahub.data.api.models.ApiError import com.sharkaboi.mediahub.data.api.models.usermanga.UserMangaListResponse import com.sharkaboi.mediahub.data.api.retrofit.UserMangaService -import com.sharkaboi.mediahub.data.wrappers.NoTokenFoundError +import com.sharkaboi.mediahub.data.wrappers.MHError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber class UserMangaListDataSource( @@ -21,14 +23,12 @@ class UserMangaListDataSource( private val showNsfw: Boolean = false ) : PagingSource() { + /** + * prevKey == null -> first page + * nextKey == null -> last page + * both prevKey and nextKey null -> only one page + */ override fun getRefreshKey(state: PagingState): Int? { - // Try to find the page key of the closest page to anchorPosition, from - // either the prevKey or the nextKey, but you need to handle nullability - // here: - // * prevKey == null -> anchorPage is the first page. - // * nextKey == null -> anchorPage is the last page. - // * both prevKey and nextKey null -> anchorPage is the initial page, so - // just return null. return state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( @@ -44,17 +44,19 @@ class UserMangaListDataSource( val limit = ApiConstants.API_PAGE_LIMIT if (accessToken == null) { return LoadResult.Error( - NoTokenFoundError.getThrowable() + MHError.LoginExpiredError.getThrowable() ) } else { - val response = userMangaService.getMangaListOfUserAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - status = getStatus(), - offset = offset, - limit = limit, - sort = mangaSortType.name, - nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY - ).await() + val response = withContext(Dispatchers.IO) { + userMangaService.getMangaListOfUserAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + status = getStatus(), + offset = offset, + limit = limit, + sort = mangaSortType.name, + nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY + ).await() + } when (response) { is NetworkResponse.Success -> { val nextOffset = if (response.body.data.isEmpty()) null else offset + limit diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/sharedpref/SharedPreferencesKeys.kt b/app/src/main/java/com/sharkaboi/mediahub/data/sharedpref/SharedPreferencesKeys.kt index 8ab42b0..dbf977a 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/sharedpref/SharedPreferencesKeys.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/sharedpref/SharedPreferencesKeys.kt @@ -1,13 +1,11 @@ package com.sharkaboi.mediahub.data.sharedpref -class SharedPreferencesKeys { - companion object { - const val DARK_MODE = "darkMode" - const val ABOUT = "about" - const val UPDATES = "updates" - const val LOG_OUT = "logout" - const val ANIME_NOTIFS = "anime_notifs" - const val TOOLBAR = "toolbar" - const val NSFW_OPTION = "nsfwOption" - } +object SharedPreferencesKeys { + const val DARK_MODE = "darkMode" + const val ABOUT = "about" + const val UPDATES = "updates" + const val LOG_OUT = "logout" + const val ANIME_NOTIFS = "anime_notifs" + const val TOOLBAR = "toolbar" + const val NSFW_OPTION = "nsfwOption" } diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/wrappers/MHError.kt b/app/src/main/java/com/sharkaboi/mediahub/data/wrappers/MHError.kt index 26ac8c3..03e16b1 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/wrappers/MHError.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/wrappers/MHError.kt @@ -3,10 +3,21 @@ package com.sharkaboi.mediahub.data.wrappers import com.sharkaboi.mediahub.common.extensions.emptyString data class MHError( - val errorMessage: String, - val throwable: Throwable? + val errorMessage: String ) { + fun getThrowable() = Throwable(message = errorMessage) + companion object { - val EmptyError = MHError(String.emptyString, null) + val EmptyError = MHError(String.emptyString) + val UnknownError = MHError("An unknown error occurred") + val InvalidStateError = MHError("An invalid state was reached") + val LoginExpiredError = MHError("Login expired, Log in again") + val NetworkError = MHError("Error with network") + val ParsingError = MHError("Error with parsing") + val ProtocolError = MHError("Protocol error") + val ApplicationError = MHError("Application error") + val AnimeNotFoundError = MHError("Anime isn't in your list") + val MangaNotFoundError = MHError("Manga isn't in your list") + fun apiErrorWithCode(code: Int) = MHError("Error with status code : $code") } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/wrappers/NoTokenFoundError.kt b/app/src/main/java/com/sharkaboi/mediahub/data/wrappers/NoTokenFoundError.kt deleted file mode 100644 index e577b34..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/data/wrappers/NoTokenFoundError.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.sharkaboi.mediahub.data.wrappers - -class NoTokenFoundError { - companion object { - fun getThrowable(): Throwable { - return Throwable(message = "Login expired, Log in again") - } - } -} diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/AlarmManagerModule.kt b/app/src/main/java/com/sharkaboi/mediahub/di/AlarmManagerModule.kt deleted file mode 100644 index 2afc3c5..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/di/AlarmManagerModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.sharkaboi.mediahub.di - -import android.content.Context -import com.sharkaboi.mediahub.common.alarm_manager.NotifyAlarmManager -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 - -@InstallIn(SingletonComponent::class) -@Module -object AlarmManagerModule { - - @Provides - @Singleton - fun getNotifyAlarmManager(@ApplicationContext context: Context): NotifyAlarmManager = - NotifyAlarmManager(context) -} diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimeListAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimeListAdapter.kt index 5cc53fe..7c24ea9 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimeListAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimeListAdapter.kt @@ -6,8 +6,9 @@ import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation -import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.extensions.getProgressStringWith +import com.sharkaboi.mediahub.common.extensions.getRatingStringWithRating import com.sharkaboi.mediahub.data.api.models.useranime.UserAnimeListResponse import com.sharkaboi.mediahub.databinding.AnimeListItemBinding @@ -22,19 +23,16 @@ class AnimeListAdapter( fun bind(item: UserAnimeListResponse.Data?) { item?.let { animeListItemBinding.apply { - ivAnimeBanner.load(it.node.mainPicture?.large ?: it.node.mainPicture?.medium) { - crossfade(true) - placeholder(R.drawable.ic_anime_placeholder) - error(R.drawable.ic_anime_placeholder) - fallback(R.drawable.ic_anime_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + ivAnimeBanner.load( + uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.AnimeImageBuilder + ) tvAnimeName.text = it.node.title - val numEpisodes = - if (it.node.numTotalEpisodes == 0) "??" else it.node.numTotalEpisodes - tvEpisodesWatched.text = - ("${it.listStatus.numWatchedEpisodes}/$numEpisodes") - tvScore.text = ("★ ${it.listStatus.score}") + tvEpisodesWatched.text = tvEpisodesWatched.context.getProgressStringWith( + it.listStatus.numWatchedEpisodes, + it.node.numTotalEpisodes + ) + tvScore.text = tvScore.context.getRatingStringWithRating(it.listStatus.score) root.setOnClickListener { onItemClick(item.node.id) } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimePagerAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimePagerAdapter.kt index 72a805a..3e6b777 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimePagerAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimePagerAdapter.kt @@ -9,7 +9,7 @@ import com.sharkaboi.mediahub.modules.anime.ui.AnimeListByStatusFragment class AnimePagerAdapter(fm: FragmentManager, lifecycle: Lifecycle) : FragmentStateAdapter(fm, lifecycle) { - override fun getItemCount(): Int = 6 + override fun getItemCount(): Int = AnimeStatus.values().count() override fun createFragment(position: Int): Fragment { return AnimeListByStatusFragment.newInstance(AnimeStatus.values()[position]) diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/repository/AnimeRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime/repository/AnimeRepositoryImpl.kt index d4e0083..3b041fc 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/repository/AnimeRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime/repository/AnimeRepositoryImpl.kt @@ -4,7 +4,7 @@ import android.content.SharedPreferences import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.AnimeStatus import com.sharkaboi.mediahub.data.api.enums.UserAnimeSortType import com.sharkaboi.mediahub.data.api.models.useranime.UserAnimeListResponse @@ -12,8 +12,10 @@ import com.sharkaboi.mediahub.data.api.retrofit.UserAnimeService import com.sharkaboi.mediahub.data.datastore.DataStoreRepository import com.sharkaboi.mediahub.data.paging.UserAnimeListDataSource import com.sharkaboi.mediahub.data.sharedpref.SharedPreferencesKeys +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withContext import timber.log.Timber class AnimeRepositoryImpl( @@ -25,11 +27,11 @@ class AnimeRepositoryImpl( override suspend fun getAnimeListFlow( animeStatus: AnimeStatus, animeSortType: UserAnimeSortType - ): Flow> { + ): Flow> = withContext(Dispatchers.IO) { val showNsfw = sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() Timber.d("accessToken: $accessToken") - return Pager( + return@withContext Pager( config = PagingConfig( pageSize = ApiConstants.API_PAGE_LIMIT, enablePlaceholders = false diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/ui/AnimeFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime/ui/AnimeFragment.kt index 42fa2ce..68e5342 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/ui/AnimeFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime/ui/AnimeFragment.kt @@ -47,7 +47,7 @@ class AnimeFragment : Fragment() { binding.vpAnime.adapter = vpAnimeAdapter binding.animeTabLayout.apply { AnimeStatus.values().forEach { - addTab(newTab().apply { text = it.getFormattedString() }) + addTab(newTab().apply { text = it.getFormattedString(context) }) } } onTabChanged = object : TabLayout.OnTabSelectedListener { @@ -59,6 +59,7 @@ class AnimeFragment : Fragment() { override fun onTabUnselected(tab: TabLayout.Tab?) = Unit override fun onTabReselected(tab: TabLayout.Tab?) { + // Scroll RV on tab reselect and bring up bottom nav childFragmentManager.findFragmentByTag("f${tab?.position ?: 0}")?.let { if (it is AnimeListByStatusFragment) { it.scrollRecyclerView() diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/ui/AnimeListByStatusFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime/ui/AnimeListByStatusFragment.kt index 36a29a9..9d9009d 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/ui/AnimeListByStatusFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime/ui/AnimeListByStatusFragment.kt @@ -13,6 +13,9 @@ import androidx.paging.LoadState import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.sharkaboi.mediahub.BottomNavGraphDirections +import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.common.extensions.showToast import com.sharkaboi.mediahub.data.api.enums.AnimeStatus import com.sharkaboi.mediahub.data.api.enums.UserAnimeSortType @@ -74,10 +77,10 @@ class AnimeListByStatusFragment : Fragment() { private fun setUpRecyclerView() { binding.rvAnimeByStatus.apply { animeListAdapter = AnimeListAdapter { animeId -> - val action = AnimeFragmentDirections.openAnimeDetailsWithId(animeId) + val action = BottomNavGraphDirections.openAnimeById(animeId) navController.navigate(action) } - layoutManager = GridLayoutManager(context, 3) + layoutManager = GridLayoutManager(context, UIConstants.AnimeAndMangaGridSpanCount) itemAnimator = DefaultItemAnimator() adapter = animeListAdapter.withLoadStateFooter( footer = AnimeLoadStateAdapter() @@ -110,10 +113,10 @@ class AnimeListByStatusFragment : Fragment() { } private fun openSortMenu() { - val singleItems = UserAnimeSortType.getFormattedArray() + val singleItems = UserAnimeSortType.getFormattedArray(requireContext()) val checkedItem = UserAnimeSortType.values().indexOf(animeViewModel.currentChosenSortType) MaterialAlertDialogBuilder(requireContext()) - .setTitle("Sort anime by") + .setTitle(R.string.sort_anime_by_hint) .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> animeViewModel.setSortType(UserAnimeSortType.values()[which]) getAnimeList() @@ -122,7 +125,7 @@ class AnimeListByStatusFragment : Fragment() { .show() } - fun getAnimeList() { + private fun getAnimeList() { resultsJob?.cancel() resultsJob = lifecycleScope.launch { animeViewModel.getAnimeList().collectLatest { pagingData -> diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/adapters/RecommendedAnimeAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/adapters/RecommendedAnimeAdapter.kt index dce120b..a86db40 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/adapters/RecommendedAnimeAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/adapters/RecommendedAnimeAdapter.kt @@ -7,8 +7,8 @@ import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse import com.sharkaboi.mediahub.databinding.AnimeListItemBinding @@ -62,17 +62,16 @@ class RecommendedAnimeAdapter(private val onClick: (Int) -> Unit) : } binding.tvAnimeName.text = item.node.title binding.tvEpisodesWatched.text = - ("Recommended ${item.numRecommendations} ${if (item.numRecommendations == 1) "time" else "times"}") + binding.tvEpisodesWatched.context.resources.getQuantityString( + R.plurals.recommendation_times, + item.numRecommendations, + item.numRecommendations + ) binding.cardRating.isGone = true binding.ivAnimeBanner.load( - item.node.mainPicture?.large ?: item.node.mainPicture?.medium - ) { - crossfade(true) - placeholder(R.drawable.ic_anime_placeholder) - error(R.drawable.ic_anime_placeholder) - fallback(R.drawable.ic_anime_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.AnimeImageBuilder + ) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/adapters/RelatedAnimeAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/adapters/RelatedAnimeAdapter.kt index cf48bbd..420e36a 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/adapters/RelatedAnimeAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/adapters/RelatedAnimeAdapter.kt @@ -7,8 +7,7 @@ import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation -import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse import com.sharkaboi.mediahub.databinding.AnimeListItemBinding @@ -64,14 +63,9 @@ class RelatedAnimeAdapter(private val onClick: (Int) -> Unit) : binding.tvEpisodesWatched.text = item.relationTypeFormatted binding.cardRating.isGone = true binding.ivAnimeBanner.load( - item.node.mainPicture?.large ?: item.node.mainPicture?.medium - ) { - crossfade(true) - placeholder(R.drawable.ic_anime_placeholder) - error(R.drawable.ic_anime_placeholder) - fallback(R.drawable.ic_anime_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.AnimeImageBuilder + ) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/adapters/RelatedMangaAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/adapters/RelatedMangaAdapter.kt index 444bb61..09acc8d 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/adapters/RelatedMangaAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/adapters/RelatedMangaAdapter.kt @@ -7,8 +7,7 @@ import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation -import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse import com.sharkaboi.mediahub.databinding.MangaListItemBinding @@ -64,14 +63,9 @@ class RelatedMangaAdapter(private val onClick: (Int) -> Unit) : binding.tvChapsRead.text = item.relationTypeFormatted binding.cardRating.isGone = true binding.ivMangaBanner.load( - item.node.mainPicture?.large ?: item.node.mainPicture?.medium - ) { - crossfade(true) - placeholder(R.drawable.ic_manga_placeholder) - error(R.drawable.ic_manga_placeholder) - fallback(R.drawable.ic_manga_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.MangaImageBuilder + ) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/repository/AnimeDetailsRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/repository/AnimeDetailsRepositoryImpl.kt index fb2c97e..3a8ea70 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/repository/AnimeDetailsRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/repository/AnimeDetailsRepositoryImpl.kt @@ -6,8 +6,7 @@ import com.apollographql.apollo.coroutines.await import com.apollographql.apollo.exception.ApolloException import com.haroldadmin.cnradapter.NetworkResponse import com.sharkaboi.mediahub.common.extensions.emptyString -import com.sharkaboi.mediahub.common.extensions.ifNullOrBlank -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse import com.sharkaboi.mediahub.data.api.retrofit.AnimeService import com.sharkaboi.mediahub.data.api.retrofit.UserAnimeService @@ -17,6 +16,7 @@ import com.sharkaboi.mediahub.data.wrappers.MHTaskState import kotlinx.coroutines.* import kotlinx.coroutines.flow.firstOrNull import timber.log.Timber +import java.util.* class AnimeDetailsRepositoryImpl( private val animeService: AnimeService, @@ -33,7 +33,7 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError("Log in has expired, Log in again.", null) + error = MHError.LoginExpiredError ) } else { val result = animeService.getAnimeByIdAsync( @@ -54,10 +54,8 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with network" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.NetworkError ) } is NetworkResponse.ServerError -> { @@ -65,10 +63,8 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.body?.message.ifNullOrBlank { "Error with status code : ${result.code}" }, - null - ) + error = result.body?.message?.let { MHError(it) } + ?: MHError.apiErrorWithCode(result.code) ) } is NetworkResponse.UnknownError -> { @@ -76,10 +72,8 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with parsing" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.ParsingError ) } } @@ -90,7 +84,7 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError(e.message.ifNullOrBlank { "Unknown error" }, e) + error = e.message?.let { MHError(it) } ?: MHError.UnknownError ) } } @@ -103,7 +97,7 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError(e.message.ifNullOrBlank { "Protocol error" }, e.cause) + error = e.message?.let { MHError(it) } ?: MHError.ProtocolError ) } @@ -113,7 +107,7 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError(errorMessage.ifNullOrBlank { "Application error" }, null) + error = errorMessage?.let { MHError(it) } ?: MHError.ApplicationError ) } else { return@withContext MHTaskState( @@ -137,7 +131,7 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError("Log in has expired, Log in again.", null) + error = MHError.LoginExpiredError ) } else { val result = userAnimeService.updateAnimeStatusAsync( @@ -161,10 +155,8 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with network" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.NetworkError ) } is NetworkResponse.ServerError -> { @@ -172,10 +164,8 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.body?.message.ifNullOrBlank { "Error with status code : ${result.code}" }, - null - ) + error = result.body?.message?.let { MHError(it) } + ?: MHError.apiErrorWithCode(result.code) ) } is NetworkResponse.UnknownError -> { @@ -183,10 +173,8 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with parsing" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.ParsingError ) } } @@ -197,7 +185,7 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError(e.message.ifNullOrBlank { "Unknown Error" }, e) + error = e.message?.let { MHError(it) } ?: MHError.UnknownError ) } } @@ -212,7 +200,7 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError("Log in has expired, Log in again.", null) + error = MHError.LoginExpiredError ) } else { val result = userAnimeService.deleteAnimeFromListAsync( @@ -233,10 +221,8 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with network" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.NetworkError ) } is NetworkResponse.ServerError -> { @@ -245,18 +231,14 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - "Anime isn't in your list", null - ) + error = MHError.AnimeNotFoundError ) } return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.body?.message.ifNullOrBlank { "Error with status code : ${result.code}" }, - null - ) + error = result.body?.message?.let { MHError(it) } + ?: MHError.apiErrorWithCode(result.code) ) } is NetworkResponse.UnknownError -> { @@ -264,10 +246,8 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with parsing" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.ParsingError ) } } @@ -278,7 +258,7 @@ class AnimeDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError(e.message.ifNullOrBlank { "Unknown error" }, e) + error = e.message?.let { MHError(it) } ?: MHError.UnknownError ) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/ui/AnimeDetailsFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/ui/AnimeDetailsFragment.kt index 7973aa0..ed294cb 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/ui/AnimeDetailsFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/ui/AnimeDetailsFragment.kt @@ -19,15 +19,19 @@ import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import coil.load -import coil.transform.RoundedCornersTransformation import com.google.android.material.chip.Chip import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.shape.ShapeAppearanceModel +import com.sharkaboi.mediahub.BottomNavGraphDirections import com.sharkaboi.mediahub.R -import com.sharkaboi.mediahub.common.constants.MALExternalLinks +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.constants.UIConstants.setMediaHubChipStyle import com.sharkaboi.mediahub.common.extensions.* import com.sharkaboi.mediahub.common.util.openUrl +import com.sharkaboi.mediahub.data.api.constants.MALExternalLinks +import com.sharkaboi.mediahub.data.api.enums.AnimeRating.getAnimeRating import com.sharkaboi.mediahub.data.api.enums.AnimeStatus +import com.sharkaboi.mediahub.data.api.enums.getAnimeAiringStatus +import com.sharkaboi.mediahub.data.api.enums.getAnimeNsfwRating import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse import com.sharkaboi.mediahub.databinding.CustomEpisodeCountDialogBinding import com.sharkaboi.mediahub.databinding.FragmentAnimeDetailsBinding @@ -112,17 +116,10 @@ class AnimeDetailsFragment : Fragment() { private val handleListStatusUpdate = { state: AnimeDetailsUpdateClass -> binding.animeDetailsUserListCard.apply { btnStatus.text = - state.animeStatus?.getFormattedString() + state.animeStatus?.getFormattedString(requireContext()) ?: getString(R.string.not_added) - btnScore.text = ("${state.score ?: 0}/10") - btnCount.text = ( - "${state.numWatchedEpisode ?: 0}/${ - if (state.totalEps == 0) - "??" - else - state.totalEps.toString() - }" - ) + btnScore.text = getString(R.string.media_rating_template, state.score ?: 0) + btnCount.text = context?.getProgressStringWith(state.numWatchedEpisode, state.totalEps) btnScore.setOnClickListener { openScoreDialog(state.score) } @@ -144,172 +141,104 @@ class AnimeDetailsFragment : Fragment() { if (nextEp == null) { binding.nextEpisodeDetails.root.isGone = true } else { - val timeFromNow = nextEp.timeUntilAiring.getAiringTimeFormatted() - val airingString = buildString { - append("Episode ") - append(nextEp.episode) - append(" airs in ") - append(timeFromNow) - } - binding.nextEpisodeDetails.tvNextEpisodeDetails.text = airingString + binding.nextEpisodeDetails.tvNextEpisodeDetails.text = + context?.getAiringTimeFormatted(nextEp) } } private fun setData(animeByIDResponse: AnimeByIDResponse) { - binding.apply { - tvAnimeName.text = animeByIDResponse.title - tvAlternateTitles.setOnClickListener { - showAlternateTitlesDialog(animeByIDResponse.alternativeTitles) - } - tvStartDate.text = - animeByIDResponse.startDate?.tryParseDate()?.formatDateDMY() - ?: getString(R.string.n_a) - tvEndDate.text = - animeByIDResponse.endDate?.tryParseDate()?.formatDateDMY() - ?: getString(R.string.n_a) - tvMeanScore.text = animeByIDResponse.mean?.toString() ?: getString(R.string.n_a) - tvRank.text = animeByIDResponse.rank?.toString() ?: getString(R.string.n_a) - tvPopularityRank.text = animeByIDResponse.popularity.toString() - studiosChipGroup.apply { - removeAllViews() - if (animeByIDResponse.studios.isEmpty()) { - addView( - TextView(context).apply { - text = getString(R.string.n_a) - } - ) - } else { - animeByIDResponse.studios.forEach { studio -> - addView( - TextView(context).apply { - setTextColor(ContextCompat.getColor(context, R.color.colorPrimary)) - setTypeface(null, Typeface.BOLD) - text = ("${studio.name} ") - setOnClickListener { - openUrl( - MALExternalLinks.getAnimeProducerPageLink(studio) - ) - } - } - ) - } - } - } - ivAnimeMainPicture.load( - animeByIDResponse.mainPicture?.large ?: animeByIDResponse.mainPicture?.medium - ) { - crossfade(true) - placeholder(R.drawable.ic_anime_placeholder) - error(R.drawable.ic_anime_placeholder) - fallback(R.drawable.ic_anime_placeholder) - transformations(RoundedCornersTransformation(8f)) + setupAnimeMainDetailLayout(animeByIDResponse) + setupAnimeUserListStatusCard(animeByIDResponse) + setupAnimeOtherDetailLayout(animeByIDResponse) + setupAnimeOtherDetailsCard(animeByIDResponse) + setupAnimeOtherDetailsButtons(animeByIDResponse) + setupAnimeRecommendationsList(animeByIDResponse.recommendations) + setupRelatedAnimeList(animeByIDResponse.relatedAnime) + setupRelatedMangaList(animeByIDResponse.relatedManga) + } + + private fun setupAnimeUserListStatusCard(animeByIDResponse: AnimeByIDResponse) = + binding.animeDetailsUserListCard.apply { + btnPlus1.setOnClickListener { + animeDetailsViewModel.add1ToWatchedEps() } - ivAnimeMainPicture.setOnClickListener { - openImagesViewPager(animeByIDResponse.pictures) + btnPlus5.setOnClickListener { + animeDetailsViewModel.add5ToWatchedEps() } - otherDetails.tvSynopsis.text = - animeByIDResponse.synopsis?.ifBlank { getString(R.string.n_a) } - ?: getString(R.string.n_a) - otherDetails.tvSynopsis.setOnClickListener { - showFullSynopsisDialog( - animeByIDResponse.synopsis?.ifBlank { getString(R.string.n_a) } - ?: getString(R.string.n_a) - ) + btnPlus10.setOnClickListener { + animeDetailsViewModel.add10ToWatchedEps() } - otherDetails.ratingsChipGroup.apply { - removeAllViews() - addView( - Chip(context).apply { - setEnsureMinTouchTargetSize(false) - shapeAppearanceModel = ShapeAppearanceModel().withCornerSize(8f) - text = - animeByIDResponse.nsfw?.getAnimeNsfwRating() ?: getString(R.string.n_a) - } - ) - addView( - Chip(context).apply { - setEnsureMinTouchTargetSize(false) - shapeAppearanceModel = ShapeAppearanceModel().withCornerSize(8f) - text = animeByIDResponse.rating?.getRating() ?: getString(R.string.n_a) - } + btnCount.setOnClickListener { + openAnimeWatchedCountDialog( + animeByIDResponse.numEpisodes, + animeByIDResponse.myListStatus?.numEpisodesWatched ) } - animeByIDResponse.genres.let { - otherDetails.genresChipGroup.removeAllViews() - if (it.isEmpty()) { - otherDetails.genresChipGroup.addView( - Chip(context).apply { - setEnsureMinTouchTargetSize(false) - shapeAppearanceModel = ShapeAppearanceModel().withCornerSize(8f) - text = getString(R.string.n_a) - } - ) - } else { - it.forEach { genre -> - otherDetails.genresChipGroup.addView( - Chip(context).apply { - setEnsureMinTouchTargetSize(false) - setOnClickListener { - openUrl( - MALExternalLinks.getAnimeGenresLink( - genre - ) - ) - } - shapeAppearanceModel = ShapeAppearanceModel().withCornerSize(8f) - text = genre.name - } - ) - } - } - } - otherDetails.btnMediaType.text = ("Type : ${animeByIDResponse.mediaType.uppercase()}") - otherDetails.btnMediaType.setOnClickListener { - val action = - AnimeDetailsFragmentDirections.openAnimeRankings(animeByIDResponse.mediaType) - navController.navigate(action) - } - otherDetails.btnAnimeCurrentStatus.text = - animeByIDResponse.status.getAnimeAiringStatus() - otherDetails.btnTotalEps.text = - animeByIDResponse.numEpisodes.let { - if (it == 0) - "${getString(R.string.n_a)} eps" - else - "$it ${if (it == 1) "ep" else "eps"}" - } - otherDetails.btnSeason.text = animeByIDResponse.startSeason?.let { - "${it.season.capitalizeFirst()} ${it.year}" - } ?: "Season : ${getString(R.string.n_a)}" - otherDetails.btnSeason.setOnClickListener { - val action = - AnimeDetailsFragmentDirections.openAnimeSeasonals( - animeByIDResponse.startSeason?.season, - animeByIDResponse.startSeason?.year ?: 0 - ) - navController.navigate(action) + btnScore.setOnClickListener { + openScoreDialog(animeByIDResponse.myListStatus?.score) } - otherDetails.tvSchedule.text = - animeByIDResponse.broadcast?.getBroadcastTime() ?: getString(R.string.n_a) - otherDetails.btnSource.text = - ( - "From ${ - animeByIDResponse.source?.replaceUnderScoreWithWhiteSpace() - ?.capitalizeFirst() - ?: getString(R.string.n_a) - }" - ) - otherDetails.btnAverageLength.text = - animeByIDResponse.averageEpisodeDuration.getEpisodeLengthFromSeconds() - otherDetails.ibNotify.setOnClickListener { - onNotifyClick(animeByIDResponse.id, animeByIDResponse.broadcast) + btnStatus.setOnClickListener { + openStatusDialog(animeByIDResponse.myListStatus?.status, animeByIDResponse.id) } - otherDetails.chipGroupOptions.forEach { - if (it is Chip) { - it.setEnsureMinTouchTargetSize(false) - it.shapeAppearanceModel = ShapeAppearanceModel().withCornerSize(8f) - } + btnConfirm.setOnClickListener { + animeDetailsViewModel.submitStatusUpdate(animeByIDResponse.id) } + } + + private fun setupRelatedMangaList(relatedManga: List) { + val rvRelatedManga = binding.otherDetails.rvRelatedManga + rvRelatedManga.adapter = RelatedMangaAdapter { mangaId -> + openMangaWithId(mangaId) + }.apply { + submitList(relatedManga) + } + binding.otherDetails.tvRelatedMangaHint.isVisible = relatedManga.isNotEmpty() + rvRelatedManga.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + rvRelatedManga.setHasFixedSize(true) + rvRelatedManga.itemAnimator = DefaultItemAnimator() + } + + private fun openMangaWithId(mangaId: Int) { + val action = BottomNavGraphDirections.openMangaById(mangaId) + navController.navigate(action) + } + + private fun setupRelatedAnimeList(relatedAnime: List) { + val rvRelatedAnime = binding.otherDetails.rvRelatedAnime + rvRelatedAnime.adapter = RelatedAnimeAdapter { animeId -> + openAnimeWithId(animeId) + }.apply { + submitList(relatedAnime) + } + binding.otherDetails.tvRelatedAnimeHint.isVisible = relatedAnime.isNotEmpty() + rvRelatedAnime.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + rvRelatedAnime.setHasFixedSize(true) + rvRelatedAnime.itemAnimator = DefaultItemAnimator() + } + + private fun setupAnimeRecommendationsList(recommendations: List) { + val rvRecommendations = binding.otherDetails.rvRecommendations + rvRecommendations.adapter = RecommendedAnimeAdapter { animeId -> + openAnimeWithId(animeId) + }.apply { + submitList(recommendations) + } + binding.otherDetails.tvRecommendations.isVisible = recommendations.isNotEmpty() + rvRecommendations.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + rvRecommendations.setHasFixedSize(true) + rvRecommendations.itemAnimator = DefaultItemAnimator() + } + + private fun openAnimeWithId(animeId: Int) { + val action = BottomNavGraphDirections.openAnimeById(animeId) + navController.navigate(action) + } + + private fun setupAnimeOtherDetailsButtons(animeByIDResponse: AnimeByIDResponse) = + binding.apply { otherDetails.btnBackground.setOnClickListener { openBackgroundDialog(animeByIDResponse.background) } @@ -356,104 +285,171 @@ class AnimeDetailsFragment : Fragment() { otherDetails.btnStatistics.setOnClickListener { openStatsDialog(animeByIDResponse.statistics) } - otherDetails.rvRecommendations.apply { - adapter = RecommendedAnimeAdapter { animeId -> - val action = AnimeDetailsFragmentDirections.animeDetailsWithId(animeId) - navController.navigate(action) - }.apply { - submitList(animeByIDResponse.recommendations) - } - otherDetails.tvRecommendations.isVisible = - animeByIDResponse.recommendations.isNotEmpty() - layoutManager = - LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - setHasFixedSize(true) - itemAnimator = DefaultItemAnimator() - } - otherDetails.rvRelatedAnime.apply { - adapter = RelatedAnimeAdapter { animeId -> - val action = AnimeDetailsFragmentDirections.animeDetailsWithId(animeId) - navController.navigate(action) - }.apply { - submitList(animeByIDResponse.relatedAnime) - } - otherDetails.tvRelatedAnimeHint.isVisible = - animeByIDResponse.relatedAnime.isNotEmpty() - layoutManager = - LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - setHasFixedSize(true) - itemAnimator = DefaultItemAnimator() + } + + private fun setupAnimeOtherDetailsCard(animeByIDResponse: AnimeByIDResponse) = binding.apply { + otherDetails.btnMediaType.text = + context?.getMediaTypeStringWith(animeByIDResponse.mediaType.capitalizeFirst()) + otherDetails.btnMediaType.setOnClickListener { + openAnimeRankingWith(animeByIDResponse.mediaType) + } + otherDetails.btnAnimeCurrentStatus.text = + context?.getAnimeAiringStatus(animeByIDResponse.status) + otherDetails.btnTotalEps.text = + context?.getEpisodesOfAnimeString(animeByIDResponse.numEpisodes) + otherDetails.btnSeason.text = context?.getAnimeSeasonString( + animeByIDResponse.startSeason?.season, + animeByIDResponse.startSeason?.year + ) + otherDetails.btnSeason.setOnClickListener { + openAnimeSeasonalWith( + animeByIDResponse.startSeason?.season, + animeByIDResponse.startSeason?.year ?: 0 + ) + } + otherDetails.tvSchedule.text = + context?.getAnimeBroadcastTime(animeByIDResponse.broadcast) + otherDetails.btnSource.text = + context?.getAnimeOriginalSourceString(animeByIDResponse.source) + otherDetails.btnAverageLength.text = + context?.getEpisodeLengthFromSeconds(animeByIDResponse.averageEpisodeDuration) + otherDetails.ibNotify.setOnClickListener { + onNotifyClick(animeByIDResponse.id, animeByIDResponse.broadcast) + } + otherDetails.chipGroupOptions.forEach { + if (it is Chip) { + it.setMediaHubChipStyle() } - otherDetails.rvRelatedManga.apply { - adapter = RelatedMangaAdapter { mangaId -> - val action = AnimeDetailsFragmentDirections.openMangaDetailsWithId(mangaId) - navController.navigate(action) - }.apply { - submitList(animeByIDResponse.relatedManga) + } + } + + private fun openAnimeSeasonalWith(season: String?, year: Int) { + val action = + AnimeDetailsFragmentDirections.openAnimeSeasonals(season, year) + navController.navigate(action) + } + + private fun openAnimeRankingWith(mediaType: String) { + val action = + AnimeDetailsFragmentDirections.openAnimeRankings(mediaType) + navController.navigate(action) + } + + private fun setupAnimeOtherDetailLayout(animeByIDResponse: AnimeByIDResponse) { + binding.otherDetails.tvSynopsis.text = + animeByIDResponse.synopsis.ifNullOrBlank { getString(R.string.n_a) } + binding.otherDetails.tvSynopsis.setOnClickListener { + showFullSynopsisDialog(animeByIDResponse.synopsis) + } + setupRatingsChipGroup(animeByIDResponse) + setupGenresChipGroup(animeByIDResponse.genres) + } + + private fun setupGenresChipGroup(genres: List) { + val chipGroup = binding.otherDetails.genresChipGroup + chipGroup.removeAllViews() + if (genres.isEmpty()) { + val naChip = Chip(context) + naChip.setMediaHubChipStyle() + naChip.text = getString(R.string.n_a) + chipGroup.addView(naChip) + } else { + genres.forEach { genre -> + val genreChip = Chip(context) + genreChip.setMediaHubChipStyle() + genreChip.text = genre.name + genreChip.setOnClickListener { + openUrl(MALExternalLinks.getAnimeGenresLink(genre)) } - otherDetails.tvRelatedMangaHint.isVisible = - animeByIDResponse.relatedManga.isNotEmpty() - layoutManager = - LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - setHasFixedSize(true) - itemAnimator = DefaultItemAnimator() + chipGroup.addView(genreChip) } - animeDetailsUserListCard.apply { - btnPlus1.setOnClickListener { - animeDetailsViewModel.add1ToWatchedEps() - } - btnPlus5.setOnClickListener { - animeDetailsViewModel.add5ToWatchedEps() - } - btnPlus10.setOnClickListener { - animeDetailsViewModel.add10ToWatchedEps() - } - btnCount.setOnClickListener { - openAnimeWatchedCountDialog( - animeByIDResponse.numEpisodes, - animeByIDResponse.myListStatus?.numEpisodesWatched - ) - } - btnScore.setOnClickListener { - openScoreDialog(animeByIDResponse.myListStatus?.score) - } - btnStatus.setOnClickListener { - openStatusDialog(animeByIDResponse.myListStatus?.status, animeByIDResponse.id) - } - btnConfirm.setOnClickListener { - animeDetailsViewModel.submitStatusUpdate(animeByIDResponse.id) + } + } + + private fun setupAnimeMainDetailLayout(animeByIDResponse: AnimeByIDResponse) = binding.apply { + setupAnimeImagePreview(animeByIDResponse) + tvAnimeName.text = animeByIDResponse.title + tvAlternateTitles.setOnClickListener { + showAlternateTitlesDialog(animeByIDResponse.alternativeTitles) + } + tvStartDate.text = + animeByIDResponse.startDate?.tryParseDate()?.formatDateDMY() + ?: getString(R.string.n_a) + tvEndDate.text = + animeByIDResponse.endDate?.tryParseDate()?.formatDateDMY() + ?: getString(R.string.n_a) + tvMeanScore.text = animeByIDResponse.mean?.toString() ?: getString(R.string.n_a) + tvRank.text = animeByIDResponse.rank?.toString() ?: getString(R.string.n_a) + tvPopularityRank.text = animeByIDResponse.popularity.toString() + setupStudiosChipGroup(animeByIDResponse.studios) + } + + private fun setupRatingsChipGroup(animeByIDResponse: AnimeByIDResponse) { + val chipGroup = binding.otherDetails.ratingsChipGroup + chipGroup.removeAllViews() + val nsfwRatingChip = Chip(context) + nsfwRatingChip.setMediaHubChipStyle() + nsfwRatingChip.text = nsfwRatingChip.context.getAnimeNsfwRating(animeByIDResponse.nsfw) + chipGroup.addView(nsfwRatingChip) + val pgRatingChip = Chip(context) + pgRatingChip.setMediaHubChipStyle() + pgRatingChip.text = pgRatingChip.context.getAnimeRating(animeByIDResponse.rating) + chipGroup.addView(pgRatingChip) + } + + private fun setupAnimeImagePreview(animeByIDResponse: AnimeByIDResponse) { + binding.ivAnimeMainPicture.load( + uri = animeByIDResponse.mainPicture?.large ?: animeByIDResponse.mainPicture?.medium, + builder = UIConstants.AnimeImageBuilder + ) + binding.ivAnimeMainPicture.setOnClickListener { + openImagesViewPager(animeByIDResponse.pictures) + } + } + + private fun setupStudiosChipGroup(studios: List) { + binding.studiosChipGroup.removeAllViews() + if (studios.isEmpty()) { + val textView = TextView(context) + textView.text = getString(R.string.n_a) + binding.studiosChipGroup.addView(textView) + } else { + studios.forEach { studio -> + val textView = TextView(context) + textView.setTextColor( + ContextCompat.getColor(textView.context, R.color.colorPrimary) + ) + textView.setTypeface(null, Typeface.BOLD) + textView.text = studio.name.plus(" ") + textView.setOnClickListener { + openUrl(MALExternalLinks.getAnimeProducerPageLink(studio)) } + binding.studiosChipGroup.addView(textView) } } } private fun onNotifyClick(id: Int, broadcast: AnimeByIDResponse.Broadcast?) { - showToast("Coming soon!") + showToast(R.string.coming_soon_hint) } private fun openImagesViewPager(pictures: List) { val images: List = pictures.map { it.large ?: it.medium } - val action = AnimeDetailsFragmentDirections.openImages(images.toTypedArray()) + val action = BottomNavGraphDirections.openImageSlider(images.toTypedArray()) navController.navigate(action) } - private fun showFullSynopsisDialog(synopsis: String) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Synopsis") - .setMessage( - synopsis - ).setPositiveButton(android.R.string.ok) { dialog, _ -> - dialog.dismiss() - }.show() - } + private fun showFullSynopsisDialog(synopsis: String?) = + requireContext().showNoActionOkDialog(R.string.synopsis, synopsis) private fun openStatusDialog(status: String?, animeId: Int) { - val singleItems = - arrayOf("Not added") + AnimeStatus.malStatuses.map { it.getFormattedString() } + val singleItems = arrayOf(getString(R.string.not_added)) + AnimeStatus.malStatuses.map { + it.getFormattedString(requireContext()) + } val checkedItem = status?.let { AnimeStatus.malStatuses.indexOfFirst { it.name == status } + 1 } ?: 0 MaterialAlertDialogBuilder(requireContext()) - .setTitle("Set status as") + .setTitle(R.string.media_set_status_hint) .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> when (which) { checkedItem -> Unit @@ -469,79 +465,69 @@ class AnimeDetailsFragment : Fragment() { val singleItems = (0..10).map { it.toString() }.toTypedArray() val checkedItem = score ?: 0 MaterialAlertDialogBuilder(requireContext()) - .setTitle("Set score as") + .setTitle(R.string.media_set_score_hint) .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> when (which) { checkedItem -> Unit else -> animeDetailsViewModel.setScore(which) } dialog.dismiss() - } - .show() + }.show() } - @SuppressLint("InflateParams") private fun openAnimeWatchedCountDialog(totalEps: Int?, watchedEps: Int?) { if (totalEps == null || totalEps == 0) { - val view = - LayoutInflater.from(context).inflate(R.layout.custom_episode_count_dialog, null) - val binding: CustomEpisodeCountDialogBinding = - CustomEpisodeCountDialogBinding.bind(view) - binding.etNum.setText(watchedEps?.toString() ?: "") - MaterialAlertDialogBuilder(requireContext()) - .setView(binding.root) - .setPositiveButton("Ok") { dialog, _ -> - val count = binding.etNum.text?.toString()?.toInt() ?: 0 - animeDetailsViewModel.setEpisodeCount(count) - dialog.dismiss() - } - .setNegativeButton("Cancel") { dialog, _ -> - dialog.dismiss() - } - .show() + showWatchedCountDialogWithTextField(watchedEps) } else { - val singleItems = (0..totalEps).map { it.toString() }.toTypedArray() - val checkedItem = watchedEps ?: 0 - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Watched till episode") - .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> - animeDetailsViewModel.setEpisodeCount(which) - dialog.dismiss() - } - .show() + showWatchedCountDialogWithList(totalEps, watchedEps) } } - private fun openBackgroundDialog(background: String?) { + private fun showWatchedCountDialogWithList(totalEps: Int, watchedEps: Int?) { + val singleItems = (0..totalEps).map { it.toString() }.toTypedArray() + val checkedItem = watchedEps ?: 0 MaterialAlertDialogBuilder(requireContext()) - .setTitle("Background") - .setMessage( - if (background != null && background.isNotBlank()) - background - else - getString(R.string.n_a) - ).setPositiveButton(android.R.string.ok) { dialog, _ -> + .setTitle(R.string.anime_watched_till_hint) + .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> + animeDetailsViewModel.setEpisodeCount(which) dialog.dismiss() - }.show() + } + .show() } - private fun openStatsDialog(statistics: AnimeByIDResponse.Statistics?) { + @SuppressLint("InflateParams") + private fun showWatchedCountDialogWithTextField(watchedEps: Int?) { + val view = LayoutInflater + .from(context) + .inflate(R.layout.custom_episode_count_dialog, null) + val binding: CustomEpisodeCountDialogBinding = + CustomEpisodeCountDialogBinding.bind(view) + binding.etNum.setText(watchedEps?.toString() ?: String.emptyString) MaterialAlertDialogBuilder(requireContext()) - .setTitle("Statistics") - .setMessage( - statistics?.getStats() ?: getString(R.string.n_a) - ).setPositiveButton(android.R.string.ok) { dialog, _ -> + .setView(binding.root) + .setPositiveButton(R.string.ok) { dialog, _ -> + val count = binding.etNum.text?.toString()?.toInt() ?: 0 + animeDetailsViewModel.setEpisodeCount(count) dialog.dismiss() - }.show() - } - - private fun showAlternateTitlesDialog(alternativeTitles: AnimeByIDResponse.AlternativeTitles?) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Alternate titles") - .setMessage( - alternativeTitles?.getFormattedString() ?: getString(R.string.n_a) - ).setPositiveButton(android.R.string.ok) { dialog, _ -> + } + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() - }.show() + } + .show() } + + private fun openBackgroundDialog(background: String?) = + requireContext().showNoActionOkDialog(R.string.background, background) + + private fun openStatsDialog(statistics: AnimeByIDResponse.Statistics?) = + requireContext().showNoActionOkDialog( + R.string.statistics, + context?.getAnimeStats(statistics) + ) + + private fun showAlternateTitlesDialog(alternativeTitles: AnimeByIDResponse.AlternativeTitles?) = + requireContext().showNoActionOkDialog( + R.string.alternate_titles_hint, + context?.getFormattedAnimeTitlesString(alternativeTitles) + ) } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/vm/AnimeDetailsViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/vm/AnimeDetailsViewModel.kt index 994e1c7..fdfbd5c 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/vm/AnimeDetailsViewModel.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/vm/AnimeDetailsViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.sharkaboi.mediahub.data.api.enums.AnimeStatus import com.sharkaboi.mediahub.data.api.enums.animeStatusFromString +import com.sharkaboi.mediahub.data.wrappers.MHError import com.sharkaboi.mediahub.modules.anime_details.repository.AnimeDetailsRepository import com.sharkaboi.mediahub.modules.anime_details.util.AnimeDetailsUpdateClass import dagger.hilt.android.lifecycle.HiltViewModel @@ -187,7 +188,7 @@ class AnimeDetailsViewModel _animeDetailState.setFailure(result.error.errorMessage) } } ?: run { - _animeDetailState.setFailure("No data to update") + _animeDetailState.setFailure(MHError.InvalidStateError.errorMessage) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/adapters/AnimeRankingDetailedAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/adapters/AnimeRankingDetailedAdapter.kt index 3ceb94d..46fd7c8 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/adapters/AnimeRankingDetailedAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/adapters/AnimeRankingDetailedAdapter.kt @@ -6,9 +6,9 @@ import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation import com.sharkaboi.mediahub.R -import com.sharkaboi.mediahub.common.extensions.roundOfString +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.extensions.getRatingStringWithRating import com.sharkaboi.mediahub.data.api.models.anime.AnimeRankingResponse import com.sharkaboi.mediahub.databinding.AnimeListItemBinding @@ -23,16 +23,16 @@ class AnimeRankingDetailedAdapter( fun bind(item: AnimeRankingResponse.Data?) { item?.let { animeListItemBinding.apply { - ivAnimeBanner.load(it.node.mainPicture?.large ?: it.node.mainPicture?.medium) { - crossfade(true) - placeholder(R.drawable.ic_anime_placeholder) - error(R.drawable.ic_anime_placeholder) - fallback(R.drawable.ic_anime_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + ivAnimeBanner.load( + uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.AnimeImageBuilder + ) tvAnimeName.text = it.node.title - tvEpisodesWatched.text = ("Rank : ${it.ranking.rank}") - tvScore.text = ("★ ${it.node.meanScore?.roundOfString() ?: "0"}") + tvEpisodesWatched.text = tvEpisodesWatched.context.getString( + R.string.media_rank_template, + it.ranking.rank + ) + tvScore.text = tvScore.context.getRatingStringWithRating(it.node.meanScore) root.setOnClickListener { onItemClick(item.node.id) } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/repository/AnimeRankingRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/repository/AnimeRankingRepositoryImpl.kt index 78964f5..3ffc5bd 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/repository/AnimeRankingRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/repository/AnimeRankingRepositoryImpl.kt @@ -4,7 +4,7 @@ import android.content.SharedPreferences import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.AnimeRankingType import com.sharkaboi.mediahub.data.api.models.anime.AnimeRankingResponse import com.sharkaboi.mediahub.data.api.retrofit.AnimeService diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/ui/AnimeRankingFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/ui/AnimeRankingFragment.kt index 6ad3eaf..e95a66c 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/ui/AnimeRankingFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/ui/AnimeRankingFragment.kt @@ -14,7 +14,7 @@ import androidx.paging.LoadState import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.chip.Chip -import com.google.android.material.shape.ShapeAppearanceModel +import com.sharkaboi.mediahub.common.constants.UIConstants.setMediaHubChipStyle import com.sharkaboi.mediahub.common.extensions.showToast import com.sharkaboi.mediahub.data.api.enums.AnimeRankingType import com.sharkaboi.mediahub.databinding.FragmentAnimeRankingBinding @@ -60,6 +60,7 @@ class AnimeRankingFragment : Fragment() { setupFilterChips() setUpRecyclerView() setObservers() + collectPagedList() } private fun initRanking() { @@ -82,14 +83,14 @@ class AnimeRankingFragment : Fragment() { AnimeRankingType.values().forEach { rankingType -> binding.rankTypeChipGroup.addView( Chip(context).apply { - text = rankingType.getAnimeRanking() - setEnsureMinTouchTargetSize(false) + text = rankingType.getAnimeRanking(context) + setMediaHubChipStyle() isCheckable = true isChecked = rankingType == animeRankingViewModel.selectedRankingType - shapeAppearanceModel = ShapeAppearanceModel().withCornerSize(8f) setOnClickListener { animeRankingViewModel.setRankingType(rankingType) collectPagedList() + binding.rvAnimeRanking.smoothScrollToPosition(0) } } ) @@ -99,7 +100,7 @@ class AnimeRankingFragment : Fragment() { private fun setUpRecyclerView() { binding.rvAnimeRanking.apply { animeRankingDetailedAdapter = AnimeRankingDetailedAdapter { animeId -> - val action = AnimeRankingFragmentDirections.openAnimeDetailsWithId(animeId) + val action = AnimeRankingFragmentDirections.openAnimeById(animeId) navController.navigate(action) } layoutManager = GridLayoutManager(context, 3) diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/adapters/AnimeSearchListAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/adapters/AnimeSearchListAdapter.kt index 1a3afed..51f3915 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/adapters/AnimeSearchListAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/adapters/AnimeSearchListAdapter.kt @@ -7,9 +7,8 @@ import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation -import com.sharkaboi.mediahub.R -import com.sharkaboi.mediahub.common.extensions.roundOfString +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.extensions.getRatingStringWithRating import com.sharkaboi.mediahub.data.api.models.anime.AnimeSearchResponse import com.sharkaboi.mediahub.databinding.AnimeListItemBinding @@ -24,16 +23,13 @@ class AnimeSearchListAdapter( fun bind(item: AnimeSearchResponse.Data?) { item?.let { animeListItemBinding.apply { - ivAnimeBanner.load(it.node.mainPicture?.large ?: it.node.mainPicture?.medium) { - crossfade(true) - placeholder(R.drawable.ic_anime_placeholder) - error(R.drawable.ic_anime_placeholder) - fallback(R.drawable.ic_anime_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + ivAnimeBanner.load( + uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.AnimeImageBuilder + ) tvAnimeName.text = it.node.title tvEpisodesWatched.isVisible = false - tvScore.text = ("★ ${it.node.meanScore?.roundOfString() ?: "0"}") + tvScore.text = tvScore.context.getRatingStringWithRating(it.node.meanScore) root.setOnClickListener { onItemClick(item.node.id) } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/repository/AnimeSearchRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/repository/AnimeSearchRepositoryImpl.kt index 2f19c36..f39384f 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/repository/AnimeSearchRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/repository/AnimeSearchRepositoryImpl.kt @@ -4,7 +4,7 @@ import android.content.SharedPreferences import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.models.anime.AnimeSearchResponse import com.sharkaboi.mediahub.data.api.retrofit.AnimeService import com.sharkaboi.mediahub.data.datastore.DataStoreRepository diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/ui/AnimeSearchFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/ui/AnimeSearchFragment.kt index a147649..235b253 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/ui/AnimeSearchFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/ui/AnimeSearchFragment.kt @@ -16,6 +16,8 @@ import androidx.paging.LoadState import androidx.paging.PagingData import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.GridLayoutManager +import com.sharkaboi.mediahub.BottomNavGraphDirections +import com.sharkaboi.mediahub.R import com.sharkaboi.mediahub.common.extensions.debounce import com.sharkaboi.mediahub.common.extensions.showToast import com.sharkaboi.mediahub.databinding.FragmentAnimeSearchBinding @@ -62,7 +64,7 @@ class AnimeSearchFragment : Fragment() { private fun setUpRecyclerView() { binding.rvSearchResults.apply { animeSearchListAdapter = AnimeSearchListAdapter { animeId -> - val action = AnimeSearchFragmentDirections.openAnimeDetailsWithId(animeId) + val action = BottomNavGraphDirections.openAnimeById(animeId) navController.navigate(action) } layoutManager = GridLayoutManager(context, 3) @@ -82,7 +84,8 @@ class AnimeSearchFragment : Fragment() { binding.progress.isShowing = loadStates.refresh is LoadState.Loading binding.searchEmptyView.root.isVisible = loadStates.refresh is LoadState.NotLoading && animeSearchListAdapter.itemCount == 0 - binding.searchEmptyView.tvHint.text = ("No anime found for query") + binding.searchEmptyView.tvHint.text = + getString(R.string.anime_search_no_result_hint) } } val debounce = debounce(scope = lifecycleScope) { @@ -99,7 +102,7 @@ class AnimeSearchFragment : Fragment() { query?.toString()?.let { if (it.length < 3) { binding.searchEmptyView.root.isVisible = true - binding.searchEmptyView.tvHint.text = ("Search for any anime") + binding.searchEmptyView.tvHint.text = getString(R.string.anime_search_hint) animeSearchListAdapter.submitData(PagingData.empty()) return@launch } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/adapters/AnimeSeasonalAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/adapters/AnimeSeasonalAdapter.kt index 4e2bfc9..5bec8f7 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/adapters/AnimeSeasonalAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/adapters/AnimeSeasonalAdapter.kt @@ -7,9 +7,8 @@ import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation -import com.sharkaboi.mediahub.R -import com.sharkaboi.mediahub.common.extensions.roundOfString +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.extensions.getRatingStringWithRating import com.sharkaboi.mediahub.data.api.models.anime.AnimeSeasonalResponse import com.sharkaboi.mediahub.databinding.AnimeListItemBinding @@ -24,16 +23,13 @@ class AnimeSeasonalAdapter( fun bind(item: AnimeSeasonalResponse.Data?) { item?.let { animeListItemBinding.apply { - ivAnimeBanner.load(it.node.mainPicture?.large ?: it.node.mainPicture?.medium) { - crossfade(true) - placeholder(R.drawable.ic_anime_placeholder) - error(R.drawable.ic_anime_placeholder) - fallback(R.drawable.ic_anime_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + ivAnimeBanner.load( + uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.AnimeImageBuilder + ) tvAnimeName.text = it.node.title tvEpisodesWatched.isVisible = false - tvScore.text = ("★ ${it.node.meanScore?.roundOfString() ?: "0"}") + tvScore.text = tvScore.context.getRatingStringWithRating(it.node.meanScore) root.setOnClickListener { onItemClick(item.node.id) } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/repository/AnimeSeasonalRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/repository/AnimeSeasonalRepositoryImpl.kt index d8d6e91..207eee0 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/repository/AnimeSeasonalRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/repository/AnimeSeasonalRepositoryImpl.kt @@ -4,7 +4,7 @@ import android.content.SharedPreferences import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.AnimeSeason import com.sharkaboi.mediahub.data.api.models.anime.AnimeSeasonalResponse import com.sharkaboi.mediahub.data.api.retrofit.AnimeService diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/ui/AnimeSeasonalFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/ui/AnimeSeasonalFragment.kt index 7121da7..44024ad 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/ui/AnimeSeasonalFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/ui/AnimeSeasonalFragment.kt @@ -13,6 +13,7 @@ import androidx.navigation.fragment.navArgs import androidx.paging.LoadState import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.GridLayoutManager +import com.sharkaboi.mediahub.BottomNavGraphDirections import com.sharkaboi.mediahub.common.extensions.capitalizeFirst import com.sharkaboi.mediahub.common.extensions.showToast import com.sharkaboi.mediahub.data.api.enums.getAnimeSeason @@ -60,6 +61,7 @@ class AnimeSeasonalFragment : Fragment() { setupSeasonButtons() setUpRecyclerView() setObservers() + collectPagedList() } private fun initSeason() { @@ -102,7 +104,7 @@ class AnimeSeasonalFragment : Fragment() { private fun setUpRecyclerView() { binding.rvAnimeSeasonal.apply { animeSeasonalAdapter = AnimeSeasonalAdapter { animeId -> - val action = AnimeSeasonalFragmentDirections.openAnimeDetailsWithId(animeId) + val action = BottomNavGraphDirections.openAnimeById(animeId) navController.navigate(action) } layoutManager = GridLayoutManager(context, 3) diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/adapters/AnimeSuggestionsAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/adapters/AnimeSuggestionsAdapter.kt index c6678dd..1ea72af 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/adapters/AnimeSuggestionsAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/adapters/AnimeSuggestionsAdapter.kt @@ -7,9 +7,8 @@ import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation -import com.sharkaboi.mediahub.R -import com.sharkaboi.mediahub.common.extensions.roundOfString +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.extensions.getRatingStringWithRating import com.sharkaboi.mediahub.data.api.models.anime.AnimeSuggestionsResponse import com.sharkaboi.mediahub.databinding.AnimeListItemBinding @@ -24,16 +23,13 @@ class AnimeSuggestionsAdapter( fun bind(item: AnimeSuggestionsResponse.Data?) { item?.let { animeListItemBinding.apply { - ivAnimeBanner.load(it.node.mainPicture?.large ?: it.node.mainPicture?.medium) { - crossfade(true) - placeholder(R.drawable.ic_anime_placeholder) - error(R.drawable.ic_anime_placeholder) - fallback(R.drawable.ic_anime_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + ivAnimeBanner.load( + uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.AnimeImageBuilder + ) tvAnimeName.text = it.node.title tvEpisodesWatched.isVisible = false - tvScore.text = ("★ ${it.node.meanScore?.roundOfString() ?: "0"}") + tvScore.text = tvScore.context.getRatingStringWithRating(it.node.meanScore) root.setOnClickListener { onItemClick(item.node.id) } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/repository/AnimeSuggestionsRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/repository/AnimeSuggestionsRepositoryImpl.kt index 078eda3..20240d1 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/repository/AnimeSuggestionsRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/repository/AnimeSuggestionsRepositoryImpl.kt @@ -4,7 +4,7 @@ import android.content.SharedPreferences import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.models.anime.AnimeSuggestionsResponse import com.sharkaboi.mediahub.data.api.retrofit.AnimeService import com.sharkaboi.mediahub.data.datastore.DataStoreRepository diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/ui/AnimeSuggestionsFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/ui/AnimeSuggestionsFragment.kt index 0779bfb..2721184 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/ui/AnimeSuggestionsFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/ui/AnimeSuggestionsFragment.kt @@ -12,6 +12,7 @@ import androidx.navigation.fragment.findNavController import androidx.paging.LoadState import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.GridLayoutManager +import com.sharkaboi.mediahub.BottomNavGraphDirections import com.sharkaboi.mediahub.common.extensions.showToast import com.sharkaboi.mediahub.databinding.FragmentAnimeSuggestionsBinding import com.sharkaboi.mediahub.modules.anime_suggestions.adapters.AnimeSuggestionsAdapter @@ -54,7 +55,7 @@ class AnimeSuggestionsFragment : Fragment() { private fun setUpRecyclerView() { binding.rvAnimeSuggestions.apply { animeSuggestionsAdapter = AnimeSuggestionsAdapter { animeId -> - val action = AnimeSuggestionsFragmentDirections.openAnimeById(animeId) + val action = BottomNavGraphDirections.openAnimeById(animeId) navController.navigate(action) } layoutManager = GridLayoutManager(context, 3) diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/auth/repository/OAuthRepository.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/auth/repository/OAuthRepository.kt index c6036b0..967faeb 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/auth/repository/OAuthRepository.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/auth/repository/OAuthRepository.kt @@ -1,5 +1,7 @@ package com.sharkaboi.mediahub.modules.auth.repository +import com.sharkaboi.mediahub.data.wrappers.MHTaskState + interface OAuthRepository { - suspend fun getAccessToken(code: String, codeVerifier: String): String? + suspend fun getAccessToken(code: String, codeVerifier: String): MHTaskState } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/auth/repository/OAuthRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/auth/repository/OAuthRepositoryImpl.kt index 13d1fb4..021ae09 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/auth/repository/OAuthRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/auth/repository/OAuthRepositoryImpl.kt @@ -5,6 +5,8 @@ import com.sharkaboi.mediahub.BuildConfig import com.sharkaboi.mediahub.common.extensions.emptyString import com.sharkaboi.mediahub.data.api.retrofit.AuthService import com.sharkaboi.mediahub.data.datastore.DataStoreRepository +import com.sharkaboi.mediahub.data.wrappers.MHError +import com.sharkaboi.mediahub.data.wrappers.MHTaskState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber @@ -14,7 +16,7 @@ class OAuthRepositoryImpl( private val dataStoreRepository: DataStoreRepository ) : OAuthRepository { - override suspend fun getAccessToken(code: String, codeVerifier: String): String? = + override suspend fun getAccessToken(code: String, codeVerifier: String): MHTaskState = withContext(Dispatchers.IO) { val response = authService.getAccessTokenAsync( clientId = BuildConfig.clientId, @@ -27,20 +29,38 @@ class OAuthRepositoryImpl( dataStoreRepository.setAccessToken(response.body.accessToken) dataStoreRepository.setExpireIn() dataStoreRepository.setRefreshToken(response.body.refreshToken) - return@withContext null + return@withContext MHTaskState( + isSuccess = true, + data = null, + error = MHError.EmptyError + ) } is NetworkResponse.ServerError -> { Timber.d(response.body.toString()) - return@withContext response.body?.message - ?: "Error with status code : ${response.code}" + return@withContext MHTaskState( + isSuccess = false, + data = null, + error = response.body?.message?.let { MHError(it) } + ?: MHError.apiErrorWithCode(response.code) + ) } is NetworkResponse.NetworkError -> { Timber.d(response.error.message ?: String.emptyString) - return@withContext response.error.message ?: "Error with network" + return@withContext MHTaskState( + isSuccess = false, + data = null, + error = response.error.message?.let { MHError(it) } + ?: MHError.NetworkError + ) } is NetworkResponse.UnknownError -> { Timber.d(response.error.message ?: String.emptyString) - return@withContext response.error.message ?: "Error with parsing" + return@withContext MHTaskState( + isSuccess = false, + data = null, + error = response.error.message?.let { MHError(it) } + ?: MHError.ParsingError + ) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/auth/ui/OAuthActivity.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/auth/ui/OAuthActivity.kt index 9c1f9d0..356b795 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/auth/ui/OAuthActivity.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/auth/ui/OAuthActivity.kt @@ -94,10 +94,14 @@ class OAuthActivity : AppCompatActivity() { val uri = intent?.data Timber.d("onNewIntent uri :$uri") if (uri != null && uri.toString().startsWith(AppConstants.oAuthDeepLinkUri)) { - val code = uri.getQueryParameter("code") + val code = uri.getQueryParameter(CODE_QUERY_PARAM_KEY) if (code != null) { oAuthViewModel.receivedAuthToken(code) } } } + + companion object { + private const val CODE_QUERY_PARAM_KEY = "code" + } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/auth/vm/OAuthViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/auth/vm/OAuthViewModel.kt index de512cf..c01f3d9 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/auth/vm/OAuthViewModel.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/auth/vm/OAuthViewModel.kt @@ -50,10 +50,10 @@ class OAuthViewModel Timber.d("challenge retrieved: $codeChallenge") Timber.d("code received: $code") val result = oAuthRepository.getAccessToken(code, codeChallenge) - if (result == null) { + if (result.isSuccess) { _oAuthState.setOAuthSuccess() } else { - _oAuthState.setOAuthFailure(result) + _oAuthState.setOAuthFailure(result.error.errorMessage) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/adapters/AiringAnimeAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/adapters/AiringAnimeAdapter.kt index 00e23bf..36f8e18 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/adapters/AiringAnimeAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/adapters/AiringAnimeAdapter.kt @@ -7,9 +7,8 @@ import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation -import com.sharkaboi.mediahub.R -import com.sharkaboi.mediahub.common.extensions.roundOfString +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.extensions.getRatingStringWithRating import com.sharkaboi.mediahub.data.api.models.anime.AnimeSeasonalResponse import com.sharkaboi.mediahub.databinding.AnimeListItemBinding @@ -63,16 +62,11 @@ class AiringAnimeAdapter(private val onClick: (Int) -> Unit) : } binding.tvAnimeName.text = item.node.title binding.tvEpisodesWatched.isVisible = false - binding.tvScore.text = ("★ ${item.node.meanScore?.roundOfString() ?: "0"}") + binding.tvScore.text = binding.tvScore.context.getRatingStringWithRating(item.node.meanScore) binding.ivAnimeBanner.load( - item.node.mainPicture?.large ?: item.node.mainPicture?.medium - ) { - crossfade(true) - placeholder(R.drawable.ic_anime_placeholder) - error(R.drawable.ic_anime_placeholder) - fallback(R.drawable.ic_anime_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.AnimeImageBuilder + ) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/adapters/AnimeRankingAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/adapters/AnimeRankingAdapter.kt index a9f6c5c..c50ecba 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/adapters/AnimeRankingAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/adapters/AnimeRankingAdapter.kt @@ -7,9 +7,8 @@ import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation -import com.sharkaboi.mediahub.R -import com.sharkaboi.mediahub.common.extensions.roundOfString +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.extensions.getRatingStringWithRating import com.sharkaboi.mediahub.data.api.models.anime.AnimeRankingResponse import com.sharkaboi.mediahub.databinding.AnimeListItemBinding @@ -63,16 +62,11 @@ class AnimeRankingAdapter(private val onClick: (Int) -> Unit) : } binding.tvAnimeName.text = item.node.title binding.tvEpisodesWatched.isVisible = false - binding.tvScore.text = ("★ ${item.node.meanScore?.roundOfString() ?: "0"}") + binding.tvScore.text = binding.tvScore.context.getRatingStringWithRating(item.node.meanScore) binding.ivAnimeBanner.load( - item.node.mainPicture?.large ?: item.node.mainPicture?.medium - ) { - crossfade(true) - placeholder(R.drawable.ic_anime_placeholder) - error(R.drawable.ic_anime_placeholder) - fallback(R.drawable.ic_anime_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.AnimeImageBuilder + ) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/adapters/AnimeSuggestionsAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/adapters/AnimeSuggestionsAdapter.kt index 4133a6b..6f23b0c 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/adapters/AnimeSuggestionsAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/adapters/AnimeSuggestionsAdapter.kt @@ -7,9 +7,8 @@ import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation -import com.sharkaboi.mediahub.R -import com.sharkaboi.mediahub.common.extensions.roundOfString +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.extensions.getRatingStringWithRating import com.sharkaboi.mediahub.data.api.models.anime.AnimeSuggestionsResponse import com.sharkaboi.mediahub.databinding.AnimeListItemBinding @@ -63,16 +62,11 @@ class AnimeSuggestionsAdapter(private val onClick: (Int) -> Unit) : } binding.tvAnimeName.text = item.node.title binding.tvEpisodesWatched.isVisible = false - binding.tvScore.text = ("★ ${item.node.meanScore?.roundOfString() ?: "0"}") + binding.tvScore.text = binding.tvScore.context.getRatingStringWithRating(item.node.meanScore) binding.ivAnimeBanner.load( - item.node.mainPicture?.large ?: item.node.mainPicture?.medium - ) { - crossfade(true) - placeholder(R.drawable.ic_anime_placeholder) - error(R.drawable.ic_anime_placeholder) - fallback(R.drawable.ic_anime_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.AnimeImageBuilder + ) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/repository/DiscoverRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/repository/DiscoverRepositoryImpl.kt index 8569204..874fe0e 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/repository/DiscoverRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/repository/DiscoverRepositoryImpl.kt @@ -3,8 +3,7 @@ package com.sharkaboi.mediahub.modules.discover.repository import android.content.SharedPreferences import com.haroldadmin.cnradapter.NetworkResponse import com.sharkaboi.mediahub.common.extensions.emptyString -import com.sharkaboi.mediahub.common.extensions.ifNullOrBlank -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.getAnimeSeason import com.sharkaboi.mediahub.data.api.models.anime.AnimeRankingResponse import com.sharkaboi.mediahub.data.api.models.anime.AnimeSeasonalResponse @@ -36,7 +35,7 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError("Log in has expired, Log in again.", null) + error = MHError.LoginExpiredError ) } else { val result = animeService.getAnimeSuggestionsAsync( @@ -59,10 +58,8 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with network" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.NetworkError ) } is NetworkResponse.ServerError -> { @@ -70,10 +67,8 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.body?.message.ifNullOrBlank { "Error with status code : ${result.code}" }, - null - ) + error = result.body?.message?.let { MHError(it) } + ?: MHError.apiErrorWithCode(result.code) ) } is NetworkResponse.UnknownError -> { @@ -81,10 +76,8 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with parsing" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.ParsingError ) } } @@ -95,7 +88,7 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError(e.message.ifNullOrBlank { "Unknown Error" }, e) + error = e.message?.let { MHError(it) } ?: MHError.UnknownError ) } } @@ -110,7 +103,7 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError("Log in has expired, Log in again.", null) + error = MHError.LoginExpiredError ) } else { val today = LocalDate.now() @@ -136,10 +129,8 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with network" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.NetworkError ) } is NetworkResponse.ServerError -> { @@ -147,10 +138,8 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.body?.message.ifNullOrBlank { "Error with status code : ${result.code}" }, - null - ) + error = result.body?.message?.let { MHError(it) } + ?: MHError.apiErrorWithCode(result.code) ) } is NetworkResponse.UnknownError -> { @@ -158,10 +147,8 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with parsing" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.ParsingError ) } } @@ -172,7 +159,7 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError(e.message.ifNullOrBlank { "Unknown Error" }, e) + error = e.message?.let { MHError(it) } ?: MHError.UnknownError ) } } @@ -187,7 +174,7 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError("Log in has expired, Log in again.", null) + error = MHError.LoginExpiredError ) } else { val result = animeService.getAnimeRankingAsync( @@ -210,10 +197,8 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with network" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.NetworkError ) } is NetworkResponse.ServerError -> { @@ -221,10 +206,8 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.body?.message.ifNullOrBlank { "Error with status code : ${result.code}" }, - null - ) + error = result.body?.message?.let { MHError(it) } + ?: MHError.apiErrorWithCode(result.code) ) } is NetworkResponse.UnknownError -> { @@ -232,10 +215,8 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with parsing" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.ParsingError ) } } @@ -246,7 +227,7 @@ class DiscoverRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError(e.message.ifNullOrBlank { "Unknown Error" }, e) + error = e.message?.let { MHError(it) } ?: MHError.UnknownError ) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/ui/DiscoverFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/ui/DiscoverFragment.kt index 1a1b6e6..40313c2 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/ui/DiscoverFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/ui/DiscoverFragment.kt @@ -10,9 +10,13 @@ import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager +import com.sharkaboi.mediahub.BottomNavGraphDirections import com.sharkaboi.mediahub.common.extensions.addFooter import com.sharkaboi.mediahub.common.extensions.observe import com.sharkaboi.mediahub.common.extensions.showToast +import com.sharkaboi.mediahub.data.api.models.anime.AnimeRankingResponse +import com.sharkaboi.mediahub.data.api.models.anime.AnimeSeasonalResponse +import com.sharkaboi.mediahub.data.api.models.anime.AnimeSuggestionsResponse import com.sharkaboi.mediahub.databinding.FragmentDiscoverBinding import com.sharkaboi.mediahub.modules.discover.adapters.AiringAnimeAdapter import com.sharkaboi.mediahub.modules.discover.adapters.AnimeRankingAdapter @@ -86,59 +90,64 @@ class DiscoverFragment : Fragment() { } private fun setupRecyclerViews(discoverAnimeListWrapper: DiscoverAnimeListWrapper) { - binding.tvAnimeRecommendationsEmpty.isVisible = - discoverAnimeListWrapper.animeSuggestions.isEmpty() - binding.rvAnimeRecommendations.apply { - adapter = AnimeSuggestionsAdapter { animeId -> - val action = DiscoverFragmentDirections.openAnimeById(animeId) - navController.navigate(action) - }.apply { - submitList(discoverAnimeListWrapper.animeSuggestions) - }.addFooter { - LoadMoreAdapter { - navController.navigate(DiscoverFragmentDirections.openAnimeSuggestions()) - } + setupAnimeRecommendationsList(discoverAnimeListWrapper.animeSuggestions) + setupAnimeAiringList(discoverAnimeListWrapper.animeOfCurrentSeason) + setupAnimeRankingList(discoverAnimeListWrapper.animeRankings) + } + + private fun setupAnimeRankingList(animeRankings: List) { + binding.tvAnimeRankingEmpty.isVisible = animeRankings.isEmpty() + binding.rvAnimeRanking.adapter = AnimeRankingAdapter { animeId -> + openAnimeWithId(animeId) + }.apply { + submitList(animeRankings) + }.addFooter { + LoadMoreAdapter { + navController.navigate(DiscoverFragmentDirections.openAnimeRankings(null)) } - layoutManager = - LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - setHasFixedSize(true) - itemAnimator = DefaultItemAnimator() } - binding.tvAnimeAiringEmpty.isVisible = - discoverAnimeListWrapper.animeOfCurrentSeason.isEmpty() - binding.rvAnimeAiring.apply { - adapter = AiringAnimeAdapter { animeId -> - val action = DiscoverFragmentDirections.openAnimeById(animeId) - navController.navigate(action) - }.apply { - submitList(discoverAnimeListWrapper.animeOfCurrentSeason) - }.addFooter { - LoadMoreAdapter { - navController.navigate(DiscoverFragmentDirections.openAnimeSeasonals(null, 0)) - } + binding.rvAnimeRanking.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + binding.rvAnimeRanking.setHasFixedSize(true) + binding.rvAnimeRanking.itemAnimator = DefaultItemAnimator() + } + + private fun setupAnimeAiringList(animeOfCurrentSeason: List) { + binding.tvAnimeAiringEmpty.isVisible = animeOfCurrentSeason.isEmpty() + binding.rvAnimeAiring.adapter = AiringAnimeAdapter { animeId -> + openAnimeWithId(animeId) + }.apply { + submitList(animeOfCurrentSeason) + }.addFooter { + LoadMoreAdapter { + navController.navigate(DiscoverFragmentDirections.openAnimeSeasonals(null, 0)) } - layoutManager = - LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - setHasFixedSize(true) - itemAnimator = DefaultItemAnimator() } - binding.tvAnimeRankingEmpty.isVisible = - discoverAnimeListWrapper.animeRankings.isEmpty() - binding.rvAnimeRanking.apply { - adapter = AnimeRankingAdapter { animeId -> - val action = DiscoverFragmentDirections.openAnimeById(animeId) - navController.navigate(action) - }.apply { - submitList(discoverAnimeListWrapper.animeRankings) - }.addFooter { - LoadMoreAdapter { - navController.navigate(DiscoverFragmentDirections.openAnimeRankings(null)) - } + binding.rvAnimeAiring.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + binding.rvAnimeAiring.setHasFixedSize(true) + binding.rvAnimeAiring.itemAnimator = DefaultItemAnimator() + } + + private fun setupAnimeRecommendationsList(animeSuggestions: List) { + binding.tvAnimeRecommendationsEmpty.isVisible = animeSuggestions.isEmpty() + binding.rvAnimeRecommendations.adapter = AnimeSuggestionsAdapter { animeId -> + openAnimeWithId(animeId) + }.apply { + submitList(animeSuggestions) + }.addFooter { + LoadMoreAdapter { + navController.navigate(DiscoverFragmentDirections.openAnimeSuggestions()) } - layoutManager = - LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - setHasFixedSize(true) - itemAnimator = DefaultItemAnimator() } + binding.rvAnimeRecommendations.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + binding.rvAnimeRecommendations.setHasFixedSize(true) + binding.rvAnimeRecommendations.itemAnimator = DefaultItemAnimator() + } + + private fun openAnimeWithId(animeId: Int) { + val action = BottomNavGraphDirections.openAnimeById(animeId) + navController.navigate(action) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/main/ui/MainActivity.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/main/ui/MainActivity.kt index 8607e65..e4147c7 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/main/ui/MainActivity.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/main/ui/MainActivity.kt @@ -14,6 +14,8 @@ import androidx.navigation.ui.setupWithNavController import com.sharkaboi.mediahub.R import com.sharkaboi.mediahub.common.extensions.observe import com.sharkaboi.mediahub.common.extensions.startAnim +import com.sharkaboi.mediahub.common.util.forceLaunchInBrowser +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.databinding.ActivityMainBinding import com.sharkaboi.mediahub.modules.auth.ui.OAuthActivity import com.sharkaboi.mediahub.modules.main.vm.MainViewModel @@ -43,7 +45,20 @@ class MainActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) - navController.handleDeepLink(intent) + if (intent?.dataString?.matches(ApiConstants.appAcceptedDeepLinkRegex) == true) { + val result = navController.handleDeepLink(intent) + if (result.not()) { + handleNonSupportedIntent(intent) + } + } else { + handleNonSupportedIntent(intent) + } + } + + private fun handleNonSupportedIntent(intent: Intent?) { + val url = intent?.dataString + Timber.d("Can't handle intent $url") + url?.let { forceLaunchInBrowser(it) } } private fun configBottomNav() { @@ -71,7 +86,7 @@ class MainActivity : AppCompatActivity() { } } - fun setVisibilityAndListeners(@IdRes id: Int) { + private fun setVisibilityAndListeners(@IdRes id: Int) { val isAnimeItem = id == R.id.anime_item && navController.currentDestination?.id == R.id.anime_item val isMangaItem = @@ -82,11 +97,11 @@ class MainActivity : AppCompatActivity() { binding.circleAnimeView.isVisible = true binding.circleAnimeView.startAnim(anim) { binding.fabSearch.isVisible = isAnimeItem || isMangaItem - binding.circleAnimeView.isVisible = false when { isAnimeItem -> navController.navigate(R.id.openAnimeSearch) isMangaItem -> navController.navigate(R.id.openMangaSearch) } + binding.circleAnimeView.isVisible = false } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/adapters/MangaListAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga/adapters/MangaListAdapter.kt index 02a5436..737cce4 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/adapters/MangaListAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga/adapters/MangaListAdapter.kt @@ -6,8 +6,9 @@ import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation -import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.extensions.getProgressStringWith +import com.sharkaboi.mediahub.common.extensions.getRatingStringWithRating import com.sharkaboi.mediahub.data.api.models.usermanga.UserMangaListResponse import com.sharkaboi.mediahub.databinding.MangaListItemBinding @@ -22,23 +23,20 @@ class MangaListAdapter( fun bind(item: UserMangaListResponse.Data?) { item?.let { mangaListItemBinding.apply { - ivMangaBanner.load(it.node.mainPicture?.large ?: it.node.mainPicture?.medium) { - crossfade(true) - placeholder(R.drawable.ic_manga_placeholder) - error(R.drawable.ic_manga_placeholder) - fallback(R.drawable.ic_manga_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + ivMangaBanner.load( + uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.MangaImageBuilder + ) tvMangaName.text = it.node.title - val numChapters = - if (it.node.numChapters == 0) "??" else it.node.numChapters - tvChapsRead.text = - ("${it.listStatus.numChaptersRead}/$numChapters") - val numVolumes = - if (it.node.numVolumes == 0) "??" else it.node.numVolumes - tvVolumesRead.text = - ("${it.listStatus.numVolumesRead}/$numVolumes") - tvScore.text = ("★ ${it.listStatus.score}") + tvChapsRead.text = tvChapsRead.context.getProgressStringWith( + it.listStatus.numChaptersRead, + it.node.numChapters + ) + tvVolumesRead.text = tvVolumesRead.context.getProgressStringWith( + it.listStatus.numVolumesRead, + it.node.numVolumes + ) + tvScore.text = tvScore.context.getRatingStringWithRating(it.listStatus.score) root.setOnClickListener { onItemClick(item.node.id) } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/repository/MangaRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga/repository/MangaRepositoryImpl.kt index b340339..f7f6edf 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/repository/MangaRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga/repository/MangaRepositoryImpl.kt @@ -4,7 +4,7 @@ import android.content.SharedPreferences import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.MangaStatus import com.sharkaboi.mediahub.data.api.enums.UserMangaSortType import com.sharkaboi.mediahub.data.api.models.usermanga.UserMangaListResponse diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/ui/MangaFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga/ui/MangaFragment.kt index 5e98c6e..c737f25 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/ui/MangaFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga/ui/MangaFragment.kt @@ -47,7 +47,7 @@ class MangaFragment : Fragment() { binding.vpManga.adapter = vpMangaAdapter binding.mangaTabLayout.apply { MangaStatus.values().forEach { - addTab(newTab().apply { text = it.getFormattedString() }) + addTab(newTab().apply { text = it.getFormattedString(context) }) } } onTabChanged = object : TabLayout.OnTabSelectedListener { diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/ui/MangaListByStatusFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga/ui/MangaListByStatusFragment.kt index f3a0743..2ef5507 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/ui/MangaListByStatusFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga/ui/MangaListByStatusFragment.kt @@ -13,6 +13,9 @@ import androidx.paging.LoadState import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.sharkaboi.mediahub.BottomNavGraphDirections +import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.common.extensions.showToast import com.sharkaboi.mediahub.data.api.enums.MangaStatus import com.sharkaboi.mediahub.data.api.enums.UserMangaSortType @@ -75,10 +78,10 @@ class MangaListByStatusFragment : Fragment() { private fun setUpRecyclerView() { binding.rvMangaByStatus.apply { mangaListAdapter = MangaListAdapter { mangaId -> - val action = MangaFragmentDirections.openMangaDetailsWithId(mangaId) + val action = BottomNavGraphDirections.openMangaById(mangaId) navController.navigate(action) } - layoutManager = GridLayoutManager(context, 3) + layoutManager = GridLayoutManager(context, UIConstants.AnimeAndMangaGridSpanCount) itemAnimator = DefaultItemAnimator() adapter = mangaListAdapter.withLoadStateFooter( footer = MangaLoadStateAdapter() @@ -113,16 +116,15 @@ class MangaListByStatusFragment : Fragment() { } private fun openSortMenu() { - val singleItems = UserMangaSortType.getFormattedArray() + val singleItems = UserMangaSortType.getFormattedArray(requireContext()) val checkedItem = UserMangaSortType.values().indexOf(mangaViewModel.currentChosenSortType) MaterialAlertDialogBuilder(requireContext()) - .setTitle("Sort manga by") + .setTitle(R.string.sort_manga_by_hint) .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> mangaViewModel.setSortType(UserMangaSortType.values()[which]) getMangaList() dialog.dismiss() - } - .show() + }.show() } private fun getMangaList() { diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/vm/MangaViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga/vm/MangaViewModel.kt index 6986009..f0b1ba4 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/vm/MangaViewModel.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga/vm/MangaViewModel.kt @@ -18,7 +18,7 @@ class MangaViewModel private val mangaRepository: MangaRepository ) : ViewModel() { private var _currentChosenMangaStatus: MangaStatus = MangaStatus.all - val currentChosenMangaStatus get() = _currentChosenMangaStatus + private val currentChosenMangaStatus get() = _currentChosenMangaStatus private var _currentChosenSortType: UserMangaSortType = UserMangaSortType.list_updated_at val currentChosenSortType get() = _currentChosenSortType private var _pagedMangaList: Flow>? = null diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/adapters/RecommendedMangaAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/adapters/RecommendedMangaAdapter.kt index 876e8b9..92cb9b2 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/adapters/RecommendedMangaAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/adapters/RecommendedMangaAdapter.kt @@ -8,8 +8,8 @@ import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse import com.sharkaboi.mediahub.databinding.MangaListItemBinding @@ -64,17 +64,16 @@ class RecommendedMangaAdapter(private val onClick: (Int) -> Unit) : binding.tvMangaName.text = item.node.title binding.tvVolumesRead.isVisible = false binding.tvChapsRead.text = - ("Recommended ${item.numRecommendations} ${if (item.numRecommendations == 1) "time" else "times"}") + binding.tvChapsRead.context.resources.getQuantityString( + R.plurals.recommendation_times, + item.numRecommendations, + item.numRecommendations + ) binding.cardRating.isGone = true binding.ivMangaBanner.load( - item.node.mainPicture?.large ?: item.node.mainPicture?.medium - ) { - crossfade(true) - placeholder(R.drawable.ic_manga_placeholder) - error(R.drawable.ic_manga_placeholder) - fallback(R.drawable.ic_manga_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.MangaImageBuilder + ) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/adapters/RelatedAnimeAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/adapters/RelatedAnimeAdapter.kt index 5855f23..f936cda 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/adapters/RelatedAnimeAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/adapters/RelatedAnimeAdapter.kt @@ -7,8 +7,7 @@ import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation -import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse import com.sharkaboi.mediahub.databinding.AnimeListItemBinding @@ -64,14 +63,9 @@ class RelatedAnimeAdapter(private val onClick: (Int) -> Unit) : binding.tvEpisodesWatched.text = item.relationTypeFormatted binding.cardRating.isGone = true binding.ivAnimeBanner.load( - item.node.mainPicture?.large ?: item.node.mainPicture?.medium - ) { - crossfade(true) - placeholder(R.drawable.ic_anime_placeholder) - error(R.drawable.ic_anime_placeholder) - fallback(R.drawable.ic_anime_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.AnimeImageBuilder + ) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/adapters/RelatedMangaAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/adapters/RelatedMangaAdapter.kt index f00a681..b784549 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/adapters/RelatedMangaAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/adapters/RelatedMangaAdapter.kt @@ -8,8 +8,7 @@ import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation -import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse import com.sharkaboi.mediahub.databinding.MangaListItemBinding @@ -66,14 +65,9 @@ class RelatedMangaAdapter(private val onClick: (Int) -> Unit) : binding.cardRating.isGone = true binding.tvVolumesRead.isVisible = false binding.ivMangaBanner.load( - item.node.mainPicture?.large ?: item.node.mainPicture?.medium - ) { - crossfade(true) - placeholder(R.drawable.ic_manga_placeholder) - error(R.drawable.ic_manga_placeholder) - fallback(R.drawable.ic_manga_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.MangaImageBuilder + ) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/repository/MangaDetailsRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/repository/MangaDetailsRepositoryImpl.kt index 9bd30fa..78509eb 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/repository/MangaDetailsRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/repository/MangaDetailsRepositoryImpl.kt @@ -2,8 +2,7 @@ package com.sharkaboi.mediahub.modules.manga_details.repository import com.haroldadmin.cnradapter.NetworkResponse import com.sharkaboi.mediahub.common.extensions.emptyString -import com.sharkaboi.mediahub.common.extensions.ifNullOrBlank -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse import com.sharkaboi.mediahub.data.api.retrofit.MangaService import com.sharkaboi.mediahub.data.api.retrofit.UserMangaService @@ -29,7 +28,7 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError("Log in has expired, Log in again.", null) + error = MHError.LoginExpiredError ) } else { val result = mangaService.getMangaByIdAsync( @@ -50,10 +49,8 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with network" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.NetworkError ) } is NetworkResponse.ServerError -> { @@ -61,10 +58,8 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.body?.message.ifNullOrBlank { "Error with status code : ${result.code}" }, - null - ) + error = result.body?.message?.let { MHError(it) } + ?: MHError.apiErrorWithCode(result.code) ) } is NetworkResponse.UnknownError -> { @@ -72,10 +67,8 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with parsing" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.ParsingError ) } } @@ -86,7 +79,7 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError(e.message.ifNullOrBlank { "Unknown Error" }, e) + error = e.message?.let { MHError(it) } ?: MHError.UnknownError ) } } @@ -101,7 +94,7 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError("Log in has expired, Log in again.", null) + error = MHError.LoginExpiredError ) } else { val result = userMangaService.updateMangaStatusAsync( @@ -126,10 +119,8 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with network" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.NetworkError ) } is NetworkResponse.ServerError -> { @@ -137,10 +128,8 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.body?.message.ifNullOrBlank { "Error with status code : ${result.code}" }, - null - ) + error = result.body?.message?.let { MHError(it) } + ?: MHError.apiErrorWithCode(result.code) ) } is NetworkResponse.UnknownError -> { @@ -148,10 +137,8 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with parsing" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.ParsingError ) } } @@ -162,7 +149,7 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError(e.message.ifNullOrBlank { "Unknown Error" }, e) + error = e.message?.let { MHError(it) } ?: MHError.UnknownError ) } } @@ -175,7 +162,7 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError("Log in has expired, Log in again.", null) + error = MHError.LoginExpiredError ) } else { val result = userMangaService.deleteMangaFromListAsync( @@ -196,10 +183,8 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with network" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.NetworkError ) } is NetworkResponse.ServerError -> { @@ -208,18 +193,14 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - "Manga isn't in your list", null - ) + error = MHError.MangaNotFoundError ) } return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.body?.message.ifNullOrBlank { "Error with status code : ${result.code}" }, - null - ) + error = result.body?.message?.let { MHError(it) } + ?: MHError.apiErrorWithCode(result.code) ) } is NetworkResponse.UnknownError -> { @@ -227,10 +208,8 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with parsing" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.ParsingError ) } } @@ -241,7 +220,7 @@ class MangaDetailsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError(e.message.ifNullOrBlank { "Unknown Error" }, e) + error = e.message?.let { MHError(it) } ?: MHError.UnknownError ) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/ui/MangaDetailsFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/ui/MangaDetailsFragment.kt index 10fde4e..ee83693 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/ui/MangaDetailsFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/ui/MangaDetailsFragment.kt @@ -17,15 +17,18 @@ import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import coil.load -import coil.transform.RoundedCornersTransformation import com.google.android.material.chip.Chip import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.shape.ShapeAppearanceModel +import com.sharkaboi.mediahub.BottomNavGraphDirections import com.sharkaboi.mediahub.R -import com.sharkaboi.mediahub.common.constants.MALExternalLinks +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.constants.UIConstants.setMediaHubChipStyle import com.sharkaboi.mediahub.common.extensions.* import com.sharkaboi.mediahub.common.util.openUrl +import com.sharkaboi.mediahub.data.api.constants.MALExternalLinks import com.sharkaboi.mediahub.data.api.enums.MangaStatus +import com.sharkaboi.mediahub.data.api.enums.getMangaNsfwRating +import com.sharkaboi.mediahub.data.api.enums.getMangaPublishStatus import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse import com.sharkaboi.mediahub.databinding.CustomReadCountDialogBinding import com.sharkaboi.mediahub.databinding.FragmentMangaDetailsBinding @@ -89,28 +92,17 @@ class MangaDetailsFragment : Fragment() { else -> Unit } } + private val handleListStatusUpdate = { state: MangaDetailsUpdateClass -> binding.mangaDetailsUserListCard.apply { btnStatus.text = - state.mangaStatus?.getFormattedString() + state.mangaStatus?.getFormattedString(requireContext()) ?: getString(R.string.not_added) - btnScore.text = ("${state.score ?: 0}/10") - btnCountVolumes.text = ( - "${state.numReadVolumes ?: 0}/${ - if (state.totalVolumes == 0) - "??" - else - state.totalVolumes.toString() - }" - ) - btnCountChaps.text = ( - "${state.numReadChapters ?: 0}/${ - if (state.totalChapters == 0) - "??" - else - state.totalChapters.toString() - }" - ) + btnScore.text = getString(R.string.media_rating_template, state.score ?: 0) + btnCountVolumes.text = + context?.getProgressStringWith(state.numReadVolumes, state.totalVolumes) + btnCountChaps.text = + context?.getProgressStringWith(state.numReadChapters, state.totalChapters) btnScore.setOnClickListener { openScoreDialog(state.score) } @@ -134,155 +126,74 @@ class MangaDetailsFragment : Fragment() { } private fun setData(mangaByIDResponse: MangaByIDResponse) { - binding.apply { - tvMangaName.text = mangaByIDResponse.title - tvAlternateTitles.setOnClickListener { - showAlternateTitlesDialog(mangaByIDResponse.alternativeTitles) - } - tvStartDate.text = - mangaByIDResponse.startDate?.tryParseDate()?.formatDateDMY() - ?: getString(R.string.n_a) - tvEndDate.text = - mangaByIDResponse.endDate?.tryParseDate()?.formatDateDMY() - ?: getString(R.string.n_a) - tvMeanScore.text = mangaByIDResponse.mean?.toString() ?: getString(R.string.n_a) - tvRank.text = mangaByIDResponse.rank?.toString() ?: getString(R.string.n_a) - tvPopularityRank.text = mangaByIDResponse.popularity.toString() - authorsChipGroup.apply { - removeAllViews() - if (mangaByIDResponse.authors.isEmpty()) { - addView( - TextView(context).apply { - text = getString(R.string.n_a) - } - ) - } else { - mangaByIDResponse.authors.forEach { author -> - addView( - TextView(context).apply { - setTextColor(ContextCompat.getColor(context, R.color.colorPrimary)) - setTypeface(null, Typeface.BOLD) - text = ("${author.node.firstName} ${author.node.lastName} ") - setOnClickListener { - openUrl( - MALExternalLinks.getMangaAuthorPageLink(author) - ) - } - } - ) - } - } - } - ivMangaMainPicture.load( - mangaByIDResponse.mainPicture?.large ?: mangaByIDResponse.mainPicture?.medium - ) { - crossfade(true) - placeholder(R.drawable.ic_manga_placeholder) - error(R.drawable.ic_manga_placeholder) - fallback(R.drawable.ic_manga_placeholder) - transformations(RoundedCornersTransformation(8f)) - } - ivMangaMainPicture.setOnClickListener { - openImagesViewPager(mangaByIDResponse.pictures) - } - otherDetails.tvSynopsis.text = - mangaByIDResponse.synopsis?.ifBlank { getString(R.string.n_a) } - ?: getString(R.string.n_a) - otherDetails.tvSynopsis.setOnClickListener { - showFullSynopsisDialog( - mangaByIDResponse.synopsis?.ifBlank { getString(R.string.n_a) } - ?: getString(R.string.n_a) - ) - } - otherDetails.tvNsfwRating.text = - mangaByIDResponse.nsfw?.getMangaNsfwRating() ?: getString(R.string.n_a) - mangaByIDResponse.genres.let { - otherDetails.genresChipGroup.removeAllViews() - if (it.isEmpty()) { - otherDetails.genresChipGroup.addView( - Chip(context).apply { - setEnsureMinTouchTargetSize(false) - shapeAppearanceModel = ShapeAppearanceModel().withCornerSize(8f) - text = getString(R.string.n_a) - } - ) - } else { - it.forEach { genre -> - otherDetails.genresChipGroup.addView( - Chip(context).apply { - setEnsureMinTouchTargetSize(false) - shapeAppearanceModel = ShapeAppearanceModel().withCornerSize(8f) - text = genre.name - setOnClickListener { - openUrl( - MALExternalLinks.getMangaGenresLink( - genre - ) - ) - } - } - ) - } - } - } - otherDetails.btnMediaType.text = - ("Type : ${mangaByIDResponse.mediaType.replace('_', ' ').capitalizeFirst()}") - otherDetails.btnMediaType.setOnClickListener { - val action = - MangaDetailsFragmentDirections.openMangaRankings(mangaByIDResponse.mediaType) - navController.navigate(action) - } - otherDetails.btnMangaCurrentStatus.text = - mangaByIDResponse.status.getMangaPublishStatus() - otherDetails.btnTotalVols.text = - mangaByIDResponse.numVolumes.let { - if (it == 0) - "${getString(R.string.n_a)} vols" - else - "$it ${if (it == 1) "vol" else "vols"}" - } - otherDetails.btnTotalChaps.text = - mangaByIDResponse.numChapters.let { - if (it == 0) - "${getString(R.string.n_a)} chaps" - else - "$it ${if (it == 1) "chap" else "chaps"}" - } - otherDetails.serializationChipGroup.apply { - removeAllViews() - if (mangaByIDResponse.serialization.isEmpty()) { - addView( - TextView(context).apply { - text = getString(R.string.n_a) - } - ) - } else { - mangaByIDResponse.serialization.forEach { magazine -> - addView( - TextView(context).apply { - setTextColor(ContextCompat.getColor(context, R.color.colorPrimary)) - setTypeface(null, Typeface.BOLD) - text = ("${magazine.node.name} ") - setOnClickListener { - openUrl( - MALExternalLinks.getMangaSerializationPageLink(magazine) - ) - } - } - ) - } - } - } - otherDetails.chipGroupOptions.forEach { - if (it is Chip) { - it.setEnsureMinTouchTargetSize(false) - it.shapeAppearanceModel = ShapeAppearanceModel().withCornerSize(8f) - } - } - otherDetails.btnBackground.setOnClickListener { + setupMangaMainDetailLayout(mangaByIDResponse) + setupMangaUserListStatusCard(mangaByIDResponse) + setupMangaOtherDetailLayout(mangaByIDResponse) + setupMangaOtherDetailsCard(mangaByIDResponse) + setupMangaOtherDetailsButtons(mangaByIDResponse) + setupMangaRecommendationsList(mangaByIDResponse.recommendations) + setupRelatedMangaList(mangaByIDResponse.relatedManga) + setupRelatedAnimeList(mangaByIDResponse.relatedAnime) + } + + private fun setupRelatedMangaList(relatedManga: List) { + val rvRelatedManga = binding.otherDetails.rvRelatedManga + rvRelatedManga.adapter = RelatedMangaAdapter { mangaId -> + openMangaWithId(mangaId) + }.apply { + submitList(relatedManga) + } + binding.otherDetails.tvRelatedMangaHint.isVisible = relatedManga.isNotEmpty() + rvRelatedManga.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + rvRelatedManga.setHasFixedSize(true) + rvRelatedManga.itemAnimator = DefaultItemAnimator() + } + + private fun setupRelatedAnimeList(relatedAnime: List) { + val rvRelatedAnime = binding.otherDetails.rvRelatedAnime + rvRelatedAnime.adapter = RelatedAnimeAdapter { animeId -> + openAnimeById(animeId) + }.apply { + submitList(relatedAnime) + } + binding.otherDetails.tvRelatedAnimeHint.isVisible = relatedAnime.isNotEmpty() + rvRelatedAnime.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + rvRelatedAnime.setHasFixedSize(true) + rvRelatedAnime.itemAnimator = DefaultItemAnimator() + } + + private fun openAnimeById(animeId: Int) { + val action = BottomNavGraphDirections.openAnimeById(animeId) + navController.navigate(action) + } + + private fun setupMangaRecommendationsList(recommendations: List) { + val rvRecommendations = binding.otherDetails.rvRecommendations + rvRecommendations.adapter = RecommendedMangaAdapter { mangaId -> + openMangaWithId(mangaId) + }.apply { + submitList(recommendations) + } + binding.otherDetails.tvRecommendations.isVisible = recommendations.isNotEmpty() + rvRecommendations.layoutManager = + LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + rvRecommendations.setHasFixedSize(true) + rvRecommendations.itemAnimator = DefaultItemAnimator() + } + + private fun openMangaWithId(mangaId: Int) { + val action = BottomNavGraphDirections.openMangaById(mangaId) + navController.navigate(action) + } + + private fun setupMangaOtherDetailsButtons(mangaByIDResponse: MangaByIDResponse) = + binding.otherDetails.apply { + btnBackground.setOnClickListener { openBackgroundDialog(mangaByIDResponse.background) } - otherDetails.btnCharacters.setOnClickListener { + btnCharacters.setOnClickListener { openUrl( MALExternalLinks.getMangaCharactersLink( mangaByIDResponse.id, @@ -290,7 +201,7 @@ class MangaDetailsFragment : Fragment() { ) ) } - otherDetails.btnReviews.setOnClickListener { + btnReviews.setOnClickListener { openUrl( MALExternalLinks.getMangaReviewsLink( mangaByIDResponse.id, @@ -298,7 +209,7 @@ class MangaDetailsFragment : Fragment() { ) ) } - otherDetails.btnNews.setOnClickListener { + btnNews.setOnClickListener { openUrl( MALExternalLinks.getMangaNewsLink( mangaByIDResponse.id, @@ -306,100 +217,193 @@ class MangaDetailsFragment : Fragment() { ) ) } - otherDetails.btnStatistics.setOnClickListener { + btnStatistics.setOnClickListener { openStatsDialog(mangaByIDResponse.numListUsers, mangaByIDResponse.numScoringUsers) } - otherDetails.rvRecommendations.apply { - adapter = RecommendedMangaAdapter { mangaId -> - val action = MangaDetailsFragmentDirections.openMangaDetailsWithId(mangaId) - navController.navigate(action) - }.apply { - submitList(mangaByIDResponse.recommendations) - } - otherDetails.tvRecommendations.isVisible = - mangaByIDResponse.recommendations.isNotEmpty() - layoutManager = - LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - setHasFixedSize(true) - itemAnimator = DefaultItemAnimator() - } - otherDetails.rvRelatedAnime.apply { - adapter = RelatedAnimeAdapter { animeId -> - val action = MangaDetailsFragmentDirections.openAnimeDetailsWithId(animeId) - navController.navigate(action) - }.apply { - submitList(mangaByIDResponse.relatedAnime) - } - otherDetails.tvRelatedAnimeHint.isVisible = - mangaByIDResponse.relatedAnime.isNotEmpty() - layoutManager = - LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - setHasFixedSize(true) - itemAnimator = DefaultItemAnimator() + } + + private fun setupMangaOtherDetailsCard(mangaByIDResponse: MangaByIDResponse) = + binding.otherDetails.apply { + btnMediaType.text = context?.getMediaTypeStringWith( + mangaByIDResponse.mediaType.replaceUnderScoreWithWhiteSpace().capitalizeFirst() + ) + btnMediaType.setOnClickListener { + openMangaRankingWith(mangaByIDResponse.mediaType) } - otherDetails.rvRelatedManga.apply { - adapter = RelatedMangaAdapter { mangaId -> - val action = MangaDetailsFragmentDirections.openMangaDetailsWithId(mangaId) - navController.navigate(action) - }.apply { - submitList(mangaByIDResponse.relatedManga) + btnMangaCurrentStatus.text = + context?.getMangaPublishStatus(mangaByIDResponse.status) + btnTotalVols.text = context?.getVolumesOfMangaString(mangaByIDResponse.numVolumes) + btnTotalChaps.text = context?.getChaptersOfMangaString(mangaByIDResponse.numChapters) + setupSerializationsChipGroup(mangaByIDResponse.serialization) + chipGroupOptions.forEach { + if (it is Chip) { + it.setMediaHubChipStyle() } - otherDetails.tvRelatedMangaHint.isVisible = - mangaByIDResponse.relatedManga.isNotEmpty() - layoutManager = - LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - setHasFixedSize(true) - itemAnimator = DefaultItemAnimator() } - mangaDetailsUserListCard.apply { - btnScore.setOnClickListener { - openScoreDialog(mangaByIDResponse.myListStatus?.score) - } - btnStatus.setOnClickListener { - openStatusDialog(mangaByIDResponse.myListStatus?.status, mangaByIDResponse.id) - } - btnCountVolumes.setOnClickListener { - openMangaVolumeCountDialog( - mangaByIDResponse.numVolumes, - mangaByIDResponse.myListStatus?.numVolumesRead + } + + private fun setupSerializationsChipGroup(serialization: List) { + val chipGroup = binding.otherDetails.serializationChipGroup + chipGroup.removeAllViews() + if (serialization.isEmpty()) { + val textView = TextView(context) + textView.text = getString(R.string.n_a) + chipGroup.addView(textView) + } else { + serialization.forEach { magazine -> + val textView = TextView(context) + textView.setTextColor( + ContextCompat.getColor(textView.context, R.color.colorPrimary) + ) + textView.setTypeface(null, Typeface.BOLD) + textView.text = magazine.node.name.plus(" ") + textView.setOnClickListener { + openUrl( + MALExternalLinks.getMangaSerializationPageLink(magazine) ) } - btnCountChaps.setOnClickListener { - openMangaChapterCountDialog( - mangaByIDResponse.numChapters, - mangaByIDResponse.myListStatus?.numChaptersRead - ) + chipGroup.addView(textView) + } + } + } + + private fun openMangaRankingWith(mediaType: String) { + val action = + MangaDetailsFragmentDirections.openMangaRankings(mediaType) + navController.navigate(action) + } + + private fun setupMangaOtherDetailLayout(mangaByIDResponse: MangaByIDResponse) = binding.apply { + otherDetails.tvSynopsis.text = + mangaByIDResponse.synopsis.ifNullOrBlank { getString(R.string.n_a) } + otherDetails.tvSynopsis.setOnClickListener { + showFullSynopsisDialog( + mangaByIDResponse.synopsis.ifNullOrBlank { getString(R.string.n_a) } + ) + } + otherDetails.tvNsfwRating.text = context?.getMangaNsfwRating(mangaByIDResponse.nsfw) + setupGenresChipGroup(mangaByIDResponse.genres) + } + + private fun setupGenresChipGroup(genres: List) { + val chipGroup = binding.otherDetails.genresChipGroup + chipGroup.removeAllViews() + if (genres.isEmpty()) { + val naChip = Chip(context) + naChip.setMediaHubChipStyle() + naChip.text = getString(R.string.n_a) + chipGroup.addView(naChip) + } else { + genres.forEach { genre -> + val chip = Chip(context) + chip.setMediaHubChipStyle() + chip.text = genre.name + chip.setOnClickListener { + openUrl(MALExternalLinks.getMangaGenresLink(genre)) } - btnConfirm.setOnClickListener { - mangaDetailsViewModel.submitStatusUpdate(mangaByIDResponse.id) + chipGroup.addView(chip) + } + } + } + + private fun setupMangaUserListStatusCard(mangaByIDResponse: MangaByIDResponse) = + binding.mangaDetailsUserListCard.apply { + btnScore.setOnClickListener { + openScoreDialog(mangaByIDResponse.myListStatus?.score) + } + btnStatus.setOnClickListener { + openStatusDialog(mangaByIDResponse.myListStatus?.status, mangaByIDResponse.id) + } + btnCountVolumes.setOnClickListener { + openMangaVolumeCountDialog( + mangaByIDResponse.numVolumes, + mangaByIDResponse.myListStatus?.numVolumesRead + ) + } + btnCountChaps.setOnClickListener { + openMangaChapterCountDialog( + mangaByIDResponse.numChapters, + mangaByIDResponse.myListStatus?.numChaptersRead + ) + } + btnConfirm.setOnClickListener { + mangaDetailsViewModel.submitStatusUpdate(mangaByIDResponse.id) + } + } + + private fun setupMangaMainDetailLayout(mangaByIDResponse: MangaByIDResponse) = binding.apply { + setupMangaImagePreview(mangaByIDResponse) + tvMangaName.text = mangaByIDResponse.title + tvAlternateTitles.setOnClickListener { + showAlternateTitlesDialog(mangaByIDResponse.alternativeTitles) + } + tvStartDate.text = + mangaByIDResponse.startDate?.tryParseDate()?.formatDateDMY() + ?: getString(R.string.n_a) + tvEndDate.text = + mangaByIDResponse.endDate?.tryParseDate()?.formatDateDMY() + ?: getString(R.string.n_a) + tvMeanScore.text = mangaByIDResponse.mean?.toString() ?: getString(R.string.n_a) + tvRank.text = mangaByIDResponse.rank?.toString() ?: getString(R.string.n_a) + tvPopularityRank.text = mangaByIDResponse.popularity?.toString() ?: getString(R.string.n_a) + setupAuthorsChipGroup(mangaByIDResponse.authors) + } + + private fun setupAuthorsChipGroup(authors: List) { + val chipGroup = binding.authorsChipGroup + chipGroup.removeAllViews() + if (authors.isEmpty()) { + val textView = TextView(context) + textView.text = getString(R.string.n_a) + chipGroup.addView(textView) + } else { + authors.forEach { author -> + val textView = TextView(context) + textView.setTextColor( + ContextCompat.getColor(textView.context, R.color.colorPrimary) + ) + textView.setTypeface(null, Typeface.BOLD) + textView.text = getString( + R.string.manga_author_template, + author.node.firstName, + author.node.lastName + ) + textView.setOnClickListener { + openUrl(MALExternalLinks.getMangaAuthorPageLink(author)) } + chipGroup.addView(textView) } } } + private fun setupMangaImagePreview(mangaByIDResponse: MangaByIDResponse) = binding.apply { + ivMangaMainPicture.load( + uri = mangaByIDResponse.mainPicture?.large ?: mangaByIDResponse.mainPicture?.medium, + builder = UIConstants.MangaImageBuilder + ) + ivMangaMainPicture.setOnClickListener { + openImagesViewPager(mangaByIDResponse.pictures) + } + } + private fun openImagesViewPager(pictures: List) { val images: List = pictures.map { it.large ?: it.medium } - val action = MangaDetailsFragmentDirections.openImages(images.toTypedArray()) + val action = BottomNavGraphDirections.openImageSlider(images.toTypedArray()) navController.navigate(action) } - private fun showFullSynopsisDialog(synopsis: String) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Synopsis") - .setMessage( - synopsis - ).setPositiveButton(android.R.string.ok) { dialog, _ -> - dialog.dismiss() - }.show() - } + private fun showFullSynopsisDialog(synopsis: String) = + requireContext().showNoActionOkDialog(R.string.synopsis, synopsis) private fun openStatusDialog(status: String?, mangaId: Int) { val singleItems = - arrayOf("Not added") + MangaStatus.malStatuses.map { it.getFormattedString() } + arrayOf(getString(R.string.not_added)) + MangaStatus.malStatuses.map { + it.getFormattedString(requireContext()) + } val checkedItem = status?.let { MangaStatus.malStatuses.indexOfFirst { it.name == status } + 1 } ?: 0 MaterialAlertDialogBuilder(requireContext()) - .setTitle("Set status as") + .setTitle(R.string.media_set_status_hint) .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> when (which) { checkedItem -> Unit @@ -407,15 +411,14 @@ class MangaDetailsFragment : Fragment() { else -> mangaDetailsViewModel.setStatus(MangaStatus.malStatuses[which - 1]) } dialog.dismiss() - } - .show() + }.show() } private fun openScoreDialog(score: Int?) { val singleItems = (0..10).map { it.toString() }.toTypedArray() val checkedItem = score ?: 0 MaterialAlertDialogBuilder(requireContext()) - .setTitle("Set score as") + .setTitle(R.string.media_set_score_hint) .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> when (which) { checkedItem -> Unit @@ -426,103 +429,94 @@ class MangaDetailsFragment : Fragment() { .show() } - @SuppressLint("InflateParams") private fun openMangaVolumeCountDialog(totalVolumes: Int?, readVolumes: Int?) { if (totalVolumes == null || totalVolumes == 0) { - val view = - LayoutInflater.from(context).inflate(R.layout.custom_read_count_dialog, null) - val binding: CustomReadCountDialogBinding = - CustomReadCountDialogBinding.bind(view) - binding.etNum.setText(readVolumes?.toString() ?: "") - MaterialAlertDialogBuilder(requireContext()) - .setView(binding.root) - .setPositiveButton("Ok") { dialog, _ -> - val count = binding.etNum.text?.toString()?.toInt() ?: 0 - mangaDetailsViewModel.setReadVolumeCount(count) - dialog.dismiss() - } - .setNegativeButton("Cancel") { dialog, _ -> - dialog.dismiss() - } - .show() + showMangaVolumeDialogWithTextField(readVolumes) } else { - val singleItems = (0..totalVolumes).map { it.toString() }.toTypedArray() - val checkedItem = readVolumes ?: 0 - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Read till volume") - .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> - mangaDetailsViewModel.setReadVolumeCount(which) - dialog.dismiss() - } - .show() + showMangaVolumesReadListDialog(totalVolumes, readVolumes) } } + private fun showMangaVolumesReadListDialog(totalVolumes: Int, readVolumes: Int?) { + val singleItems = (0..totalVolumes).map { it.toString() }.toTypedArray() + val checkedItem = readVolumes ?: 0 + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.manga_volume_till_hint) + .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> + mangaDetailsViewModel.setReadVolumeCount(which) + dialog.dismiss() + }.show() + } + @SuppressLint("InflateParams") + private fun showMangaVolumeDialogWithTextField(readVolumes: Int?) { + val view = + LayoutInflater.from(context).inflate(R.layout.custom_read_count_dialog, null) + val binding: CustomReadCountDialogBinding = + CustomReadCountDialogBinding.bind(view) + binding.etNum.setText(readVolumes?.toString() ?: String.emptyString) + MaterialAlertDialogBuilder(requireContext()) + .setView(binding.root) + .setPositiveButton(R.string.ok) { dialog, _ -> + val count = binding.etNum.text?.toString()?.toInt() ?: 0 + mangaDetailsViewModel.setReadVolumeCount(count) + dialog.dismiss() + } + .setNegativeButton(R.string.cancel) { dialog, _ -> + dialog.dismiss() + }.show() + } + private fun openMangaChapterCountDialog(totalChapters: Int?, readChapters: Int?) { if (totalChapters == null || totalChapters == 0) { - val view = - LayoutInflater.from(context).inflate(R.layout.custom_read_count_dialog, null) - val binding: CustomReadCountDialogBinding = - CustomReadCountDialogBinding.bind(view) - binding.etNum.setText(readChapters?.toString() ?: "") - MaterialAlertDialogBuilder(requireContext()) - .setView(binding.root) - .setPositiveButton("Ok") { dialog, _ -> - val count = binding.etNum.text?.toString()?.toInt() ?: 0 - mangaDetailsViewModel.setReadChapterCount(count) - dialog.dismiss() - } - .setNegativeButton("Cancel") { dialog, _ -> - dialog.dismiss() - } - .show() + showMangaChapterDialogWithTextField(readChapters) } else { - val singleItems = (0..totalChapters).map { it.toString() }.toTypedArray() - val checkedItem = readChapters ?: 0 - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Read till chapter") - .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> - mangaDetailsViewModel.setReadChapterCount(which) - dialog.dismiss() - } - .show() + showMangaChapterReadListDialog(totalChapters, readChapters) } } - private fun openBackgroundDialog(background: String?) { + private fun showMangaChapterReadListDialog(totalChapters: Int, readChapters: Int?) { + val singleItems = (0..totalChapters).map { it.toString() }.toTypedArray() + val checkedItem = readChapters ?: 0 MaterialAlertDialogBuilder(requireContext()) - .setTitle("Background") - .setMessage( - if (background != null && background.isNotBlank()) - background - else - getString(R.string.n_a) - ).setPositiveButton(android.R.string.ok) { dialog, _ -> + .setTitle(R.string.manga_chapter_till_hint) + .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> + mangaDetailsViewModel.setReadChapterCount(which) dialog.dismiss() }.show() } - private fun openStatsDialog(numListUsers: Int, numScoringUsers: Int) { + @SuppressLint("InflateParams") + private fun showMangaChapterDialogWithTextField(readChapters: Int?) { + val view = + LayoutInflater.from(context).inflate(R.layout.custom_read_count_dialog, null) + val binding: CustomReadCountDialogBinding = + CustomReadCountDialogBinding.bind(view) + binding.etNum.setText(readChapters?.toString() ?: String.emptyString) MaterialAlertDialogBuilder(requireContext()) - .setTitle("Statistics") - .setMessage( - """ - Added in $numListUsers manga lists - Scored by $numScoringUsers users - """.trimIndent() - ).setPositiveButton(android.R.string.ok) { dialog, _ -> + .setView(binding.root) + .setPositiveButton(R.string.ok) { dialog, _ -> + val count = binding.etNum.text?.toString()?.toInt() ?: 0 + mangaDetailsViewModel.setReadChapterCount(count) dialog.dismiss() - }.show() - } - - private fun showAlternateTitlesDialog(alternativeTitles: MangaByIDResponse.AlternativeTitles?) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Alternate titles") - .setMessage( - alternativeTitles?.getFormattedString() ?: getString(R.string.n_a) - ).setPositiveButton(android.R.string.ok) { dialog, _ -> + } + .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() }.show() } + + private fun openBackgroundDialog(background: String?) = + requireContext().showNoActionOkDialog(R.string.background, background) + + private fun openStatsDialog(numListUsers: Int, numScoringUsers: Int) = + requireContext().showNoActionOkDialog( + R.string.statistics, + context?.getMangaStats(numListUsers, numScoringUsers) + ) + + private fun showAlternateTitlesDialog(alternativeTitles: MangaByIDResponse.AlternativeTitles?) = + requireContext().showNoActionOkDialog( + R.string.alternate_titles_hint, + context?.getFormattedMangaTitlesString(alternativeTitles) + ) } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/vm/MangaDetailsViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/vm/MangaDetailsViewModel.kt index 174b700..34a6141 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/vm/MangaDetailsViewModel.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/vm/MangaDetailsViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.sharkaboi.mediahub.data.api.enums.MangaStatus import com.sharkaboi.mediahub.data.api.enums.mangaStatusFromString +import com.sharkaboi.mediahub.data.wrappers.MHError import com.sharkaboi.mediahub.modules.manga_details.repository.MangaDetailsRepository import com.sharkaboi.mediahub.modules.manga_details.util.MangaDetailsUpdateClass import dagger.hilt.android.lifecycle.HiltViewModel @@ -104,7 +105,7 @@ class MangaDetailsViewModel _uiState.setFailure(result.error.errorMessage) } } ?: run { - _uiState.setFailure("No data to update") + _uiState.setFailure(MHError.InvalidStateError.errorMessage) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/adapters/MangaRankingDetailedAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/adapters/MangaRankingDetailedAdapter.kt index 2be18a6..caf46c1 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/adapters/MangaRankingDetailedAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/adapters/MangaRankingDetailedAdapter.kt @@ -7,9 +7,9 @@ import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation import com.sharkaboi.mediahub.R -import com.sharkaboi.mediahub.common.extensions.roundOfString +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.extensions.getRatingStringWithRating import com.sharkaboi.mediahub.data.api.models.manga.MangaRankingResponse import com.sharkaboi.mediahub.databinding.MangaListItemBinding @@ -24,17 +24,17 @@ class MangaRankingDetailedAdapter( fun bind(item: MangaRankingResponse.Data?) { item?.let { mangaListItemBinding.apply { - ivMangaBanner.load(it.node.mainPicture?.large ?: it.node.mainPicture?.medium) { - crossfade(true) - placeholder(R.drawable.ic_manga_placeholder) - error(R.drawable.ic_manga_placeholder) - fallback(R.drawable.ic_manga_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + ivMangaBanner.load( + uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.MangaImageBuilder + ) tvMangaName.text = it.node.title - tvChapsRead.text = ("Rank : ${it.ranking.rank}") + tvChapsRead.text = tvChapsRead.context?.getString( + R.string.media_rank_template, + it.ranking.rank + ) tvVolumesRead.isVisible = false - tvScore.text = ("★ ${it.node.meanScore?.roundOfString() ?: "0"}") + tvScore.text = tvScore.context.getRatingStringWithRating(it.node.meanScore) root.setOnClickListener { onItemClick(item.node.id) } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/repository/MangaRankingRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/repository/MangaRankingRepositoryImpl.kt index 4dd03d7..22425c0 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/repository/MangaRankingRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/repository/MangaRankingRepositoryImpl.kt @@ -4,7 +4,7 @@ import android.content.SharedPreferences import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.enums.MangaRankingType import com.sharkaboi.mediahub.data.api.models.manga.MangaRankingResponse import com.sharkaboi.mediahub.data.api.retrofit.MangaService diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/ui/MangaRankingFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/ui/MangaRankingFragment.kt index cf7fcf2..979da98 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/ui/MangaRankingFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/ui/MangaRankingFragment.kt @@ -14,7 +14,8 @@ import androidx.paging.LoadState import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.chip.Chip -import com.google.android.material.shape.ShapeAppearanceModel +import com.sharkaboi.mediahub.BottomNavGraphDirections +import com.sharkaboi.mediahub.common.constants.UIConstants.setMediaHubChipStyle import com.sharkaboi.mediahub.common.extensions.showToast import com.sharkaboi.mediahub.data.api.enums.MangaRankingType import com.sharkaboi.mediahub.databinding.FragmentMangaRankingBinding @@ -60,46 +61,36 @@ class MangaRankingFragment : Fragment() { setupFilterChips() setUpRecyclerView() setObservers() + getMangaRankingList() } private fun initRanking() { mangaRankingViewModel.setRankingType( - if (args.mangaRankingType == null) { - MangaRankingType.all - } else { - runCatching { - MangaRankingType.valueOf( - args.mangaRankingType?.lowercase() - ?: MangaRankingType.all.name - ) - }.getOrElse { MangaRankingType.all } - } + MangaRankingType.getMangaRankingFromString(args.mangaRankingType) ) } private fun setupFilterChips() { binding.rankTypeChipGroup.removeAllViews() MangaRankingType.values().forEach { rankingType -> - binding.rankTypeChipGroup.addView( - Chip(context).apply { - text = rankingType.getFormattedString() - setEnsureMinTouchTargetSize(false) - isCheckable = true - isChecked = rankingType == mangaRankingViewModel.selectedRankingType - shapeAppearanceModel = ShapeAppearanceModel().withCornerSize(8f) - setOnClickListener { - mangaRankingViewModel.setRankingType(rankingType) - getMangaRankingList() - } - } - ) + val rankChip = Chip(context) + rankChip.text = rankingType.getFormattedString(rankChip.context) + rankChip.setMediaHubChipStyle() + rankChip.isCheckable = true + rankChip.isChecked = rankingType == mangaRankingViewModel.selectedRankingType + rankChip.setOnClickListener { + mangaRankingViewModel.setRankingType(rankingType) + getMangaRankingList() + binding.rvMangaRanking.smoothScrollToPosition(0) + } + binding.rankTypeChipGroup.addView(rankChip) } } private fun setUpRecyclerView() { binding.rvMangaRanking.apply { mangaRankingDetailedAdapter = MangaRankingDetailedAdapter { mangaId -> - val action = MangaRankingFragmentDirections.openMangaDetailsWithId(mangaId) + val action = BottomNavGraphDirections.openMangaById(mangaId) navController.navigate(action) } layoutManager = GridLayoutManager(context, 3) diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/adapters/MangaSearchListAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/adapters/MangaSearchListAdapter.kt index 19cd882..a0918aa 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/adapters/MangaSearchListAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/adapters/MangaSearchListAdapter.kt @@ -7,9 +7,8 @@ import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.load -import coil.transform.RoundedCornersTransformation -import com.sharkaboi.mediahub.R -import com.sharkaboi.mediahub.common.extensions.roundOfString +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.extensions.getRatingStringWithRating import com.sharkaboi.mediahub.data.api.models.manga.MangaSearchResponse import com.sharkaboi.mediahub.databinding.MangaListItemBinding @@ -24,17 +23,14 @@ class MangaSearchListAdapter( fun bind(item: MangaSearchResponse.Data?) { item?.let { mangaListItemBinding.apply { - ivMangaBanner.load(it.node.mainPicture?.large ?: it.node.mainPicture?.medium) { - crossfade(true) - placeholder(R.drawable.ic_manga_placeholder) - error(R.drawable.ic_manga_placeholder) - fallback(R.drawable.ic_manga_placeholder) - transformations(RoundedCornersTransformation(topLeft = 8f, topRight = 8f)) - } + ivMangaBanner.load( + uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.MangaImageBuilder + ) tvMangaName.text = it.node.title tvChapsRead.isVisible = false tvVolumesRead.isVisible = false - tvScore.text = ("★ ${it.node.meanScore?.roundOfString() ?: "0"}") + tvScore.text = tvScore.context.getRatingStringWithRating(it.node.meanScore) root.setOnClickListener { onItemClick(item.node.id) } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/repository/MangaSearchRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/repository/MangaSearchRepositoryImpl.kt index ea6dfa5..0cb30fa 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/repository/MangaSearchRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/repository/MangaSearchRepositoryImpl.kt @@ -4,7 +4,7 @@ import android.content.SharedPreferences import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.models.manga.MangaSearchResponse import com.sharkaboi.mediahub.data.api.retrofit.MangaService import com.sharkaboi.mediahub.data.datastore.DataStoreRepository diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/ui/MangaSearchFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/ui/MangaSearchFragment.kt index 4c613d9..3588750 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/ui/MangaSearchFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/ui/MangaSearchFragment.kt @@ -16,6 +16,8 @@ import androidx.paging.LoadState import androidx.paging.PagingData import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.GridLayoutManager +import com.sharkaboi.mediahub.BottomNavGraphDirections +import com.sharkaboi.mediahub.R import com.sharkaboi.mediahub.common.extensions.debounce import com.sharkaboi.mediahub.common.extensions.showToast import com.sharkaboi.mediahub.databinding.FragmentMangaSearchBinding @@ -62,7 +64,7 @@ class MangaSearchFragment : Fragment() { private fun setUpRecyclerView() { binding.rvSearchResults.apply { mangaSearchListAdapter = MangaSearchListAdapter { mangaId -> - val action = MangaSearchFragmentDirections.openMangaDetailsWithId(mangaId) + val action = BottomNavGraphDirections.openMangaById(mangaId) navController.navigate(action) } layoutManager = GridLayoutManager(context, 3) @@ -83,7 +85,8 @@ class MangaSearchFragment : Fragment() { binding.progress.isShowing = loadStates.refresh is LoadState.Loading binding.searchEmptyView.root.isVisible = loadStates.refresh is LoadState.NotLoading && mangaSearchListAdapter.itemCount == 0 - binding.searchEmptyView.tvHint.text = ("No manga found for query") + binding.searchEmptyView.tvHint.text = + getString(R.string.manga_search_no_result_hint) } } val debounce = debounce(scope = lifecycleScope) { @@ -100,7 +103,7 @@ class MangaSearchFragment : Fragment() { query?.toString()?.let { if (it.length < 3) { binding.searchEmptyView.root.isVisible = true - binding.searchEmptyView.tvHint.text = ("Search for any manga") + binding.searchEmptyView.tvHint.text = getString(R.string.manga_search_hint) mangaSearchListAdapter.submitData(PagingData.empty()) return@launch } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/profile/repository/ProfileRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/profile/repository/ProfileRepositoryImpl.kt index 03ecd22..b5b1f29 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/profile/repository/ProfileRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/profile/repository/ProfileRepositoryImpl.kt @@ -2,8 +2,7 @@ package com.sharkaboi.mediahub.modules.profile.repository import com.haroldadmin.cnradapter.NetworkResponse import com.sharkaboi.mediahub.common.extensions.emptyString -import com.sharkaboi.mediahub.common.extensions.ifNullOrBlank -import com.sharkaboi.mediahub.data.api.ApiConstants +import com.sharkaboi.mediahub.data.api.constants.ApiConstants import com.sharkaboi.mediahub.data.api.models.user.UserDetailsResponse import com.sharkaboi.mediahub.data.api.retrofit.UserService import com.sharkaboi.mediahub.data.datastore.DataStoreRepository @@ -27,7 +26,7 @@ class ProfileRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError("Log in has expired, Log in again.", null) + error = MHError.LoginExpiredError ) } else { val result = userService.getUserDetailsAsync( @@ -47,10 +46,8 @@ class ProfileRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with network" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.NetworkError ) } is NetworkResponse.ServerError -> { @@ -58,10 +55,8 @@ class ProfileRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.body?.message.ifNullOrBlank { "Error with status code : ${result.code}" }, - null - ) + error = result.body?.message?.let { MHError(it) } + ?: MHError.apiErrorWithCode(result.code) ) } is NetworkResponse.UnknownError -> { @@ -69,10 +64,8 @@ class ProfileRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError( - result.error.message.ifNullOrBlank { "Error with parsing" }, - null - ) + error = result.error.message?.let { MHError(it) } + ?: MHError.ParsingError ) } } @@ -83,7 +76,7 @@ class ProfileRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError(e.message.ifNullOrBlank { "Unknown Error" }, e) + error = e.message?.let { MHError(it) } ?: MHError.UnknownError ) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/profile/ui/ProfileFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/profile/ui/ProfileFragment.kt index 0b18a38..780c0a0 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/profile/ui/ProfileFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/profile/ui/ProfileFragment.kt @@ -12,23 +12,23 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import coil.load -import coil.transform.RoundedCornersTransformation import com.github.mikephil.charting.data.PieData import com.github.mikephil.charting.data.PieDataSet import com.github.mikephil.charting.data.PieEntry import com.google.android.material.chip.Chip import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.shape.ShapeAppearanceModel +import com.sharkaboi.mediahub.BottomNavGraphDirections import com.sharkaboi.mediahub.R -import com.sharkaboi.mediahub.common.constants.MALExternalLinks +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.constants.UIConstants.setMediaHubChipStyle import com.sharkaboi.mediahub.common.extensions.* import com.sharkaboi.mediahub.common.util.MPAndroidChartValueFormatter import com.sharkaboi.mediahub.common.util.openShareChooser import com.sharkaboi.mediahub.common.util.openUrl +import com.sharkaboi.mediahub.data.api.constants.MALExternalLinks import com.sharkaboi.mediahub.data.api.models.user.UserDetailsResponse import com.sharkaboi.mediahub.databinding.FragmentProfileBinding -import com.sharkaboi.mediahub.modules.anime_details.ui.AnimeDetailsFragmentDirections import com.sharkaboi.mediahub.modules.profile.vm.ProfileStates import com.sharkaboi.mediahub.modules.profile.vm.ProfileViewModel import dagger.hilt.android.AndroidEntryPoint @@ -62,14 +62,13 @@ class ProfileFragment : Fragment() { private fun setListeners() { binding.apply { - profileContent.ivProfileImage.load(R.drawable.ic_profile_placeholder) { - crossfade(true) - transformations(RoundedCornersTransformation(10f)) - } + profileContent.ivProfileImage.load( + drawableResId = R.drawable.ic_profile_placeholder, + builder = UIConstants.ProfileImageBuilder + ) profileContent.chipGroupOptions.forEach { if (it is Chip) { - it.setEnsureMinTouchTargetSize(false) - it.shapeAppearanceModel = ShapeAppearanceModel().withCornerSize(8f) + it.setMediaHubChipStyle() } } profileContent.ibSettings.setOnClickListener(showSettings) @@ -95,158 +94,169 @@ class ProfileFragment : Fragment() { } private fun setData(userDetailsResponse: UserDetailsResponse) { - binding.apply { - profileContent.ibCollapseDetails.setOnClickListener(toggleDetailsCard) - toggleDetailsCard.onClick(null) - profileContent.apply { - ivProfileImage.load(userDetailsResponse.profilePicUrl) { - crossfade(true) - transformations(RoundedCornersTransformation(10f)) - placeholder(R.drawable.ic_profile_placeholder) - error(R.drawable.ic_profile_placeholder) - fallback(R.drawable.ic_profile_placeholder) - } - ivProfileImage.setOnClickListener { - val action = - AnimeDetailsFragmentDirections.openImages(arrayOf(userDetailsResponse.profilePicUrl)) - navController.navigate(action) - } - tvName.text = userDetailsResponse.name - ibShare.setOnClickListener { - showShareDialog(userDetailsResponse.name) - } - profileDetailsCardContent.apply { - tvBirthDay.text = - userDetailsResponse.birthday?.tryParseDateTime()?.formatDateDMY() - ?: getString(R.string.n_a) - tvGender.text = - userDetailsResponse.gender?.capitalizeFirst() - ?: getString(R.string.n_a) - tvJoinedAt.text = - userDetailsResponse.joinedAt.tryParseDateTime()?.formatDateDMY() - ?: getString(R.string.n_a) - tvLocation.text = - userDetailsResponse.location?.ifBlank { getString(R.string.n_a) } - ?: getString(R.string.n_a) - tvTimeZone.text = userDetailsResponse.timeZone ?: getString(R.string.n_a) - tvSupporter.text = - if (userDetailsResponse.isSupporter == null || !userDetailsResponse.isSupporter) { - "No" - } else { - "Yes" - } - } - if (userDetailsResponse.animeStatistics == null) { - tvStatsEmptyHint.isVisible = true + setUpBannerSection(userDetailsResponse) + setUserDetailsSection(userDetailsResponse) + setUserStatsSection(userDetailsResponse) + setOtherDetailsSection(userDetailsResponse.name) + binding.profileContent.ibCollapseDetails.setOnClickListener(toggleDetailsCard) + toggleDetailsCard.onClick(null) + } + + private fun setOtherDetailsSection(name: String) = binding.profileContent.apply { + btnBlogs.setOnClickListener { + openUrl(MALExternalLinks.getBlogsLink(name)) + } + btnClubs.setOnClickListener { + openUrl(MALExternalLinks.getClubsLink(name)) + } + btnForumTopics.setOnClickListener { + openUrl(MALExternalLinks.getForumTopicsLink(name)) + } + btnFriends.setOnClickListener { + openUrl(MALExternalLinks.getFriendsLink(name)) + } + btnHistory.setOnClickListener { + openUrl(MALExternalLinks.getHistoryLink(name)) + } + btnRecommendations.setOnClickListener { + openUrl(MALExternalLinks.getRecommendationsLink(name)) + } + btnReviews.setOnClickListener { + openUrl(MALExternalLinks.getReviewsLink(name)) + } + } + + private fun setUserStatsSection(userDetailsResponse: UserDetailsResponse) { + if (userDetailsResponse.animeStatistics == null) { + binding.profileContent.tvStatsEmptyHint.isVisible = true + } else { + binding.profileContent.profileStatsContent.root.isVisible = true + binding.profileContent.profileStatsContent.apply { + tvEpisodes.text = + context?.getEpisodesOfAnimeFullString(userDetailsResponse.animeStatistics.numEpisodes) + tvDaysWatched.text = + context?.getDaysCountString(userDetailsResponse.animeStatistics.numDaysCompleted.toLong()) + tvReWatchCount.text = context?.resources?.getQuantityString( + R.plurals.re_watch_times, + userDetailsResponse.animeStatistics.numTimesReWatched.toInt(), + userDetailsResponse.animeStatistics.numTimesReWatched.toLong() + ) + tvMeanScore.text = + userDetailsResponse.animeStatistics.meanScore.toString() + setupPieChart(userDetailsResponse.animeStatistics) + } + } + } + + private fun setupPieChart(animeStatistics: UserDetailsResponse.AnimeStatistics) { + val pieChart = binding.profileContent.profileStatsContent.pieItemCounts + val entries = listOf( + PieEntry( + animeStatistics.numItemsCompleted.toFloat(), + getString(R.string.anime_status_completed) + ), + PieEntry( + animeStatistics.numItemsOnHold.toFloat(), + getString(R.string.anime_status_on_hold) + ), + PieEntry( + animeStatistics.numItemsDropped.toFloat(), + getString(R.string.anime_status_dropped) + ), + PieEntry( + animeStatistics.numItemsWatching.toFloat(), + getString(R.string.anime_status_watching) + ), + PieEntry( + animeStatistics.numItemsPlanToWatch.toFloat(), + getString(R.string.anime_status_planned) + ) + ) + if (entries.count { it.value == 0f } != entries.count()) { + val pieDataSet = PieDataSet(entries, String.emptyString) + pieDataSet.colors = listOf( + "#2ecc71".parseRGB(), + "#ffa500".parseRGB(), + "#e74c3c".parseRGB(), + "#3498db".parseRGB(), + "#5634eb".parseRGB(), + ) + pieDataSet.valueTextSize = 14f + pieDataSet.valueTextColor = Color.WHITE + pieDataSet.valueFormatter = MPAndroidChartValueFormatter() + pieChart.data = PieData(pieDataSet) + pieChart.setTouchEnabled(false) + pieChart.setDrawEntryLabels(false) + pieChart.setNoDataTextColor("#ba68c8".parseRGB()) + pieChart.legend.textColor = MaterialColors.getColor(pieChart, R.attr.colorOnSurface) + pieChart.setHoleColor(Color.TRANSPARENT) + pieChart.description.text = String.emptyString + pieChart.description.isEnabled = false + pieChart.animateY(1500) + pieChart.invalidate() + } + } + + private fun setUserDetailsSection(userDetailsResponse: UserDetailsResponse) = + binding.profileContent.profileDetailsCardContent.apply { + tvBirthDay.text = + userDetailsResponse.birthday?.tryParseDateTime()?.formatDateDMY() + ?: getString(R.string.n_a) + tvGender.text = + userDetailsResponse.gender?.capitalizeFirst() + ?: getString(R.string.n_a) + tvJoinedAt.text = + userDetailsResponse.joinedAt.tryParseDateTime()?.formatDateDMY() + ?: getString(R.string.n_a) + tvLocation.text = + userDetailsResponse.location.ifNullOrBlank { getString(R.string.n_a) } + tvTimeZone.text = userDetailsResponse.timeZone ?: getString(R.string.n_a) + tvSupporter.text = + if (userDetailsResponse.isSupporter == null || !userDetailsResponse.isSupporter) { + getString(R.string.no) } else { - profileStatsContent.root.isVisible = true - profileStatsContent.apply { - tvEpisodes.text = - ("${userDetailsResponse.animeStatistics.numEpisodes.toInt()} episodes") - tvDaysWatched.text = - ("${userDetailsResponse.animeStatistics.numDaysCompleted.toInt()} days") - tvReWatchCount.text = - ("Re-watched ${userDetailsResponse.animeStatistics.numTimesReWatched.toInt()} times") - tvMeanScore.text = - userDetailsResponse.animeStatistics.meanScore.roundOfString() - pieItemCounts.apply { - val pieChart = this - val entries = listOf( - PieEntry( - userDetailsResponse.animeStatistics.numItemsCompleted.toFloat(), - "Completed" - ), - PieEntry( - userDetailsResponse.animeStatistics.numItemsOnHold.toFloat(), - "On Hold" - ), - PieEntry( - userDetailsResponse.animeStatistics.numItemsDropped.toFloat(), - "Dropped" - ), - PieEntry( - userDetailsResponse.animeStatistics.numItemsWatching.toFloat(), - "Watching" - ), - PieEntry( - userDetailsResponse.animeStatistics.numItemsPlanToWatch.toFloat(), - "Planned" - ) - ) - if (entries.count { it.value == 0f } != entries.count()) { - val pieData = PieData( - PieDataSet(entries, "").apply { - colors = listOf( - "#2ecc71".parseRGB(), - "#ffa500".parseRGB(), - "#e74c3c".parseRGB(), - "#3498db".parseRGB(), - "#5634eb".parseRGB(), - ) - valueTextSize = 14f - valueTextColor = Color.WHITE - valueFormatter = MPAndroidChartValueFormatter() - } - ) - data = pieData - setTouchEnabled(false) - setDrawEntryLabels(false) - setNoDataTextColor("#ba68c8".parseRGB()) - val themeColor = - MaterialColors.getColor(pieChart, R.attr.colorOnSurface) - legend.textColor = themeColor - setHoleColor(Color.TRANSPARENT) - description.text = "" - description.isEnabled = false - animateY(1500) - invalidate() - } - } - } - } - btnBlogs.setOnClickListener { - openUrl(MALExternalLinks.getBlogsLink(userDetailsResponse.name)) - } - btnClubs.setOnClickListener { - openUrl(MALExternalLinks.getClubsLink(userDetailsResponse.name)) - } - btnForumTopics.setOnClickListener { - openUrl(MALExternalLinks.getForumTopicsLink(userDetailsResponse.name)) - } - btnFriends.setOnClickListener { - openUrl(MALExternalLinks.getFriendsLink(userDetailsResponse.name)) - } - btnHistory.setOnClickListener { - openUrl(MALExternalLinks.getHistoryLink(userDetailsResponse.name)) - } - btnRecommendations.setOnClickListener { - openUrl(MALExternalLinks.getRecommendationsLink(userDetailsResponse.name)) - } - btnReviews.setOnClickListener { - openUrl(MALExternalLinks.getReviewsLink(userDetailsResponse.name)) + getString(R.string.yes) } + } + + private fun setUpBannerSection(userDetailsResponse: UserDetailsResponse) = + binding.profileContent.apply { + ivProfileImage.load( + uri = userDetailsResponse.profilePicUrl, + builder = UIConstants.ProfileImageBuilder + ) + ivProfileImage.setOnClickListener { + val action = + BottomNavGraphDirections.openImageSlider(arrayOf(userDetailsResponse.profilePicUrl)) + navController.navigate(action) + } + tvName.text = userDetailsResponse.name + ibShare.setOnClickListener { + showShareDialog(userDetailsResponse.name) } } - } private fun showShareDialog(name: String) { - val items = arrayOf("MAL profile", "Anime list", "Manga list") + val items = arrayOf( + getString(R.string.share_mal_profile), + getString(R.string.share_anime_list), + getString(R.string.share_manga_list) + ) MaterialAlertDialogBuilder(requireContext()) - .setTitle("Share your") + .setTitle(R.string.share_your_dialog_hint) .setItems(items) { dialog, which -> onShareClick(name, which) dialog.dismiss() - } - .show() + }.show() } private fun onShareClick(name: String, which: Int) { val url = when (which) { - 0 -> "https://myanimelist.net/profile/$name" - 1 -> "https://myanimelist.net/animelist/$name" - else -> "https://myanimelist.net/mangalist/$name" + 0 -> MALExternalLinks.getProfileLink(name) + 1 -> MALExternalLinks.getUserAnimeListLink(name) + else -> MALExternalLinks.getUserMangaListLink(name) } - openShareChooser(url, "Share your MAL link to") + openShareChooser(url, getString(R.string.share_your_hint)) } private val showSettings = View.OnClickListener { @@ -268,15 +278,9 @@ class ProfileFragment : Fragment() { } private val rotateCloseArrow by lazy { - AnimationUtils.loadAnimation( - context, - R.anim.rotate_open_anim - ) + AnimationUtils.loadAnimation(context, R.anim.rotate_open_anim) } private val rotateOpenArrow by lazy { - AnimationUtils.loadAnimation( - context, - R.anim.rotate_close_anim - ) + AnimationUtils.loadAnimation(context, R.anim.rotate_close_anim) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/settings/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/settings/repository/SettingsRepositoryImpl.kt index 9d4ce50..70c470c 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/settings/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/settings/repository/SettingsRepositoryImpl.kt @@ -1,7 +1,6 @@ package com.sharkaboi.mediahub.modules.settings.repository import com.sharkaboi.mediahub.common.extensions.emptyString -import com.sharkaboi.mediahub.common.extensions.ifNullOrBlank import com.sharkaboi.mediahub.data.datastore.DataStoreRepository import com.sharkaboi.mediahub.data.wrappers.MHError import com.sharkaboi.mediahub.data.wrappers.MHTaskState @@ -27,7 +26,7 @@ class SettingsRepositoryImpl( return@withContext MHTaskState( isSuccess = false, data = null, - error = MHError(e.message.ifNullOrBlank { "Unknown Error" }, e) + error = e.message?.let { MHError(it) } ?: MHError.UnknownError ) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/settings/ui/SettingsFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/settings/ui/SettingsFragment.kt index 9c26d69..1c71893 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/settings/ui/SettingsFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/settings/ui/SettingsFragment.kt @@ -18,6 +18,7 @@ import com.sharkaboi.mediahub.BuildConfig import com.sharkaboi.mediahub.R import com.sharkaboi.mediahub.common.constants.AppConstants import com.sharkaboi.mediahub.common.extensions.observe +import com.sharkaboi.mediahub.common.extensions.showNoActionOkDialog import com.sharkaboi.mediahub.common.extensions.showToast import com.sharkaboi.mediahub.common.util.openUrl import com.sharkaboi.mediahub.common.views.MaterialToolBarPreference @@ -46,7 +47,7 @@ class SettingsFragment : PreferenceFragmentCompat() { private fun setData() { findPreference(SharedPreferencesKeys.ABOUT)?.summary = - "v${BuildConfig.VERSION_NAME}" + getString(R.string.app_version_template, BuildConfig.VERSION_NAME) findPreference(SharedPreferencesKeys.TOOLBAR)?.setNavigationIconListener { navController.navigateUp() } @@ -100,42 +101,43 @@ class SettingsFragment : PreferenceFragmentCompat() { appUpdater.start() } - private fun showAnimeNotificationsDialog() { - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Anime notifications") - .setMessage("This feature is coming soon!") - .setPositiveButton("Pog") { dialog, _ -> - dialog.dismiss() - }.show() - } + private fun showAnimeNotificationsDialog() = + requireContext().showNoActionOkDialog( + R.string.anime_notifs_hint, + getString(R.string.coming_soon_hint) + ) private fun showLogOutDialog() { MaterialAlertDialogBuilder(requireContext()) - .setTitle("Log out?") - .setMessage("This is permanent and you have to log in again after to use MediaHub.") - .setPositiveButton("Yes, Log me out") { _, _ -> + .setTitle(R.string.log_out_hint) + .setMessage(R.string.log_out_message) + .setPositiveButton(R.string.log_out_positive_hint) { _, _ -> settingsViewModel.logOutUser() } - .setNegativeButton("No, take me back") { dialog, _ -> + .setNegativeButton(R.string.log_out_negative_hint) { dialog, _ -> dialog.dismiss() }.show() } private fun showAboutDialog() { MaterialAlertDialogBuilder(requireContext()) - .setTitle("About") + .setTitle(R.string.about) .setMessage(AppConstants.description) - .setNeutralButton("View licenses") { _, _ -> - startActivity(Intent(context, OssLicensesMenuActivity::class.java)) - activity?.overridePendingTransition(R.anim.fade_in, R.anim.fade_out) - }.setNegativeButton("Github") { _, _ -> + .setNeutralButton(R.string.view_licenses_hint) { _, _ -> + openLicenses() + }.setNegativeButton(R.string.github) { _, _ -> openUrl(AppConstants.githubLink) activity?.overridePendingTransition(R.anim.fade_in, R.anim.fade_out) - }.setPositiveButton(android.R.string.ok) { dialog, _ -> + }.setPositiveButton(R.string.ok) { dialog, _ -> dialog.dismiss() }.show() } + private fun openLicenses() { + startActivity(Intent(context, OssLicensesMenuActivity::class.java)) + activity?.overridePendingTransition(R.anim.fade_in, R.anim.fade_out) + } + private fun moveToOAuthScreen() { startActivity(Intent(context, OAuthActivity::class.java)) activity?.overridePendingTransition(R.anim.fade_in, R.anim.fade_out) diff --git a/app/src/main/res/layout/activity_auth.xml b/app/src/main/res/layout/activity_auth.xml index 9f20458..d0a08c6 100644 --- a/app/src/main/res/layout/activity_auth.xml +++ b/app/src/main/res/layout/activity_auth.xml @@ -9,7 +9,7 @@ + tools:text="Watching" />