diff --git a/.github/workflows/android.yaml b/.github/workflows/android.yaml index 340ac91..f37acc7 100644 --- a/.github/workflows/android.yaml +++ b/.github/workflows/android.yaml @@ -1,6 +1,11 @@ name: Android CI -on: [ push, pull_request ] +on: + pull_request: + push: + branches: + - master + - main jobs: build: diff --git a/README.md b/README.md index 3251ac4..c6c5bc7 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ * [Settings preference](https://developer.android.com/reference/androidx/preference/package-summary) * [Coil](https://coil-kt.github.io/coil/) * [Timber](https://github.com/JakeWharton/timber) +* [Robolectric](https://github.com/robolectric/robolectric) * [ktlint-gradle](https://github.com/jlleitschuh/ktlint-gradle) * [LeakCanary](https://square.github.io/leakcanary/) * [Google secrets gradle plugin](https://github.com/google/secrets-gradle-plugin) diff --git a/app/build.gradle b/app/build.gradle index 9f5d3d9..6e05fc2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,19 +7,18 @@ plugins { id 'com.google.android.gms.oss-licenses-plugin' id 'androidx.navigation.safeargs.kotlin' id 'org.jlleitschuh.gradle.ktlint' - id 'com.apollographql.apollo' + id 'com.apollographql.apollo3' } android { - compileSdkVersion 31 + compileSdkVersion 32 defaultConfig { applicationId "com.sharkaboi.mediahub" minSdkVersion 23 - targetSdkVersion 31 + targetSdkVersion 32 versionCode 1 - versionName "1.3" - + versionName "1.4" multiDexEnabled true testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -32,13 +31,17 @@ android { 'proguard-rules.pro' } } + compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 } + kotlinOptions { jvmTarget = '11' + freeCompilerArgs += "-Xopt-in=kotlin.time.ExperimentalTime" + freeCompilerArgs += "-Xopt-in=kotlin.ExperimentalStdlibApi" } buildFeatures { @@ -49,6 +52,12 @@ android { propertiesFileName 'secrets.properties' defaultPropertiesFileName = 'secrets.defaults.properties' } + + testOptions { + unitTests { + includeAndroidResources = true + } + } } dependencies { @@ -68,9 +77,8 @@ dependencies { //retrofit implementation "com.squareup.retrofit2:retrofit:$retrofit_version" implementation "com.squareup.okhttp3:logging-interceptor:$logging_version" - //apollo grahql client - implementation "com.apollographql.apollo:apollo-runtime:$apollo_version" - implementation "com.apollographql.apollo:apollo-coroutines-support:$apollo_version" + //apollo graphql client + implementation "com.apollographql.apollo3:apollo-runtime:$apollo_version" //network util implementation "com.github.haroldadmin:NetworkResponseAdapter:$reponse_version" //moshi @@ -98,14 +106,20 @@ dependencies { //testing debugImplementation "com.squareup.leakcanary:leakcanary-android:$leak_version" testImplementation "junit:junit:$junit_version" + testImplementation "androidx.test:core-ktx:$core_text_ktx_version" + testImplementation "org.robolectric:robolectric:$robolectric_version" androidTestImplementation "androidx.test.ext:junit:$junit_ext_version" androidTestImplementation "androidx.test.espresso:espresso-core:$expresso_version" } apollo { - generateKotlinModels.set(true) + packageName.set("com.sharkaboi.mediahub") + srcDir("src/main/graphql") } ktlint { - disabledRules.set(["no-wildcard-imports"]) + disabledRules.set([ + "no-wildcard-imports", + "final-newline" + ]) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index da2d8e2..e89029e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,7 +12,6 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:largeHeap="true" - android:resizeableActivity="false" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.MediaHub" @@ -36,7 +35,7 @@ android:exported="true" android:launchMode="singleTop" android:screenOrientation="sensorPortrait" - android:theme="@style/Theme.MediaHub" + android:theme="@style/Theme.MediaHub.Auth" tools:ignore="LockedOrientationActivity"> @@ -53,7 +52,6 @@ android:name=".modules.main.ui.MainActivity" android:exported="true" android:launchMode="singleInstance" - android:screenOrientation="sensorPortrait" android:theme="@style/Theme.MediaHub" android:windowSoftInputMode="adjustPan" tools:ignore="LockedOrientationActivity"> diff --git a/app/src/main/graphql/nextAiringAnimeEpisode.graphql b/app/src/main/graphql/GetNextAiringAnimeEpisode.graphql similarity index 70% rename from app/src/main/graphql/nextAiringAnimeEpisode.graphql rename to app/src/main/graphql/GetNextAiringAnimeEpisode.graphql index 206968b..e781aec 100644 --- a/app/src/main/graphql/nextAiringAnimeEpisode.graphql +++ b/app/src/main/graphql/GetNextAiringAnimeEpisode.graphql @@ -1,7 +1,7 @@ query GetNextAiringAnimeEpisode( $idMal: Int! ) { - Media(idMal: $idMal, type: ANIME) { + returnedMedia : Media(idMal: $idMal, type: ANIME) { nextAiringEpisode { episode timeUntilAiring diff --git a/app/src/main/graphql/schema.graphqls b/app/src/main/graphql/schema.graphqls index 0edf186..59742b6 100644 --- a/app/src/main/graphql/schema.graphqls +++ b/app/src/main/graphql/schema.graphqls @@ -1,102 +1,132 @@ type Query { - Page("""The page number""" page: Int, """The amount of entries per page, max 50""" perPage: Int): Page + Page("The page number" page: Int, "The amount of entries per page, max 50" perPage: Int): Page + """ Media query """ - Media("""Filter by the media id""" id: Int, """Filter by the media's MyAnimeList id""" idMal: Int, """Filter by the start date of the media""" startDate: FuzzyDateInt, """Filter by the end date of the media""" endDate: FuzzyDateInt, """Filter by the season the media was released in""" season: MediaSeason, """The year of the season (Winter 2017 would also include December 2016 releases). Requires season argument""" seasonYear: Int, """Filter by the media's type""" type: MediaType, """Filter by the media's format""" format: MediaFormat, """Filter by the media's current release status""" status: MediaStatus, """Filter by amount of episodes the media has""" episodes: Int, """Filter by the media's episode length""" duration: Int, """Filter by the media's chapter count""" chapters: Int, """Filter by the media's volume count""" volumes: Int, """Filter by if the media's intended for 18+ adult audiences""" isAdult: Boolean, """Filter by the media's genres""" genre: String, """Filter by the media's tags""" tag: String, """Only apply the tags filter argument to tags above this rank. Default: 18""" minimumTagRank: Int, """Filter by the media's tags with in a tag category""" tagCategory: String, """Filter by the media on the authenticated user's lists""" onList: Boolean, """Filter media by sites with a online streaming or reading license""" licensedBy: String, """Filter by the media's average score""" averageScore: Int, """Filter by the number of users with this media on their list""" popularity: Int, """Filter by the source type of the media""" source: MediaSource, """Filter by the media's country of origin""" countryOfOrigin: CountryCode, """Filter by search query""" search: String, """Filter by the media id""" id_not: Int, """Filter by the media id""" id_in: [Int], """Filter by the media id""" id_not_in: [Int], """Filter by the media's MyAnimeList id""" idMal_not: Int, """Filter by the media's MyAnimeList id""" idMal_in: [Int], """Filter by the media's MyAnimeList id""" idMal_not_in: [Int], """Filter by the start date of the media""" startDate_greater: FuzzyDateInt, """Filter by the start date of the media""" startDate_lesser: FuzzyDateInt, """Filter by the start date of the media""" startDate_like: String, """Filter by the end date of the media""" endDate_greater: FuzzyDateInt, """Filter by the end date of the media""" endDate_lesser: FuzzyDateInt, """Filter by the end date of the media""" endDate_like: String, """Filter by the media's format""" format_in: [MediaFormat], """Filter by the media's format""" format_not: MediaFormat, """Filter by the media's format""" format_not_in: [MediaFormat], """Filter by the media's current release status""" status_in: [MediaStatus], """Filter by the media's current release status""" status_not: MediaStatus, """Filter by the media's current release status""" status_not_in: [MediaStatus], """Filter by amount of episodes the media has""" episodes_greater: Int, """Filter by amount of episodes the media has""" episodes_lesser: Int, """Filter by the media's episode length""" duration_greater: Int, """Filter by the media's episode length""" duration_lesser: Int, """Filter by the media's chapter count""" chapters_greater: Int, """Filter by the media's chapter count""" chapters_lesser: Int, """Filter by the media's volume count""" volumes_greater: Int, """Filter by the media's volume count""" volumes_lesser: Int, """Filter by the media's genres""" genre_in: [String], """Filter by the media's genres""" genre_not_in: [String], """Filter by the media's tags""" tag_in: [String], """Filter by the media's tags""" tag_not_in: [String], """Filter by the media's tags with in a tag category""" tagCategory_in: [String], """Filter by the media's tags with in a tag category""" tagCategory_not_in: [String], """Filter media by sites with a online streaming or reading license""" licensedBy_in: [String], """Filter by the media's average score""" averageScore_not: Int, """Filter by the media's average score""" averageScore_greater: Int, """Filter by the media's average score""" averageScore_lesser: Int, """Filter by the number of users with this media on their list""" popularity_not: Int, """Filter by the number of users with this media on their list""" popularity_greater: Int, """Filter by the number of users with this media on their list""" popularity_lesser: Int, """Filter by the source type of the media""" source_in: [MediaSource], """The order the results will be returned in""" sort: [MediaSort]): Media + Media("Filter by the media id" id: Int, "Filter by the media's MyAnimeList id" idMal: Int, "Filter by the start date of the media" startDate: FuzzyDateInt, "Filter by the end date of the media" endDate: FuzzyDateInt, "Filter by the season the media was released in" season: MediaSeason, "The year of the season (Winter 2017 would also include December 2016 releases). Requires season argument" seasonYear: Int, "Filter by the media's type" type: MediaType, "Filter by the media's format" format: MediaFormat, "Filter by the media's current release status" status: MediaStatus, "Filter by amount of episodes the media has" episodes: Int, "Filter by the media's episode length" duration: Int, "Filter by the media's chapter count" chapters: Int, "Filter by the media's volume count" volumes: Int, "Filter by if the media's intended for 18+ adult audiences" isAdult: Boolean, "Filter by the media's genres" genre: String, "Filter by the media's tags" tag: String, "Only apply the tags filter argument to tags above this rank. Default: 18" minimumTagRank: Int, "Filter by the media's tags with in a tag category" tagCategory: String, "Filter by the media on the authenticated user's lists" onList: Boolean, "Filter media by sites name with a online streaming or reading license" licensedBy: String, "Filter media by sites id with a online streaming or reading license" licensedById: Int, "Filter by the media's average score" averageScore: Int, "Filter by the number of users with this media on their list" popularity: Int, "Filter by the source type of the media" source: MediaSource, "Filter by the media's country of origin" countryOfOrigin: CountryCode, "If the media is officially licensed or a self-published doujin release" isLicensed: Boolean, "Filter by search query" search: String, "Filter by the media id" id_not: Int, "Filter by the media id" id_in: [Int], "Filter by the media id" id_not_in: [Int], "Filter by the media's MyAnimeList id" idMal_not: Int, "Filter by the media's MyAnimeList id" idMal_in: [Int], "Filter by the media's MyAnimeList id" idMal_not_in: [Int], "Filter by the start date of the media" startDate_greater: FuzzyDateInt, "Filter by the start date of the media" startDate_lesser: FuzzyDateInt, "Filter by the start date of the media" startDate_like: String, "Filter by the end date of the media" endDate_greater: FuzzyDateInt, "Filter by the end date of the media" endDate_lesser: FuzzyDateInt, "Filter by the end date of the media" endDate_like: String, "Filter by the media's format" format_in: [MediaFormat], "Filter by the media's format" format_not: MediaFormat, "Filter by the media's format" format_not_in: [MediaFormat], "Filter by the media's current release status" status_in: [MediaStatus], "Filter by the media's current release status" status_not: MediaStatus, "Filter by the media's current release status" status_not_in: [MediaStatus], "Filter by amount of episodes the media has" episodes_greater: Int, "Filter by amount of episodes the media has" episodes_lesser: Int, "Filter by the media's episode length" duration_greater: Int, "Filter by the media's episode length" duration_lesser: Int, "Filter by the media's chapter count" chapters_greater: Int, "Filter by the media's chapter count" chapters_lesser: Int, "Filter by the media's volume count" volumes_greater: Int, "Filter by the media's volume count" volumes_lesser: Int, "Filter by the media's genres" genre_in: [String], "Filter by the media's genres" genre_not_in: [String], "Filter by the media's tags" tag_in: [String], "Filter by the media's tags" tag_not_in: [String], "Filter by the media's tags with in a tag category" tagCategory_in: [String], "Filter by the media's tags with in a tag category" tagCategory_not_in: [String], "Filter media by sites name with a online streaming or reading license" licensedBy_in: [String], "Filter media by sites id with a online streaming or reading license" licensedById_in: [Int], "Filter by the media's average score" averageScore_not: Int, "Filter by the media's average score" averageScore_greater: Int, "Filter by the media's average score" averageScore_lesser: Int, "Filter by the number of users with this media on their list" popularity_not: Int, "Filter by the number of users with this media on their list" popularity_greater: Int, "Filter by the number of users with this media on their list" popularity_lesser: Int, "Filter by the source type of the media" source_in: [MediaSource], "The order the results will be returned in" sort: [MediaSort]): Media + """ Media Trend query """ - MediaTrend("""Filter by the media id""" mediaId: Int, """Filter by date""" date: Int, """Filter by trending amount""" trending: Int, """Filter by score""" averageScore: Int, """Filter by popularity""" popularity: Int, """Filter by episode number""" episode: Int, """Filter to stats recorded while the media was releasing""" releasing: Boolean, """Filter by the media id""" mediaId_not: Int, """Filter by the media id""" mediaId_in: [Int], """Filter by the media id""" mediaId_not_in: [Int], """Filter by date""" date_greater: Int, """Filter by date""" date_lesser: Int, """Filter by trending amount""" trending_greater: Int, """Filter by trending amount""" trending_lesser: Int, """Filter by trending amount""" trending_not: Int, """Filter by score""" averageScore_greater: Int, """Filter by score""" averageScore_lesser: Int, """Filter by score""" averageScore_not: Int, """Filter by popularity""" popularity_greater: Int, """Filter by popularity""" popularity_lesser: Int, """Filter by popularity""" popularity_not: Int, """Filter by episode number""" episode_greater: Int, """Filter by episode number""" episode_lesser: Int, """Filter by episode number""" episode_not: Int, """The order the results will be returned in""" sort: [MediaTrendSort]): MediaTrend + MediaTrend("Filter by the media id" mediaId: Int, "Filter by date" date: Int, "Filter by trending amount" trending: Int, "Filter by score" averageScore: Int, "Filter by popularity" popularity: Int, "Filter by episode number" episode: Int, "Filter to stats recorded while the media was releasing" releasing: Boolean, "Filter by the media id" mediaId_not: Int, "Filter by the media id" mediaId_in: [Int], "Filter by the media id" mediaId_not_in: [Int], "Filter by date" date_greater: Int, "Filter by date" date_lesser: Int, "Filter by trending amount" trending_greater: Int, "Filter by trending amount" trending_lesser: Int, "Filter by trending amount" trending_not: Int, "Filter by score" averageScore_greater: Int, "Filter by score" averageScore_lesser: Int, "Filter by score" averageScore_not: Int, "Filter by popularity" popularity_greater: Int, "Filter by popularity" popularity_lesser: Int, "Filter by popularity" popularity_not: Int, "Filter by episode number" episode_greater: Int, "Filter by episode number" episode_lesser: Int, "Filter by episode number" episode_not: Int, "The order the results will be returned in" sort: [MediaTrendSort]): MediaTrend + """ Airing schedule query """ - AiringSchedule("""Filter by the id of the airing schedule item""" id: Int, """Filter by the id of associated media""" mediaId: Int, """Filter by the airing episode number""" episode: Int, """Filter by the time of airing""" airingAt: Int, """Filter to episodes that haven't yet aired""" notYetAired: Boolean, """Filter by the id of the airing schedule item""" id_not: Int, """Filter by the id of the airing schedule item""" id_in: [Int], """Filter by the id of the airing schedule item""" id_not_in: [Int], """Filter by the id of associated media""" mediaId_not: Int, """Filter by the id of associated media""" mediaId_in: [Int], """Filter by the id of associated media""" mediaId_not_in: [Int], """Filter by the airing episode number""" episode_not: Int, """Filter by the airing episode number""" episode_in: [Int], """Filter by the airing episode number""" episode_not_in: [Int], """Filter by the airing episode number""" episode_greater: Int, """Filter by the airing episode number""" episode_lesser: Int, """Filter by the time of airing""" airingAt_greater: Int, """Filter by the time of airing""" airingAt_lesser: Int, """The order the results will be returned in""" sort: [AiringSort]): AiringSchedule + AiringSchedule("Filter by the id of the airing schedule item" id: Int, "Filter by the id of associated media" mediaId: Int, "Filter by the airing episode number" episode: Int, "Filter by the time of airing" airingAt: Int, "Filter to episodes that haven't yet aired" notYetAired: Boolean, "Filter by the id of the airing schedule item" id_not: Int, "Filter by the id of the airing schedule item" id_in: [Int], "Filter by the id of the airing schedule item" id_not_in: [Int], "Filter by the id of associated media" mediaId_not: Int, "Filter by the id of associated media" mediaId_in: [Int], "Filter by the id of associated media" mediaId_not_in: [Int], "Filter by the airing episode number" episode_not: Int, "Filter by the airing episode number" episode_in: [Int], "Filter by the airing episode number" episode_not_in: [Int], "Filter by the airing episode number" episode_greater: Int, "Filter by the airing episode number" episode_lesser: Int, "Filter by the time of airing" airingAt_greater: Int, "Filter by the time of airing" airingAt_lesser: Int, "The order the results will be returned in" sort: [AiringSort]): AiringSchedule + """ Character query """ - Character("""Filter by character id""" id: Int, """Filter by character by if its their birthday today""" isBirthday: Boolean, """Filter by search query""" search: String, """Filter by character id""" id_not: Int, """Filter by character id""" id_in: [Int], """Filter by character id""" id_not_in: [Int], """The order the results will be returned in""" sort: [CharacterSort]): Character + Character("Filter by character id" id: Int, "Filter by character by if its their birthday today" isBirthday: Boolean, "Filter by search query" search: String, "Filter by character id" id_not: Int, "Filter by character id" id_in: [Int], "Filter by character id" id_not_in: [Int], "The order the results will be returned in" sort: [CharacterSort]): Character + """ Staff query """ - Staff("""Filter by the staff id""" id: Int, """Filter by staff by if its their birthday today""" isBirthday: Boolean, """Filter by search query""" search: String, """Filter by the staff id""" id_not: Int, """Filter by the staff id""" id_in: [Int], """Filter by the staff id""" id_not_in: [Int], """The order the results will be returned in""" sort: [StaffSort]): Staff + Staff("Filter by the staff id" id: Int, "Filter by staff by if its their birthday today" isBirthday: Boolean, "Filter by search query" search: String, "Filter by the staff id" id_not: Int, "Filter by the staff id" id_in: [Int], "Filter by the staff id" id_not_in: [Int], "The order the results will be returned in" sort: [StaffSort]): Staff + """ Media list query """ - MediaList("""Filter by a list entry's id""" id: Int, """Filter by a user's id""" userId: Int, """Filter by a user's name""" userName: String, """Filter by the list entries media type""" type: MediaType, """Filter by the watching/reading status""" status: MediaListStatus, """Filter by the media id of the list entry""" mediaId: Int, """Filter list entries to users who are being followed by the authenticated user""" isFollowing: Boolean, """Filter by note words and #tags""" notes: String, """Filter by the date the user started the media""" startedAt: FuzzyDateInt, """Filter by the date the user completed the media""" completedAt: FuzzyDateInt, """Limit to only entries also on the auth user's list. Requires user id or name arguments.""" compareWithAuthList: Boolean, """Filter by a user's id""" userId_in: [Int], """Filter by the watching/reading status""" status_in: [MediaListStatus], """Filter by the watching/reading status""" status_not_in: [MediaListStatus], """Filter by the watching/reading status""" status_not: MediaListStatus, """Filter by the media id of the list entry""" mediaId_in: [Int], """Filter by the media id of the list entry""" mediaId_not_in: [Int], """Filter by note words and #tags""" notes_like: String, """Filter by the date the user started the media""" startedAt_greater: FuzzyDateInt, """Filter by the date the user started the media""" startedAt_lesser: FuzzyDateInt, """Filter by the date the user started the media""" startedAt_like: String, """Filter by the date the user completed the media""" completedAt_greater: FuzzyDateInt, """Filter by the date the user completed the media""" completedAt_lesser: FuzzyDateInt, """Filter by the date the user completed the media""" completedAt_like: String, """The order the results will be returned in""" sort: [MediaListSort]): MediaList + MediaList("Filter by a list entry's id" id: Int, "Filter by a user's id" userId: Int, "Filter by a user's name" userName: String, "Filter by the list entries media type" type: MediaType, "Filter by the watching\/reading status" status: MediaListStatus, "Filter by the media id of the list entry" mediaId: Int, "Filter list entries to users who are being followed by the authenticated user" isFollowing: Boolean, "Filter by note words and #tags" notes: String, "Filter by the date the user started the media" startedAt: FuzzyDateInt, "Filter by the date the user completed the media" completedAt: FuzzyDateInt, "Limit to only entries also on the auth user's list. Requires user id or name arguments." compareWithAuthList: Boolean, "Filter by a user's id" userId_in: [Int], "Filter by the watching\/reading status" status_in: [MediaListStatus], "Filter by the watching\/reading status" status_not_in: [MediaListStatus], "Filter by the watching\/reading status" status_not: MediaListStatus, "Filter by the media id of the list entry" mediaId_in: [Int], "Filter by the media id of the list entry" mediaId_not_in: [Int], "Filter by note words and #tags" notes_like: String, "Filter by the date the user started the media" startedAt_greater: FuzzyDateInt, "Filter by the date the user started the media" startedAt_lesser: FuzzyDateInt, "Filter by the date the user started the media" startedAt_like: String, "Filter by the date the user completed the media" completedAt_greater: FuzzyDateInt, "Filter by the date the user completed the media" completedAt_lesser: FuzzyDateInt, "Filter by the date the user completed the media" completedAt_like: String, "The order the results will be returned in" sort: [MediaListSort]): MediaList + """ Media list collection query, provides list pre-grouped by status & custom lists. User ID and Media Type arguments required. """ - MediaListCollection("""Filter by a user's id""" userId: Int, """Filter by a user's name""" userName: String, """Filter by the list entries media type""" type: MediaType, """Filter by the watching/reading status""" status: MediaListStatus, """Filter by note words and #tags""" notes: String, """Filter by the date the user started the media""" startedAt: FuzzyDateInt, """Filter by the date the user completed the media""" completedAt: FuzzyDateInt, """Always return completed list entries in one group, overriding the user's split completed option.""" forceSingleCompletedList: Boolean, """Which chunk of list entries to load""" chunk: Int, """The amount of entries per chunk, max 500""" perChunk: Int, """Filter by the watching/reading status""" status_in: [MediaListStatus], """Filter by the watching/reading status""" status_not_in: [MediaListStatus], """Filter by the watching/reading status""" status_not: MediaListStatus, """Filter by note words and #tags""" notes_like: String, """Filter by the date the user started the media""" startedAt_greater: FuzzyDateInt, """Filter by the date the user started the media""" startedAt_lesser: FuzzyDateInt, """Filter by the date the user started the media""" startedAt_like: String, """Filter by the date the user completed the media""" completedAt_greater: FuzzyDateInt, """Filter by the date the user completed the media""" completedAt_lesser: FuzzyDateInt, """Filter by the date the user completed the media""" completedAt_like: String, """The order the results will be returned in""" sort: [MediaListSort]): MediaListCollection + MediaListCollection("Filter by a user's id" userId: Int, "Filter by a user's name" userName: String, "Filter by the list entries media type" type: MediaType, "Filter by the watching\/reading status" status: MediaListStatus, "Filter by note words and #tags" notes: String, "Filter by the date the user started the media" startedAt: FuzzyDateInt, "Filter by the date the user completed the media" completedAt: FuzzyDateInt, "Always return completed list entries in one group, overriding the user's split completed option." forceSingleCompletedList: Boolean, "Which chunk of list entries to load" chunk: Int, "The amount of entries per chunk, max 500" perChunk: Int, "Filter by the watching\/reading status" status_in: [MediaListStatus], "Filter by the watching\/reading status" status_not_in: [MediaListStatus], "Filter by the watching\/reading status" status_not: MediaListStatus, "Filter by note words and #tags" notes_like: String, "Filter by the date the user started the media" startedAt_greater: FuzzyDateInt, "Filter by the date the user started the media" startedAt_lesser: FuzzyDateInt, "Filter by the date the user started the media" startedAt_like: String, "Filter by the date the user completed the media" completedAt_greater: FuzzyDateInt, "Filter by the date the user completed the media" completedAt_lesser: FuzzyDateInt, "Filter by the date the user completed the media" completedAt_like: String, "The order the results will be returned in" sort: [MediaListSort]): MediaListCollection + """ Collection of all the possible media genres """ GenreCollection: [String] + """ Collection of all the possible media tags """ - MediaTagCollection("""Mod Only""" status: Int): [MediaTag] + MediaTagCollection("Mod Only" status: Int): [MediaTag] + """ User query """ - User("""Filter by the user id""" id: Int, """Filter by the name of the user""" name: String, """Filter to moderators only if true""" isModerator: Boolean, """Filter by search query""" search: String, """The order the results will be returned in""" sort: [UserSort]): User + User("Filter by the user id" id: Int, "Filter by the name of the user" name: String, "Filter to moderators only if true" isModerator: Boolean, "Filter by search query" search: String, "The order the results will be returned in" sort: [UserSort]): User + """ Get the currently authenticated user """ Viewer: User + """ Notification query """ - Notification("""Filter by the type of notifications""" type: NotificationType, """Reset the unread notification count to 0 on load""" resetNotificationCount: Boolean, """Filter by the type of notifications""" type_in: [NotificationType]): NotificationUnion + Notification("Filter by the type of notifications" type: NotificationType, "Reset the unread notification count to 0 on load" resetNotificationCount: Boolean, "Filter by the type of notifications" type_in: [NotificationType]): NotificationUnion + """ Studio query """ - Studio("""Filter by the studio id""" id: Int, """Filter by search query""" search: String, """Filter by the studio id""" id_not: Int, """Filter by the studio id""" id_in: [Int], """Filter by the studio id""" id_not_in: [Int], """The order the results will be returned in""" sort: [StudioSort]): Studio + Studio("Filter by the studio id" id: Int, "Filter by search query" search: String, "Filter by the studio id" id_not: Int, "Filter by the studio id" id_in: [Int], "Filter by the studio id" id_not_in: [Int], "The order the results will be returned in" sort: [StudioSort]): Studio + """ Review query """ - Review("""Filter by Review id""" id: Int, """Filter by media id""" mediaId: Int, """Filter by user id""" userId: Int, """Filter by media type""" mediaType: MediaType, """The order the results will be returned in""" sort: [ReviewSort]): Review + Review("Filter by Review id" id: Int, "Filter by media id" mediaId: Int, "Filter by user id" userId: Int, "Filter by media type" mediaType: MediaType, "The order the results will be returned in" sort: [ReviewSort]): Review + """ Activity query """ - Activity("""Filter by the activity id""" id: Int, """Filter by the owner user id""" userId: Int, """Filter by the id of the user who sent a message""" messengerId: Int, """Filter by the associated media id of the activity""" mediaId: Int, """Filter by the type of activity""" type: ActivityType, """Filter activity to users who are being followed by the authenticated user""" isFollowing: Boolean, """Filter activity to only activity with replies""" hasReplies: Boolean, """Filter activity to only activity with replies or is of type text""" hasRepliesOrTypeText: Boolean, """Filter by the time the activity was created""" createdAt: Int, """Filter by the activity id""" id_not: Int, """Filter by the activity id""" id_in: [Int], """Filter by the activity id""" id_not_in: [Int], """Filter by the owner user id""" userId_not: Int, """Filter by the owner user id""" userId_in: [Int], """Filter by the owner user id""" userId_not_in: [Int], """Filter by the id of the user who sent a message""" messengerId_not: Int, """Filter by the id of the user who sent a message""" messengerId_in: [Int], """Filter by the id of the user who sent a message""" messengerId_not_in: [Int], """Filter by the associated media id of the activity""" mediaId_not: Int, """Filter by the associated media id of the activity""" mediaId_in: [Int], """Filter by the associated media id of the activity""" mediaId_not_in: [Int], """Filter by the type of activity""" type_not: ActivityType, """Filter by the type of activity""" type_in: [ActivityType], """Filter by the type of activity""" type_not_in: [ActivityType], """Filter by the time the activity was created""" createdAt_greater: Int, """Filter by the time the activity was created""" createdAt_lesser: Int, """The order the results will be returned in""" sort: [ActivitySort]): ActivityUnion + Activity("Filter by the activity id" id: Int, "Filter by the owner user id" userId: Int, "Filter by the id of the user who sent a message" messengerId: Int, "Filter by the associated media id of the activity" mediaId: Int, "Filter by the type of activity" type: ActivityType, "Filter activity to users who are being followed by the authenticated user" isFollowing: Boolean, "Filter activity to only activity with replies" hasReplies: Boolean, "Filter activity to only activity with replies or is of type text" hasRepliesOrTypeText: Boolean, "Filter by the time the activity was created" createdAt: Int, "Filter by the activity id" id_not: Int, "Filter by the activity id" id_in: [Int], "Filter by the activity id" id_not_in: [Int], "Filter by the owner user id" userId_not: Int, "Filter by the owner user id" userId_in: [Int], "Filter by the owner user id" userId_not_in: [Int], "Filter by the id of the user who sent a message" messengerId_not: Int, "Filter by the id of the user who sent a message" messengerId_in: [Int], "Filter by the id of the user who sent a message" messengerId_not_in: [Int], "Filter by the associated media id of the activity" mediaId_not: Int, "Filter by the associated media id of the activity" mediaId_in: [Int], "Filter by the associated media id of the activity" mediaId_not_in: [Int], "Filter by the type of activity" type_not: ActivityType, "Filter by the type of activity" type_in: [ActivityType], "Filter by the type of activity" type_not_in: [ActivityType], "Filter by the time the activity was created" createdAt_greater: Int, "Filter by the time the activity was created" createdAt_lesser: Int, "The order the results will be returned in" sort: [ActivitySort]): ActivityUnion + """ Activity reply query """ - ActivityReply("""Filter by the reply id""" id: Int, """Filter by the parent id""" activityId: Int): ActivityReply + ActivityReply("Filter by the reply id" id: Int, "Filter by the parent id" activityId: Int): ActivityReply + """ Follow query """ - Following("""User id of the follower/followed""" userId: Int!, """The order the results will be returned in""" sort: [UserSort]): User + Following("User id of the follower\/followed" userId: Int!, "The order the results will be returned in" sort: [UserSort]): User + """ Follow query """ - Follower("""User id of the follower/followed""" userId: Int!, """The order the results will be returned in""" sort: [UserSort]): User + Follower("User id of the follower\/followed" userId: Int!, "The order the results will be returned in" sort: [UserSort]): User + """ Thread query """ - Thread("""Filter by the thread id""" id: Int, """Filter by the user id of the thread's creator""" userId: Int, """Filter by the user id of the last user to comment on the thread""" replyUserId: Int, """Filter by if the currently authenticated user's subscribed threads""" subscribed: Boolean, """Filter by thread category id""" categoryId: Int, """Filter by thread media id category""" mediaCategoryId: Int, """Filter by search query""" search: String, """Filter by the thread id""" id_in: [Int], """The order the results will be returned in""" sort: [ThreadSort]): Thread + Thread("Filter by the thread id" id: Int, "Filter by the user id of the thread's creator" userId: Int, "Filter by the user id of the last user to comment on the thread" replyUserId: Int, "Filter by if the currently authenticated user's subscribed threads" subscribed: Boolean, "Filter by thread category id" categoryId: Int, "Filter by thread media id category" mediaCategoryId: Int, "Filter by search query" search: String, "Filter by the thread id" id_in: [Int], "The order the results will be returned in" sort: [ThreadSort]): Thread + """ Comment query """ - ThreadComment("""Filter by the comment id""" id: Int, """Filter by the thread id""" threadId: Int, """Filter by the user id of the comment's creator""" userId: Int, """The order the results will be returned in""" sort: [ThreadCommentSort]): [ThreadComment] + ThreadComment("Filter by the comment id" id: Int, "Filter by the thread id" threadId: Int, "Filter by the user id of the comment's creator" userId: Int, "The order the results will be returned in" sort: [ThreadCommentSort]): [ThreadComment] + """ Recommendation query """ - Recommendation("""Filter by recommendation id""" id: Int, """Filter by media id""" mediaId: Int, """Filter by media recommendation id""" mediaRecommendationId: Int, """Filter by user who created the recommendation""" userId: Int, """Filter by total rating of the recommendation""" rating: Int, """Filter by the media on the authenticated user's lists""" onList: Boolean, """Filter by total rating of the recommendation""" rating_greater: Int, """Filter by total rating of the recommendation""" rating_lesser: Int, """The order the results will be returned in""" sort: [RecommendationSort]): Recommendation + Recommendation("Filter by recommendation id" id: Int, "Filter by media id" mediaId: Int, "Filter by media recommendation id" mediaRecommendationId: Int, "Filter by user who created the recommendation" userId: Int, "Filter by total rating of the recommendation" rating: Int, "Filter by the media on the authenticated user's lists" onList: Boolean, "Filter by total rating of the recommendation" rating_greater: Int, "Filter by total rating of the recommendation" rating_lesser: Int, "The order the results will be returned in" sort: [RecommendationSort]): Recommendation + """ Like query """ - Like("""The id of the likeable type""" likeableId: Int, """The type of model the id applies to""" type: LikeableType): User + Like("The id of the likeable type" likeableId: Int, "The type of model the id applies to" type: LikeableType): User + """ Provide AniList markdown to be converted to html (Requires auth) """ - Markdown("""The markdown to be parsed to html""" markdown: String!): ParsedMarkdown + Markdown("The markdown to be parsed to html" markdown: String!): ParsedMarkdown + AniChartUser: AniChartUser + """ Site statistics query """ SiteStatistics: SiteStatistics + + """ + ExternalLinkSource collection query + """ + ExternalLinkSourceCollection("Filter by the link id" id: Int, type: ExternalLinkType, mediaType: ExternalLinkMediaType): [MediaExternalLink] } """ @@ -107,43 +137,65 @@ type Page { The pagination information """ pageInfo: PageInfo - users("""Filter by the user id""" id: Int, """Filter by the name of the user""" name: String, """Filter to moderators only if true""" isModerator: Boolean, """Filter by search query""" search: String, """The order the results will be returned in""" sort: [UserSort]): [User] - media("""Filter by the media id""" id: Int, """Filter by the media's MyAnimeList id""" idMal: Int, """Filter by the start date of the media""" startDate: FuzzyDateInt, """Filter by the end date of the media""" endDate: FuzzyDateInt, """Filter by the season the media was released in""" season: MediaSeason, """The year of the season (Winter 2017 would also include December 2016 releases). Requires season argument""" seasonYear: Int, """Filter by the media's type""" type: MediaType, """Filter by the media's format""" format: MediaFormat, """Filter by the media's current release status""" status: MediaStatus, """Filter by amount of episodes the media has""" episodes: Int, """Filter by the media's episode length""" duration: Int, """Filter by the media's chapter count""" chapters: Int, """Filter by the media's volume count""" volumes: Int, """Filter by if the media's intended for 18+ adult audiences""" isAdult: Boolean, """Filter by the media's genres""" genre: String, """Filter by the media's tags""" tag: String, """Only apply the tags filter argument to tags above this rank. Default: 18""" minimumTagRank: Int, """Filter by the media's tags with in a tag category""" tagCategory: String, """Filter by the media on the authenticated user's lists""" onList: Boolean, """Filter media by sites with a online streaming or reading license""" licensedBy: String, """Filter by the media's average score""" averageScore: Int, """Filter by the number of users with this media on their list""" popularity: Int, """Filter by the source type of the media""" source: MediaSource, """Filter by the media's country of origin""" countryOfOrigin: CountryCode, """Filter by search query""" search: String, """Filter by the media id""" id_not: Int, """Filter by the media id""" id_in: [Int], """Filter by the media id""" id_not_in: [Int], """Filter by the media's MyAnimeList id""" idMal_not: Int, """Filter by the media's MyAnimeList id""" idMal_in: [Int], """Filter by the media's MyAnimeList id""" idMal_not_in: [Int], """Filter by the start date of the media""" startDate_greater: FuzzyDateInt, """Filter by the start date of the media""" startDate_lesser: FuzzyDateInt, """Filter by the start date of the media""" startDate_like: String, """Filter by the end date of the media""" endDate_greater: FuzzyDateInt, """Filter by the end date of the media""" endDate_lesser: FuzzyDateInt, """Filter by the end date of the media""" endDate_like: String, """Filter by the media's format""" format_in: [MediaFormat], """Filter by the media's format""" format_not: MediaFormat, """Filter by the media's format""" format_not_in: [MediaFormat], """Filter by the media's current release status""" status_in: [MediaStatus], """Filter by the media's current release status""" status_not: MediaStatus, """Filter by the media's current release status""" status_not_in: [MediaStatus], """Filter by amount of episodes the media has""" episodes_greater: Int, """Filter by amount of episodes the media has""" episodes_lesser: Int, """Filter by the media's episode length""" duration_greater: Int, """Filter by the media's episode length""" duration_lesser: Int, """Filter by the media's chapter count""" chapters_greater: Int, """Filter by the media's chapter count""" chapters_lesser: Int, """Filter by the media's volume count""" volumes_greater: Int, """Filter by the media's volume count""" volumes_lesser: Int, """Filter by the media's genres""" genre_in: [String], """Filter by the media's genres""" genre_not_in: [String], """Filter by the media's tags""" tag_in: [String], """Filter by the media's tags""" tag_not_in: [String], """Filter by the media's tags with in a tag category""" tagCategory_in: [String], """Filter by the media's tags with in a tag category""" tagCategory_not_in: [String], """Filter media by sites with a online streaming or reading license""" licensedBy_in: [String], """Filter by the media's average score""" averageScore_not: Int, """Filter by the media's average score""" averageScore_greater: Int, """Filter by the media's average score""" averageScore_lesser: Int, """Filter by the number of users with this media on their list""" popularity_not: Int, """Filter by the number of users with this media on their list""" popularity_greater: Int, """Filter by the number of users with this media on their list""" popularity_lesser: Int, """Filter by the source type of the media""" source_in: [MediaSource], """The order the results will be returned in""" sort: [MediaSort]): [Media] - characters("""Filter by character id""" id: Int, """Filter by character by if its their birthday today""" isBirthday: Boolean, """Filter by search query""" search: String, """Filter by character id""" id_not: Int, """Filter by character id""" id_in: [Int], """Filter by character id""" id_not_in: [Int], """The order the results will be returned in""" sort: [CharacterSort]): [Character] - staff("""Filter by the staff id""" id: Int, """Filter by staff by if its their birthday today""" isBirthday: Boolean, """Filter by search query""" search: String, """Filter by the staff id""" id_not: Int, """Filter by the staff id""" id_in: [Int], """Filter by the staff id""" id_not_in: [Int], """The order the results will be returned in""" sort: [StaffSort]): [Staff] - studios("""Filter by the studio id""" id: Int, """Filter by search query""" search: String, """Filter by the studio id""" id_not: Int, """Filter by the studio id""" id_in: [Int], """Filter by the studio id""" id_not_in: [Int], """The order the results will be returned in""" sort: [StudioSort]): [Studio] - mediaList("""Filter by a list entry's id""" id: Int, """Filter by a user's id""" userId: Int, """Filter by a user's name""" userName: String, """Filter by the list entries media type""" type: MediaType, """Filter by the watching/reading status""" status: MediaListStatus, """Filter by the media id of the list entry""" mediaId: Int, """Filter list entries to users who are being followed by the authenticated user""" isFollowing: Boolean, """Filter by note words and #tags""" notes: String, """Filter by the date the user started the media""" startedAt: FuzzyDateInt, """Filter by the date the user completed the media""" completedAt: FuzzyDateInt, """Limit to only entries also on the auth user's list. Requires user id or name arguments.""" compareWithAuthList: Boolean, """Filter by a user's id""" userId_in: [Int], """Filter by the watching/reading status""" status_in: [MediaListStatus], """Filter by the watching/reading status""" status_not_in: [MediaListStatus], """Filter by the watching/reading status""" status_not: MediaListStatus, """Filter by the media id of the list entry""" mediaId_in: [Int], """Filter by the media id of the list entry""" mediaId_not_in: [Int], """Filter by note words and #tags""" notes_like: String, """Filter by the date the user started the media""" startedAt_greater: FuzzyDateInt, """Filter by the date the user started the media""" startedAt_lesser: FuzzyDateInt, """Filter by the date the user started the media""" startedAt_like: String, """Filter by the date the user completed the media""" completedAt_greater: FuzzyDateInt, """Filter by the date the user completed the media""" completedAt_lesser: FuzzyDateInt, """Filter by the date the user completed the media""" completedAt_like: String, """The order the results will be returned in""" sort: [MediaListSort]): [MediaList] - airingSchedules("""Filter by the id of the airing schedule item""" id: Int, """Filter by the id of associated media""" mediaId: Int, """Filter by the airing episode number""" episode: Int, """Filter by the time of airing""" airingAt: Int, """Filter to episodes that haven't yet aired""" notYetAired: Boolean, """Filter by the id of the airing schedule item""" id_not: Int, """Filter by the id of the airing schedule item""" id_in: [Int], """Filter by the id of the airing schedule item""" id_not_in: [Int], """Filter by the id of associated media""" mediaId_not: Int, """Filter by the id of associated media""" mediaId_in: [Int], """Filter by the id of associated media""" mediaId_not_in: [Int], """Filter by the airing episode number""" episode_not: Int, """Filter by the airing episode number""" episode_in: [Int], """Filter by the airing episode number""" episode_not_in: [Int], """Filter by the airing episode number""" episode_greater: Int, """Filter by the airing episode number""" episode_lesser: Int, """Filter by the time of airing""" airingAt_greater: Int, """Filter by the time of airing""" airingAt_lesser: Int, """The order the results will be returned in""" sort: [AiringSort]): [AiringSchedule] - mediaTrends("""Filter by the media id""" mediaId: Int, """Filter by date""" date: Int, """Filter by trending amount""" trending: Int, """Filter by score""" averageScore: Int, """Filter by popularity""" popularity: Int, """Filter by episode number""" episode: Int, """Filter to stats recorded while the media was releasing""" releasing: Boolean, """Filter by the media id""" mediaId_not: Int, """Filter by the media id""" mediaId_in: [Int], """Filter by the media id""" mediaId_not_in: [Int], """Filter by date""" date_greater: Int, """Filter by date""" date_lesser: Int, """Filter by trending amount""" trending_greater: Int, """Filter by trending amount""" trending_lesser: Int, """Filter by trending amount""" trending_not: Int, """Filter by score""" averageScore_greater: Int, """Filter by score""" averageScore_lesser: Int, """Filter by score""" averageScore_not: Int, """Filter by popularity""" popularity_greater: Int, """Filter by popularity""" popularity_lesser: Int, """Filter by popularity""" popularity_not: Int, """Filter by episode number""" episode_greater: Int, """Filter by episode number""" episode_lesser: Int, """Filter by episode number""" episode_not: Int, """The order the results will be returned in""" sort: [MediaTrendSort]): [MediaTrend] - notifications("""Filter by the type of notifications""" type: NotificationType, """Reset the unread notification count to 0 on load""" resetNotificationCount: Boolean, """Filter by the type of notifications""" type_in: [NotificationType]): [NotificationUnion] - followers("""User id of the follower/followed""" userId: Int!, """The order the results will be returned in""" sort: [UserSort]): [User] - following("""User id of the follower/followed""" userId: Int!, """The order the results will be returned in""" sort: [UserSort]): [User] - activities("""Filter by the activity id""" id: Int, """Filter by the owner user id""" userId: Int, """Filter by the id of the user who sent a message""" messengerId: Int, """Filter by the associated media id of the activity""" mediaId: Int, """Filter by the type of activity""" type: ActivityType, """Filter activity to users who are being followed by the authenticated user""" isFollowing: Boolean, """Filter activity to only activity with replies""" hasReplies: Boolean, """Filter activity to only activity with replies or is of type text""" hasRepliesOrTypeText: Boolean, """Filter by the time the activity was created""" createdAt: Int, """Filter by the activity id""" id_not: Int, """Filter by the activity id""" id_in: [Int], """Filter by the activity id""" id_not_in: [Int], """Filter by the owner user id""" userId_not: Int, """Filter by the owner user id""" userId_in: [Int], """Filter by the owner user id""" userId_not_in: [Int], """Filter by the id of the user who sent a message""" messengerId_not: Int, """Filter by the id of the user who sent a message""" messengerId_in: [Int], """Filter by the id of the user who sent a message""" messengerId_not_in: [Int], """Filter by the associated media id of the activity""" mediaId_not: Int, """Filter by the associated media id of the activity""" mediaId_in: [Int], """Filter by the associated media id of the activity""" mediaId_not_in: [Int], """Filter by the type of activity""" type_not: ActivityType, """Filter by the type of activity""" type_in: [ActivityType], """Filter by the type of activity""" type_not_in: [ActivityType], """Filter by the time the activity was created""" createdAt_greater: Int, """Filter by the time the activity was created""" createdAt_lesser: Int, """The order the results will be returned in""" sort: [ActivitySort]): [ActivityUnion] - activityReplies("""Filter by the reply id""" id: Int, """Filter by the parent id""" activityId: Int): [ActivityReply] - threads("""Filter by the thread id""" id: Int, """Filter by the user id of the thread's creator""" userId: Int, """Filter by the user id of the last user to comment on the thread""" replyUserId: Int, """Filter by if the currently authenticated user's subscribed threads""" subscribed: Boolean, """Filter by thread category id""" categoryId: Int, """Filter by thread media id category""" mediaCategoryId: Int, """Filter by search query""" search: String, """Filter by the thread id""" id_in: [Int], """The order the results will be returned in""" sort: [ThreadSort]): [Thread] - threadComments("""Filter by the comment id""" id: Int, """Filter by the thread id""" threadId: Int, """Filter by the user id of the comment's creator""" userId: Int, """The order the results will be returned in""" sort: [ThreadCommentSort]): [ThreadComment] - reviews("""Filter by Review id""" id: Int, """Filter by media id""" mediaId: Int, """Filter by user id""" userId: Int, """Filter by media type""" mediaType: MediaType, """The order the results will be returned in""" sort: [ReviewSort]): [Review] - recommendations("""Filter by recommendation id""" id: Int, """Filter by media id""" mediaId: Int, """Filter by media recommendation id""" mediaRecommendationId: Int, """Filter by user who created the recommendation""" userId: Int, """Filter by total rating of the recommendation""" rating: Int, """Filter by the media on the authenticated user's lists""" onList: Boolean, """Filter by total rating of the recommendation""" rating_greater: Int, """Filter by total rating of the recommendation""" rating_lesser: Int, """The order the results will be returned in""" sort: [RecommendationSort]): [Recommendation] - likes("""The id of the likeable type""" likeableId: Int, """The type of model the id applies to""" type: LikeableType): [User] + + users("Filter by the user id" id: Int, "Filter by the name of the user" name: String, "Filter to moderators only if true" isModerator: Boolean, "Filter by search query" search: String, "The order the results will be returned in" sort: [UserSort]): [User] + + media("Filter by the media id" id: Int, "Filter by the media's MyAnimeList id" idMal: Int, "Filter by the start date of the media" startDate: FuzzyDateInt, "Filter by the end date of the media" endDate: FuzzyDateInt, "Filter by the season the media was released in" season: MediaSeason, "The year of the season (Winter 2017 would also include December 2016 releases). Requires season argument" seasonYear: Int, "Filter by the media's type" type: MediaType, "Filter by the media's format" format: MediaFormat, "Filter by the media's current release status" status: MediaStatus, "Filter by amount of episodes the media has" episodes: Int, "Filter by the media's episode length" duration: Int, "Filter by the media's chapter count" chapters: Int, "Filter by the media's volume count" volumes: Int, "Filter by if the media's intended for 18+ adult audiences" isAdult: Boolean, "Filter by the media's genres" genre: String, "Filter by the media's tags" tag: String, "Only apply the tags filter argument to tags above this rank. Default: 18" minimumTagRank: Int, "Filter by the media's tags with in a tag category" tagCategory: String, "Filter by the media on the authenticated user's lists" onList: Boolean, "Filter media by sites name with a online streaming or reading license" licensedBy: String, "Filter media by sites id with a online streaming or reading license" licensedById: Int, "Filter by the media's average score" averageScore: Int, "Filter by the number of users with this media on their list" popularity: Int, "Filter by the source type of the media" source: MediaSource, "Filter by the media's country of origin" countryOfOrigin: CountryCode, "If the media is officially licensed or a self-published doujin release" isLicensed: Boolean, "Filter by search query" search: String, "Filter by the media id" id_not: Int, "Filter by the media id" id_in: [Int], "Filter by the media id" id_not_in: [Int], "Filter by the media's MyAnimeList id" idMal_not: Int, "Filter by the media's MyAnimeList id" idMal_in: [Int], "Filter by the media's MyAnimeList id" idMal_not_in: [Int], "Filter by the start date of the media" startDate_greater: FuzzyDateInt, "Filter by the start date of the media" startDate_lesser: FuzzyDateInt, "Filter by the start date of the media" startDate_like: String, "Filter by the end date of the media" endDate_greater: FuzzyDateInt, "Filter by the end date of the media" endDate_lesser: FuzzyDateInt, "Filter by the end date of the media" endDate_like: String, "Filter by the media's format" format_in: [MediaFormat], "Filter by the media's format" format_not: MediaFormat, "Filter by the media's format" format_not_in: [MediaFormat], "Filter by the media's current release status" status_in: [MediaStatus], "Filter by the media's current release status" status_not: MediaStatus, "Filter by the media's current release status" status_not_in: [MediaStatus], "Filter by amount of episodes the media has" episodes_greater: Int, "Filter by amount of episodes the media has" episodes_lesser: Int, "Filter by the media's episode length" duration_greater: Int, "Filter by the media's episode length" duration_lesser: Int, "Filter by the media's chapter count" chapters_greater: Int, "Filter by the media's chapter count" chapters_lesser: Int, "Filter by the media's volume count" volumes_greater: Int, "Filter by the media's volume count" volumes_lesser: Int, "Filter by the media's genres" genre_in: [String], "Filter by the media's genres" genre_not_in: [String], "Filter by the media's tags" tag_in: [String], "Filter by the media's tags" tag_not_in: [String], "Filter by the media's tags with in a tag category" tagCategory_in: [String], "Filter by the media's tags with in a tag category" tagCategory_not_in: [String], "Filter media by sites name with a online streaming or reading license" licensedBy_in: [String], "Filter media by sites id with a online streaming or reading license" licensedById_in: [Int], "Filter by the media's average score" averageScore_not: Int, "Filter by the media's average score" averageScore_greater: Int, "Filter by the media's average score" averageScore_lesser: Int, "Filter by the number of users with this media on their list" popularity_not: Int, "Filter by the number of users with this media on their list" popularity_greater: Int, "Filter by the number of users with this media on their list" popularity_lesser: Int, "Filter by the source type of the media" source_in: [MediaSource], "The order the results will be returned in" sort: [MediaSort]): [Media] + + characters("Filter by character id" id: Int, "Filter by character by if its their birthday today" isBirthday: Boolean, "Filter by search query" search: String, "Filter by character id" id_not: Int, "Filter by character id" id_in: [Int], "Filter by character id" id_not_in: [Int], "The order the results will be returned in" sort: [CharacterSort]): [Character] + + staff("Filter by the staff id" id: Int, "Filter by staff by if its their birthday today" isBirthday: Boolean, "Filter by search query" search: String, "Filter by the staff id" id_not: Int, "Filter by the staff id" id_in: [Int], "Filter by the staff id" id_not_in: [Int], "The order the results will be returned in" sort: [StaffSort]): [Staff] + + studios("Filter by the studio id" id: Int, "Filter by search query" search: String, "Filter by the studio id" id_not: Int, "Filter by the studio id" id_in: [Int], "Filter by the studio id" id_not_in: [Int], "The order the results will be returned in" sort: [StudioSort]): [Studio] + + mediaList("Filter by a list entry's id" id: Int, "Filter by a user's id" userId: Int, "Filter by a user's name" userName: String, "Filter by the list entries media type" type: MediaType, "Filter by the watching\/reading status" status: MediaListStatus, "Filter by the media id of the list entry" mediaId: Int, "Filter list entries to users who are being followed by the authenticated user" isFollowing: Boolean, "Filter by note words and #tags" notes: String, "Filter by the date the user started the media" startedAt: FuzzyDateInt, "Filter by the date the user completed the media" completedAt: FuzzyDateInt, "Limit to only entries also on the auth user's list. Requires user id or name arguments." compareWithAuthList: Boolean, "Filter by a user's id" userId_in: [Int], "Filter by the watching\/reading status" status_in: [MediaListStatus], "Filter by the watching\/reading status" status_not_in: [MediaListStatus], "Filter by the watching\/reading status" status_not: MediaListStatus, "Filter by the media id of the list entry" mediaId_in: [Int], "Filter by the media id of the list entry" mediaId_not_in: [Int], "Filter by note words and #tags" notes_like: String, "Filter by the date the user started the media" startedAt_greater: FuzzyDateInt, "Filter by the date the user started the media" startedAt_lesser: FuzzyDateInt, "Filter by the date the user started the media" startedAt_like: String, "Filter by the date the user completed the media" completedAt_greater: FuzzyDateInt, "Filter by the date the user completed the media" completedAt_lesser: FuzzyDateInt, "Filter by the date the user completed the media" completedAt_like: String, "The order the results will be returned in" sort: [MediaListSort]): [MediaList] + + airingSchedules("Filter by the id of the airing schedule item" id: Int, "Filter by the id of associated media" mediaId: Int, "Filter by the airing episode number" episode: Int, "Filter by the time of airing" airingAt: Int, "Filter to episodes that haven't yet aired" notYetAired: Boolean, "Filter by the id of the airing schedule item" id_not: Int, "Filter by the id of the airing schedule item" id_in: [Int], "Filter by the id of the airing schedule item" id_not_in: [Int], "Filter by the id of associated media" mediaId_not: Int, "Filter by the id of associated media" mediaId_in: [Int], "Filter by the id of associated media" mediaId_not_in: [Int], "Filter by the airing episode number" episode_not: Int, "Filter by the airing episode number" episode_in: [Int], "Filter by the airing episode number" episode_not_in: [Int], "Filter by the airing episode number" episode_greater: Int, "Filter by the airing episode number" episode_lesser: Int, "Filter by the time of airing" airingAt_greater: Int, "Filter by the time of airing" airingAt_lesser: Int, "The order the results will be returned in" sort: [AiringSort]): [AiringSchedule] + + mediaTrends("Filter by the media id" mediaId: Int, "Filter by date" date: Int, "Filter by trending amount" trending: Int, "Filter by score" averageScore: Int, "Filter by popularity" popularity: Int, "Filter by episode number" episode: Int, "Filter to stats recorded while the media was releasing" releasing: Boolean, "Filter by the media id" mediaId_not: Int, "Filter by the media id" mediaId_in: [Int], "Filter by the media id" mediaId_not_in: [Int], "Filter by date" date_greater: Int, "Filter by date" date_lesser: Int, "Filter by trending amount" trending_greater: Int, "Filter by trending amount" trending_lesser: Int, "Filter by trending amount" trending_not: Int, "Filter by score" averageScore_greater: Int, "Filter by score" averageScore_lesser: Int, "Filter by score" averageScore_not: Int, "Filter by popularity" popularity_greater: Int, "Filter by popularity" popularity_lesser: Int, "Filter by popularity" popularity_not: Int, "Filter by episode number" episode_greater: Int, "Filter by episode number" episode_lesser: Int, "Filter by episode number" episode_not: Int, "The order the results will be returned in" sort: [MediaTrendSort]): [MediaTrend] + + notifications("Filter by the type of notifications" type: NotificationType, "Reset the unread notification count to 0 on load" resetNotificationCount: Boolean, "Filter by the type of notifications" type_in: [NotificationType]): [NotificationUnion] + + followers("User id of the follower\/followed" userId: Int!, "The order the results will be returned in" sort: [UserSort]): [User] + + following("User id of the follower\/followed" userId: Int!, "The order the results will be returned in" sort: [UserSort]): [User] + + activities("Filter by the activity id" id: Int, "Filter by the owner user id" userId: Int, "Filter by the id of the user who sent a message" messengerId: Int, "Filter by the associated media id of the activity" mediaId: Int, "Filter by the type of activity" type: ActivityType, "Filter activity to users who are being followed by the authenticated user" isFollowing: Boolean, "Filter activity to only activity with replies" hasReplies: Boolean, "Filter activity to only activity with replies or is of type text" hasRepliesOrTypeText: Boolean, "Filter by the time the activity was created" createdAt: Int, "Filter by the activity id" id_not: Int, "Filter by the activity id" id_in: [Int], "Filter by the activity id" id_not_in: [Int], "Filter by the owner user id" userId_not: Int, "Filter by the owner user id" userId_in: [Int], "Filter by the owner user id" userId_not_in: [Int], "Filter by the id of the user who sent a message" messengerId_not: Int, "Filter by the id of the user who sent a message" messengerId_in: [Int], "Filter by the id of the user who sent a message" messengerId_not_in: [Int], "Filter by the associated media id of the activity" mediaId_not: Int, "Filter by the associated media id of the activity" mediaId_in: [Int], "Filter by the associated media id of the activity" mediaId_not_in: [Int], "Filter by the type of activity" type_not: ActivityType, "Filter by the type of activity" type_in: [ActivityType], "Filter by the type of activity" type_not_in: [ActivityType], "Filter by the time the activity was created" createdAt_greater: Int, "Filter by the time the activity was created" createdAt_lesser: Int, "The order the results will be returned in" sort: [ActivitySort]): [ActivityUnion] + + activityReplies("Filter by the reply id" id: Int, "Filter by the parent id" activityId: Int): [ActivityReply] + + threads("Filter by the thread id" id: Int, "Filter by the user id of the thread's creator" userId: Int, "Filter by the user id of the last user to comment on the thread" replyUserId: Int, "Filter by if the currently authenticated user's subscribed threads" subscribed: Boolean, "Filter by thread category id" categoryId: Int, "Filter by thread media id category" mediaCategoryId: Int, "Filter by search query" search: String, "Filter by the thread id" id_in: [Int], "The order the results will be returned in" sort: [ThreadSort]): [Thread] + + threadComments("Filter by the comment id" id: Int, "Filter by the thread id" threadId: Int, "Filter by the user id of the comment's creator" userId: Int, "The order the results will be returned in" sort: [ThreadCommentSort]): [ThreadComment] + + reviews("Filter by Review id" id: Int, "Filter by media id" mediaId: Int, "Filter by user id" userId: Int, "Filter by media type" mediaType: MediaType, "The order the results will be returned in" sort: [ReviewSort]): [Review] + + recommendations("Filter by recommendation id" id: Int, "Filter by media id" mediaId: Int, "Filter by media recommendation id" mediaRecommendationId: Int, "Filter by user who created the recommendation" userId: Int, "Filter by total rating of the recommendation" rating: Int, "Filter by the media on the authenticated user's lists" onList: Boolean, "Filter by total rating of the recommendation" rating_greater: Int, "Filter by total rating of the recommendation" rating_lesser: Int, "The order the results will be returned in" sort: [RecommendationSort]): [Recommendation] + + likes("The id of the likeable type" likeableId: Int, "The type of model the id applies to" type: LikeableType): [User] } type PageInfo { """ - The total number of items + The total number of items. Note: This value is not guaranteed to be accurate, do not rely on this for logic """ total: Int + """ The count on a page """ perPage: Int + """ The current page """ currentPage: Int + """ The last page """ lastPage: Int + """ If there is another page """ @@ -155,13 +207,21 @@ User sort enums """ enum UserSort { ID + ID_DESC + USERNAME + USERNAME_DESC + WATCHED_TIME + WATCHED_TIME_DESC + CHAPTERS_READ + CHAPTERS_READ_DESC + SEARCH_MATCH } @@ -173,87 +233,113 @@ type User { The id of the user """ id: Int! + """ The name of the user """ name: String! + """ The bio written by user (Markdown) """ - about("""Return the string in pre-parsed html instead of markdown""" asHtml: Boolean): String + about("Return the string in pre-parsed html instead of markdown" asHtml: Boolean): String + """ The user's avatar images """ avatar: UserAvatar + """ The user's banner images """ bannerImage: String + """ If the authenticated user if following this user """ isFollowing: Boolean + """ If this user if following the authenticated user """ isFollower: Boolean + """ If the user is blocked by the authenticated user """ isBlocked: Boolean + bans: Json + """ The user's general options """ options: UserOptions + """ The user's media list options """ mediaListOptions: MediaListOptions + """ The users favourites """ - favourites("""Deprecated. Use page arguments on each favourite field instead.""" page: Int): Favourites + favourites("Deprecated. Use page arguments on each favourite field instead." page: Int): Favourites + """ The users anime & manga list statistics """ statistics: UserStatisticTypes + """ The number of unread notifications the user has """ unreadNotificationCount: Int + """ The url for the user page on the AniList website """ siteUrl: String + """ The donation tier of the user """ donatorTier: Int + """ Custom donation badge text """ donatorBadge: String + """ The user's moderator roles if they are a site moderator """ moderatorRoles: [ModRole] + """ When the user's account was created. (Does not exist for accounts created before 2020) """ createdAt: Int + """ When the user's data was last updated """ updatedAt: Int + """ The user's statistics """ stats: UserStats @deprecated(reason: "Deprecated. Replaced with statistics field.") + """ If the user is a moderator or data moderator """ moderatorStatus: String @deprecated(reason: "Deprecated. Replaced with moderatorRoles field.") + + """ + The user's previously used names. + """ + previousNames: [UserPreviousName] } """ @@ -264,6 +350,7 @@ type UserAvatar { The avatar of user at its largest size """ large: String + """ The avatar of user at medium size """ @@ -280,34 +367,51 @@ type UserOptions { The language the user wants to see media titles in """ titleLanguage: UserTitleLanguage + """ Whether the user has enabled viewing of 18+ content """ displayAdultContent: Boolean + """ Whether the user receives notifications when a show they are watching aires """ airingNotifications: Boolean + """ Profile highlight color (blue, purple, pink, orange, red, green, gray) """ profileColor: String + """ Notification options """ notificationOptions: [NotificationOption] + """ The user's timezone offset (Auth user only) """ timezone: String + """ Minutes between activity for them to be merged together. 0 is Never, Above 2 weeks (20160 mins) is Always. """ activityMergeTime: Int + """ The language the user wants to see staff and character names in """ staffNameLanguage: UserStaffNameLanguage + + """ + Whether the user only allow messages from users they follow + """ + restrictMessagesToFollowing: Boolean + + """ + The list activity types the user has disabled from being created from list updates + """ + disabledListActivity: [ListActivityOption] } """ @@ -318,22 +422,27 @@ enum UserTitleLanguage { The romanization of the native language title """ ROMAJI + """ The official english title """ ENGLISH + """ Official title in it's native language """ NATIVE + """ The romanization of the native language title, stylised by media creator """ ROMAJI_STYLISED + """ The official english title, stylised by media creator """ ENGLISH_STYLISED + """ Official title in it's native language, stylised by media creator """ @@ -348,6 +457,7 @@ type NotificationOption { The type of notification """ type: NotificationType + """ Whether this type of notification is enabled """ @@ -362,58 +472,86 @@ enum NotificationType { A user has sent you message """ ACTIVITY_MESSAGE + """ A user has replied to your activity """ ACTIVITY_REPLY + """ A user has followed you """ FOLLOWING + """ A user has mentioned you in their activity """ ACTIVITY_MENTION + """ A user has mentioned you in a forum comment """ THREAD_COMMENT_MENTION + """ A user has commented in one of your subscribed forum threads """ THREAD_SUBSCRIBED + """ A user has replied to your forum comment """ THREAD_COMMENT_REPLY + """ An anime you are currently watching has aired """ AIRING + """ A user has liked your activity """ ACTIVITY_LIKE + """ A user has liked your activity reply """ ACTIVITY_REPLY_LIKE + """ A user has liked your forum thread """ THREAD_LIKE + """ A user has liked your forum comment """ THREAD_COMMENT_LIKE + """ A user has replied to activity you have also replied to """ ACTIVITY_REPLY_SUBSCRIBED + """ A new anime or manga has been added to the site where its related media is on the user's list """ RELATED_MEDIA_ADDITION + + """ + An anime or manga has had a data change that affects how a user may track it in their lists + """ + MEDIA_DATA_CHANGE + + """ + Anime or manga entries on the user's list have been merged into a single entry + """ + MEDIA_MERGE + + """ + An anime or manga on the user's list has been deleted from the site + """ + MEDIA_DELETION } """ @@ -424,16 +562,59 @@ enum UserStaffNameLanguage { The romanization of the staff or character's native name, with western name ordering """ ROMAJI_WESTERN + """ The romanization of the staff or character's native name """ ROMAJI + """ The staff or character's name in their native language """ NATIVE } +type ListActivityOption { + disabled: Boolean + + type: MediaListStatus +} + +""" +Media list watching/reading status enum. +""" +enum MediaListStatus { + """ + Currently watching/reading + """ + CURRENT + + """ + Planning to watch/read + """ + PLANNING + + """ + Finished watching/reading + """ + COMPLETED + + """ + Stopped watching/reading before completing + """ + DROPPED + + """ + Paused watching/reading + """ + PAUSED + + """ + Re-watching/reading + """ + REPEATING +} + """ A user's list options """ @@ -442,23 +623,29 @@ type MediaListOptions { The score format the user is using for media lists """ scoreFormat: ScoreFormat + """ The default order list rows should be displayed in """ rowOrder: String + useLegacyLists: Boolean @deprecated(reason: "No longer used") + """ The user's anime list options """ animeList: MediaListTypeOptions + """ The user's manga list options """ mangaList: MediaListTypeOptions + """ The list theme options for both lists """ sharedTheme: Json @deprecated(reason: "No longer used") + """ If the shared theme should be used instead of the individual list themes """ @@ -473,18 +660,22 @@ enum ScoreFormat { An integer from 0-100 """ POINT_100 + """ A float from 0-10 with 1 decimal place """ POINT_10_DECIMAL + """ An integer from 0-10 """ POINT_10 + """ An integer from 0-5. Should be represented in Stars """ POINT_5 + """ An integer from 0-3. Should be represented in Smileys. 0 => No Score, 1 => :(, 2 => :|, 3 => :) """ @@ -499,22 +690,27 @@ type MediaListTypeOptions { The order each list should be displayed in """ sectionOrder: [String] + """ If the completed sections of the list should be separated by format """ splitCompletedSectionByFormat: Boolean + """ The list theme options """ theme: Json @deprecated(reason: "This field has not yet been fully implemented and may change without warning") + """ The names of the user's custom lists """ customLists: [String] + """ The names of the user's advanced scoring sections """ advancedScoring: [String] + """ If advanced scoring is enabled """ @@ -528,28 +724,34 @@ type Favourites { """ Favourite anime """ - anime("""The page number""" page: Int, """The amount of entries per page, max 25""" perPage: Int): MediaConnection + anime("The page number" page: Int, "The amount of entries per page, max 25" perPage: Int): MediaConnection + """ Favourite manga """ - manga("""The page number""" page: Int, """The amount of entries per page, max 25""" perPage: Int): MediaConnection + manga("The page number" page: Int, "The amount of entries per page, max 25" perPage: Int): MediaConnection + """ Favourite characters """ - characters("""The page number""" page: Int, """The amount of entries per page, max 25""" perPage: Int): CharacterConnection + characters("The page number" page: Int, "The amount of entries per page, max 25" perPage: Int): CharacterConnection + """ Favourite staff """ - staff("""The page number""" page: Int, """The amount of entries per page, max 25""" perPage: Int): StaffConnection + staff("The page number" page: Int, "The amount of entries per page, max 25" perPage: Int): StaffConnection + """ Favourite studios """ - studios("""The page number""" page: Int, """The amount of entries per page, max 25""" perPage: Int): StudioConnection + studios("The page number" page: Int, "The amount of entries per page, max 25" perPage: Int): StudioConnection } type MediaConnection { edges: [MediaEdge] + nodes: [Media] + """ The pagination information """ @@ -561,50 +763,62 @@ Media connection edge """ type MediaEdge { node: Media + """ The id of the connection """ id: Int + """ The type of relation to the parent model """ - relationType("""Provide 2 to use new version 2 of relation enum""" version: Int): MediaRelation + relationType("Provide 2 to use new version 2 of relation enum" version: Int): MediaRelation + """ If the studio is the main animation studio of the media (For Studio->MediaConnection field only) """ isMainStudio: Boolean! + """ The characters in the media voiced by the parent actor """ characters: [Character] + """ The characters role in the media """ characterRole: CharacterRole + """ Media specific character name """ characterName: String + """ Notes regarding the VA's role for the character """ roleNotes: String + """ Used for grouping roles where multiple dubs exist for the same language. Either dubbing company name or language variant. """ dubGroup: String + """ The role of the staff member in the production of the media """ staffRole: String + """ The voice actors of the character """ voiceActors(language: StaffLanguage, sort: [StaffSort]): [Staff] + """ The voice actors of the character with role date """ voiceActorRoles(language: StaffLanguage, sort: [StaffSort]): [StaffRoleType] + """ The order the media should be displayed from the users favourites """ @@ -619,207 +833,269 @@ type Media { The id of the media """ id: Int! + """ The mal id of the media """ idMal: Int + """ The official titles of the media in various languages """ title: MediaTitle + """ The type of the media; anime or manga """ type: MediaType + """ The format the media was released in """ format: MediaFormat + """ The current releasing status of the media """ - status("""Provide 2 to use new version 2 of sources enum""" version: Int): MediaStatus + status("Provide 2 to use new version 2 of sources enum" version: Int): MediaStatus + """ Short description of the media's story and characters """ - description("""Return the string in pre-parsed html instead of markdown""" asHtml: Boolean): String + description("Return the string in pre-parsed html instead of markdown" asHtml: Boolean): String + """ The first official release date of the media """ startDate: FuzzyDate + """ The last official release date of the media """ endDate: FuzzyDate + """ The season the media was initially released in """ season: MediaSeason + """ The season year the media was initially released in """ seasonYear: Int + """ The year & season the media was initially released in """ seasonInt: Int @deprecated(reason: "") + """ The amount of episodes the anime has when complete """ episodes: Int + """ The general length of each anime episode in minutes """ duration: Int + """ The amount of chapters the manga has when complete """ chapters: Int + """ The amount of volumes the manga has when complete """ volumes: Int + """ Where the media was created. (ISO 3166-1 alpha-2) """ countryOfOrigin: CountryCode + """ If the media is officially licensed or a self-published doujin release """ isLicensed: Boolean + """ Source type the media was adapted from. """ - source("""Provide 2 to use new version 2 of sources enum""" version: Int): MediaSource + source("Provide 2 or 3 to use new version 2 or 3 of sources enum" version: Int): MediaSource + """ Official Twitter hashtags for the media """ hashtag: String + """ Media trailer or advertisement """ trailer: MediaTrailer + """ When the media's data was last updated """ updatedAt: Int + """ The cover images of the media """ coverImage: MediaCoverImage + """ The banner image of the media """ bannerImage: String + """ The genres of the media """ genres: [String] + """ Alternative titles of the media """ synonyms: [String] + """ A weighted average score of all the user's scores of the media """ averageScore: Int + """ Mean score of all the user's scores of the media """ meanScore: Int + """ The number of users with the media on their list """ popularity: Int + """ Locked media may not be added to lists our favorited. This may be due to the entry pending for deletion or other reasons. """ isLocked: Boolean + """ The amount of related activity in the past hour """ trending: Int + """ The amount of user's who have favourited the media """ favourites: Int + """ List of tags that describes elements and themes of the media """ tags: [MediaTag] + """ Other media in the same or connecting franchise """ relations: MediaConnection + """ The characters in the media """ - characters(sort: [CharacterSort], role: CharacterRole, """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): CharacterConnection + characters(sort: [CharacterSort], role: CharacterRole, "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): CharacterConnection + """ The staff who produced the media """ - staff(sort: [StaffSort], """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): StaffConnection + staff(sort: [StaffSort], "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): StaffConnection + """ The companies who produced the media """ studios(sort: [StudioSort], isMain: Boolean): StudioConnection + """ If the media is marked as favourite by the current authenticated user """ isFavourite: Boolean! + + """ + If the media is blocked from being added to favourites + """ + isFavouriteBlocked: Boolean! + """ If the media is intended only for 18+ adult audiences """ isAdult: Boolean + """ The media's next episode airing schedule """ nextAiringEpisode: AiringSchedule + """ The media's entire airing schedule """ - airingSchedule("""Filter to episodes that have not yet aired""" notYetAired: Boolean, """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): AiringScheduleConnection + airingSchedule("Filter to episodes that have not yet aired" notYetAired: Boolean, "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): AiringScheduleConnection + """ The media's daily trend stats """ - trends(sort: [MediaTrendSort], """Filter to stats recorded while the media was releasing""" releasing: Boolean, """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): MediaTrendConnection + trends(sort: [MediaTrendSort], "Filter to stats recorded while the media was releasing" releasing: Boolean, "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): MediaTrendConnection + """ External links to another site related to the media """ externalLinks: [MediaExternalLink] + """ Data and links to legal streaming episodes on external sites """ streamingEpisodes: [MediaStreamingEpisode] + """ The ranking of the media in a particular time span and format compared to other media """ rankings: [MediaRank] + """ The authenticated user's media list entry for the media """ mediaListEntry: MediaList + """ User reviews of the media """ - reviews(limit: Int, sort: [ReviewSort], """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): ReviewConnection + reviews(limit: Int, sort: [ReviewSort], "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): ReviewConnection + """ User recommendations for similar media """ - recommendations(sort: [RecommendationSort], """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): RecommendationConnection + recommendations(sort: [RecommendationSort], "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): RecommendationConnection + stats: MediaStats + """ The url for the media page on the AniList website """ siteUrl: String + """ If the media should have forum thread automatically created for it on airing episode release """ autoCreateForumThread: Boolean + """ If the media is blocked from being recommended to/from """ isRecommendationBlocked: Boolean + + """ + If the media is blocked from being reviewed + """ + isReviewBlocked: Boolean + """ Notes for site moderators """ @@ -834,14 +1110,17 @@ type MediaTitle { The romanization of the native language title """ romaji(stylised: Boolean): String + """ The official english title """ english(stylised: Boolean): String + """ Official title in it's native language """ native(stylised: Boolean): String + """ The currently authenticated users preferred title language. Default romaji for non-authenticated """ @@ -856,6 +1135,7 @@ enum MediaType { Japanese Anime """ ANIME + """ Asian comic """ @@ -870,38 +1150,47 @@ enum MediaFormat { Anime broadcast on television """ TV + """ Anime which are under 15 minutes in length and broadcast on television """ TV_SHORT + """ Anime movies with a theatrical release """ MOVIE + """ Special episodes that have been included in DVD/Blu-ray releases, picture dramas, pilots, etc """ SPECIAL + """ (Original Video Animation) Anime that have been released directly on DVD/Blu-ray without originally going through a theatrical release or television broadcast """ OVA + """ (Original Net Animation) Anime that have been originally released online or are only available through streaming services. """ ONA + """ Short anime released as a music video """ MUSIC + """ Professionally published manga with more than one chapter """ MANGA + """ Written books released as a series of light novels """ NOVEL + """ Manga with just one chapter """ @@ -916,18 +1205,22 @@ enum MediaStatus { Has completed and is no longer being released """ FINISHED + """ Currently releasing """ RELEASING + """ To be released at a later date """ NOT_YET_RELEASED + """ Ended before the work could be finished """ CANCELLED + """ Version 2 only. Is currently paused from releasing and will resume at a later date """ @@ -942,10 +1235,12 @@ type FuzzyDate { Numeric Year (2017) """ year: Int + """ Numeric Month (3) """ month: Int + """ Numeric Day (24) """ @@ -957,14 +1252,17 @@ enum MediaSeason { Months December to February """ WINTER + """ Months March to May """ SPRING + """ Months June to August """ SUMMER + """ Months September to November """ @@ -984,38 +1282,76 @@ enum MediaSource { An original production not based of another work """ ORIGINAL + """ Asian comic book """ MANGA + """ Written work published in volumes """ LIGHT_NOVEL + """ Video game driven primary by text and narrative """ VISUAL_NOVEL + """ Video game """ VIDEO_GAME + """ Other """ OTHER + """ - Version 2 only. Written works not published in volumes + Version 2+ only. Written works not published in volumes """ NOVEL + """ - Version 2 only. Self-published works + Version 2+ only. Self-published works """ DOUJINSHI + """ - Version 2 only. Japanese Anime + Version 2+ only. Japanese Anime """ ANIME + + """ + Version 3 only. Written works published online + """ + WEB_NOVEL + + """ + Version 3 only. Live action media such as movies or TV show + """ + LIVE_ACTION + + """ + Version 3 only. Games excluding video games + """ + GAME + + """ + Version 3 only. Comics excluding manga + """ + COMIC + + """ + Version 3 only. Multimedia project + """ + MULTIMEDIA_PROJECT + + """ + Version 3 only. Picture book + """ + PICTURE_BOOK } """ @@ -1026,10 +1362,12 @@ type MediaTrailer { The trailer video id """ id: String + """ The site the video is hosted by (Currently either youtube or dailymotion) """ site: String + """ The url for the thumbnail image of the video """ @@ -1041,14 +1379,17 @@ type MediaCoverImage { The cover image url of the media at its largest size. If this size isn't available, large will be provided instead. """ extraLarge: String + """ The cover image url of the media at a large size """ large: String + """ The cover image url of the media at medium size """ medium: String + """ Average #hex color of cover image """ @@ -1063,34 +1404,46 @@ type MediaTag { The id of the tag """ id: Int! + """ The name of the tag """ name: String! + """ A general description of the tag """ description: String + """ The categories of tags this tag belongs to """ category: String + """ The relevance ranking of the tag out of the 100 for this media """ rank: Int + """ If the tag could be a spoiler for any media """ isGeneralSpoiler: Boolean + """ If the tag is a spoiler for this media """ isMediaSpoiler: Boolean + """ If the tag is only for adult 18+ media """ isAdult: Boolean + + """ + The user who submitted the tag + """ + userId: Int } """ @@ -1098,12 +1451,19 @@ Character sort enums """ enum CharacterSort { ID + ID_DESC + ROLE + ROLE_DESC + SEARCH_MATCH + FAVOURITES + FAVOURITES_DESC + """ Order manually decided by moderators """ @@ -1118,10 +1478,12 @@ enum CharacterRole { A primary character role in the media """ MAIN + """ A supporting character role in the media """ SUPPORTING + """ A background character in the media """ @@ -1130,7 +1492,9 @@ enum CharacterRole { type CharacterConnection { edges: [CharacterEdge] + nodes: [Character] + """ The pagination information """ @@ -1142,30 +1506,37 @@ Character connection edge """ type CharacterEdge { node: Character + """ The id of the connection """ id: Int + """ The characters role in the media """ role: CharacterRole + """ Media specific character name """ name: String + """ The voice actors of the character """ voiceActors(language: StaffLanguage, sort: [StaffSort]): [Staff] + """ The voice actors of the character with role date """ voiceActorRoles(language: StaffLanguage, sort: [StaffSort]): [StaffRoleType] + """ The media the character is in """ media: [Media] + """ The order the character should be displayed from the users favourites """ @@ -1180,51 +1551,69 @@ type Character { The id of the character """ id: Int! + """ The names of the character """ name: CharacterName + """ Character images """ image: CharacterImage + """ A general description of the character """ - description("""Return the string in pre-parsed html instead of markdown""" asHtml: Boolean): String + description("Return the string in pre-parsed html instead of markdown" asHtml: Boolean): String + """ The character's gender. Usually Male, Female, or Non-binary but can be any string. """ gender: String + """ The character's birth date """ dateOfBirth: FuzzyDate + """ The character's age. Note this is a string, not an int, it may contain further text and additional ages. """ age: String + + """ + The characters blood type + """ + bloodType: String + """ If the character is marked as favourite by the currently authenticated user """ isFavourite: Boolean! + """ If the character is blocked from being added to favourites """ isFavouriteBlocked: Boolean! + """ The url for the character page on the AniList website """ siteUrl: String + """ Media that includes the character """ - media(sort: [MediaSort], type: MediaType, onList: Boolean, """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): MediaConnection + media(sort: [MediaSort], type: MediaType, onList: Boolean, "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): MediaConnection + updatedAt: Int @deprecated(reason: "No data available") + """ The amount of user's who have favourited the character """ favourites: Int + """ Notes for site moderators """ @@ -1239,30 +1628,37 @@ type CharacterName { The character's given name """ first: String + """ The character's middle name """ middle: String + """ The character's surname """ last: String + """ The character's first and last name """ full: String + """ The character's full name in their native language """ native: String + """ Other names the character might be referred to as """ alternative: [String] + """ Other names the character might be referred to as but are spoilers """ alternativeSpoiler: [String] + """ The currently authenticated users preferred name language. Default romaji for non-authenticated """ @@ -1274,6 +1670,7 @@ type CharacterImage { The character's image of media at its largest size """ large: String + """ The character's image of media at medium size """ @@ -1285,41 +1682,77 @@ Media sort enums """ enum MediaSort { ID + ID_DESC + TITLE_ROMAJI + TITLE_ROMAJI_DESC + TITLE_ENGLISH + TITLE_ENGLISH_DESC + TITLE_NATIVE + TITLE_NATIVE_DESC + TYPE + TYPE_DESC + FORMAT + FORMAT_DESC + START_DATE + START_DATE_DESC + END_DATE + END_DATE_DESC + SCORE + SCORE_DESC + POPULARITY + POPULARITY_DESC + TRENDING + TRENDING_DESC + EPISODES + EPISODES_DESC + DURATION + DURATION_DESC + STATUS + STATUS_DESC + CHAPTERS + CHAPTERS_DESC + VOLUMES + VOLUMES_DESC + UPDATED_AT + UPDATED_AT_DESC + SEARCH_MATCH + FAVOURITES + FAVOURITES_DESC } @@ -1331,38 +1764,47 @@ enum StaffLanguage { Japanese """ JAPANESE + """ English """ ENGLISH + """ Korean """ KOREAN + """ Italian """ ITALIAN + """ Spanish """ SPANISH + """ Portuguese """ PORTUGUESE + """ French """ FRENCH + """ German """ GERMAN + """ Hebrew """ HEBREW + """ Hungarian """ @@ -1374,14 +1816,23 @@ Staff sort enums """ enum StaffSort { ID + ID_DESC + ROLE + ROLE_DESC + LANGUAGE + LANGUAGE_DESC + SEARCH_MATCH + FAVOURITES + FAVOURITES_DESC + """ Order manually decided by moderators """ @@ -1396,93 +1847,123 @@ type Staff { The id of the staff member """ id: Int! + """ The names of the staff member """ name: StaffName + """ The primary language the staff member dub's in """ language: StaffLanguage @deprecated(reason: "Replaced with languageV2") + """ - The primary language of the staff member. Current values: Japanese, English, Korean, Italian, Spanish, Portuguese, French, German, Hebrew, Hungarian, Chinese, Arabic, Filipino, Catalan + The primary language of the staff member. Current values: Japanese, English, Korean, Italian, Spanish, Portuguese, French, German, Hebrew, Hungarian, Chinese, Arabic, Filipino, Catalan, Finnish, Turkish, Dutch, Swedish, Thai, Tagalog, Malaysian, Indonesian, Vietnamese, Nepali, Hindi, Urdu """ languageV2: String + """ The staff images """ image: StaffImage + """ A general description of the staff member """ - description("""Return the string in pre-parsed html instead of markdown""" asHtml: Boolean): String + description("Return the string in pre-parsed html instead of markdown" asHtml: Boolean): String + """ The person's primary occupations """ primaryOccupations: [String] + """ The staff's gender. Usually Male, Female, or Non-binary but can be any string. """ gender: String + dateOfBirth: FuzzyDate + dateOfDeath: FuzzyDate + """ The person's age in years """ age: Int + """ [startYear, endYear] (If the 2nd value is not present staff is still active) """ yearsActive: [Int] + """ The persons birthplace or hometown """ homeTown: String + + """ + The persons blood type + """ + bloodType: String + """ If the staff member is marked as favourite by the currently authenticated user """ isFavourite: Boolean! + """ If the staff member is blocked from being added to favourites """ isFavouriteBlocked: Boolean! + """ The url for the staff page on the AniList website """ siteUrl: String + """ Media where the staff member has a production role """ - staffMedia(sort: [MediaSort], type: MediaType, onList: Boolean, """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): MediaConnection + staffMedia(sort: [MediaSort], type: MediaType, onList: Boolean, "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): MediaConnection + """ Characters voiced by the actor """ - characters(sort: [CharacterSort], """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): CharacterConnection + characters(sort: [CharacterSort], "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): CharacterConnection + """ Media the actor voiced characters in. (Same data as characters with media as node instead of characters) """ - characterMedia(sort: [MediaSort], onList: Boolean, """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): MediaConnection + characterMedia(sort: [MediaSort], onList: Boolean, "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): MediaConnection + updatedAt: Int @deprecated(reason: "No data available") + """ Staff member that the submission is referencing """ staff: Staff + """ Submitter for the submission """ submitter: User + """ Status of the submission """ submissionStatus: Int + """ Inner details of submission status """ submissionNotes: String + """ The amount of user's who have favourited the staff member """ favourites: Int + """ Notes for site moderators """ @@ -1497,26 +1978,32 @@ type StaffName { The person's given name """ first: String + """ The person's middle name """ middle: String + """ The person's surname """ last: String + """ The person's first and last name """ full: String + """ The person's full name in their native language """ native: String + """ Other names the staff member might be referred to as (pen names) """ alternative: [String] + """ The currently authenticated users preferred name language. Default romaji for non-authenticated """ @@ -1528,6 +2015,7 @@ type StaffImage { The person's image of media at its largest size """ large: String + """ The person's image of media at medium size """ @@ -1542,10 +2030,12 @@ type StaffRoleType { The voice actors of the character """ voiceActor: Staff + """ Notes regarding the VA's role for the character """ roleNotes: String + """ Used for grouping roles where multiple dubs exist for the same language. Either dubbing company name or language variant. """ @@ -1554,7 +2044,9 @@ type StaffRoleType { type StaffConnection { edges: [StaffEdge] + nodes: [Staff] + """ The pagination information """ @@ -1566,14 +2058,17 @@ Staff connection edge """ type StaffEdge { node: Staff + """ The id of the connection """ id: Int + """ The role of the staff member in the production of the media """ role: String + """ The order the staff should be displayed from the users favourites """ @@ -1585,17 +2080,25 @@ Studio sort enums """ enum StudioSort { ID + ID_DESC + NAME + NAME_DESC + SEARCH_MATCH + FAVOURITES + FAVOURITES_DESC } type StudioConnection { edges: [StudioEdge] + nodes: [Studio] + """ The pagination information """ @@ -1607,14 +2110,17 @@ Studio connection edge """ type StudioEdge { node: Studio + """ The id of the connection """ id: Int + """ If the studio is the main animation studio of the anime """ isMain: Boolean! + """ The order the character should be displayed from the users favourites """ @@ -1629,26 +2135,32 @@ type Studio { The id of the studio """ id: Int! + """ The name of the studio """ name: String! + """ If the studio is an animation studio or a different kind of company """ isAnimationStudio: Boolean! + """ The media the studio has worked on """ - media("""The order the results will be returned in""" sort: [MediaSort], """If the studio was the primary animation studio of the media""" isMain: Boolean, onList: Boolean, """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): MediaConnection + media("The order the results will be returned in" sort: [MediaSort], "If the studio was the primary animation studio of the media" isMain: Boolean, onList: Boolean, "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): MediaConnection + """ The url for the studio page on the AniList website """ siteUrl: String + """ If the studio is marked as favourite by the currently authenticated user """ isFavourite: Boolean! + """ The amount of user's who have favourited the studio """ @@ -1663,22 +2175,27 @@ type AiringSchedule { The id of the airing schedule item """ id: Int! + """ The time the episode airs at """ airingAt: Int! + """ Seconds until episode starts airing """ timeUntilAiring: Int! + """ The airing episode number """ episode: Int! + """ The associate media id of the airing episode """ mediaId: Int! + """ The associate media of the airing episode """ @@ -1687,7 +2204,9 @@ type AiringSchedule { type AiringScheduleConnection { edges: [AiringScheduleEdge] + nodes: [AiringSchedule] + """ The pagination information """ @@ -1699,6 +2218,7 @@ AiringSchedule connection edge """ type AiringScheduleEdge { node: AiringSchedule + """ The id of the connection """ @@ -1710,24 +2230,39 @@ Media trend sort enums """ enum MediaTrendSort { ID + ID_DESC + MEDIA_ID + MEDIA_ID_DESC + DATE + DATE_DESC + SCORE + SCORE_DESC + POPULARITY + POPULARITY_DESC + TRENDING + TRENDING_DESC + EPISODE + EPISODE_DESC } type MediaTrendConnection { edges: [MediaTrendEdge] + nodes: [MediaTrend] + """ The pagination information """ @@ -1749,34 +2284,42 @@ type MediaTrend { The id of the tag """ mediaId: Int! + """ The day the data was recorded (timestamp) """ date: Int! + """ The amount of media activity on the day """ trending: Int! + """ A weighted average score of all the user's scores of the media """ averageScore: Int + """ The number of users with the media on their list """ popularity: Int + """ The number of users with watching/reading the media """ inProgress: Int + """ If the media was being released at this time """ releasing: Boolean! + """ The episode number of the anime released on this day """ episode: Int + """ The related media """ @@ -1784,21 +2327,54 @@ type MediaTrend { } """ -An external link to another site related to the media +An external link to another site related to the media or staff member """ type MediaExternalLink { """ The id of the external link """ id: Int! + """ - The url of the external link + The url of the external link or base url of link source """ - url: String! + url: String + """ - The site location of the external link + The links website site name """ site: String! + + """ + The links website site id + """ + siteId: Int + + type: ExternalLinkType + + """ + Language the site content is in. See Staff language field for values. + """ + language: String + + color: String + + """ + The icon image url of the site. Not available for all links. Transparent PNG 64x64 + """ + icon: String + + notes: String + + isDisabled: Boolean +} + +enum ExternalLinkType { + INFO + + STREAMING + + SOCIAL } """ @@ -1809,14 +2385,17 @@ type MediaStreamingEpisode { Title of the episode """ title: String + """ Url of episode image thumbnail """ thumbnail: String + """ The url of the episode """ url: String + """ The site location of the streaming episodes """ @@ -1831,30 +2410,37 @@ type MediaRank { The id of the rank """ id: Int! + """ The numerical rank of the media """ rank: Int! + """ The type of ranking """ type: MediaRankType! + """ The format the media is ranked within """ format: MediaFormat! + """ The year the media is ranked within """ year: Int + """ The season the media is ranked within """ season: MediaSeason + """ If the ranking is based on all time instead of a season/year """ allTime: Boolean + """ String that gives context to the ranking type and time span """ @@ -1869,6 +2455,7 @@ enum MediaRankType { Ranking is based on the media's ratings/score """ RATED + """ Ranking is based on the media's popularity """ @@ -1883,106 +2470,95 @@ type MediaList { The id of the list entry """ id: Int! + """ The id of the user owner of the list entry """ userId: Int! + """ The id of the media """ mediaId: Int! + """ The watching/reading status """ status: MediaListStatus + """ The score of the entry """ - score("""Force the score to be returned in the provided format type.""" format: ScoreFormat): Float + score("Force the score to be returned in the provided format type." format: ScoreFormat): Float + """ The amount of episodes/chapters consumed by the user """ progress: Int + """ The amount of volumes read by the user """ progressVolumes: Int + """ The amount of times the user has rewatched/read the media """ repeat: Int + """ Priority of planning """ priority: Int + """ If the entry should only be visible to authenticated user """ private: Boolean + """ Text notes """ notes: String + """ If the entry shown be hidden from non-custom lists """ hiddenFromStatusLists: Boolean + """ Map of booleans for which custom lists the entry are in """ - customLists("""Change return structure to an array of objects""" asArray: Boolean): Json + customLists("Change return structure to an array of objects" asArray: Boolean): Json + """ Map of advanced scores with name keys """ advancedScores: Json + """ When the entry was started by the user """ startedAt: FuzzyDate + """ When the entry was completed by the user """ completedAt: FuzzyDate + """ When the entry data was last updated """ updatedAt: Int + """ When the entry data was created """ createdAt: Int + media: Media - user: User -} -""" -Media list watching/reading status enum. -""" -enum MediaListStatus { - """ - Currently watching/reading - """ - CURRENT - """ - Planning to watch/read - """ - PLANNING - """ - Finished watching/reading - """ - COMPLETED - """ - Stopped watching/reading before completing - """ - DROPPED - """ - Paused watching/reading - """ - PAUSED - """ - Re-watching/reading - """ - REPEATING + user: User } """ @@ -1990,20 +2566,31 @@ Review sort enums """ enum ReviewSort { ID + ID_DESC + SCORE + SCORE_DESC + RATING + RATING_DESC + CREATED_AT + CREATED_AT_DESC + UPDATED_AT + UPDATED_AT_DESC } type ReviewConnection { edges: [ReviewEdge] + nodes: [Review] + """ The pagination information """ @@ -2025,62 +2612,77 @@ type Review { The id of the review """ id: Int! + """ The id of the review's creator """ userId: Int! + """ The id of the review's media """ mediaId: Int! + """ For which type of media the review is for """ mediaType: MediaType + """ A short summary of the review """ summary: String + """ The main review body text """ - body("""Return the string in pre-parsed html instead of markdown""" asHtml: Boolean): String + body("Return the string in pre-parsed html instead of markdown" asHtml: Boolean): String + """ The total user rating of the review """ rating: Int + """ The amount of user ratings of the review """ ratingAmount: Int + """ The rating of the review by currently authenticated user """ userRating: ReviewRating + """ The review score of the media """ score: Int + """ If the review is not yet publicly published and is only viewable by creator """ private: Boolean + """ The url for the review page on the AniList website """ siteUrl: String + """ The time of the thread creation """ createdAt: Int! + """ The time of the thread last update """ updatedAt: Int! + """ The creator of the review """ user: User + """ The media the review is of """ @@ -2092,7 +2694,9 @@ Review rating enums """ enum ReviewRating { NO_VOTE + UP_VOTE + DOWN_VOTE } @@ -2101,14 +2705,19 @@ Recommendation sort enums """ enum RecommendationSort { ID + ID_DESC + RATING + RATING_DESC } type RecommendationConnection { edges: [RecommendationEdge] + nodes: [Recommendation] + """ The pagination information """ @@ -2130,22 +2739,27 @@ type Recommendation { The id of the recommendation """ id: Int! + """ Users rating of the recommendation """ rating: Int + """ The rating of the recommendation by currently authenticated user """ userRating: RecommendationRating + """ The media the recommendation is from """ media: Media + """ The recommended media """ mediaRecommendation: Media + """ The user that first created the recommendation """ @@ -2157,7 +2771,9 @@ Recommendation rating enums """ enum RecommendationRating { NO_RATING + RATE_UP + RATE_DOWN } @@ -2166,7 +2782,9 @@ A media's statistics """ type MediaStats { scoreDistribution: [ScoreDistribution] + statusDistribution: [StatusDistribution] + airingProgression: [AiringProgression] @deprecated(reason: "Replaced by MediaTrends") } @@ -2175,6 +2793,7 @@ A user's list score distribution. """ type ScoreDistribution { score: Int + """ The amount of list entries with this score """ @@ -2189,6 +2808,7 @@ type StatusDistribution { The day the activity took place (Unix timestamp) """ status: MediaListStatus + """ The amount of entries with this status """ @@ -2203,10 +2823,12 @@ type AiringProgression { The episode the stats were recorded at. .5 is the mid point between 2 episodes airing dates. """ episode: Float + """ The average score for the media """ score: Float + """ The amount of users watching the anime """ @@ -2221,50 +2843,62 @@ enum MediaRelation { An adaption of this media into a different format """ ADAPTATION + """ Released before the relation """ PREQUEL + """ Released after the relation """ SEQUEL + """ The media a side story is from """ PARENT + """ A side story of the parent media """ SIDE_STORY + """ Shares at least 1 character """ CHARACTER + """ A shortened and summarized version """ SUMMARY + """ An alternative version of the same media """ ALTERNATIVE + """ An alternative version of the media with a different primary focus """ SPIN_OFF + """ Other """ OTHER + """ Version 2 only. The source material the media was adapted from """ SOURCE + """ Version 2 only. """ COMPILATION + """ Version 2 only. """ @@ -2273,28 +2907,47 @@ enum MediaRelation { type UserStatisticTypes { anime: UserStatistics + manga: UserStatistics } type UserStatistics { count: Int! + meanScore: Float! + standardDeviation: Float! + minutesWatched: Int! + episodesWatched: Int! + chaptersRead: Int! + volumesRead: Int! + formats(limit: Int, sort: [UserStatisticsSort]): [UserFormatStatistic] + statuses(limit: Int, sort: [UserStatisticsSort]): [UserStatusStatistic] + scores(limit: Int, sort: [UserStatisticsSort]): [UserScoreStatistic] + lengths(limit: Int, sort: [UserStatisticsSort]): [UserLengthStatistic] + releaseYears(limit: Int, sort: [UserStatisticsSort]): [UserReleaseYearStatistic] + startYears(limit: Int, sort: [UserStatisticsSort]): [UserStartYearStatistic] + genres(limit: Int, sort: [UserStatisticsSort]): [UserGenreStatistic] + tags(limit: Int, sort: [UserStatisticsSort]): [UserTagStatistic] + countries(limit: Int, sort: [UserStatisticsSort]): [UserCountryStatistic] + voiceActors(limit: Int, sort: [UserStatisticsSort]): [UserVoiceActorStatistic] + staff(limit: Int, sort: [UserStatisticsSort]): [UserStaffStatistic] + studios(limit: Int, sort: [UserStatisticsSort]): [UserStudioStatistic] } @@ -2303,121 +2956,189 @@ User statistics sort enum """ enum UserStatisticsSort { ID + ID_DESC + COUNT + COUNT_DESC + PROGRESS + PROGRESS_DESC + MEAN_SCORE + MEAN_SCORE_DESC } type UserFormatStatistic { count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + format: MediaFormat } type UserStatusStatistic { count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + status: MediaListStatus } type UserScoreStatistic { count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + score: Int } type UserLengthStatistic { count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + length: String } type UserReleaseYearStatistic { count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + releaseYear: Int } type UserStartYearStatistic { count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + startYear: Int } type UserGenreStatistic { count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + genre: String } type UserTagStatistic { count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + tag: MediaTag } type UserCountryStatistic { count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + country: CountryCode } type UserVoiceActorStatistic { count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + voiceActor: Staff + characterIds: [Int]! } type UserStaffStatistic { count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + staff: Staff } type UserStudioStatistic { count: Int! + meanScore: Float! + minutesWatched: Int! + chaptersRead: Int! + mediaIds: [Int]! + studio: Studio } @@ -2429,50 +3150,62 @@ enum ModRole { An AniList administrator """ ADMIN + """ A head developer of AniList """ LEAD_DEVELOPER + """ An AniList developer """ DEVELOPER + """ A lead community moderator """ LEAD_COMMUNITY + """ A community moderator """ COMMUNITY + """ A discord community moderator """ DISCORD_COMMUNITY + """ A lead anime data moderator """ LEAD_ANIME_DATA + """ An anime data moderator """ ANIME_DATA + """ A lead manga data moderator """ LEAD_MANGA_DATA + """ A manga data moderator """ MANGA_DATA + """ A lead social media moderator """ LEAD_SOCIAL_MEDIA + """ A social media moderator """ SOCIAL_MEDIA + """ A retired moderator """ @@ -2487,24 +3220,40 @@ type UserStats { The amount of anime the user has watched in minutes """ watchedTime: Int + """ The amount of manga chapters the user has read """ chaptersRead: Int + activityHistory: [UserActivityHistory] + animeStatusDistribution: [StatusDistribution] + mangaStatusDistribution: [StatusDistribution] + animeScoreDistribution: [ScoreDistribution] + mangaScoreDistribution: [ScoreDistribution] + animeListScores: ListScoreStats + mangaListScores: ListScoreStats + favouredGenresOverview: [GenreStats] + favouredGenres: [GenreStats] + favouredTags: [TagStats] + favouredActors: [StaffStats] + favouredStaff: [StaffStats] + favouredStudios: [StudioStats] + favouredYears: [YearStats] + favouredFormats: [FormatStats] } @@ -2516,10 +3265,12 @@ type UserActivityHistory { The day the activity took place (Unix timestamp) """ date: Int + """ The amount of activity on the day """ amount: Int + """ The level of activity represented on a 1-10 scale """ @@ -2531,6 +3282,7 @@ User's list score statistics """ type ListScoreStats { meanScore: Int + standardDeviation: Int } @@ -2539,8 +3291,11 @@ User's genre statistics """ type GenreStats { genre: String + amount: Int + meanScore: Int + """ The amount of time in minutes the genre has been watched by the user """ @@ -2552,8 +3307,11 @@ User's tag statistics """ type TagStats { tag: MediaTag + amount: Int + meanScore: Int + """ The amount of time in minutes the tag has been watched by the user """ @@ -2565,8 +3323,11 @@ User's staff statistics """ type StaffStats { staff: Staff + amount: Int + meanScore: Int + """ The amount of time in minutes the staff member has been watched by the user """ @@ -2578,8 +3339,11 @@ User's studio statistics """ type StudioStats { studio: Studio + amount: Int + meanScore: Int + """ The amount of time in minutes the studio's works have been watched by the user """ @@ -2591,7 +3355,9 @@ User's year statistics """ type YearStats { year: Int + amount: Int + meanScore: Int } @@ -2600,9 +3366,30 @@ User's format statistics """ type FormatStats { format: MediaFormat + amount: Int } +""" +A user's previous name +""" +type UserPreviousName { + """ + A previous name of the user. + """ + name: String + + """ + When the user first changed from this name. + """ + createdAt: Int + + """ + When the user most recently changed from this name. + """ + updatedAt: Int +} + """ 8 digit long date integer (YYYYMMDD). Unknown dates represented by 0. E.g. 2016: 20160000, May 1976: 19760500 """ @@ -2613,34 +3400,63 @@ Media list sort enums """ enum MediaListSort { MEDIA_ID + MEDIA_ID_DESC + SCORE + SCORE_DESC + STATUS + STATUS_DESC + PROGRESS + PROGRESS_DESC + PROGRESS_VOLUMES + PROGRESS_VOLUMES_DESC + REPEAT + REPEAT_DESC + PRIORITY + PRIORITY_DESC + STARTED_ON + STARTED_ON_DESC + FINISHED_ON + FINISHED_ON_DESC + ADDED_TIME + ADDED_TIME_DESC + UPDATED_TIME + UPDATED_TIME_DESC + MEDIA_TITLE_ROMAJI + MEDIA_TITLE_ROMAJI_DESC + MEDIA_TITLE_ENGLISH + MEDIA_TITLE_ENGLISH_DESC + MEDIA_TITLE_NATIVE + MEDIA_TITLE_NATIVE_DESC + MEDIA_POPULARITY + MEDIA_POPULARITY_DESC } @@ -2649,19 +3465,23 @@ Airing schedule sort enums """ enum AiringSort { ID + ID_DESC + MEDIA_ID + MEDIA_ID_DESC + TIME + TIME_DESC + EPISODE + EPISODE_DESC } -""" -Notification union type -""" -union NotificationUnion = AiringNotification | FollowingNotification | ActivityMessageNotification | ActivityMentionNotification | ActivityReplyNotification | ActivityReplySubscribedNotification | ActivityLikeNotification | ActivityReplyLikeNotification | ThreadCommentMentionNotification | ThreadCommentReplyNotification | ThreadCommentSubscribedNotification | ThreadCommentLikeNotification | ThreadLikeNotification | RelatedMediaAdditionNotification +union NotificationUnion = AiringNotification|FollowingNotification|ActivityMessageNotification|ActivityMentionNotification|ActivityReplyNotification|ActivityReplySubscribedNotification|ActivityLikeNotification|ActivityReplyLikeNotification|ThreadCommentMentionNotification|ThreadCommentReplyNotification|ThreadCommentSubscribedNotification|ThreadCommentLikeNotification|ThreadLikeNotification|RelatedMediaAdditionNotification|MediaDataChangeNotification|MediaMergeNotification|MediaDeletionNotification """ Notification for when an episode of anime airs @@ -2671,26 +3491,32 @@ type AiringNotification { The id of the Notification """ id: Int! + """ The type of notification """ type: NotificationType + """ The id of the aired anime """ animeId: Int! + """ The episode number that just aired """ episode: Int! + """ The notification context text """ contexts: [String] + """ The time the notification was created at """ createdAt: Int + """ The associated media of the airing schedule """ @@ -2705,22 +3531,27 @@ type FollowingNotification { The id of the Notification """ id: Int! + """ The id of the user who followed the authenticated user """ userId: Int! + """ The type of notification """ type: NotificationType + """ The notification context text """ context: String + """ The time the notification was created at """ createdAt: Int + """ The liked activity """ @@ -2735,30 +3566,37 @@ type ActivityMessageNotification { The id of the Notification """ id: Int! + """ The if of the user who send the message """ userId: Int! + """ The type of notification """ type: NotificationType + """ The id of the activity message """ activityId: Int! + """ The notification context text """ context: String + """ The time the notification was created at """ createdAt: Int + """ The message activity """ message: MessageActivity + """ The user who sent the message """ @@ -2773,66 +3611,82 @@ type MessageActivity { The id of the activity """ id: Int! + """ The user id of the activity's recipient """ recipientId: Int + """ The user id of the activity's sender """ messengerId: Int + """ The type of the activity """ type: ActivityType + """ The number of activity replies """ replyCount: Int! + """ The message text (Markdown) """ - message("""Return the string in pre-parsed html instead of markdown""" asHtml: Boolean): String + message("Return the string in pre-parsed html instead of markdown" asHtml: Boolean): String + """ If the activity is locked and can receive replies """ isLocked: Boolean + """ If the currently authenticated user is subscribed to the activity """ isSubscribed: Boolean + """ The amount of likes the activity has """ likeCount: Int! + """ If the currently authenticated user liked the activity """ isLiked: Boolean + """ If the message is private and only viewable to the sender and recipients """ isPrivate: Boolean + """ The url for the activity page on the AniList website """ siteUrl: String + """ The time the activity was created at """ createdAt: Int! + """ The user who the activity message was sent to """ recipient: User + """ The user who sent the activity message """ messenger: User + """ The written replies to the activity """ replies: [ActivityReply] + """ The users who liked the activity """ @@ -2847,18 +3701,22 @@ enum ActivityType { A text activity """ TEXT + """ A anime list update activity """ ANIME_LIST + """ A manga list update activity """ MANGA_LIST + """ A text message activity sent to another user """ MESSAGE + """ Anime & Manga list update, only used in query arguments """ @@ -2873,34 +3731,42 @@ type ActivityReply { The id of the reply """ id: Int! + """ The id of the replies creator """ userId: Int + """ The id of the parent activity """ activityId: Int + """ The reply text """ - text("""Return the string in pre-parsed html instead of markdown""" asHtml: Boolean): String + text("Return the string in pre-parsed html instead of markdown" asHtml: Boolean): String + """ The amount of likes the reply has """ likeCount: Int! + """ If the currently authenticated user liked the reply """ isLiked: Boolean + """ The time the reply was created at """ createdAt: Int! + """ The user who created reply """ user: User + """ The users who liked the reply """ @@ -2915,40 +3781,44 @@ type ActivityMentionNotification { The id of the Notification """ id: Int! + """ The id of the user who mentioned the authenticated user """ userId: Int! + """ The type of notification """ type: NotificationType + """ The id of the activity where mentioned """ activityId: Int! + """ The notification context text """ context: String + """ The time the notification was created at """ createdAt: Int + """ The liked activity """ activity: ActivityUnion + """ The user who mentioned the authenticated user """ user: User } -""" -Activity union type -""" -union ActivityUnion = TextActivity | ListActivity | MessageActivity +union ActivityUnion = TextActivity|ListActivity|MessageActivity """ User text activity @@ -2958,54 +3828,72 @@ type TextActivity { The id of the activity """ id: Int! + """ The user id of the activity's creator """ userId: Int + """ The type of activity """ type: ActivityType + """ The number of activity replies """ replyCount: Int! + """ The status text (Markdown) """ - text("""Return the string in pre-parsed html instead of markdown""" asHtml: Boolean): String + text("Return the string in pre-parsed html instead of markdown" asHtml: Boolean): String + """ The url for the activity page on the AniList website """ siteUrl: String + """ If the activity is locked and can receive replies """ isLocked: Boolean + """ If the currently authenticated user is subscribed to the activity """ isSubscribed: Boolean + """ The amount of likes the activity has """ likeCount: Int! + """ If the currently authenticated user liked the activity """ isLiked: Boolean + + """ + If the activity is pinned to the top of the users activity feed + """ + isPinned: Boolean + """ The time the activity was created at """ createdAt: Int! + """ The user who created the activity """ user: User + """ The written replies to the activity """ replies: [ActivityReply] + """ The users who liked the activity """ @@ -3020,62 +3908,82 @@ type ListActivity { The id of the activity """ id: Int! + """ The user id of the activity's creator """ userId: Int + """ The type of activity """ type: ActivityType + """ The number of activity replies """ replyCount: Int! + """ The list item's textual status """ status: String + """ The list progress made """ progress: String + """ If the activity is locked and can receive replies """ isLocked: Boolean + """ If the currently authenticated user is subscribed to the activity """ isSubscribed: Boolean + """ The amount of likes the activity has """ likeCount: Int! + """ If the currently authenticated user liked the activity """ isLiked: Boolean + + """ + If the activity is pinned to the top of the users activity feed + """ + isPinned: Boolean + """ The url for the activity page on the AniList website """ siteUrl: String + """ The time the activity was created at """ createdAt: Int! + """ The owner of the activity """ user: User + """ The associated media to the activity update """ media: Media + """ The written replies to the activity """ replies: [ActivityReply] + """ The users who liked the activity """ @@ -3090,30 +3998,37 @@ type ActivityReplyNotification { The id of the Notification """ id: Int! + """ The id of the user who replied to the activity """ userId: Int! + """ The type of notification """ type: NotificationType + """ The id of the activity which was replied too """ activityId: Int! + """ The notification context text """ context: String + """ The time the notification was created at """ createdAt: Int + """ The liked activity """ activity: ActivityUnion + """ The user who replied to the activity """ @@ -3128,30 +4043,37 @@ type ActivityReplySubscribedNotification { The id of the Notification """ id: Int! + """ The id of the user who replied to the activity """ userId: Int! + """ The type of notification """ type: NotificationType + """ The id of the activity which was replied too """ activityId: Int! + """ The notification context text """ context: String + """ The time the notification was created at """ createdAt: Int + """ The liked activity """ activity: ActivityUnion + """ The user who replied to the activity """ @@ -3166,30 +4088,37 @@ type ActivityLikeNotification { The id of the Notification """ id: Int! + """ The id of the user who liked to the activity """ userId: Int! + """ The type of notification """ type: NotificationType + """ The id of the activity which was liked """ activityId: Int! + """ The notification context text """ context: String + """ The time the notification was created at """ createdAt: Int + """ The liked activity """ activity: ActivityUnion + """ The user who liked the activity """ @@ -3204,30 +4133,37 @@ type ActivityReplyLikeNotification { The id of the Notification """ id: Int! + """ The id of the user who liked to the activity reply """ userId: Int! + """ The type of notification """ type: NotificationType + """ The id of the activity where the reply which was liked """ activityId: Int! + """ The notification context text """ context: String + """ The time the notification was created at """ createdAt: Int + """ The liked activity """ activity: ActivityUnion + """ The user who liked the activity reply """ @@ -3242,34 +4178,42 @@ type ThreadCommentMentionNotification { The id of the Notification """ id: Int! + """ The id of the user who mentioned the authenticated user """ userId: Int! + """ The type of notification """ type: NotificationType + """ The id of the comment where mentioned """ commentId: Int! + """ The notification context text """ context: String + """ The time the notification was created at """ createdAt: Int + """ The thread that the relevant comment belongs to """ thread: Thread + """ The thread comment that included the @ mention """ comment: ThreadComment + """ The user who mentioned the authenticated user """ @@ -3284,86 +4228,107 @@ type Thread { The id of the thread """ id: Int! + """ The title of the thread """ title: String + """ The text body of the thread (Markdown) """ - body("""Return the string in pre-parsed html instead of markdown""" asHtml: Boolean): String + body("Return the string in pre-parsed html instead of markdown" asHtml: Boolean): String + """ The id of the thread owner user """ userId: Int! + """ The id of the user who most recently commented on the thread """ replyUserId: Int + """ The id of the most recent comment on the thread """ replyCommentId: Int + """ The number of comments on the thread """ replyCount: Int + """ The number of times users have viewed the thread """ viewCount: Int + """ If the thread is locked and can receive comments """ isLocked: Boolean + """ If the thread is stickied and should be displayed at the top of the page """ isSticky: Boolean + """ If the currently authenticated user is subscribed to the thread """ isSubscribed: Boolean + """ The amount of likes the thread has """ likeCount: Int! + """ If the currently authenticated user liked the thread """ isLiked: Boolean + """ The time of the last reply """ repliedAt: Int + """ The time of the thread creation """ createdAt: Int! + """ The time of the thread last update """ updatedAt: Int! + """ The owner of the thread """ user: User + """ The user to last reply to the thread """ replyUser: User + """ The users who liked the thread """ likes: [User] + """ The url for the thread page on the AniList website """ siteUrl: String + """ The categories of the thread """ categories: [ThreadCategory] + """ The media categories of the thread """ @@ -3378,6 +4343,7 @@ type ThreadCategory { The id of the category """ id: Int! + """ The name of the category """ @@ -3392,54 +4358,71 @@ type ThreadComment { The id of the comment """ id: Int! + """ The user id of the comment's owner """ userId: Int + """ The id of thread the comment belongs to """ threadId: Int + """ The text content of the comment (Markdown) """ - comment("""Return the string in pre-parsed html instead of markdown""" asHtml: Boolean): String + comment("Return the string in pre-parsed html instead of markdown" asHtml: Boolean): String + """ The amount of likes the comment has """ likeCount: Int! + """ If the currently authenticated user liked the comment """ isLiked: Boolean + """ The url for the comment page on the AniList website """ siteUrl: String + """ The time of the comments creation """ createdAt: Int! + """ The time of the comments last update """ updatedAt: Int! + """ The thread the comment belongs to """ thread: Thread + """ The user who created the comment """ user: User + """ The users who liked the comment """ likes: [User] + """ The comment's child reply comments """ childComments: Json + + """ + If the comment tree is locked and may not receive replies or edits + """ + isLocked: Boolean } """ @@ -3450,34 +4433,42 @@ type ThreadCommentReplyNotification { The id of the Notification """ id: Int! + """ The id of the user who create the comment reply """ userId: Int! + """ The type of notification """ type: NotificationType + """ The id of the reply comment """ commentId: Int! + """ The notification context text """ context: String + """ The time the notification was created at """ createdAt: Int + """ The thread that the relevant comment belongs to """ thread: Thread + """ The reply thread comment """ comment: ThreadComment + """ The user who replied to the activity """ @@ -3492,34 +4483,42 @@ type ThreadCommentSubscribedNotification { The id of the Notification """ id: Int! + """ The id of the user who commented on the thread """ userId: Int! + """ The type of notification """ type: NotificationType + """ The id of the new comment in the subscribed thread """ commentId: Int! + """ The notification context text """ context: String + """ The time the notification was created at """ createdAt: Int + """ The thread that the relevant comment belongs to """ thread: Thread + """ The reply thread comment """ comment: ThreadComment + """ The user who replied to the subscribed thread """ @@ -3534,34 +4533,42 @@ type ThreadCommentLikeNotification { The id of the Notification """ id: Int! + """ The id of the user who liked to the activity """ userId: Int! + """ The type of notification """ type: NotificationType + """ The id of the activity which was liked """ commentId: Int! + """ The notification context text """ context: String + """ The time the notification was created at """ createdAt: Int + """ The thread that the relevant comment belongs to """ thread: Thread + """ The thread comment that was liked """ comment: ThreadComment + """ The user who liked the activity """ @@ -3576,34 +4583,42 @@ type ThreadLikeNotification { The id of the Notification """ id: Int! + """ The id of the user who liked to the activity """ userId: Int! + """ The type of notification """ type: NotificationType + """ The id of the thread which was liked """ threadId: Int! + """ The notification context text """ context: String + """ The time the notification was created at """ createdAt: Int + """ The thread that the relevant comment belongs to """ thread: Thread + """ The liked thread comment """ comment: ThreadComment + """ The user who liked the activity """ @@ -3618,34 +4633,162 @@ type RelatedMediaAdditionNotification { The id of the Notification """ id: Int! + """ The type of notification """ type: NotificationType + """ The id of the new media """ mediaId: Int! + """ The notification context text """ context: String + """ The time the notification was created at """ createdAt: Int + """ The associated media of the airing schedule """ media: Media } +""" +Notification for when a media entry's data was changed in a significant way impacting users' list tracking +""" +type MediaDataChangeNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the media that received data changes + """ + mediaId: Int! + + """ + The reason for the media data change + """ + context: String + + """ + The reason for the media data change + """ + reason: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The media that received data changes + """ + media: Media +} + +""" +Notification for when a media entry is merged into another for a user who had it on their list +""" +type MediaMergeNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The id of the media that was merged into + """ + mediaId: Int! + + """ + The title of the deleted media + """ + deletedMediaTitles: [String] + + """ + The reason for the media data change + """ + context: String + + """ + The reason for the media merge + """ + reason: String + + """ + The time the notification was created at + """ + createdAt: Int + + """ + The media that was merged into + """ + media: Media +} + +""" +Notification for when a media tracked in a user's list is deleted from the site +""" +type MediaDeletionNotification { + """ + The id of the Notification + """ + id: Int! + + """ + The type of notification + """ + type: NotificationType + + """ + The title of the deleted media + """ + deletedMediaTitle: String + + """ + The reason for the media deletion + """ + context: String + + """ + The reason for the media deletion + """ + reason: String + + """ + The time the notification was created at + """ + createdAt: Int +} + """ Activity sort enums """ enum ActivitySort { ID + ID_DESC + + PINNED } """ @@ -3653,20 +4796,35 @@ Thread sort enums """ enum ThreadSort { ID + ID_DESC + TITLE + TITLE_DESC + CREATED_AT + CREATED_AT_DESC + UPDATED_AT + UPDATED_AT_DESC + REPLIED_AT + REPLIED_AT_DESC + REPLY_COUNT + REPLY_COUNT_DESC + VIEW_COUNT + VIEW_COUNT_DESC + IS_STICKY + SEARCH_MATCH } @@ -3675,6 +4833,7 @@ Thread comments sort enums """ enum ThreadCommentSort { ID + ID_DESC } @@ -3683,8 +4842,11 @@ Types that can be liked """ enum LikeableType { THREAD + THREAD_COMMENT + ACTIVITY + ACTIVITY_REPLY } @@ -3696,18 +4858,22 @@ type MediaListCollection { Grouped media list entries """ lists: [MediaListGroup] + """ The owner of the list """ user: User + """ If there is another chunk """ hasNextChunk: Boolean + """ A map of media list entry arrays grouped by status """ statusLists(asArray: Boolean): [[MediaList]] @deprecated(reason: "Not GraphQL spec compliant, use lists field instead.") + """ A map of media list entry arrays grouped by custom lists """ @@ -3722,9 +4888,13 @@ type MediaListGroup { Media list entries """ entries: [MediaList] + name: String + isCustomList: Boolean + isSplitCompletedList: Boolean + status: MediaListStatus } @@ -3740,18 +4910,26 @@ type ParsedMarkdown { type AniChartUser { user: User + settings: Json + highlights: Json } type SiteStatistics { - users(sort: [SiteTrendSort], """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): SiteTrendConnection - anime(sort: [SiteTrendSort], """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): SiteTrendConnection - manga(sort: [SiteTrendSort], """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): SiteTrendConnection - characters(sort: [SiteTrendSort], """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): SiteTrendConnection - staff(sort: [SiteTrendSort], """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): SiteTrendConnection - studios(sort: [SiteTrendSort], """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): SiteTrendConnection - reviews(sort: [SiteTrendSort], """The page""" page: Int, """The amount of entries per page, max 25""" perPage: Int): SiteTrendConnection + users(sort: [SiteTrendSort], "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): SiteTrendConnection + + anime(sort: [SiteTrendSort], "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): SiteTrendConnection + + manga(sort: [SiteTrendSort], "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): SiteTrendConnection + + characters(sort: [SiteTrendSort], "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): SiteTrendConnection + + staff(sort: [SiteTrendSort], "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): SiteTrendConnection + + studios(sort: [SiteTrendSort], "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): SiteTrendConnection + + reviews(sort: [SiteTrendSort], "The page" page: Int, "The amount of entries per page, max 25" perPage: Int): SiteTrendConnection } """ @@ -3759,16 +4937,23 @@ Site trend sort enums """ enum SiteTrendSort { DATE + DATE_DESC + COUNT + COUNT_DESC + CHANGE + CHANGE_DESC } type SiteTrendConnection { edges: [SiteTrendEdge] + nodes: [SiteTrend] + """ The pagination information """ @@ -3790,117 +4975,159 @@ type SiteTrend { The day the data was recorded (timestamp) """ date: Int! + count: Int! + """ The change from yesterday """ change: Int! } +enum ExternalLinkMediaType { + ANIME + + MANGA + + STAFF +} + type Mutation { - UpdateUser("""User's about/bio text""" about: String, """User's title language""" titleLanguage: UserTitleLanguage, """If the user should see media marked as adult-only""" displayAdultContent: Boolean, """If the user should get notifications when a show they are watching aires""" airingNotifications: Boolean, """The user's list scoring system""" scoreFormat: ScoreFormat, """The user's default list order""" rowOrder: String, """Profile highlight color""" profileColor: String, """Profile highlight color""" donatorBadge: String, """Notification options""" notificationOptions: [NotificationOptionInput], """Timezone offset format: -?HH:MM""" timezone: String, """Minutes between activity for them to be merged together. 0 is Never, Above 2 weeks (20160 mins) is Always.""" activityMergeTime: Int, """The user's anime list options""" animeListOptions: MediaListOptionsInput, """The user's anime list options""" mangaListOptions: MediaListOptionsInput, """The language the user wants to see staff and character names in""" staffNameLanguage: UserStaffNameLanguage): User + UpdateUser("User's about\/bio text" about: String, "User's title language" titleLanguage: UserTitleLanguage, "If the user should see media marked as adult-only" displayAdultContent: Boolean, "If the user should get notifications when a show they are watching aires" airingNotifications: Boolean, "The user's list scoring system" scoreFormat: ScoreFormat, "The user's default list order" rowOrder: String, "Profile highlight color" profileColor: String, "Profile highlight color" donatorBadge: String, "Notification options" notificationOptions: [NotificationOptionInput], "Timezone offset format: -?HH:MM" timezone: String, "Minutes between activity for them to be merged together. 0 is Never, Above 2 weeks (20160 mins) is Always." activityMergeTime: Int, "The user's anime list options" animeListOptions: MediaListOptionsInput, "The user's anime list options" mangaListOptions: MediaListOptionsInput, "The language the user wants to see staff and character names in" staffNameLanguage: UserStaffNameLanguage, "Only allow messages from other users the user follows" restrictMessagesToFollowing: Boolean, disabledListActivity: [ListActivityOptionInput]): User + """ Create or update a media list entry """ - SaveMediaListEntry("""The list entry id, required for updating""" id: Int, """The id of the media the entry is of""" mediaId: Int, """The watching/reading status""" status: MediaListStatus, """The score of the media in the user's chosen scoring method""" score: Float, """The score of the media in 100 point""" scoreRaw: Int, """The amount of episodes/chapters consumed by the user""" progress: Int, """The amount of volumes read by the user""" progressVolumes: Int, """The amount of times the user has rewatched/read the media""" repeat: Int, """Priority of planning""" priority: Int, """If the entry should only be visible to authenticated user""" private: Boolean, """Text notes""" notes: String, """If the entry shown be hidden from non-custom lists""" hiddenFromStatusLists: Boolean, """Array of custom list names which should be enabled for this entry""" customLists: [String], """Array of advanced scores""" advancedScores: [Float], """When the entry was started by the user""" startedAt: FuzzyDateInput, """When the entry was completed by the user""" completedAt: FuzzyDateInput): MediaList + SaveMediaListEntry("The list entry id, required for updating" id: Int, "The id of the media the entry is of" mediaId: Int, "The watching\/reading status" status: MediaListStatus, "The score of the media in the user's chosen scoring method" score: Float, "The score of the media in 100 point" scoreRaw: Int, "The amount of episodes\/chapters consumed by the user" progress: Int, "The amount of volumes read by the user" progressVolumes: Int, "The amount of times the user has rewatched\/read the media" repeat: Int, "Priority of planning" priority: Int, "If the entry should only be visible to authenticated user" private: Boolean, "Text notes" notes: String, "If the entry shown be hidden from non-custom lists" hiddenFromStatusLists: Boolean, "Array of custom list names which should be enabled for this entry" customLists: [String], "Array of advanced scores" advancedScores: [Float], "When the entry was started by the user" startedAt: FuzzyDateInput, "When the entry was completed by the user" completedAt: FuzzyDateInput): MediaList + """ Update multiple media list entries to the same values """ - UpdateMediaListEntries("""The watching/reading status""" status: MediaListStatus, """The score of the media in the user's chosen scoring method""" score: Float, """The score of the media in 100 point""" scoreRaw: Int, """The amount of episodes/chapters consumed by the user""" progress: Int, """The amount of volumes read by the user""" progressVolumes: Int, """The amount of times the user has rewatched/read the media""" repeat: Int, """Priority of planning""" priority: Int, """If the entry should only be visible to authenticated user""" private: Boolean, """Text notes""" notes: String, """If the entry shown be hidden from non-custom lists""" hiddenFromStatusLists: Boolean, """Array of advanced scores""" advancedScores: [Float], """When the entry was started by the user""" startedAt: FuzzyDateInput, """When the entry was completed by the user""" completedAt: FuzzyDateInput, """The list entries ids to update""" ids: [Int]): [MediaList] + UpdateMediaListEntries("The watching\/reading status" status: MediaListStatus, "The score of the media in the user's chosen scoring method" score: Float, "The score of the media in 100 point" scoreRaw: Int, "The amount of episodes\/chapters consumed by the user" progress: Int, "The amount of volumes read by the user" progressVolumes: Int, "The amount of times the user has rewatched\/read the media" repeat: Int, "Priority of planning" priority: Int, "If the entry should only be visible to authenticated user" private: Boolean, "Text notes" notes: String, "If the entry shown be hidden from non-custom lists" hiddenFromStatusLists: Boolean, "Array of advanced scores" advancedScores: [Float], "When the entry was started by the user" startedAt: FuzzyDateInput, "When the entry was completed by the user" completedAt: FuzzyDateInput, "The list entries ids to update" ids: [Int]): [MediaList] + """ Delete a media list entry """ - DeleteMediaListEntry("""The id of the media list entry to delete""" id: Int): Deleted + DeleteMediaListEntry("The id of the media list entry to delete" id: Int): Deleted + """ Delete a custom list and remove the list entries from it """ - DeleteCustomList("""The name of the custom list to delete""" customList: String, """The media list type of the custom list""" type: MediaType): Deleted + DeleteCustomList("The name of the custom list to delete" customList: String, "The media list type of the custom list" type: MediaType): Deleted + """ Create or update text activity for the currently authenticated user """ - SaveTextActivity("""The activity's id, required for updating""" id: Int, """The activity text""" text: String, """If the activity should be locked. (Mod Only)""" locked: Boolean): TextActivity + SaveTextActivity("The activity's id, required for updating" id: Int, "The activity text" text: String, "If the activity should be locked. (Mod Only)" locked: Boolean): TextActivity + """ Create or update message activity for the currently authenticated user """ - SaveMessageActivity("""The activity id, required for updating""" id: Int, """The activity message text""" message: String, """The id of the user the message is being sent to""" recipientId: Int, """If the activity should be private""" private: Boolean, """If the activity should be locked. (Mod Only)""" locked: Boolean, """If the message should be sent from the Moderator account (Mod Only)""" asMod: Boolean): MessageActivity + SaveMessageActivity("The activity id, required for updating" id: Int, "The activity message text" message: String, "The id of the user the message is being sent to" recipientId: Int, "If the activity should be private" private: Boolean, "If the activity should be locked. (Mod Only)" locked: Boolean, "If the message should be sent from the Moderator account (Mod Only)" asMod: Boolean): MessageActivity + """ Update list activity (Mod Only) """ - SaveListActivity("""The activity's id, required for updating""" id: Int, """If the activity should be locked. (Mod Only)""" locked: Boolean): ListActivity + SaveListActivity("The activity's id, required for updating" id: Int, "If the activity should be locked. (Mod Only)" locked: Boolean): ListActivity + """ Delete an activity item of the authenticated users """ - DeleteActivity("""The id of the activity to delete""" id: Int): Deleted + DeleteActivity("The id of the activity to delete" id: Int): Deleted + + """ + Toggle activity to be pinned to the top of the user's activity feed + """ + ToggleActivityPin("Toggle activity id to be pinned" id: Int, "If the activity should be pinned or unpinned" pinned: Boolean): ActivityUnion + """ Toggle the subscription of an activity item """ - ToggleActivitySubscription("""The id of the activity to un/subscribe""" activityId: Int, """Whether to subscribe or unsubscribe from the activity""" subscribe: Boolean): ActivityUnion + ToggleActivitySubscription("The id of the activity to un\/subscribe" activityId: Int, "Whether to subscribe or unsubscribe from the activity" subscribe: Boolean): ActivityUnion + """ Create or update an activity reply """ - SaveActivityReply("""The activity reply id, required for updating""" id: Int, """The id of the parent activity being replied to""" activityId: Int, """The reply text""" text: String, """If the reply should be sent from the Moderator account (Mod Only)""" asMod: Boolean): ActivityReply + SaveActivityReply("The activity reply id, required for updating" id: Int, "The id of the parent activity being replied to" activityId: Int, "The reply text" text: String, "If the reply should be sent from the Moderator account (Mod Only)" asMod: Boolean): ActivityReply + """ Delete an activity reply of the authenticated users """ - DeleteActivityReply("""The id of the reply to delete""" id: Int): Deleted + DeleteActivityReply("The id of the reply to delete" id: Int): Deleted + """ Add or remove a like from a likeable type. - Returns all the users who liked the same model + Returns all the users who liked the same model """ - ToggleLike("""The id of the likeable type""" id: Int, """The type of model to be un/liked""" type: LikeableType): [User] + ToggleLike("The id of the likeable type" id: Int, "The type of model to be un\/liked" type: LikeableType): [User] + """ Add or remove a like from a likeable type. """ - ToggleLikeV2("""The id of the likeable type""" id: Int, """The type of model to be un/liked""" type: LikeableType): LikeableUnion + ToggleLikeV2("The id of the likeable type" id: Int, "The type of model to be un\/liked" type: LikeableType): LikeableUnion + """ Toggle the un/following of a user """ - ToggleFollow("""The id of the user to un/follow""" userId: Int): User + ToggleFollow("The id of the user to un\/follow" userId: Int): User + """ Favourite or unfavourite an anime, manga, character, staff member, or studio """ - ToggleFavourite("""The id of the anime to un/favourite""" animeId: Int, """The id of the manga to un/favourite""" mangaId: Int, """The id of the character to un/favourite""" characterId: Int, """The id of the staff to un/favourite""" staffId: Int, """The id of the studio to un/favourite""" studioId: Int): Favourites + ToggleFavourite("The id of the anime to un\/favourite" animeId: Int, "The id of the manga to un\/favourite" mangaId: Int, "The id of the character to un\/favourite" characterId: Int, "The id of the staff to un\/favourite" staffId: Int, "The id of the studio to un\/favourite" studioId: Int): Favourites + """ Update the order favourites are displayed in """ - UpdateFavouriteOrder("""The id of the anime to un/favourite""" animeIds: [Int], """The id of the manga to un/favourite""" mangaIds: [Int], """The id of the character to un/favourite""" characterIds: [Int], """The id of the staff to un/favourite""" staffIds: [Int], """The id of the studio to un/favourite""" studioIds: [Int], """List of integers which the anime should be ordered by (Asc)""" animeOrder: [Int], """List of integers which the manga should be ordered by (Asc)""" mangaOrder: [Int], """List of integers which the character should be ordered by (Asc)""" characterOrder: [Int], """List of integers which the staff should be ordered by (Asc)""" staffOrder: [Int], """List of integers which the studio should be ordered by (Asc)""" studioOrder: [Int]): Favourites + UpdateFavouriteOrder("The id of the anime to un\/favourite" animeIds: [Int], "The id of the manga to un\/favourite" mangaIds: [Int], "The id of the character to un\/favourite" characterIds: [Int], "The id of the staff to un\/favourite" staffIds: [Int], "The id of the studio to un\/favourite" studioIds: [Int], "List of integers which the anime should be ordered by (Asc)" animeOrder: [Int], "List of integers which the manga should be ordered by (Asc)" mangaOrder: [Int], "List of integers which the character should be ordered by (Asc)" characterOrder: [Int], "List of integers which the staff should be ordered by (Asc)" staffOrder: [Int], "List of integers which the studio should be ordered by (Asc)" studioOrder: [Int]): Favourites + """ Create or update a review """ - SaveReview("""The review id, required for updating""" id: Int, """The id of the media the review is of""" mediaId: Int, """The main review text. Min:2200 characters""" body: String, """A short summary/preview of the review. Min:20, Max:120 characters""" summary: String, """A short summary/preview of the review. Min:20, Max:120 characters""" score: Int, """If the review should only be visible to its creator""" private: Boolean): Review + SaveReview("The review id, required for updating" id: Int, "The id of the media the review is of" mediaId: Int, "The main review text. Min:2200 characters" body: String, "A short summary\/preview of the review. Min:20, Max:120 characters" summary: String, "A short summary\/preview of the review. Min:20, Max:120 characters" score: Int, "If the review should only be visible to its creator" private: Boolean): Review + """ Delete a review """ - DeleteReview("""The id of the review to delete""" id: Int): Deleted + DeleteReview("The id of the review to delete" id: Int): Deleted + """ Rate a review """ - RateReview("""The id of the review to rate""" reviewId: Int, """The rating to apply to the review""" rating: ReviewRating): Review + RateReview("The id of the review to rate" reviewId: Int, "The rating to apply to the review" rating: ReviewRating): Review + """ Recommendation a media """ - SaveRecommendation("""The id of the base media""" mediaId: Int, """The id of the media to recommend""" mediaRecommendationId: Int, """The rating to give the recommendation""" rating: RecommendationRating): Recommendation + SaveRecommendation("The id of the base media" mediaId: Int, "The id of the media to recommend" mediaRecommendationId: Int, "The rating to give the recommendation" rating: RecommendationRating): Recommendation + """ Create or update a forum thread """ - SaveThread("""The thread id, required for updating""" id: Int, """The title of the thread""" title: String, """The main text body of the thread""" body: String, """Forum categories the thread should be within""" categories: [Int], """Media related to the contents of the thread""" mediaCategories: [Int], """If the thread should be stickied. (Mod Only)""" sticky: Boolean, """If the thread should be locked. (Mod Only)""" locked: Boolean): Thread + SaveThread("The thread id, required for updating" id: Int, "The title of the thread" title: String, "The main text body of the thread" body: String, "Forum categories the thread should be within" categories: [Int], "Media related to the contents of the thread" mediaCategories: [Int], "If the thread should be stickied. (Mod Only)" sticky: Boolean, "If the thread should be locked. (Mod Only)" locked: Boolean): Thread + """ Delete a thread """ - DeleteThread("""The id of the thread to delete""" id: Int): Deleted + DeleteThread("The id of the thread to delete" id: Int): Deleted + """ Toggle the subscription of a forum thread """ - ToggleThreadSubscription("""The id of the forum thread to un/subscribe""" threadId: Int, """Whether to subscribe or unsubscribe from the forum thread""" subscribe: Boolean): Thread + ToggleThreadSubscription("The id of the forum thread to un\/subscribe" threadId: Int, "Whether to subscribe or unsubscribe from the forum thread" subscribe: Boolean): Thread + """ Create or update a thread comment """ - SaveThreadComment("""The comment id, required for updating""" id: Int, """The id of thread the comment belongs to""" threadId: Int, """The id of thread comment to reply to""" parentCommentId: Int, """The comment markdown text""" comment: String): ThreadComment + SaveThreadComment("The comment id, required for updating" id: Int, "The id of thread the comment belongs to" threadId: Int, "The id of thread comment to reply to" parentCommentId: Int, "The comment markdown text" comment: String, "If the comment tree should be locked. (Mod Only)" locked: Boolean): ThreadComment + """ Delete a thread comment """ - DeleteThreadComment("""The id of the thread comment to delete""" id: Int): Deleted + DeleteThreadComment("The id of the thread comment to delete" id: Int): Deleted + UpdateAniChartSettings(titleLanguage: String, outgoingLinkProvider: String, theme: String, sort: String): Json + UpdateAniChartHighlights(highlights: [AniChartHighlightInput]): Json } @@ -3912,6 +5139,7 @@ input NotificationOptionInput { The type of notification """ type: NotificationType + """ Whether this type of notification is enabled """ @@ -3926,28 +5154,39 @@ input MediaListOptionsInput { The order each list should be displayed in """ sectionOrder: [String] + """ If the completed sections of the list should be separated by format """ splitCompletedSectionByFormat: Boolean + """ The names of the user's custom lists """ customLists: [String] + """ The names of the user's advanced scoring sections """ advancedScoring: [String] + """ If advanced scoring is enabled """ advancedScoringEnabled: Boolean + """ list theme """ theme: String } +input ListActivityOptionInput { + disabled: Boolean + + type: MediaListStatus +} + """ Date object that allows for incomplete date values (fuzzy) """ @@ -3956,10 +5195,12 @@ input FuzzyDateInput { Numeric Year (2017) """ year: Int + """ Numeric Month (3) """ month: Int + """ Numeric Day (24) """ @@ -3976,13 +5217,11 @@ type Deleted { deleted: Boolean } -""" -Likeable union type -""" -union LikeableUnion = ListActivity | TextActivity | MessageActivity | ActivityReply | Thread | ThreadComment +union LikeableUnion = ListActivity|TextActivity|MessageActivity|ActivityReply|Thread|ThreadComment input AniChartHighlightInput { mediaId: Int + highlight: String } @@ -3990,34 +5229,60 @@ input AniChartHighlightInput { Page of data (Used for internal use only) """ type InternalPage { - mediaSubmissions(mediaId: Int, submissionId: Int, userId: Int, status: SubmissionStatus, """Filter by the media's type""" type: MediaType, """The order the results will be returned in""" sort: [SubmissionSort]): [MediaSubmission] - characterSubmissions(characterId: Int, """Filter by the submitter of the submission""" userId: Int, """Filter by the status of the submission""" status: SubmissionStatus, """The order the results will be returned in""" sort: [SubmissionSort]): [CharacterSubmission] - staffSubmissions(staffId: Int, """Filter by the submitter of the submission""" userId: Int, """Filter by the status of the submission""" status: SubmissionStatus, """The order the results will be returned in""" sort: [SubmissionSort]): [StaffSubmission] - revisionHistory("""Filter by the user id""" userId: Int, """Filter by the media id""" mediaId: Int, """Filter by the character id""" characterId: Int, """Filter by the staff id""" staffId: Int, """Filter by the studio id""" studioId: Int): [RevisionHistory] + mediaSubmissions(mediaId: Int, submissionId: Int, userId: Int, assigneeId: Int, status: SubmissionStatus, "Filter by the media's type" type: MediaType, "The order the results will be returned in" sort: [SubmissionSort]): [MediaSubmission] + + characterSubmissions(characterId: Int, "Filter by the submitter of the submission" userId: Int, assigneeId: Int, "Filter by the status of the submission" status: SubmissionStatus, "The order the results will be returned in" sort: [SubmissionSort]): [CharacterSubmission] + + staffSubmissions(staffId: Int, "Filter by the submitter of the submission" userId: Int, assigneeId: Int, "Filter by the status of the submission" status: SubmissionStatus, "The order the results will be returned in" sort: [SubmissionSort]): [StaffSubmission] + + revisionHistory("Filter by the user id" userId: Int, "Filter by the media id" mediaId: Int, "Filter by the character id" characterId: Int, "Filter by the staff id" staffId: Int, "Filter by the studio id" studioId: Int): [RevisionHistory] + reports(reporterId: Int, reportedId: Int): [Report] + modActions(userId: Int, modId: Int): [ModAction] + + userBlockSearch("Filter by search query" search: String): [User] + """ The pagination information """ pageInfo: PageInfo - users("""Filter by the user id""" id: Int, """Filter by the name of the user""" name: String, """Filter to moderators only if true""" isModerator: Boolean, """Filter by search query""" search: String, """The order the results will be returned in""" sort: [UserSort]): [User] - media("""Filter by the media id""" id: Int, """Filter by the media's MyAnimeList id""" idMal: Int, """Filter by the start date of the media""" startDate: FuzzyDateInt, """Filter by the end date of the media""" endDate: FuzzyDateInt, """Filter by the season the media was released in""" season: MediaSeason, """The year of the season (Winter 2017 would also include December 2016 releases). Requires season argument""" seasonYear: Int, """Filter by the media's type""" type: MediaType, """Filter by the media's format""" format: MediaFormat, """Filter by the media's current release status""" status: MediaStatus, """Filter by amount of episodes the media has""" episodes: Int, """Filter by the media's episode length""" duration: Int, """Filter by the media's chapter count""" chapters: Int, """Filter by the media's volume count""" volumes: Int, """Filter by if the media's intended for 18+ adult audiences""" isAdult: Boolean, """Filter by the media's genres""" genre: String, """Filter by the media's tags""" tag: String, """Only apply the tags filter argument to tags above this rank. Default: 18""" minimumTagRank: Int, """Filter by the media's tags with in a tag category""" tagCategory: String, """Filter by the media on the authenticated user's lists""" onList: Boolean, """Filter media by sites with a online streaming or reading license""" licensedBy: String, """Filter by the media's average score""" averageScore: Int, """Filter by the number of users with this media on their list""" popularity: Int, """Filter by the source type of the media""" source: MediaSource, """Filter by the media's country of origin""" countryOfOrigin: CountryCode, """Filter by search query""" search: String, """Filter by the media id""" id_not: Int, """Filter by the media id""" id_in: [Int], """Filter by the media id""" id_not_in: [Int], """Filter by the media's MyAnimeList id""" idMal_not: Int, """Filter by the media's MyAnimeList id""" idMal_in: [Int], """Filter by the media's MyAnimeList id""" idMal_not_in: [Int], """Filter by the start date of the media""" startDate_greater: FuzzyDateInt, """Filter by the start date of the media""" startDate_lesser: FuzzyDateInt, """Filter by the start date of the media""" startDate_like: String, """Filter by the end date of the media""" endDate_greater: FuzzyDateInt, """Filter by the end date of the media""" endDate_lesser: FuzzyDateInt, """Filter by the end date of the media""" endDate_like: String, """Filter by the media's format""" format_in: [MediaFormat], """Filter by the media's format""" format_not: MediaFormat, """Filter by the media's format""" format_not_in: [MediaFormat], """Filter by the media's current release status""" status_in: [MediaStatus], """Filter by the media's current release status""" status_not: MediaStatus, """Filter by the media's current release status""" status_not_in: [MediaStatus], """Filter by amount of episodes the media has""" episodes_greater: Int, """Filter by amount of episodes the media has""" episodes_lesser: Int, """Filter by the media's episode length""" duration_greater: Int, """Filter by the media's episode length""" duration_lesser: Int, """Filter by the media's chapter count""" chapters_greater: Int, """Filter by the media's chapter count""" chapters_lesser: Int, """Filter by the media's volume count""" volumes_greater: Int, """Filter by the media's volume count""" volumes_lesser: Int, """Filter by the media's genres""" genre_in: [String], """Filter by the media's genres""" genre_not_in: [String], """Filter by the media's tags""" tag_in: [String], """Filter by the media's tags""" tag_not_in: [String], """Filter by the media's tags with in a tag category""" tagCategory_in: [String], """Filter by the media's tags with in a tag category""" tagCategory_not_in: [String], """Filter media by sites with a online streaming or reading license""" licensedBy_in: [String], """Filter by the media's average score""" averageScore_not: Int, """Filter by the media's average score""" averageScore_greater: Int, """Filter by the media's average score""" averageScore_lesser: Int, """Filter by the number of users with this media on their list""" popularity_not: Int, """Filter by the number of users with this media on their list""" popularity_greater: Int, """Filter by the number of users with this media on their list""" popularity_lesser: Int, """Filter by the source type of the media""" source_in: [MediaSource], """The order the results will be returned in""" sort: [MediaSort]): [Media] - characters("""Filter by character id""" id: Int, """Filter by character by if its their birthday today""" isBirthday: Boolean, """Filter by search query""" search: String, """Filter by character id""" id_not: Int, """Filter by character id""" id_in: [Int], """Filter by character id""" id_not_in: [Int], """The order the results will be returned in""" sort: [CharacterSort]): [Character] - staff("""Filter by the staff id""" id: Int, """Filter by staff by if its their birthday today""" isBirthday: Boolean, """Filter by search query""" search: String, """Filter by the staff id""" id_not: Int, """Filter by the staff id""" id_in: [Int], """Filter by the staff id""" id_not_in: [Int], """The order the results will be returned in""" sort: [StaffSort]): [Staff] - studios("""Filter by the studio id""" id: Int, """Filter by search query""" search: String, """Filter by the studio id""" id_not: Int, """Filter by the studio id""" id_in: [Int], """Filter by the studio id""" id_not_in: [Int], """The order the results will be returned in""" sort: [StudioSort]): [Studio] - mediaList("""Filter by a list entry's id""" id: Int, """Filter by a user's id""" userId: Int, """Filter by a user's name""" userName: String, """Filter by the list entries media type""" type: MediaType, """Filter by the watching/reading status""" status: MediaListStatus, """Filter by the media id of the list entry""" mediaId: Int, """Filter list entries to users who are being followed by the authenticated user""" isFollowing: Boolean, """Filter by note words and #tags""" notes: String, """Filter by the date the user started the media""" startedAt: FuzzyDateInt, """Filter by the date the user completed the media""" completedAt: FuzzyDateInt, """Limit to only entries also on the auth user's list. Requires user id or name arguments.""" compareWithAuthList: Boolean, """Filter by a user's id""" userId_in: [Int], """Filter by the watching/reading status""" status_in: [MediaListStatus], """Filter by the watching/reading status""" status_not_in: [MediaListStatus], """Filter by the watching/reading status""" status_not: MediaListStatus, """Filter by the media id of the list entry""" mediaId_in: [Int], """Filter by the media id of the list entry""" mediaId_not_in: [Int], """Filter by note words and #tags""" notes_like: String, """Filter by the date the user started the media""" startedAt_greater: FuzzyDateInt, """Filter by the date the user started the media""" startedAt_lesser: FuzzyDateInt, """Filter by the date the user started the media""" startedAt_like: String, """Filter by the date the user completed the media""" completedAt_greater: FuzzyDateInt, """Filter by the date the user completed the media""" completedAt_lesser: FuzzyDateInt, """Filter by the date the user completed the media""" completedAt_like: String, """The order the results will be returned in""" sort: [MediaListSort]): [MediaList] - airingSchedules("""Filter by the id of the airing schedule item""" id: Int, """Filter by the id of associated media""" mediaId: Int, """Filter by the airing episode number""" episode: Int, """Filter by the time of airing""" airingAt: Int, """Filter to episodes that haven't yet aired""" notYetAired: Boolean, """Filter by the id of the airing schedule item""" id_not: Int, """Filter by the id of the airing schedule item""" id_in: [Int], """Filter by the id of the airing schedule item""" id_not_in: [Int], """Filter by the id of associated media""" mediaId_not: Int, """Filter by the id of associated media""" mediaId_in: [Int], """Filter by the id of associated media""" mediaId_not_in: [Int], """Filter by the airing episode number""" episode_not: Int, """Filter by the airing episode number""" episode_in: [Int], """Filter by the airing episode number""" episode_not_in: [Int], """Filter by the airing episode number""" episode_greater: Int, """Filter by the airing episode number""" episode_lesser: Int, """Filter by the time of airing""" airingAt_greater: Int, """Filter by the time of airing""" airingAt_lesser: Int, """The order the results will be returned in""" sort: [AiringSort]): [AiringSchedule] - mediaTrends("""Filter by the media id""" mediaId: Int, """Filter by date""" date: Int, """Filter by trending amount""" trending: Int, """Filter by score""" averageScore: Int, """Filter by popularity""" popularity: Int, """Filter by episode number""" episode: Int, """Filter to stats recorded while the media was releasing""" releasing: Boolean, """Filter by the media id""" mediaId_not: Int, """Filter by the media id""" mediaId_in: [Int], """Filter by the media id""" mediaId_not_in: [Int], """Filter by date""" date_greater: Int, """Filter by date""" date_lesser: Int, """Filter by trending amount""" trending_greater: Int, """Filter by trending amount""" trending_lesser: Int, """Filter by trending amount""" trending_not: Int, """Filter by score""" averageScore_greater: Int, """Filter by score""" averageScore_lesser: Int, """Filter by score""" averageScore_not: Int, """Filter by popularity""" popularity_greater: Int, """Filter by popularity""" popularity_lesser: Int, """Filter by popularity""" popularity_not: Int, """Filter by episode number""" episode_greater: Int, """Filter by episode number""" episode_lesser: Int, """Filter by episode number""" episode_not: Int, """The order the results will be returned in""" sort: [MediaTrendSort]): [MediaTrend] - notifications("""Filter by the type of notifications""" type: NotificationType, """Reset the unread notification count to 0 on load""" resetNotificationCount: Boolean, """Filter by the type of notifications""" type_in: [NotificationType]): [NotificationUnion] - followers("""User id of the follower/followed""" userId: Int!, """The order the results will be returned in""" sort: [UserSort]): [User] - following("""User id of the follower/followed""" userId: Int!, """The order the results will be returned in""" sort: [UserSort]): [User] - activities("""Filter by the activity id""" id: Int, """Filter by the owner user id""" userId: Int, """Filter by the id of the user who sent a message""" messengerId: Int, """Filter by the associated media id of the activity""" mediaId: Int, """Filter by the type of activity""" type: ActivityType, """Filter activity to users who are being followed by the authenticated user""" isFollowing: Boolean, """Filter activity to only activity with replies""" hasReplies: Boolean, """Filter activity to only activity with replies or is of type text""" hasRepliesOrTypeText: Boolean, """Filter by the time the activity was created""" createdAt: Int, """Filter by the activity id""" id_not: Int, """Filter by the activity id""" id_in: [Int], """Filter by the activity id""" id_not_in: [Int], """Filter by the owner user id""" userId_not: Int, """Filter by the owner user id""" userId_in: [Int], """Filter by the owner user id""" userId_not_in: [Int], """Filter by the id of the user who sent a message""" messengerId_not: Int, """Filter by the id of the user who sent a message""" messengerId_in: [Int], """Filter by the id of the user who sent a message""" messengerId_not_in: [Int], """Filter by the associated media id of the activity""" mediaId_not: Int, """Filter by the associated media id of the activity""" mediaId_in: [Int], """Filter by the associated media id of the activity""" mediaId_not_in: [Int], """Filter by the type of activity""" type_not: ActivityType, """Filter by the type of activity""" type_in: [ActivityType], """Filter by the type of activity""" type_not_in: [ActivityType], """Filter by the time the activity was created""" createdAt_greater: Int, """Filter by the time the activity was created""" createdAt_lesser: Int, """The order the results will be returned in""" sort: [ActivitySort]): [ActivityUnion] - activityReplies("""Filter by the reply id""" id: Int, """Filter by the parent id""" activityId: Int): [ActivityReply] - threads("""Filter by the thread id""" id: Int, """Filter by the user id of the thread's creator""" userId: Int, """Filter by the user id of the last user to comment on the thread""" replyUserId: Int, """Filter by if the currently authenticated user's subscribed threads""" subscribed: Boolean, """Filter by thread category id""" categoryId: Int, """Filter by thread media id category""" mediaCategoryId: Int, """Filter by search query""" search: String, """Filter by the thread id""" id_in: [Int], """The order the results will be returned in""" sort: [ThreadSort]): [Thread] - threadComments("""Filter by the comment id""" id: Int, """Filter by the thread id""" threadId: Int, """Filter by the user id of the comment's creator""" userId: Int, """The order the results will be returned in""" sort: [ThreadCommentSort]): [ThreadComment] - reviews("""Filter by Review id""" id: Int, """Filter by media id""" mediaId: Int, """Filter by user id""" userId: Int, """Filter by media type""" mediaType: MediaType, """The order the results will be returned in""" sort: [ReviewSort]): [Review] - recommendations("""Filter by recommendation id""" id: Int, """Filter by media id""" mediaId: Int, """Filter by media recommendation id""" mediaRecommendationId: Int, """Filter by user who created the recommendation""" userId: Int, """Filter by total rating of the recommendation""" rating: Int, """Filter by the media on the authenticated user's lists""" onList: Boolean, """Filter by total rating of the recommendation""" rating_greater: Int, """Filter by total rating of the recommendation""" rating_lesser: Int, """The order the results will be returned in""" sort: [RecommendationSort]): [Recommendation] - likes("""The id of the likeable type""" likeableId: Int, """The type of model the id applies to""" type: LikeableType): [User] + + users("Filter by the user id" id: Int, "Filter by the name of the user" name: String, "Filter to moderators only if true" isModerator: Boolean, "Filter by search query" search: String, "The order the results will be returned in" sort: [UserSort]): [User] + + media("Filter by the media id" id: Int, "Filter by the media's MyAnimeList id" idMal: Int, "Filter by the start date of the media" startDate: FuzzyDateInt, "Filter by the end date of the media" endDate: FuzzyDateInt, "Filter by the season the media was released in" season: MediaSeason, "The year of the season (Winter 2017 would also include December 2016 releases). Requires season argument" seasonYear: Int, "Filter by the media's type" type: MediaType, "Filter by the media's format" format: MediaFormat, "Filter by the media's current release status" status: MediaStatus, "Filter by amount of episodes the media has" episodes: Int, "Filter by the media's episode length" duration: Int, "Filter by the media's chapter count" chapters: Int, "Filter by the media's volume count" volumes: Int, "Filter by if the media's intended for 18+ adult audiences" isAdult: Boolean, "Filter by the media's genres" genre: String, "Filter by the media's tags" tag: String, "Only apply the tags filter argument to tags above this rank. Default: 18" minimumTagRank: Int, "Filter by the media's tags with in a tag category" tagCategory: String, "Filter by the media on the authenticated user's lists" onList: Boolean, "Filter media by sites name with a online streaming or reading license" licensedBy: String, "Filter media by sites id with a online streaming or reading license" licensedById: Int, "Filter by the media's average score" averageScore: Int, "Filter by the number of users with this media on their list" popularity: Int, "Filter by the source type of the media" source: MediaSource, "Filter by the media's country of origin" countryOfOrigin: CountryCode, "If the media is officially licensed or a self-published doujin release" isLicensed: Boolean, "Filter by search query" search: String, "Filter by the media id" id_not: Int, "Filter by the media id" id_in: [Int], "Filter by the media id" id_not_in: [Int], "Filter by the media's MyAnimeList id" idMal_not: Int, "Filter by the media's MyAnimeList id" idMal_in: [Int], "Filter by the media's MyAnimeList id" idMal_not_in: [Int], "Filter by the start date of the media" startDate_greater: FuzzyDateInt, "Filter by the start date of the media" startDate_lesser: FuzzyDateInt, "Filter by the start date of the media" startDate_like: String, "Filter by the end date of the media" endDate_greater: FuzzyDateInt, "Filter by the end date of the media" endDate_lesser: FuzzyDateInt, "Filter by the end date of the media" endDate_like: String, "Filter by the media's format" format_in: [MediaFormat], "Filter by the media's format" format_not: MediaFormat, "Filter by the media's format" format_not_in: [MediaFormat], "Filter by the media's current release status" status_in: [MediaStatus], "Filter by the media's current release status" status_not: MediaStatus, "Filter by the media's current release status" status_not_in: [MediaStatus], "Filter by amount of episodes the media has" episodes_greater: Int, "Filter by amount of episodes the media has" episodes_lesser: Int, "Filter by the media's episode length" duration_greater: Int, "Filter by the media's episode length" duration_lesser: Int, "Filter by the media's chapter count" chapters_greater: Int, "Filter by the media's chapter count" chapters_lesser: Int, "Filter by the media's volume count" volumes_greater: Int, "Filter by the media's volume count" volumes_lesser: Int, "Filter by the media's genres" genre_in: [String], "Filter by the media's genres" genre_not_in: [String], "Filter by the media's tags" tag_in: [String], "Filter by the media's tags" tag_not_in: [String], "Filter by the media's tags with in a tag category" tagCategory_in: [String], "Filter by the media's tags with in a tag category" tagCategory_not_in: [String], "Filter media by sites name with a online streaming or reading license" licensedBy_in: [String], "Filter media by sites id with a online streaming or reading license" licensedById_in: [Int], "Filter by the media's average score" averageScore_not: Int, "Filter by the media's average score" averageScore_greater: Int, "Filter by the media's average score" averageScore_lesser: Int, "Filter by the number of users with this media on their list" popularity_not: Int, "Filter by the number of users with this media on their list" popularity_greater: Int, "Filter by the number of users with this media on their list" popularity_lesser: Int, "Filter by the source type of the media" source_in: [MediaSource], "The order the results will be returned in" sort: [MediaSort]): [Media] + + characters("Filter by character id" id: Int, "Filter by character by if its their birthday today" isBirthday: Boolean, "Filter by search query" search: String, "Filter by character id" id_not: Int, "Filter by character id" id_in: [Int], "Filter by character id" id_not_in: [Int], "The order the results will be returned in" sort: [CharacterSort]): [Character] + + staff("Filter by the staff id" id: Int, "Filter by staff by if its their birthday today" isBirthday: Boolean, "Filter by search query" search: String, "Filter by the staff id" id_not: Int, "Filter by the staff id" id_in: [Int], "Filter by the staff id" id_not_in: [Int], "The order the results will be returned in" sort: [StaffSort]): [Staff] + + studios("Filter by the studio id" id: Int, "Filter by search query" search: String, "Filter by the studio id" id_not: Int, "Filter by the studio id" id_in: [Int], "Filter by the studio id" id_not_in: [Int], "The order the results will be returned in" sort: [StudioSort]): [Studio] + + mediaList("Filter by a list entry's id" id: Int, "Filter by a user's id" userId: Int, "Filter by a user's name" userName: String, "Filter by the list entries media type" type: MediaType, "Filter by the watching\/reading status" status: MediaListStatus, "Filter by the media id of the list entry" mediaId: Int, "Filter list entries to users who are being followed by the authenticated user" isFollowing: Boolean, "Filter by note words and #tags" notes: String, "Filter by the date the user started the media" startedAt: FuzzyDateInt, "Filter by the date the user completed the media" completedAt: FuzzyDateInt, "Limit to only entries also on the auth user's list. Requires user id or name arguments." compareWithAuthList: Boolean, "Filter by a user's id" userId_in: [Int], "Filter by the watching\/reading status" status_in: [MediaListStatus], "Filter by the watching\/reading status" status_not_in: [MediaListStatus], "Filter by the watching\/reading status" status_not: MediaListStatus, "Filter by the media id of the list entry" mediaId_in: [Int], "Filter by the media id of the list entry" mediaId_not_in: [Int], "Filter by note words and #tags" notes_like: String, "Filter by the date the user started the media" startedAt_greater: FuzzyDateInt, "Filter by the date the user started the media" startedAt_lesser: FuzzyDateInt, "Filter by the date the user started the media" startedAt_like: String, "Filter by the date the user completed the media" completedAt_greater: FuzzyDateInt, "Filter by the date the user completed the media" completedAt_lesser: FuzzyDateInt, "Filter by the date the user completed the media" completedAt_like: String, "The order the results will be returned in" sort: [MediaListSort]): [MediaList] + + airingSchedules("Filter by the id of the airing schedule item" id: Int, "Filter by the id of associated media" mediaId: Int, "Filter by the airing episode number" episode: Int, "Filter by the time of airing" airingAt: Int, "Filter to episodes that haven't yet aired" notYetAired: Boolean, "Filter by the id of the airing schedule item" id_not: Int, "Filter by the id of the airing schedule item" id_in: [Int], "Filter by the id of the airing schedule item" id_not_in: [Int], "Filter by the id of associated media" mediaId_not: Int, "Filter by the id of associated media" mediaId_in: [Int], "Filter by the id of associated media" mediaId_not_in: [Int], "Filter by the airing episode number" episode_not: Int, "Filter by the airing episode number" episode_in: [Int], "Filter by the airing episode number" episode_not_in: [Int], "Filter by the airing episode number" episode_greater: Int, "Filter by the airing episode number" episode_lesser: Int, "Filter by the time of airing" airingAt_greater: Int, "Filter by the time of airing" airingAt_lesser: Int, "The order the results will be returned in" sort: [AiringSort]): [AiringSchedule] + + mediaTrends("Filter by the media id" mediaId: Int, "Filter by date" date: Int, "Filter by trending amount" trending: Int, "Filter by score" averageScore: Int, "Filter by popularity" popularity: Int, "Filter by episode number" episode: Int, "Filter to stats recorded while the media was releasing" releasing: Boolean, "Filter by the media id" mediaId_not: Int, "Filter by the media id" mediaId_in: [Int], "Filter by the media id" mediaId_not_in: [Int], "Filter by date" date_greater: Int, "Filter by date" date_lesser: Int, "Filter by trending amount" trending_greater: Int, "Filter by trending amount" trending_lesser: Int, "Filter by trending amount" trending_not: Int, "Filter by score" averageScore_greater: Int, "Filter by score" averageScore_lesser: Int, "Filter by score" averageScore_not: Int, "Filter by popularity" popularity_greater: Int, "Filter by popularity" popularity_lesser: Int, "Filter by popularity" popularity_not: Int, "Filter by episode number" episode_greater: Int, "Filter by episode number" episode_lesser: Int, "Filter by episode number" episode_not: Int, "The order the results will be returned in" sort: [MediaTrendSort]): [MediaTrend] + + notifications("Filter by the type of notifications" type: NotificationType, "Reset the unread notification count to 0 on load" resetNotificationCount: Boolean, "Filter by the type of notifications" type_in: [NotificationType]): [NotificationUnion] + + followers("User id of the follower\/followed" userId: Int!, "The order the results will be returned in" sort: [UserSort]): [User] + + following("User id of the follower\/followed" userId: Int!, "The order the results will be returned in" sort: [UserSort]): [User] + + activities("Filter by the activity id" id: Int, "Filter by the owner user id" userId: Int, "Filter by the id of the user who sent a message" messengerId: Int, "Filter by the associated media id of the activity" mediaId: Int, "Filter by the type of activity" type: ActivityType, "Filter activity to users who are being followed by the authenticated user" isFollowing: Boolean, "Filter activity to only activity with replies" hasReplies: Boolean, "Filter activity to only activity with replies or is of type text" hasRepliesOrTypeText: Boolean, "Filter by the time the activity was created" createdAt: Int, "Filter by the activity id" id_not: Int, "Filter by the activity id" id_in: [Int], "Filter by the activity id" id_not_in: [Int], "Filter by the owner user id" userId_not: Int, "Filter by the owner user id" userId_in: [Int], "Filter by the owner user id" userId_not_in: [Int], "Filter by the id of the user who sent a message" messengerId_not: Int, "Filter by the id of the user who sent a message" messengerId_in: [Int], "Filter by the id of the user who sent a message" messengerId_not_in: [Int], "Filter by the associated media id of the activity" mediaId_not: Int, "Filter by the associated media id of the activity" mediaId_in: [Int], "Filter by the associated media id of the activity" mediaId_not_in: [Int], "Filter by the type of activity" type_not: ActivityType, "Filter by the type of activity" type_in: [ActivityType], "Filter by the type of activity" type_not_in: [ActivityType], "Filter by the time the activity was created" createdAt_greater: Int, "Filter by the time the activity was created" createdAt_lesser: Int, "The order the results will be returned in" sort: [ActivitySort]): [ActivityUnion] + + activityReplies("Filter by the reply id" id: Int, "Filter by the parent id" activityId: Int): [ActivityReply] + + threads("Filter by the thread id" id: Int, "Filter by the user id of the thread's creator" userId: Int, "Filter by the user id of the last user to comment on the thread" replyUserId: Int, "Filter by if the currently authenticated user's subscribed threads" subscribed: Boolean, "Filter by thread category id" categoryId: Int, "Filter by thread media id category" mediaCategoryId: Int, "Filter by search query" search: String, "Filter by the thread id" id_in: [Int], "The order the results will be returned in" sort: [ThreadSort]): [Thread] + + threadComments("Filter by the comment id" id: Int, "Filter by the thread id" threadId: Int, "Filter by the user id of the comment's creator" userId: Int, "The order the results will be returned in" sort: [ThreadCommentSort]): [ThreadComment] + + reviews("Filter by Review id" id: Int, "Filter by media id" mediaId: Int, "Filter by user id" userId: Int, "Filter by media type" mediaType: MediaType, "The order the results will be returned in" sort: [ReviewSort]): [Review] + + recommendations("Filter by recommendation id" id: Int, "Filter by media id" mediaId: Int, "Filter by media recommendation id" mediaRecommendationId: Int, "Filter by user who created the recommendation" userId: Int, "Filter by total rating of the recommendation" rating: Int, "Filter by the media on the authenticated user's lists" onList: Boolean, "Filter by total rating of the recommendation" rating_greater: Int, "Filter by total rating of the recommendation" rating_lesser: Int, "The order the results will be returned in" sort: [RecommendationSort]): [Recommendation] + + likes("The id of the likeable type" likeableId: Int, "The type of model the id applies to" type: LikeableType): [User] } """ @@ -4025,8 +5290,11 @@ Submission status """ enum SubmissionStatus { PENDING + REJECTED + PARTIALLY_ACCEPTED + ACCEPTED } @@ -4035,6 +5303,7 @@ Submission sort enums """ enum SubmissionSort { ID + ID_DESC } @@ -4046,25 +5315,49 @@ type MediaSubmission { The id of the submission """ id: Int! + """ User submitter of the submission """ submitter: User + + """ + Data Mod assigned to handle the submission + """ + assignee: User + """ Status of the submission """ status: SubmissionStatus + submitterStats: Json + notes: String + source: String + changes: [String] + + """ + Whether the submission is locked + """ + locked: Boolean + media: Media + submission: Media + characters: [MediaSubmissionComparison] + staff: [MediaSubmissionComparison] + studios: [MediaSubmissionComparison] + relations: [MediaEdge] - externalLinks: [MediaExternalLink] + + externalLinks: [MediaSubmissionComparison] + createdAt: Int } @@ -4073,9 +5366,14 @@ Media submission with comparison to current data """ type MediaSubmissionComparison { submission: MediaSubmissionEdge + character: MediaCharacter + staff: StaffEdge + studio: StudioEdge + + externalLink: MediaExternalLink } type MediaSubmissionEdge { @@ -4083,19 +5381,35 @@ type MediaSubmissionEdge { The id of the direct submission """ id: Int + characterRole: CharacterRole + staffRole: String + roleNotes: String + dubGroup: String + characterName: String + isMain: Boolean + character: Character + characterSubmission: Character + voiceActor: Staff + voiceActorSubmission: Staff + staff: Staff + staffSubmission: Staff + studio: Studio + + externalLink: MediaExternalLink + media: Media } @@ -4107,20 +5421,26 @@ type MediaCharacter { The id of the connection """ id: Int + """ The characters role in the media """ role: CharacterRole + roleNotes: String + dubGroup: String + """ Media specific character name """ characterName: String + """ The characters in the media voiced by the parent actor """ character: Character + """ The voice actor of the character """ @@ -4135,27 +5455,44 @@ type CharacterSubmission { The id of the submission """ id: Int! + """ Character that the submission is referencing """ character: Character + """ The character submission changes """ submission: Character + """ Submitter for the submission """ submitter: User + + """ + Data Mod assigned to handle the submission + """ + assignee: User + """ Status of the submission """ status: SubmissionStatus + """ Inner details of submission status """ notes: String + source: String + + """ + Whether the submission is locked + """ + locked: Boolean + createdAt: Int } @@ -4167,27 +5504,44 @@ type StaffSubmission { The id of the submission """ id: Int! + """ Staff that the submission is referencing """ staff: Staff + """ The staff submission changes """ submission: Staff + """ Submitter for the submission """ submitter: User + + """ + Data Mod assigned to handle the submission + """ + assignee: User + """ Status of the submission """ status: SubmissionStatus + """ Inner details of submission status """ notes: String + source: String + + """ + Whether the submission is locked + """ + locked: Boolean + createdAt: Int } @@ -4199,34 +5553,47 @@ type RevisionHistory { The id of the media """ id: Int! + """ The action taken on the objects """ action: RevisionHistoryAction + """ A JSON object of the fields that changed """ changes: Json + """ The user who made the edit to the object """ user: User + """ The media the mod feed entry references """ media: Media + """ The character the mod feed entry references """ character: Character + """ The staff member the mod feed entry references """ staff: Staff + """ The studio the mod feed entry references """ studio: Studio + + """ + The external link source the mod feed entry references + """ + externalLink: MediaExternalLink + """ When the mod feed entry was created """ @@ -4238,18 +5605,24 @@ Revision history actions """ enum RevisionHistoryAction { CREATE + EDIT } type Report { id: Int! + reporter: User + reported: User + reason: String + """ When the entry data was created """ createdAt: Int + cleared: Boolean } @@ -4258,23 +5631,37 @@ type ModAction { The id of the action """ id: Int! + user: User + mod: User + type: ModActionType + objectId: Int + objectType: String + data: String + createdAt: Int! } enum ModActionType { NOTE + BAN + DELETE + EDIT + EXPIRE + REPORT + RESET + ANON } @@ -4286,16 +5673,26 @@ input MediaTitleInput { The romanization of the native language title """ romaji: String + """ The official english title """ english: String + """ Official title in it's native language """ native: String } +input AiringScheduleInput { + airingAt: Int + + episode: Int + + timeUntilAiring: Int +} + """ An external link to another site related to the media """ @@ -4304,22 +5701,18 @@ input MediaExternalLinkInput { The id of the external link """ id: Int! + """ The url of the external link """ url: String! + """ The site location of the external link """ site: String! } -input AiringScheduleInput { - airingAt: Int - episode: Int - timeUntilAiring: Int -} - """ The names of the character """ @@ -4328,22 +5721,27 @@ input CharacterNameInput { The character's given name """ first: String + """ The character's middle name """ middle: String + """ The character's surname """ last: String + """ The character's full name in their native language """ native: String + """ Other names the character might be referred by """ alternative: [String] + """ Other names the character might be referred to as but are spoilers """ @@ -4352,7 +5750,9 @@ input CharacterNameInput { type CharacterSubmissionConnection { edges: [CharacterSubmissionEdge] + nodes: [CharacterSubmission] + """ The pagination information """ @@ -4364,14 +5764,17 @@ CharacterSubmission connection edge """ type CharacterSubmissionEdge { node: CharacterSubmission + """ The characters role in the media """ role: CharacterRole + """ The voice actors of the character """ voiceActors: [Staff] + """ The submitted voice actors of the character """ @@ -4386,18 +5789,22 @@ input StaffNameInput { The person's given name """ first: String + """ The person's middle name """ middle: String + """ The person's surname """ last: String + """ The person's full name in their native language """ native: String + """ Other names the character might be referred by """ @@ -4409,10 +5816,15 @@ User data for moderators """ type UserModData { alts: [User] + bans: Json + ip: Json + counts: Json + privacy: Int + email: String } 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 index c694055..752727c 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/constants/UIConstants.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/common/constants/UIConstants.kt @@ -1,5 +1,7 @@ package com.sharkaboi.mediahub.common.constants +import android.content.Context +import androidx.recyclerview.widget.GridLayoutManager import coil.request.ImageRequest import coil.transform.RoundedCornersTransformation import com.google.android.material.chip.Chip @@ -7,11 +9,11 @@ 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 = { + + val TopRoundedAnimeImageBuilder: ImageRequest.Builder.() -> Unit = { crossfade(true) placeholder(R.drawable.ic_anime_placeholder) error(R.drawable.ic_anime_placeholder) @@ -23,7 +25,20 @@ object UIConstants { ) ) } - val MangaImageBuilder: ImageRequest.Builder.() -> Unit = { + + val AllRoundedAnimeImageBuilder: 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( + radius = AnimeAndMangaImageCornerRadius + ) + ) + } + + val TopRoundedMangaImageBuilder: ImageRequest.Builder.() -> Unit = { crossfade(true) placeholder(R.drawable.ic_manga_placeholder) error(R.drawable.ic_manga_placeholder) @@ -35,6 +50,19 @@ object UIConstants { ) ) } + + val AllRoundedMangaImageBuilder: 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( + radius = AnimeAndMangaImageCornerRadius + ) + ) + } + val ProfileImageBuilder: ImageRequest.Builder.() -> Unit = { crossfade(true) placeholder(R.drawable.ic_profile_placeholder) @@ -49,4 +77,11 @@ object UIConstants { shapeAppearanceModel = ChipShapeAppearanceModel } } + + fun getGridLayoutManager(context: Context): GridLayoutManager { + return GridLayoutManager( + context, + context.resources.getInteger(R.integer.list_grid_span_count) + ) + } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/extensions/CoroutineExtensions.kt b/app/src/main/java/com/sharkaboi/mediahub/common/extensions/CoroutineExtensions.kt index 79665bf..559ec3b 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/extensions/CoroutineExtensions.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/common/extensions/CoroutineExtensions.kt @@ -1,9 +1,10 @@ package com.sharkaboi.mediahub.common.extensions -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import androidx.paging.PagingSource +import com.sharkaboi.mediahub.data.wrappers.MHError +import com.sharkaboi.mediahub.data.wrappers.MHTaskState +import kotlinx.coroutines.* +import timber.log.Timber fun debounce( delay: Long = 800L, @@ -19,3 +20,33 @@ fun debounce( } } } + +suspend fun getCatching(block: suspend () -> MHTaskState): MHTaskState { + return withContext(Dispatchers.IO) { + try { + block() + } catch (e: Exception) { + e.printStackTrace() + Timber.d(e.message ?: String.emptyString) + return@withContext MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError(e.message, MHError.UnknownError) + ) + } + } +} + +suspend fun getCatchingPaging( + block: suspend () -> PagingSource.LoadResult +): PagingSource.LoadResult { + return withContext(Dispatchers.IO) { + try { + block() + } catch (e: Exception) { + e.printStackTrace() + Timber.d(e.message ?: String.emptyString) + return@withContext PagingSource.LoadResult.Error(e) + } + } +} \ No newline at end of file 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 8b771f6..dd53518 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,19 +1,9 @@ package com.sharkaboi.mediahub.common.extensions -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.time.format.DateTimeFormatter -import kotlin.time.Duration -import kotlin.time.ExperimentalTime internal fun Context.getProgressStringWith(progress: Int?, total: Int?): String { val totalCountString = if (total == null || total == 0) @@ -28,61 +18,6 @@ internal fun Context.getProgressStringWith(progress: Int?, total: Int?): String ) } -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 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 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 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 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 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, @@ -98,125 +33,6 @@ internal fun Context.getRatingStringWithRating(rating: Number?): String { ) } -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 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 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 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 Context.getEpisodeLengthFromSeconds(seconds: Int?): String { - runCatching { - if (seconds == null || seconds <= 0) { - return getString(R.string.anime_episode_length_n_a) - } - val duration = Duration.seconds(seconds.toLong()) - val hours = duration.inWholeHours.toInt() - 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 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(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() - if (days <= 0 && hours <= 0) { - getString(R.string.anime_next_episode_airing_minutes, minutes) - } else if (days <= 0) { - getString(R.string.anime_next_episode_airing_hours, hours, minutes) - } else { - getString(R.string.anime_next_episode_airing_days, days, hours, minutes) - } - }.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) 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 20f9fc7..c184acf 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 @@ -39,9 +39,12 @@ internal fun String.replaceUnderScoreWithWhiteSpace(): String { } internal fun String.capitalizeFirst(): String { - return this.lowercase().replaceFirstChar { - it.uppercase() + if (isBlank()) { + return this } + + val firstChar = this.first().titlecaseChar() + return firstChar + this.substring(1).lowercase() } internal fun String.tryParseDate(): LocalDate? { diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/util/DateUtils.kt b/app/src/main/java/com/sharkaboi/mediahub/common/util/DateUtils.kt index 09de24a..606a035 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/util/DateUtils.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/common/util/DateUtils.kt @@ -6,21 +6,23 @@ import java.time.ZoneId import java.time.ZonedDateTime import java.time.temporal.WeekFields -fun getLocalDateFromDayAndTime(dayOfTheWeek: String, startTime: String): ZonedDateTime? { - val dayOfWeek = DayOfWeek.valueOf(dayOfTheWeek.uppercase()) - val fieldIso = WeekFields.ISO.dayOfWeek() - val now = OffsetDateTime.now().with(fieldIso, dayOfWeek.value.toLong()) - val (hour, mins) = startTime.split(":").map { it.toInt() } - val japanTime = ZonedDateTime.of( - now.year, - now.month.value, - now.dayOfMonth, - hour, - mins, - 0, - 0, - ZoneId.of("Asia/Tokyo") - ) - val localTimeZone = ZoneId.systemDefault() - return japanTime.withZoneSameInstant(localTimeZone) -} +object DateUtils { + fun getLocalDateFromDayAndTime(dayOfTheWeek: String, startTime: String): ZonedDateTime? { + val dayOfWeek = DayOfWeek.valueOf(dayOfTheWeek.uppercase()) + val fieldIso = WeekFields.ISO.dayOfWeek() + val now = OffsetDateTime.now().with(fieldIso, dayOfWeek.value.toLong()) + val (hour, mins) = startTime.split(":").map { it.toInt() } + val japanTime = ZonedDateTime.of( + now.year, + now.month.value, + now.dayOfMonth, + hour, + mins, + 0, + 0, + ZoneId.of("Asia/Tokyo") + ) + val localTimeZone = ZoneId.systemDefault() + return japanTime.withZoneSameInstant(localTimeZone) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/constants/ApiConstants.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/constants/ApiConstants.kt index 14950f2..e5d2a8b 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/constants/ApiConstants.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/constants/ApiConstants.kt @@ -16,5 +16,6 @@ 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() + 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 index 9fd641e..4e8bf9f 100644 --- 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 @@ -5,6 +5,10 @@ import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse object MALExternalLinks { + fun getAnimeLink(anime: AnimeByIDResponse): String { + return "https://myanimelist.net/anime/${anime.id}" + } + fun getAnimeGenresLink(genre: AnimeByIDResponse.Genre): String { return "https://myanimelist.net/anime/genre" + "/${genre.id}/${genre.name.replaceWhiteSpaceWithUnderScore()}" @@ -40,6 +44,10 @@ object MALExternalLinks { "/${studio.id}/${studio.name.replaceWhiteSpaceWithUnderScore()}" } + fun getMangaLink(manga: MangaByIDResponse): String { + return "https://myanimelist.net/manga/${manga.id}" + } + fun getMangaAuthorPageLink(author: MangaByIDResponse.Author): String { val name = "${author.node.firstName} ${author.node.lastName}" 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 index acadfd2..6516b12 100644 --- 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 @@ -3,6 +3,7 @@ package com.sharkaboi.mediahub.data.api.enums import android.content.Context import com.sharkaboi.mediahub.R +@Suppress("EnumEntryName") enum class AnimeNsfwRating { white, gray, 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 ea02b15..768fce3 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 @@ -15,7 +15,7 @@ enum class AnimeRankingType { bypopularity, // Top Anime by Popularity favorite; // Top Favorited Anime - fun getAnimeRanking(context: Context): String { + fun getFormattedString(context: Context): String { return when (this) { all -> context.getString(R.string.anime_ranking_all) airing -> context.getString(R.string.anime_ranking_airing) @@ -28,4 +28,13 @@ enum class AnimeRankingType { favorite -> context.getString(R.string.anime_ranking_in_your_list) } } + + companion object { + fun getAnimeRankingFromString(ranking: String?): AnimeRankingType { + return when (ranking) { + null -> all + else -> runCatching { valueOf(ranking.lowercase()) }.getOrElse { all } + } + } + } } 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 4f667d0..8c93299 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 @@ -24,17 +24,16 @@ enum class AnimeStatus { } companion object { + // All status doesn't actually exist in the api val malStatuses = values().filter { it != all } - } -} -fun String.animeStatusFromString(): AnimeStatus? { - return when (this) { - 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 + fun parse(string: String?): AnimeStatus? { + if (string == null) { + return null + } + return runCatching { + valueOf(string) + }.getOrNull() + } } } 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 index 098cb7c..d640deb 100644 --- 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 @@ -3,6 +3,7 @@ package com.sharkaboi.mediahub.data.api.enums import android.content.Context import com.sharkaboi.mediahub.R +@Suppress("EnumEntryName") enum class MangaNsfwRating { white, gray, @@ -10,11 +11,10 @@ enum class MangaNsfwRating { } 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) + return when (rating?.trim()) { + MangaNsfwRating.white.name -> getString(R.string.manga_nsfw_rating_white) + MangaNsfwRating.gray.name -> getString(R.string.manga_nsfw_rating_gray) + 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 index bbe4736..497b18f 100644 --- 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 @@ -3,6 +3,7 @@ package com.sharkaboi.mediahub.data.api.enums import android.content.Context import com.sharkaboi.mediahub.R +@Suppress("EnumEntryName") enum class MangaPublishingStatus { finished, currently_publishing, @@ -10,10 +11,10 @@ enum class MangaPublishingStatus { } 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) + return when (status.trim()) { + MangaPublishingStatus.finished.name -> getString(R.string.manga_publishing_finished) + MangaPublishingStatus.currently_publishing.name -> getString(R.string.manga_publishing_ongoing) + 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 9191cb7..e1dcbbc 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 @@ -36,11 +36,11 @@ enum class MangaRankingType { 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 + return when (ranking?.lowercase()) { + null -> all + lightNovelSlug -> lightnovels + oneShotSlug -> oneshots + 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 dfdc990..fb78bce 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 @@ -25,16 +25,14 @@ enum class MangaStatus { companion object { val malStatuses = values().filter { it != all } - } -} -fun String.mangaStatusFromString(): MangaStatus? { - return when (this) { - 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 + fun parse(string: String?): MangaStatus? { + if (string == null) { + return null + } + return runCatching { + valueOf(string) + }.getOrNull() + } } } 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 7f7a51e..daed2db 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 @@ -17,5 +17,14 @@ enum class UserAnimeSortType { context.getString(R.string.user_anime_sort_by_alphabetical), context.getString(R.string.user_anime_sort_by_newest) ) + + fun parse(string: String?): UserAnimeSortType? { + if (string == null) { + return null + } + return runCatching { + valueOf(string) + }.getOrNull() + } } } 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 d1f03f1..4f26a7e 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 @@ -17,5 +17,14 @@ enum class UserMangaSortType { context.getString(R.string.user_manga_sort_by_alphabetical), context.getString(R.string.user_manga_sort_by_newest) ) + + fun parse(string: String?): UserMangaSortType? { + if (string == null) { + return null + } + return runCatching { + valueOf(string) + }.getOrNull() + } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/models/anime/AnimeByIDResponse.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/models/anime/AnimeByIDResponse.kt index 679b17e..018fe2a 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/models/anime/AnimeByIDResponse.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/models/anime/AnimeByIDResponse.kt @@ -20,7 +20,7 @@ data class AnimeByIDResponse( @Json(name = "end_date") val endDate: String?, @Json(name = "genres") - val genres: List, + val genres: List?, @Json(name = "id") val id: Int, @Json(name = "main_picture") diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/api/models/manga/MangaByIDResponse.kt b/app/src/main/java/com/sharkaboi/mediahub/data/api/models/manga/MangaByIDResponse.kt index 1d94e31..6ee371f 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/api/models/manga/MangaByIDResponse.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/api/models/manga/MangaByIDResponse.kt @@ -18,7 +18,7 @@ data class MangaByIDResponse( @Json(name = "end_date") val endDate: String?, @Json(name = "genres") - val genres: List, + val genres: List?, @Json(name = "id") val id: Int, @Json(name = "main_picture") 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 deleted file mode 100644 index c4efe16..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeRankingDataSource.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.sharkaboi.mediahub.data.paging - -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.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.MHError -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber - -class AnimeRankingDataSource( - private val animeService: AnimeService, - private val accessToken: String?, - private val animeRankingType: AnimeRankingType, - 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? { - return state.anchorPosition?.let { anchorPosition -> - val anchorPage = state.closestPageToPosition(anchorPosition) - anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( - ApiConstants.API_PAGE_LIMIT - ) - } - } - - override suspend fun load(params: LoadParams): LoadResult { - Timber.d("params : ${params.key}") - try { - val offset = params.key ?: ApiConstants.API_START_OFFSET - val limit = ApiConstants.API_PAGE_LIMIT - if (accessToken == null) { - return LoadResult.Error( - MHError.LoginExpiredError.getThrowable() - ) - } else { - 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 - return LoadResult.Page( - data = response.body.data, - prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, - nextKey = nextOffset - ) - } - is NetworkResponse.UnknownError -> { - return LoadResult.Error( - response.error - ) - } - is NetworkResponse.ServerError -> { - return LoadResult.Error( - response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() - ) - } - is NetworkResponse.NetworkError -> { - return LoadResult.Error( - response.error - ) - } - } - } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return LoadResult.Error(e) - } - } -} 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 deleted file mode 100644 index 2977caf..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSearchDataSource.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.sharkaboi.mediahub.data.paging - -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.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.MHError -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber - -class AnimeSearchDataSource( - private val animeService: AnimeService, - private val accessToken: String?, - private val query: String, - 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? { - return state.anchorPosition?.let { anchorPosition -> - val anchorPage = state.closestPageToPosition(anchorPosition) - anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( - ApiConstants.API_PAGE_LIMIT - ) - } - } - - override suspend fun load(params: LoadParams): LoadResult { - Timber.d("params : ${params.key}") - try { - val offset = params.key ?: ApiConstants.API_START_OFFSET - val limit = ApiConstants.API_PAGE_LIMIT - if (accessToken == null) { - return LoadResult.Error( - MHError.LoginExpiredError.getThrowable() - ) - } else { - 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 - return LoadResult.Page( - data = response.body.data, - prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, - nextKey = nextOffset - ) - } - is NetworkResponse.UnknownError -> { - return LoadResult.Error( - response.error - ) - } - is NetworkResponse.ServerError -> { - return LoadResult.Error( - response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() - ) - } - is NetworkResponse.NetworkError -> { - return LoadResult.Error( - response.error - ) - } - } - } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return LoadResult.Error(e) - } - } -} 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 deleted file mode 100644 index 2ce278f..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSeasonalDataSource.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.sharkaboi.mediahub.data.paging - -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.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.MHError -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber - -class AnimeSeasonalDataSource( - private val animeService: AnimeService, - private val accessToken: String?, - private val animeSeason: AnimeSeason, - private val year: Int, - 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? { - return state.anchorPosition?.let { anchorPosition -> - val anchorPage = state.closestPageToPosition(anchorPosition) - anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( - ApiConstants.API_PAGE_LIMIT - ) - } - } - - override suspend fun load(params: LoadParams): LoadResult { - Timber.d("params : ${params.key}") - try { - val offset = params.key ?: ApiConstants.API_START_OFFSET - val limit = ApiConstants.API_PAGE_LIMIT - if (accessToken == null) { - return LoadResult.Error( - MHError.LoginExpiredError.getThrowable() - ) - } else { - 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 - return LoadResult.Page( - data = response.body.data, - prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, - nextKey = nextOffset - ) - } - is NetworkResponse.UnknownError -> { - return LoadResult.Error( - response.error - ) - } - is NetworkResponse.ServerError -> { - return LoadResult.Error( - response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() - ) - } - is NetworkResponse.NetworkError -> { - return LoadResult.Error( - response.error - ) - } - } - } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return LoadResult.Error(e) - } - } -} 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 deleted file mode 100644 index ecdb4b5..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/AnimeSuggestionsDataSource.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.sharkaboi.mediahub.data.paging - -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.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.MHError -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber - -class AnimeSuggestionsDataSource( - private val animeService: AnimeService, - private val accessToken: String?, - 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? { - return state.anchorPosition?.let { anchorPosition -> - val anchorPage = state.closestPageToPosition(anchorPosition) - anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( - ApiConstants.API_PAGE_LIMIT - ) - } - } - - override suspend fun load(params: LoadParams): LoadResult { - Timber.d("params : ${params.key}") - try { - val offset = params.key ?: ApiConstants.API_START_OFFSET - val limit = ApiConstants.API_PAGE_LIMIT - if (accessToken == null) { - return LoadResult.Error( - MHError.LoginExpiredError.getThrowable() - ) - } else { - 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 - return LoadResult.Page( - data = response.body.data, - prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, - nextKey = nextOffset - ) - } - is NetworkResponse.UnknownError -> { - return LoadResult.Error( - response.error - ) - } - is NetworkResponse.ServerError -> { - return LoadResult.Error( - response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() - ) - } - is NetworkResponse.NetworkError -> { - return LoadResult.Error( - response.error - ) - } - } - } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return LoadResult.Error(e) - } - } -} 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 deleted file mode 100644 index eb15a5e..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/MangaRankingDataSource.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.sharkaboi.mediahub.data.paging - -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.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.MHError -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber - -class MangaRankingDataSource( - private val mangaService: MangaService, - private val accessToken: String?, - private val mangaRankingType: MangaRankingType, - 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? { - return state.anchorPosition?.let { anchorPosition -> - val anchorPage = state.closestPageToPosition(anchorPosition) - anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( - ApiConstants.API_PAGE_LIMIT - ) - } - } - - override suspend fun load(params: LoadParams): LoadResult { - Timber.d("params : ${params.key}") - try { - val offset = params.key ?: ApiConstants.API_START_OFFSET - val limit = ApiConstants.API_PAGE_LIMIT - if (accessToken == null) { - return LoadResult.Error( - MHError.LoginExpiredError.getThrowable() - ) - } else { - 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 - return LoadResult.Page( - data = response.body.data, - prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, - nextKey = nextOffset - ) - } - is NetworkResponse.UnknownError -> { - return LoadResult.Error( - response.error - ) - } - is NetworkResponse.ServerError -> { - return LoadResult.Error( - response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() - ) - } - is NetworkResponse.NetworkError -> { - return LoadResult.Error( - response.error - ) - } - } - } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return LoadResult.Error(e) - } - } -} 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 deleted file mode 100644 index 7a12d61..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/MangaSearchDataSource.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.sharkaboi.mediahub.data.paging - -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.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.MHError -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber - -class MangaSearchDataSource( - private val mangaService: MangaService, - private val accessToken: String?, - private val query: String, - 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? { - return state.anchorPosition?.let { anchorPosition -> - val anchorPage = state.closestPageToPosition(anchorPosition) - anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( - ApiConstants.API_PAGE_LIMIT - ) - } - } - - override suspend fun load(params: LoadParams): LoadResult { - Timber.d("params : ${params.key}") - try { - val offset = params.key ?: ApiConstants.API_START_OFFSET - val limit = ApiConstants.API_PAGE_LIMIT - if (accessToken == null) { - return LoadResult.Error( - MHError.LoginExpiredError.getThrowable() - ) - } else { - 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 - return LoadResult.Page( - data = response.body.data, - prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, - nextKey = nextOffset - ) - } - is NetworkResponse.UnknownError -> { - return LoadResult.Error( - response.error - ) - } - is NetworkResponse.ServerError -> { - return LoadResult.Error( - response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() - ) - } - is NetworkResponse.NetworkError -> { - return LoadResult.Error( - response.error - ) - } - } - } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return LoadResult.Error(e) - } - } -} 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 deleted file mode 100644 index 1eb2a47..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/UserAnimeListDataSource.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.sharkaboi.mediahub.data.paging - -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.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.MHError -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber - -class UserAnimeListDataSource( - private val userAnimeService: UserAnimeService, - private val accessToken: String?, - private val animeStatus: AnimeStatus, - private val animeSortType: UserAnimeSortType = UserAnimeSortType.list_updated_at, - 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? { - return state.anchorPosition?.let { anchorPosition -> - val anchorPage = state.closestPageToPosition(anchorPosition) - anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( - ApiConstants.API_PAGE_LIMIT - ) - } - } - - override suspend fun load(params: LoadParams): LoadResult { - Timber.d("params : ${params.key}") - try { - val offset = params.key ?: ApiConstants.API_START_OFFSET - val limit = ApiConstants.API_PAGE_LIMIT - if (accessToken == null) { - return LoadResult.Error( - MHError.LoginExpiredError.getThrowable() - ) - } else { - 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 - return LoadResult.Page( - data = response.body.data, - prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, - nextKey = nextOffset - ) - } - is NetworkResponse.UnknownError -> { - return LoadResult.Error( - response.error - ) - } - is NetworkResponse.ServerError -> { - return LoadResult.Error( - response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() - ) - } - is NetworkResponse.NetworkError -> { - return LoadResult.Error( - response.error - ) - } - } - } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return LoadResult.Error(e) - } - } - - private fun getStatus(): String? = if (animeStatus == AnimeStatus.all) { - null - } else { - animeStatus.name - } -} 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 deleted file mode 100644 index 1a1d0be..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/data/paging/UserMangaListDataSource.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.sharkaboi.mediahub.data.paging - -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.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.MHError -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber - -class UserMangaListDataSource( - private val userMangaService: UserMangaService, - private val accessToken: String?, - private val mangaStatus: MangaStatus, - private val mangaSortType: UserMangaSortType = UserMangaSortType.list_updated_at, - 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? { - return state.anchorPosition?.let { anchorPosition -> - val anchorPage = state.closestPageToPosition(anchorPosition) - anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) ?: anchorPage?.nextKey?.minus( - ApiConstants.API_PAGE_LIMIT - ) - } - } - - override suspend fun load(params: LoadParams): LoadResult { - Timber.d("params : ${params.key}") - try { - val offset = params.key ?: ApiConstants.API_START_OFFSET - val limit = ApiConstants.API_PAGE_LIMIT - if (accessToken == null) { - return LoadResult.Error( - MHError.LoginExpiredError.getThrowable() - ) - } else { - 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 - return LoadResult.Page( - data = response.body.data, - prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, - nextKey = nextOffset - ) - } - is NetworkResponse.UnknownError -> { - return LoadResult.Error( - response.error - ) - } - is NetworkResponse.ServerError -> { - return LoadResult.Error( - response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() - ) - } - is NetworkResponse.NetworkError -> { - return LoadResult.Error( - response.error - ) - } - } - } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return LoadResult.Error(e) - } - } - - private fun getStatus(): String? = if (mangaStatus == MangaStatus.all) { - null - } else { - mangaStatus.name - } -} 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 03e16b1..f9a675a 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 @@ -19,5 +19,8 @@ data class MHError( 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") + + fun getError(message: String?, error: MHError = UnknownError) = + message?.let { MHError(it) } ?: error } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/data/wrappers/MHTaskState.kt b/app/src/main/java/com/sharkaboi/mediahub/data/wrappers/MHTaskState.kt index 4232f27..f240e74 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/data/wrappers/MHTaskState.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/data/wrappers/MHTaskState.kt @@ -1,7 +1,29 @@ package com.sharkaboi.mediahub.data.wrappers -data class MHTaskState( - val isSuccess: Boolean, - val data: T?, - val error: MHError -) +class MHTaskState private constructor() { + private var internalDataHolder: T? = null + var isSuccess: Boolean = false + private set + var error: MHError = MHError.EmptyError + private set + + constructor( + isSuccess: Boolean, + data: T?, + error: MHError + ) : this() { + this.isSuccess = isSuccess + this.internalDataHolder = data + this.error = error + } + + val data: T + get() { + if (!isSuccess) { + throw IllegalStateException("data() was called with isSuccess false. State - $this") + } + + return internalDataHolder + ?: throw IllegalStateException("data() was called with data null. State - $this") + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/DataModule.kt b/app/src/main/java/com/sharkaboi/mediahub/di/AppModule.kt similarity index 88% rename from app/src/main/java/com/sharkaboi/mediahub/di/DataModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/di/AppModule.kt index 78ed2df..7e0d2d9 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/DataModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/di/AppModule.kt @@ -3,7 +3,8 @@ package com.sharkaboi.mediahub.di import android.content.Context import android.content.SharedPreferences import androidx.preference.PreferenceManager -import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo3.ApolloClient +import com.apollographql.apollo3.network.okHttpClient import com.haroldadmin.cnradapter.NetworkResponseAdapterFactory import com.sharkaboi.mediahub.BuildConfig import com.sharkaboi.mediahub.data.api.retrofit.* @@ -11,7 +12,6 @@ import com.sharkaboi.mediahub.data.datastore.DataStoreRepository import com.sharkaboi.mediahub.data.datastore.DataStoreRepositoryImpl import com.sharkaboi.mediahub.data.datastore.dataStore import com.squareup.moshi.Moshi -import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -25,7 +25,7 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module -object DataModule { +object AppModule { @Provides @Singleton @@ -44,16 +44,16 @@ object DataModule { @Provides @Singleton - fun getRetrofitBuilder(okHttpClient: OkHttpClient): Retrofit = + fun getMoshi(): Moshi = Moshi.Builder().build() + + @Provides + @Singleton + fun getRetrofitBuilder(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit = Retrofit.Builder() .baseUrl("https://api.myanimelist.net/v2/") .addCallAdapterFactory(NetworkResponseAdapterFactory()) .addConverterFactory( - MoshiConverterFactory.create( - Moshi.Builder() - .addLast(KotlinJsonAdapterFactory()) - .build() - ) + MoshiConverterFactory.create(moshi) ) .client(okHttpClient) .build() @@ -61,7 +61,7 @@ object DataModule { @Provides @Singleton fun getApolloClient(okHttpClient: OkHttpClient): ApolloClient = - ApolloClient.builder() + ApolloClient.Builder() .serverUrl("https://graphql.anilist.co") .okHttpClient(okHttpClient) .build() diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/vm/AnimeViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime/vm/AnimeViewModel.kt deleted file mode 100644 index 7d3d4a5..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/vm/AnimeViewModel.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.sharkaboi.mediahub.modules.anime.vm - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.paging.PagingData -import androidx.paging.cachedIn -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 -import com.sharkaboi.mediahub.modules.anime.repository.AnimeRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -@HiltViewModel -class AnimeViewModel -@Inject constructor( - private val animeRepository: AnimeRepository -) : ViewModel() { - private var _currentChosenAnimeStatus: AnimeStatus = AnimeStatus.all - val currentChosenAnimeStatus: AnimeStatus get() = _currentChosenAnimeStatus - private var _currentChosenSortType: UserAnimeSortType = UserAnimeSortType.list_updated_at - val currentChosenSortType: UserAnimeSortType get() = _currentChosenSortType - private var _pagedAnimeList: Flow>? = null - - suspend fun getAnimeList(): Flow> { - val newResult: Flow> = - animeRepository.getAnimeListFlow( - animeStatus = currentChosenAnimeStatus, - animeSortType = currentChosenSortType - ).cachedIn(viewModelScope) - _pagedAnimeList = newResult - return newResult - } - - fun setSortType(userAnimeSortType: UserAnimeSortType) { - _currentChosenSortType = userAnimeSortType - } - - fun setAnimeStatus(animeStatus: AnimeStatus) { - _currentChosenAnimeStatus = animeStatus - } -} 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 a86db40..2276df5 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 @@ -10,7 +10,7 @@ import coil.load 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 +import com.sharkaboi.mediahub.databinding.AnimeListItemHorizontalBinding class RecommendedAnimeAdapter(private val onClick: (Int) -> Unit) : RecyclerView.Adapter() { @@ -34,10 +34,12 @@ class RecommendedAnimeAdapter(private val onClick: (Int) -> Unit) : private val listDiffer = AsyncListDiffer(this, diffUtilItemCallback) - private lateinit var binding: AnimeListItemBinding - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecommendedAnimeViewHolder { - binding = AnimeListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val binding = AnimeListItemHorizontalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return RecommendedAnimeViewHolder(binding, onClick) } @@ -52,10 +54,14 @@ class RecommendedAnimeAdapter(private val onClick: (Int) -> Unit) : } class RecommendedAnimeViewHolder( - private val binding: AnimeListItemBinding, + private val binding: AnimeListItemHorizontalBinding, private val onClick: (Int) -> Unit ) : RecyclerView.ViewHolder(binding.root) { + init { + binding.cardRating.isGone = true + } + fun bind(item: AnimeByIDResponse.Recommendation) { binding.root.setOnClickListener { onClick(item.node.id) @@ -67,10 +73,9 @@ class RecommendedAnimeAdapter(private val onClick: (Int) -> Unit) : item.numRecommendations, item.numRecommendations ) - binding.cardRating.isGone = true binding.ivAnimeBanner.load( - uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, - builder = UIConstants.AnimeImageBuilder + item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.TopRoundedAnimeImageBuilder ) } } 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 420e36a..92564d7 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 @@ -9,7 +9,7 @@ import androidx.recyclerview.widget.RecyclerView import coil.load import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse -import com.sharkaboi.mediahub.databinding.AnimeListItemBinding +import com.sharkaboi.mediahub.databinding.AnimeListItemHorizontalBinding class RelatedAnimeAdapter(private val onClick: (Int) -> Unit) : RecyclerView.Adapter() { @@ -33,10 +33,12 @@ class RelatedAnimeAdapter(private val onClick: (Int) -> Unit) : private val listDiffer = AsyncListDiffer(this, diffUtilItemCallback) - private lateinit var binding: AnimeListItemBinding - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RelatedAnimeViewHolder { - binding = AnimeListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val binding = AnimeListItemHorizontalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return RelatedAnimeViewHolder(binding, onClick) } @@ -51,20 +53,23 @@ class RelatedAnimeAdapter(private val onClick: (Int) -> Unit) : } class RelatedAnimeViewHolder( - private val binding: AnimeListItemBinding, + private val binding: AnimeListItemHorizontalBinding, private val onClick: (Int) -> Unit ) : RecyclerView.ViewHolder(binding.root) { + init { + binding.cardRating.isGone = true + } + fun bind(item: AnimeByIDResponse.RelatedAnime) { binding.root.setOnClickListener { onClick(item.node.id) } binding.tvAnimeName.text = item.node.title binding.tvEpisodesWatched.text = item.relationTypeFormatted - binding.cardRating.isGone = true binding.ivAnimeBanner.load( - uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, - builder = UIConstants.AnimeImageBuilder + item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.TopRoundedAnimeImageBuilder ) } } 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 09acc8d..b7e9578 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 @@ -9,7 +9,7 @@ import androidx.recyclerview.widget.RecyclerView import coil.load import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse -import com.sharkaboi.mediahub.databinding.MangaListItemBinding +import com.sharkaboi.mediahub.databinding.MangaListItemHorizontalBinding class RelatedMangaAdapter(private val onClick: (Int) -> Unit) : RecyclerView.Adapter() { @@ -33,10 +33,12 @@ class RelatedMangaAdapter(private val onClick: (Int) -> Unit) : private val listDiffer = AsyncListDiffer(this, diffUtilItemCallback) - private lateinit var binding: MangaListItemBinding - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RelatedMangaViewHolder { - binding = MangaListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val binding = MangaListItemHorizontalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return RelatedMangaViewHolder(binding, onClick) } @@ -51,20 +53,23 @@ class RelatedMangaAdapter(private val onClick: (Int) -> Unit) : } class RelatedMangaViewHolder( - private val binding: MangaListItemBinding, + private val binding: MangaListItemHorizontalBinding, private val onClick: (Int) -> Unit ) : RecyclerView.ViewHolder(binding.root) { + init { + binding.cardRating.isGone = true + } + fun bind(item: AnimeByIDResponse.RelatedManga) { binding.root.setOnClickListener { onClick(item.node.id) } binding.tvMangaName.text = item.node.title binding.tvChapsRead.text = item.relationTypeFormatted - binding.cardRating.isGone = true binding.ivMangaBanner.load( - uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, - builder = UIConstants.MangaImageBuilder + item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.TopRoundedMangaImageBuilder ) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/AnimeDetailsModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/di/AnimeDetailsModule.kt similarity index 91% rename from app/src/main/java/com/sharkaboi/mediahub/di/AnimeDetailsModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/di/AnimeDetailsModule.kt index f708211..79d1a7b 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/AnimeDetailsModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/di/AnimeDetailsModule.kt @@ -1,6 +1,6 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.anime_details.di -import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo3.ApolloClient import com.sharkaboi.mediahub.data.api.retrofit.AnimeService import com.sharkaboi.mediahub.data.api.retrofit.UserAnimeService import com.sharkaboi.mediahub.data.datastore.DataStoreRepository diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/repository/AnimeDetailsRepository.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/repository/AnimeDetailsRepository.kt index f8493b0..2593bfc 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/repository/AnimeDetailsRepository.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/repository/AnimeDetailsRepository.kt @@ -1,20 +1,18 @@ package com.sharkaboi.mediahub.modules.anime_details.repository -import GetNextAiringAnimeEpisodeQuery +import com.sharkaboi.mediahub.GetNextAiringAnimeEpisodeQuery import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse import com.sharkaboi.mediahub.data.wrappers.MHTaskState +import com.sharkaboi.mediahub.modules.anime_details.util.AnimeDetailsUpdateClass interface AnimeDetailsRepository { suspend fun getAnimeById(animeId: Int): MHTaskState - suspend fun getNextAiringEpisodeById(animeId: Int): MHTaskState + suspend fun getNextAiringEpisodeById(animeId: Int): MHTaskState suspend fun updateAnimeStatus( - animeId: Int, - animeStatus: String?, - score: Int?, - numWatchedEps: Int? + animeDetailsUpdateClass: AnimeDetailsUpdateClass ): MHTaskState suspend fun removeAnimeFromList(animeId: Int): MHTaskState 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 3a8ea70..59e3c6e 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 @@ -1,11 +1,10 @@ package com.sharkaboi.mediahub.modules.anime_details.repository -import GetNextAiringAnimeEpisodeQuery -import com.apollographql.apollo.ApolloClient -import com.apollographql.apollo.coroutines.await -import com.apollographql.apollo.exception.ApolloException +import com.apollographql.apollo3.ApolloClient +import com.apollographql.apollo3.exception.ApolloException import com.haroldadmin.cnradapter.NetworkResponse -import com.sharkaboi.mediahub.common.extensions.emptyString +import com.sharkaboi.mediahub.GetNextAiringAnimeEpisodeQuery +import com.sharkaboi.mediahub.common.extensions.getCatching 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 @@ -13,10 +12,9 @@ import com.sharkaboi.mediahub.data.api.retrofit.UserAnimeService import com.sharkaboi.mediahub.data.datastore.DataStoreRepository import com.sharkaboi.mediahub.data.wrappers.MHError import com.sharkaboi.mediahub.data.wrappers.MHTaskState -import kotlinx.coroutines.* +import com.sharkaboi.mediahub.modules.anime_details.util.AnimeDetailsUpdateClass import kotlinx.coroutines.flow.firstOrNull import timber.log.Timber -import java.util.* class AnimeDetailsRepositoryImpl( private val animeService: AnimeService, @@ -25,241 +23,195 @@ class AnimeDetailsRepositoryImpl( private val dataStoreRepository: DataStoreRepository ) : AnimeDetailsRepository { - override suspend fun getAnimeById(animeId: Int): MHTaskState = - withContext(Dispatchers.IO) { - try { - val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - if (accessToken == null) { - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = MHError.LoginExpiredError - ) - } else { - val result = animeService.getAnimeByIdAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - animeId = animeId - ).await() - when (result) { - is NetworkResponse.Success -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = true, - data = result.body, - error = MHError.EmptyError - ) - } - is NetworkResponse.NetworkError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.NetworkError - ) - } - is NetworkResponse.ServerError -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.body?.message?.let { MHError(it) } - ?: MHError.apiErrorWithCode(result.code) - ) - } - is NetworkResponse.UnknownError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.ParsingError - ) - } - } - } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return@withContext MHTaskState( + override suspend fun getAnimeById( + animeId: Int + ): MHTaskState = getCatching { + val accessToken: String = dataStoreRepository.accessTokenFlow.firstOrNull() + ?: return@getCatching MHTaskState( + isSuccess = false, + data = null, + error = MHError.LoginExpiredError + ) + + val result = animeService.getAnimeByIdAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + animeId = animeId + ).await() + Timber.d(result.toString()) + + return@getCatching when (result) { + is NetworkResponse.Success -> { + MHTaskState( + isSuccess = true, + data = result.body, + error = MHError.EmptyError + ) + } + is NetworkResponse.NetworkError -> { + MHTaskState( isSuccess = false, data = null, - error = e.message?.let { MHError(it) } ?: MHError.UnknownError + error = MHError.getError(result.error.message, MHError.NetworkError) ) } - } - - override suspend fun getNextAiringEpisodeById(animeId: Int): MHTaskState = - withContext(Dispatchers.IO) { - val response = try { - apolloClient.query(GetNextAiringAnimeEpisodeQuery(idMal = animeId)).await() - } catch (e: ApolloException) { - return@withContext MHTaskState( + is NetworkResponse.ServerError -> { + MHTaskState( isSuccess = false, data = null, - error = e.message?.let { MHError(it) } ?: MHError.ProtocolError + error = MHError.getError( + result.body?.message, + MHError.apiErrorWithCode(result.code) + ) ) } - - val mediaDetails = response.data?.media - if (mediaDetails == null || response.hasErrors()) { - val errorMessage = response.errors?.first()?.message - return@withContext MHTaskState( + is NetworkResponse.UnknownError -> { + MHTaskState( isSuccess = false, data = null, - error = errorMessage?.let { MHError(it) } ?: MHError.ApplicationError - ) - } else { - return@withContext MHTaskState( - isSuccess = true, - data = mediaDetails, - error = MHError.EmptyError + error = MHError.getError(result.error.message, MHError.ParsingError) ) } } + } + + override suspend fun getNextAiringEpisodeById( + animeId: Int + ): MHTaskState = getCatching { + val response = try { + apolloClient.query(GetNextAiringAnimeEpisodeQuery(idMal = animeId)).execute() + } catch (e: ApolloException) { + return@getCatching MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError(e.message, MHError.ProtocolError) + ) + } + + val mediaDetails = response.data?.returnedMedia + if (mediaDetails == null || response.hasErrors()) { + val errorMessage = response.errors?.first()?.message + return@getCatching MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError(errorMessage, MHError.ApplicationError) + ) + } + + return@getCatching MHTaskState( + isSuccess = true, + data = mediaDetails, + error = MHError.EmptyError + ) + } override suspend fun updateAnimeStatus( - animeId: Int, - animeStatus: String?, - score: Int?, - numWatchedEps: Int? - ): MHTaskState = - withContext(Dispatchers.IO) { - try { - val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - if (accessToken == null) { - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = MHError.LoginExpiredError + animeDetailsUpdateClass: AnimeDetailsUpdateClass + ): MHTaskState = getCatching { + val accessToken: String = dataStoreRepository.accessTokenFlow.firstOrNull() + ?: return@getCatching MHTaskState( + isSuccess = false, + data = null, + error = MHError.LoginExpiredError + ) + + val result = userAnimeService.updateAnimeStatusAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + animeId = animeDetailsUpdateClass.animeId, + animeStatus = animeDetailsUpdateClass.animeStatus?.name, + score = animeDetailsUpdateClass.score, + numWatchedEps = animeDetailsUpdateClass.numWatchedEpisode + ).await() + Timber.d(result.toString()) + + return@getCatching when (result) { + is NetworkResponse.Success -> { + MHTaskState( + isSuccess = true, + data = Unit, + error = MHError.EmptyError + ) + } + is NetworkResponse.NetworkError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError(result.error.message, MHError.NetworkError) + ) + } + is NetworkResponse.ServerError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError( + result.body?.message, + MHError.apiErrorWithCode(result.code) ) - } else { - val result = userAnimeService.updateAnimeStatusAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - animeId = animeId, - animeStatus = animeStatus, - score = score, - numWatchedEps = numWatchedEps - ).await() - when (result) { - is NetworkResponse.Success -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = true, - data = Unit, - error = MHError.EmptyError - ) - } - is NetworkResponse.NetworkError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.NetworkError - ) - } - is NetworkResponse.ServerError -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.body?.message?.let { MHError(it) } - ?: MHError.apiErrorWithCode(result.code) - ) - } - is NetworkResponse.UnknownError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.ParsingError - ) - } - } - } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return@withContext MHTaskState( + ) + } + is NetworkResponse.UnknownError -> { + return@getCatching MHTaskState( isSuccess = false, data = null, - error = e.message?.let { MHError(it) } ?: MHError.UnknownError + error = MHError.getError(result.error.message, MHError.ParsingError) ) } } + } override suspend fun removeAnimeFromList( animeId: Int ): MHTaskState = - withContext(Dispatchers.IO) { - try { - val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - if (accessToken == null) { - return@withContext MHTaskState( + getCatching { + val accessToken: String = dataStoreRepository.accessTokenFlow.firstOrNull() + ?: return@getCatching MHTaskState( + isSuccess = false, + data = null, + error = MHError.LoginExpiredError + ) + + val result = userAnimeService.deleteAnimeFromListAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + animeId = animeId + ).await() + Timber.d(result.toString()) + + return@getCatching when (result) { + is NetworkResponse.Success -> { + MHTaskState( + isSuccess = true, + data = Unit, + error = MHError.EmptyError + ) + } + is NetworkResponse.NetworkError -> { + MHTaskState( isSuccess = false, data = null, - error = MHError.LoginExpiredError + error = MHError.getError(result.error.message, MHError.NetworkError) ) - } else { - val result = userAnimeService.deleteAnimeFromListAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - animeId = animeId - ).await() - when (result) { - is NetworkResponse.Success -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = true, - data = Unit, - error = MHError.EmptyError - ) - } - is NetworkResponse.NetworkError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.NetworkError - ) - } - is NetworkResponse.ServerError -> { - Timber.d(result.body.toString()) - if (result.code == 404) { - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = MHError.AnimeNotFoundError - ) - } - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.body?.message?.let { MHError(it) } - ?: MHError.apiErrorWithCode(result.code) - ) - } - is NetworkResponse.UnknownError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.ParsingError - ) - } + } + is NetworkResponse.ServerError -> { + val error = if (result.code == 404) { + MHError.AnimeNotFoundError + } else { + MHError.getError( + result.body?.message, + MHError.apiErrorWithCode(result.code) + ) } + MHTaskState( + isSuccess = false, + data = null, + error = error + ) + } + is NetworkResponse.UnknownError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError(result.error.message, MHError.ParsingError) + ) } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - 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 ed294cb..d7f5e88 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 @@ -1,6 +1,5 @@ package com.sharkaboi.mediahub.modules.anime_details.ui -import GetNextAiringAnimeEpisodeQuery import android.annotation.SuppressLint import android.graphics.Typeface import android.os.Bundle @@ -15,17 +14,18 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import coil.load import com.google.android.material.chip.Chip import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.sharkaboi.mediahub.BottomNavGraphDirections +import com.sharkaboi.mediahub.GetNextAiringAnimeEpisodeQuery import com.sharkaboi.mediahub.R 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.openShareChooser import com.sharkaboi.mediahub.common.util.openUrl import com.sharkaboi.mediahub.data.api.constants.MALExternalLinks import com.sharkaboi.mediahub.data.api.enums.AnimeRating.getAnimeRating @@ -38,20 +38,17 @@ import com.sharkaboi.mediahub.databinding.FragmentAnimeDetailsBinding import com.sharkaboi.mediahub.modules.anime_details.adapters.RecommendedAnimeAdapter import com.sharkaboi.mediahub.modules.anime_details.adapters.RelatedAnimeAdapter import com.sharkaboi.mediahub.modules.anime_details.adapters.RelatedMangaAdapter -import com.sharkaboi.mediahub.modules.anime_details.util.AnimeDetailsUpdateClass +import com.sharkaboi.mediahub.modules.anime_details.util.* import com.sharkaboi.mediahub.modules.anime_details.vm.AnimeDetailsState import com.sharkaboi.mediahub.modules.anime_details.vm.AnimeDetailsViewModel import com.sharkaboi.mediahub.modules.anime_details.vm.NextEpisodeDetailsState import dagger.hilt.android.AndroidEntryPoint -import kotlin.time.ExperimentalTime @AndroidEntryPoint -@ExperimentalTime class AnimeDetailsFragment : Fragment() { private var _binding: FragmentAnimeDetailsBinding? = null private val binding get() = _binding!! private val navController by lazy { findNavController() } - private val args: AnimeDetailsFragmentArgs by navArgs() private val animeDetailsViewModel by viewModels() override fun onCreateView( @@ -80,8 +77,7 @@ class AnimeDetailsFragment : Fragment() { } private val handleSwipeRefresh = { - animeDetailsViewModel.getAnimeDetails(args.animeId) - animeDetailsViewModel.getNextEpisodeDetails(args.animeId) + animeDetailsViewModel.refreshDetails() binding.swipeRefresh.isRefreshing = false } @@ -94,7 +90,6 @@ class AnimeDetailsFragment : Fragment() { private val handleAnimeDetailsStateUpdate = { state: AnimeDetailsState -> binding.progressBar.isShowing = state is AnimeDetailsState.Loading when (state) { - is AnimeDetailsState.Idle -> animeDetailsViewModel.getAnimeDetails(args.animeId) is AnimeDetailsState.FetchSuccess -> setData(state.animeByIDResponse) is AnimeDetailsState.AnimeDetailsFailure -> showToast(state.message) else -> Unit @@ -107,14 +102,13 @@ class AnimeDetailsFragment : Fragment() { binding.nextEpisodeDetails.root.isGone = state is NextEpisodeDetailsState.NextEpisodeDetailsFailure when (state) { - is NextEpisodeDetailsState.Idle -> animeDetailsViewModel.getNextEpisodeDetails(args.animeId) is NextEpisodeDetailsState.FetchSuccess -> setNextEpisodeData(state.nextAiringEpisode) else -> Unit } } private val handleListStatusUpdate = { state: AnimeDetailsUpdateClass -> - binding.animeDetailsUserListCard.apply { + binding.animeDetailsUserListCard.run { btnStatus.text = state.animeStatus?.getFormattedString(requireContext()) ?: getString(R.string.not_added) @@ -124,7 +118,7 @@ class AnimeDetailsFragment : Fragment() { openScoreDialog(state.score) } btnStatus.setOnClickListener { - openStatusDialog(state.animeStatus?.name, state.animeId) + openStatusDialog() } btnCount.setOnClickListener { openAnimeWatchedCountDialog( @@ -133,14 +127,12 @@ class AnimeDetailsFragment : Fragment() { ) } } - Unit } - private fun setNextEpisodeData(nextAiringEpisodeDetails: GetNextAiringAnimeEpisodeQuery.Media) { + private fun setNextEpisodeData(nextAiringEpisodeDetails: GetNextAiringAnimeEpisodeQuery.ReturnedMedia) { val nextEp = nextAiringEpisodeDetails.nextAiringEpisode - if (nextEp == null) { - binding.nextEpisodeDetails.root.isGone = true - } else { + binding.nextEpisodeDetails.root.isGone = nextEp == null + if (nextEp != null) { binding.nextEpisodeDetails.tvNextEpisodeDetails.text = context?.getAiringTimeFormatted(nextEp) } @@ -158,15 +150,15 @@ class AnimeDetailsFragment : Fragment() { } private fun setupAnimeUserListStatusCard(animeByIDResponse: AnimeByIDResponse) = - binding.animeDetailsUserListCard.apply { + binding.animeDetailsUserListCard.run { btnPlus1.setOnClickListener { - animeDetailsViewModel.add1ToWatchedEps() + animeDetailsViewModel.addToWatchedEps(1) } btnPlus5.setOnClickListener { - animeDetailsViewModel.add5ToWatchedEps() + animeDetailsViewModel.addToWatchedEps(5) } btnPlus10.setOnClickListener { - animeDetailsViewModel.add10ToWatchedEps() + animeDetailsViewModel.addToWatchedEps(10) } btnCount.setOnClickListener { openAnimeWatchedCountDialog( @@ -178,10 +170,10 @@ class AnimeDetailsFragment : Fragment() { openScoreDialog(animeByIDResponse.myListStatus?.score) } btnStatus.setOnClickListener { - openStatusDialog(animeByIDResponse.myListStatus?.status, animeByIDResponse.id) + openStatusDialog() } btnConfirm.setOnClickListener { - animeDetailsViewModel.submitStatusUpdate(animeByIDResponse.id) + animeDetailsViewModel.submitStatusUpdate() } } @@ -238,7 +230,7 @@ class AnimeDetailsFragment : Fragment() { } private fun setupAnimeOtherDetailsButtons(animeByIDResponse: AnimeByIDResponse) = - binding.apply { + binding.run { otherDetails.btnBackground.setOnClickListener { openBackgroundDialog(animeByIDResponse.background) } @@ -345,24 +337,25 @@ class AnimeDetailsFragment : Fragment() { setupGenresChipGroup(animeByIDResponse.genres) } - private fun setupGenresChipGroup(genres: List) { + private fun setupGenresChipGroup(genres: List?) { val chipGroup = binding.otherDetails.genresChipGroup chipGroup.removeAllViews() - if (genres.isEmpty()) { + if (genres.isNullOrEmpty()) { 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)) - } - chipGroup.addView(genreChip) + return + } + + genres.forEach { genre -> + val genreChip = Chip(context) + genreChip.setMediaHubChipStyle() + genreChip.text = genre.name + genreChip.setOnClickListener { + openUrl(MALExternalLinks.getAnimeGenresLink(genre)) } + chipGroup.addView(genreChip) } } @@ -382,6 +375,9 @@ class AnimeDetailsFragment : Fragment() { tvRank.text = animeByIDResponse.rank?.toString() ?: getString(R.string.n_a) tvPopularityRank.text = animeByIDResponse.popularity.toString() setupStudiosChipGroup(animeByIDResponse.studios) + ibShare.setOnClickListener { + openShareChooser(MALExternalLinks.getAnimeLink(animeByIDResponse)) + } } private fun setupRatingsChipGroup(animeByIDResponse: AnimeByIDResponse) { @@ -399,8 +395,8 @@ class AnimeDetailsFragment : Fragment() { private fun setupAnimeImagePreview(animeByIDResponse: AnimeByIDResponse) { binding.ivAnimeMainPicture.load( - uri = animeByIDResponse.mainPicture?.large ?: animeByIDResponse.mainPicture?.medium, - builder = UIConstants.AnimeImageBuilder + animeByIDResponse.mainPicture?.large ?: animeByIDResponse.mainPicture?.medium, + builder = UIConstants.AllRoundedAnimeImageBuilder ) binding.ivAnimeMainPicture.setOnClickListener { openImagesViewPager(animeByIDResponse.pictures) @@ -413,19 +409,20 @@ class AnimeDetailsFragment : Fragment() { 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) + return + } + + 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) } } @@ -442,10 +439,12 @@ class AnimeDetailsFragment : Fragment() { private fun showFullSynopsisDialog(synopsis: String?) = requireContext().showNoActionOkDialog(R.string.synopsis, synopsis) - private fun openStatusDialog(status: String?, animeId: Int) { + private fun openStatusDialog() { val singleItems = arrayOf(getString(R.string.not_added)) + AnimeStatus.malStatuses.map { it.getFormattedString(requireContext()) } + val status = animeDetailsViewModel.animeDetailsUpdate.value?.animeStatus?.name + // Add 1 of offset "Not Added" status, Default to 0 to default to "Not Added" val checkedItem = status?.let { AnimeStatus.malStatuses.indexOfFirst { it.name == status } + 1 } ?: 0 MaterialAlertDialogBuilder(requireContext()) @@ -453,12 +452,11 @@ class AnimeDetailsFragment : Fragment() { .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> when (which) { checkedItem -> Unit - 0 -> animeDetailsViewModel.removeFromList(animeId) + 0 -> animeDetailsViewModel.removeFromList() else -> animeDetailsViewModel.setStatus(AnimeStatus.malStatuses[which - 1]) } dialog.dismiss() - } - .show() + }.show() } private fun openScoreDialog(score: Int?) { diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/util/AnimeDetailsUpdateClass.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/util/AnimeDetailsUpdateClass.kt index 200da6f..450c9d2 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/util/AnimeDetailsUpdateClass.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/util/AnimeDetailsUpdateClass.kt @@ -1,5 +1,6 @@ package com.sharkaboi.mediahub.modules.anime_details.util +import androidx.lifecycle.MutableLiveData import com.sharkaboi.mediahub.data.api.enums.AnimeStatus data class AnimeDetailsUpdateClass( @@ -9,3 +10,41 @@ data class AnimeDetailsUpdateClass( val totalEps: Int, val animeId: Int ) + +fun MutableLiveData.setStatus(animeStatus: AnimeStatus?) { + value = value?.copy(animeStatus = animeStatus) +} + +// WARN : Doesnt check with total ep count +fun MutableLiveData.setWatchedEps(numWatchedEpisode: Int?) { + value = value?.copy(numWatchedEpisode = numWatchedEpisode) +} + +// WARN : Doesnt check if > 10 +fun MutableLiveData.setScore(score: Int) { + value = value?.copy(score = score) +} + +fun MutableLiveData.setWatchedAsTotal() { + // If total eps not available (??), keep watching count as same + if (value?.totalEps == 0) { + return + } + + setWatchedEps(value?.totalEps) +} + +fun MutableLiveData.isNotOfStatus(vararg animeStatuses: AnimeStatus): Boolean { + return value?.animeStatus == null || value?.animeStatus !in animeStatuses +} + +fun MutableLiveData.isMoreOrEqualToTotal(count: Int): Boolean { + val isTotalAvailable = value?.totalEps != 0 + val totalEps = value?.totalEps ?: 0 + return isTotalAvailable && count >= totalEps +} + +fun MutableLiveData.getAddedWatchedEps(count: Int): Int { + // set watched eps as count when null as well + return value?.numWatchedEpisode?.plus(count) ?: count +} \ No newline at end of file diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/util/PresentationExtensions.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/util/PresentationExtensions.kt new file mode 100644 index 0000000..8f31c52 --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/util/PresentationExtensions.kt @@ -0,0 +1,138 @@ +package com.sharkaboi.mediahub.modules.anime_details.util + +import android.content.Context +import android.text.Spanned +import androidx.core.text.HtmlCompat +import androidx.core.text.toSpanned +import com.sharkaboi.mediahub.GetNextAiringAnimeEpisodeQuery +import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.extensions.capitalizeFirst +import com.sharkaboi.mediahub.common.extensions.ifNullOrBlank +import com.sharkaboi.mediahub.common.extensions.replaceUnderScoreWithWhiteSpace +import com.sharkaboi.mediahub.common.util.DateUtils +import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse +import java.time.format.DateTimeFormatter +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds + +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 Context.getAnimeSeasonString(season: String?, year: Int?): String { + if (season == null || year == null) { + return getString(R.string.anime_season_unknown_template, getString(R.string.n_a)) + } + return getString(R.string.anime_season_template, season.capitalizeFirst(), year) +} + +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.getAnimeBroadcastTime(broadcast: AnimeByIDResponse.Broadcast?): String { + runCatching { + if (broadcast == null) { + return getString(R.string.n_a) + } + + if (broadcast.startTime == null) { + return getString(R.string.anime_broadcast_on_day, broadcast.dayOfTheWeek) + } + + val localTime = + DateUtils.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 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 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.getEpisodeLengthFromSeconds(seconds: Int?): String { + runCatching { + if (seconds == null || seconds <= 0) { + return getString(R.string.anime_episode_length_n_a) + } + val duration = seconds.seconds + val hours = duration.inWholeHours.toInt() + val minutes = duration.minus(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) + } +} + +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 = nextEp.timeUntilAiring.seconds + val days = currentDuration.inWholeDays.toInt() + currentDuration = currentDuration.minus(days.days) + val hours = currentDuration.inWholeHours.toInt() + currentDuration = currentDuration.minus(hours.hours) + val minutes = currentDuration.inWholeMinutes.toInt() + if (days <= 0 && hours <= 0) { + getString(R.string.anime_next_episode_airing_minutes, minutes) + } else if (days <= 0) { + getString(R.string.anime_next_episode_airing_hours, hours, minutes) + } else { + getString(R.string.anime_next_episode_airing_days, days, hours, minutes) + } + }.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) + } +} 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 fdfbd5c..dee8924 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 @@ -1,14 +1,10 @@ package com.sharkaboi.mediahub.modules.anime_details.vm -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* 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 com.sharkaboi.mediahub.modules.anime_details.util.* import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import timber.log.Timber @@ -17,191 +13,126 @@ import javax.inject.Inject @HiltViewModel class AnimeDetailsViewModel @Inject constructor( - private val animeDetailsRepository: AnimeDetailsRepository + private val animeDetailsRepository: AnimeDetailsRepository, + savedStateHandle: SavedStateHandle ) : ViewModel() { + private val animeId = savedStateHandle.get(ANIME_ID_KEY) ?: 0 private val _animeDetailState = MutableLiveData().getDefault() val animeDetailState: LiveData = _animeDetailState + private var _animeDetailsUpdate: MutableLiveData = MutableLiveData() val animeDetailsUpdate: LiveData = _animeDetailsUpdate + private var _nextEpisodeDetails: MutableLiveData = MutableLiveData().getDefault() val nextEpisodeDetails: LiveData = _nextEpisodeDetails - fun getAnimeDetails(animeId: Int) { - viewModelScope.launch { - _animeDetailState.setLoading() - val result = animeDetailsRepository.getAnimeById(animeId) - if (result.isSuccess) { - result.data?.let { - Timber.d("getAnimeDetails: ${result.data}") - _animeDetailsUpdate.value = AnimeDetailsUpdateClass( - animeStatus = it.myListStatus?.status?.animeStatusFromString(), - animeId = it.id, - score = it.myListStatus?.score, - numWatchedEpisode = it.myListStatus?.numEpisodesWatched, - totalEps = it.numEpisodes - ) - _animeDetailState.setFetchSuccess(result.data) - } - } else { - _animeDetailState.setFailure(result.error.errorMessage) - } + init { + Timber.d("Saved state anime id $animeId") + getAnimeDetails(animeId) + getNextEpisodeDetails(animeId) + } + + private fun getAnimeDetails(animeId: Int) = viewModelScope.launch { + _animeDetailState.setLoading() + val result = animeDetailsRepository.getAnimeById(animeId) + if (!result.isSuccess) { + _animeDetailState.setFailure(result.error.errorMessage) + return@launch } + + Timber.d("getAnimeDetails: ${result.data}") + _animeDetailsUpdate.value = AnimeDetailsUpdateClass( + animeStatus = AnimeStatus.parse(result.data.myListStatus?.status), + animeId = result.data.id, + score = result.data.myListStatus?.score, + numWatchedEpisode = result.data.myListStatus?.numEpisodesWatched, + totalEps = result.data.numEpisodes + ) + _animeDetailState.setFetchSuccess(result.data) } - fun getNextEpisodeDetails(animeId: Int) { - viewModelScope.launch { - _nextEpisodeDetails.setLoading() - val result = animeDetailsRepository.getNextAiringEpisodeById(animeId) - if (result.isSuccess) { - result.data?.let { - Timber.d("nextEpisodeDetails: ${result.data}") - _nextEpisodeDetails.setFetchSuccess(result.data) - } - } else { - _nextEpisodeDetails.setFailure(result.error.errorMessage) - } + private fun getNextEpisodeDetails(animeId: Int) = viewModelScope.launch { + _nextEpisodeDetails.setLoading() + val result = animeDetailsRepository.getNextAiringEpisodeById(animeId) + if (!result.isSuccess) { + _nextEpisodeDetails.setFailure(result.error.errorMessage) + return@launch } + + Timber.d("nextEpisodeDetails: ${result.data}") + _nextEpisodeDetails.setFetchSuccess(result.data) } fun setStatus(animeStatus: AnimeStatus) { - _animeDetailsUpdate.apply { - value = value?.copy(animeStatus = animeStatus) - if (animeStatus == AnimeStatus.completed) { - value = value?.copy(numWatchedEpisode = value?.totalEps) - } + _animeDetailsUpdate.setStatus(animeStatus) + + if (animeStatus == AnimeStatus.completed) { + _animeDetailsUpdate.setWatchedAsTotal() } } fun setEpisodeCount(numWatchedEps: Int) { - _animeDetailsUpdate.apply { - if (this.value?.animeStatus == null || - ( - this.value?.animeStatus != AnimeStatus.watching && - this.value?.animeStatus != AnimeStatus.completed - ) - ) { - value = this.value?.copy(animeStatus = AnimeStatus.watching) - } - value = if (value?.totalEps != 0 && numWatchedEps >= value?.totalEps ?: 0) { - value?.copy( - numWatchedEpisode = value?.totalEps, - animeStatus = AnimeStatus.completed - ) - } else { - value?.copy(numWatchedEpisode = numWatchedEps) - } + if (_animeDetailsUpdate.isNotOfStatus(AnimeStatus.watching, AnimeStatus.completed)) { + _animeDetailsUpdate.setStatus(AnimeStatus.watching) } - } - fun add1ToWatchedEps() { - Timber.d(_animeDetailsUpdate.value.toString()) - _animeDetailsUpdate.apply { - if (this.value?.animeStatus == null || - ( - this.value?.animeStatus != AnimeStatus.watching && - this.value?.animeStatus != AnimeStatus.completed - ) - ) { - value = this.value?.copy(animeStatus = AnimeStatus.watching) - } - val res = value?.numWatchedEpisode?.plus(1) ?: 0 - value = if (value?.totalEps != 0 && res >= value?.totalEps ?: 0) { - value?.copy( - numWatchedEpisode = value?.totalEps, - animeStatus = AnimeStatus.completed - ) - } else { - value?.copy(numWatchedEpisode = res) - } + if (_animeDetailsUpdate.isMoreOrEqualToTotal(numWatchedEps)) { + _animeDetailsUpdate.setWatchedAsTotal() + _animeDetailsUpdate.setStatus(AnimeStatus.completed) + return } - } - fun add5ToWatchedEps() { - Timber.d(_animeDetailsUpdate.value.toString()) - _animeDetailsUpdate.apply { - if (this.value?.animeStatus == null || - ( - this.value?.animeStatus != AnimeStatus.watching && - this.value?.animeStatus != AnimeStatus.completed - ) - ) { - value = this.value?.copy(animeStatus = AnimeStatus.watching) - } - val res = value?.numWatchedEpisode?.plus(5) ?: 0 - value = if (value?.totalEps != 0 && res >= value?.totalEps ?: 0) { - value?.copy( - numWatchedEpisode = value?.totalEps, - animeStatus = AnimeStatus.completed - ) - } else { - value?.copy(numWatchedEpisode = res) - } - } + _animeDetailsUpdate.setWatchedEps(numWatchedEps) } - fun add10ToWatchedEps() { - Timber.d(_animeDetailsUpdate.value.toString()) - _animeDetailsUpdate.apply { - if (this.value?.animeStatus == null || - ( - this.value?.animeStatus != AnimeStatus.watching && - this.value?.animeStatus != AnimeStatus.completed - ) - ) { - value = this.value?.copy(animeStatus = AnimeStatus.watching) - } - val res = value?.numWatchedEpisode?.plus(10) ?: 0 - value = if (value?.totalEps != 0 && res >= value?.totalEps ?: 0) { - value?.copy( - numWatchedEpisode = value?.totalEps, - animeStatus = AnimeStatus.completed - ) - } else { - value?.copy(numWatchedEpisode = res) - } - } + fun addToWatchedEps(count: Int) { + val addedCount = _animeDetailsUpdate.getAddedWatchedEps(count) + setEpisodeCount(addedCount) } fun setScore(score: Int) { - _animeDetailsUpdate.apply { - value = value?.copy(score = score) - } + _animeDetailsUpdate.setScore(score) } - fun submitStatusUpdate(animeId: Int) { - viewModelScope.launch { - _animeDetailState.setLoading() - animeDetailsUpdate.value?.let { - Timber.d("submitStatusUpdate: $it") - val result = animeDetailsRepository.updateAnimeStatus( - animeId = animeId, - animeStatus = it.animeStatus?.name, - score = it.score, - numWatchedEps = it.numWatchedEpisode - ) - if (result.isSuccess) { - getAnimeDetails(animeId) - } else { - _animeDetailState.setFailure(result.error.errorMessage) - } - } ?: run { - _animeDetailState.setFailure(MHError.InvalidStateError.errorMessage) - } + fun submitStatusUpdate() = viewModelScope.launch { + _animeDetailState.setLoading() + val details = animeDetailsUpdate.value + if (details == null) { + _animeDetailState.setFailure(MHError.InvalidStateError.errorMessage) + return@launch } + + val result = animeDetailsRepository.updateAnimeStatus(details) + + if (!result.isSuccess) { + _animeDetailState.setFailure(result.error.errorMessage) + return@launch + } + + refreshDetails() } - fun removeFromList(animeId: Int) { - viewModelScope.launch { - _animeDetailState.setLoading() - val result = animeDetailsRepository.removeAnimeFromList(animeId = animeId) - if (result.isSuccess) { - getAnimeDetails(animeId) - } else { - _animeDetailState.setFailure(result.error.errorMessage) - } + fun removeFromList() = viewModelScope.launch { + val id = animeDetailsUpdate.value?.animeId ?: return@launch + _animeDetailState.setLoading() + val result = animeDetailsRepository.removeAnimeFromList(animeId = id) + if (!result.isSuccess) { + _animeDetailState.setFailure(result.error.errorMessage) + return@launch } + + refreshDetails() + } + + fun refreshDetails() { + getAnimeDetails(animeId) + getNextEpisodeDetails(animeId) + } + + companion object { + private const val ANIME_ID_KEY = "animeId" } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/vm/NextEpisodeDetailsState.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/vm/NextEpisodeDetailsState.kt index d2e7f7b..56c5b94 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/vm/NextEpisodeDetailsState.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_details/vm/NextEpisodeDetailsState.kt @@ -1,13 +1,14 @@ package com.sharkaboi.mediahub.modules.anime_details.vm -import GetNextAiringAnimeEpisodeQuery import androidx.lifecycle.MutableLiveData +import com.sharkaboi.mediahub.GetNextAiringAnimeEpisodeQuery sealed class NextEpisodeDetailsState { object Idle : NextEpisodeDetailsState() object Loading : NextEpisodeDetailsState() - data class FetchSuccess(val nextAiringEpisode: GetNextAiringAnimeEpisodeQuery.Media) : - NextEpisodeDetailsState() + data class FetchSuccess( + val nextAiringEpisode: GetNextAiringAnimeEpisodeQuery.ReturnedMedia + ) : NextEpisodeDetailsState() data class NextEpisodeDetailsFailure(val message: String) : NextEpisodeDetailsState() } @@ -20,10 +21,11 @@ fun MutableLiveData.getDefault() = this.apply { value = NextEpisodeDetailsState.Idle } -fun MutableLiveData.setFetchSuccess(nextAiringEpisode: GetNextAiringAnimeEpisodeQuery.Media) = - this.apply { - value = NextEpisodeDetailsState.FetchSuccess(nextAiringEpisode) - } +fun MutableLiveData.setFetchSuccess( + nextAiringEpisode: GetNextAiringAnimeEpisodeQuery.ReturnedMedia +) = this.apply { + value = NextEpisodeDetailsState.FetchSuccess(nextAiringEpisode) +} fun MutableLiveData.setFailure(message: String) = this.apply { value = NextEpisodeDetailsState.NextEpisodeDetailsFailure(message) 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_list/adapters/AnimeListAdapter.kt similarity index 92% rename from app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimeListAdapter.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/adapters/AnimeListAdapter.kt index 7c24ea9..c11d629 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimeListAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/adapters/AnimeListAdapter.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.modules.anime.adapters +package com.sharkaboi.mediahub.modules.anime_list.adapters import android.view.LayoutInflater import android.view.ViewGroup @@ -24,8 +24,8 @@ class AnimeListAdapter( item?.let { animeListItemBinding.apply { ivAnimeBanner.load( - uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, - builder = UIConstants.AnimeImageBuilder + it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.TopRoundedAnimeImageBuilder ) tvAnimeName.text = it.node.title tvEpisodesWatched.text = tvEpisodesWatched.context.getProgressStringWith( diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimeLoadStateAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/adapters/AnimeLoadStateAdapter.kt similarity index 95% rename from app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimeLoadStateAdapter.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/adapters/AnimeLoadStateAdapter.kt index 058b3ef..3205172 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimeLoadStateAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/adapters/AnimeLoadStateAdapter.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.modules.anime.adapters +package com.sharkaboi.mediahub.modules.anime_list.adapters import android.view.LayoutInflater import android.view.ViewGroup 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_list/adapters/AnimePagerAdapter.kt similarity index 69% rename from app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimePagerAdapter.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/adapters/AnimePagerAdapter.kt index 3e6b777..7ec8798 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/adapters/AnimePagerAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/adapters/AnimePagerAdapter.kt @@ -1,17 +1,17 @@ -package com.sharkaboi.mediahub.modules.anime.adapters +package com.sharkaboi.mediahub.modules.anime_list.adapters import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.viewpager2.adapter.FragmentStateAdapter import com.sharkaboi.mediahub.data.api.enums.AnimeStatus -import com.sharkaboi.mediahub.modules.anime.ui.AnimeListByStatusFragment +import com.sharkaboi.mediahub.modules.anime_list.ui.AnimeListByStatusFragment class AnimePagerAdapter(fm: FragmentManager, lifecycle: Lifecycle) : FragmentStateAdapter(fm, lifecycle) { override fun getItemCount(): Int = AnimeStatus.values().count() override fun createFragment(position: Int): Fragment { - return AnimeListByStatusFragment.newInstance(AnimeStatus.values()[position]) + return AnimeListByStatusFragment() } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/data/UserAnimeListDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/data/UserAnimeListDataSource.kt new file mode 100644 index 0000000..0af3193 --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/data/UserAnimeListDataSource.kt @@ -0,0 +1,86 @@ +package com.sharkaboi.mediahub.modules.anime_list.data + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.haroldadmin.cnradapter.NetworkResponse +import com.sharkaboi.mediahub.common.extensions.getCatchingPaging +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.MHError +import timber.log.Timber + +class UserAnimeListDataSource( + private val userAnimeService: UserAnimeService, + private val accessToken: String?, + private val animeStatus: AnimeStatus, + private val animeSortType: UserAnimeSortType = UserAnimeSortType.list_updated_at, + 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? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) + ?: anchorPage?.nextKey?.minus(ApiConstants.API_PAGE_LIMIT) + } + } + + override suspend fun load( + params: LoadParams + ): LoadResult = getCatchingPaging { + Timber.d("params : ${params.key}") + val offset = params.key ?: ApiConstants.API_START_OFFSET + val limit = ApiConstants.API_PAGE_LIMIT + if (accessToken == null) { + return@getCatchingPaging LoadResult.Error( + MHError.LoginExpiredError.getThrowable() + ) + } + + 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() + + return@getCatchingPaging when (response) { + is NetworkResponse.Success -> { + val nextOffset = if (response.body.data.isEmpty()) null else offset + limit + LoadResult.Page( + data = response.body.data, + prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, + nextKey = nextOffset + ) + } + is NetworkResponse.UnknownError -> { + LoadResult.Error(response.error) + } + is NetworkResponse.ServerError -> { + LoadResult.Error( + response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() + ) + } + is NetworkResponse.NetworkError -> { + LoadResult.Error(response.error) + } + } + } + + private fun getStatus(): String? = if (animeStatus == AnimeStatus.all) { + null + } else { + animeStatus.name + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/AnimeModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/di/AnimeListModule.kt similarity index 63% rename from app/src/main/java/com/sharkaboi/mediahub/di/AnimeModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/di/AnimeListModule.kt index 8be5ece..b48eecc 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/AnimeModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/di/AnimeListModule.kt @@ -1,10 +1,10 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.anime_list.di import android.content.SharedPreferences import com.sharkaboi.mediahub.data.api.retrofit.UserAnimeService import com.sharkaboi.mediahub.data.datastore.DataStoreRepository -import com.sharkaboi.mediahub.modules.anime.repository.AnimeRepository -import com.sharkaboi.mediahub.modules.anime.repository.AnimeRepositoryImpl +import com.sharkaboi.mediahub.modules.anime_list.repository.AnimeListRepository +import com.sharkaboi.mediahub.modules.anime_list.repository.AnimeListRepositoryImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -13,7 +13,7 @@ import dagger.hilt.android.scopes.ActivityRetainedScoped @InstallIn(ActivityRetainedComponent::class) @Module -object AnimeModule { +object AnimeListModule { @Provides @ActivityRetainedScoped @@ -21,6 +21,6 @@ object AnimeModule { userAnimeService: UserAnimeService, dataStoreRepository: DataStoreRepository, sharedPreferences: SharedPreferences - ): AnimeRepository = - AnimeRepositoryImpl(userAnimeService, dataStoreRepository, sharedPreferences) + ): AnimeListRepository = + AnimeListRepositoryImpl(userAnimeService, dataStoreRepository, sharedPreferences) } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/repository/AnimeRepository.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/repository/AnimeListRepository.kt similarity index 82% rename from app/src/main/java/com/sharkaboi/mediahub/modules/anime/repository/AnimeRepository.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/repository/AnimeListRepository.kt index 77b4c57..744f1d8 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/repository/AnimeRepository.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/repository/AnimeListRepository.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.modules.anime.repository +package com.sharkaboi.mediahub.modules.anime_list.repository import androidx.paging.PagingData import com.sharkaboi.mediahub.data.api.enums.AnimeStatus @@ -6,7 +6,7 @@ import com.sharkaboi.mediahub.data.api.enums.UserAnimeSortType import com.sharkaboi.mediahub.data.api.models.useranime.UserAnimeListResponse import kotlinx.coroutines.flow.Flow -interface AnimeRepository { +interface AnimeListRepository { suspend fun getAnimeListFlow( animeStatus: AnimeStatus, 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_list/repository/AnimeListRepositoryImpl.kt similarity index 79% rename from app/src/main/java/com/sharkaboi/mediahub/modules/anime/repository/AnimeRepositoryImpl.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/repository/AnimeListRepositoryImpl.kt index 3b041fc..633dc76 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/repository/AnimeRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/repository/AnimeListRepositoryImpl.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.modules.anime.repository +package com.sharkaboi.mediahub.modules.anime_list.repository import android.content.SharedPreferences import androidx.paging.Pager @@ -10,28 +10,24 @@ import com.sharkaboi.mediahub.data.api.enums.UserAnimeSortType import com.sharkaboi.mediahub.data.api.models.useranime.UserAnimeListResponse 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 com.sharkaboi.mediahub.modules.anime_list.data.UserAnimeListDataSource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.withContext -import timber.log.Timber -class AnimeRepositoryImpl( +class AnimeListRepositoryImpl( private val userAnimeService: UserAnimeService, private val dataStoreRepository: DataStoreRepository, private val sharedPreferences: SharedPreferences -) : AnimeRepository { +) : AnimeListRepository { override suspend fun getAnimeListFlow( animeStatus: AnimeStatus, animeSortType: UserAnimeSortType - ): Flow> = withContext(Dispatchers.IO) { + ): Flow> { val showNsfw = sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - Timber.d("accessToken: $accessToken") - return@withContext Pager( + return 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_list/ui/AnimeFragment.kt similarity index 66% rename from app/src/main/java/com/sharkaboi/mediahub/modules/anime/ui/AnimeFragment.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/ui/AnimeFragment.kt index 68e5342..9986c4e 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/ui/AnimeFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/ui/AnimeFragment.kt @@ -1,27 +1,40 @@ -package com.sharkaboi.mediahub.modules.anime.ui +package com.sharkaboi.mediahub.modules.anime_list.ui import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.coordinatorlayout.widget.CoordinatorLayout +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.AnimationUtils +import androidx.core.view.isInvisible +import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback -import com.google.android.material.behavior.HideBottomViewOnScrollBehavior -import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.tabs.TabLayout import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.extensions.startAnim import com.sharkaboi.mediahub.data.api.enums.AnimeStatus import com.sharkaboi.mediahub.databinding.FragmentAnimeBinding -import com.sharkaboi.mediahub.modules.anime.adapters.AnimePagerAdapter +import com.sharkaboi.mediahub.modules.anime_list.adapters.AnimePagerAdapter +import com.sharkaboi.mediahub.modules.anime_list.vm.AnimeViewModel +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class AnimeFragment : Fragment() { - private lateinit var vpAnimeAdapter: AnimePagerAdapter private var _binding: FragmentAnimeBinding? = null private val binding get() = _binding!! private lateinit var onTabChanged: TabLayout.OnTabSelectedListener private lateinit var onPageChanged: OnPageChangeCallback + private val animeViewModel by activityViewModels() + private val navController by lazy { findNavController() } + private val anim by lazy { + AnimationUtils.loadAnimation(requireContext(), R.anim.fab_explode).apply { + interpolator = AccelerateDecelerateInterpolator() + } + } override fun onCreateView( inflater: LayoutInflater, @@ -63,26 +76,31 @@ class AnimeFragment : Fragment() { childFragmentManager.findFragmentByTag("f${tab?.position ?: 0}")?.let { if (it is AnimeListByStatusFragment) { it.scrollRecyclerView() - activity?.findViewById(R.id.bottomNav) - ?.let { bottomNav -> - val layoutParams = - bottomNav.layoutParams as CoordinatorLayout.LayoutParams? - val bottomViewNavigationBehavior = - layoutParams?.behavior as HideBottomViewOnScrollBehavior? - bottomViewNavigationBehavior?.slideUp(bottomNav) - } } } } } onPageChanged = object : OnPageChangeCallback() { override fun onPageSelected(position: Int) { + animeViewModel.setAnimeStatus(AnimeStatus.values()[position]) binding.animeTabLayout.apply { selectTab(getTabAt(position)) } } } - binding.animeTabLayout.addOnTabSelectedListener(onTabChanged) binding.vpAnime.registerOnPageChangeCallback(onPageChanged) + binding.animeTabLayout.addOnTabSelectedListener(onTabChanged) + binding.fabSearch.setOnClickListener { + binding.fabSearch.isVisible = false + binding.circleAnimeView.isVisible = true + binding.circleAnimeView.startAnim( + anim, + onEnd = { + binding.circleAnimeView.isInvisible = true + navController.navigate(R.id.openAnimeSearch) + binding.fabSearch.isVisible = true + } + ) + } } } 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_list/ui/AnimeListByStatusFragment.kt similarity index 57% rename from app/src/main/java/com/sharkaboi/mediahub/modules/anime/ui/AnimeListByStatusFragment.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/ui/AnimeListByStatusFragment.kt index 9d9009d..cfbb64d 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime/ui/AnimeListByStatusFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/ui/AnimeListByStatusFragment.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.modules.anime.ui +package com.sharkaboi.mediahub.modules.anime_list.ui import android.os.Bundle import android.view.LayoutInflater @@ -6,36 +6,33 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels +import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import androidx.paging.CombinedLoadStates 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.observe import com.sharkaboi.mediahub.common.extensions.showToast -import com.sharkaboi.mediahub.data.api.enums.AnimeStatus import com.sharkaboi.mediahub.data.api.enums.UserAnimeSortType import com.sharkaboi.mediahub.databinding.FragmentAnimeListByStatusBinding -import com.sharkaboi.mediahub.modules.anime.adapters.AnimeListAdapter -import com.sharkaboi.mediahub.modules.anime.adapters.AnimeLoadStateAdapter -import com.sharkaboi.mediahub.modules.anime.vm.AnimeViewModel +import com.sharkaboi.mediahub.modules.anime_list.adapters.AnimeListAdapter +import com.sharkaboi.mediahub.modules.anime_list.adapters.AnimeLoadStateAdapter +import com.sharkaboi.mediahub.modules.anime_list.vm.AnimeViewModel import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class AnimeListByStatusFragment : Fragment() { private var _binding: FragmentAnimeListByStatusBinding? = null private val binding get() = _binding!! - private val animeViewModel by viewModels() + private val animeViewModel by activityViewModels() private lateinit var animeListAdapter: AnimeListAdapter private val navController by lazy { findNavController() } - private var resultsJob: Job? = null override fun onCreateView( inflater: LayoutInflater, @@ -47,8 +44,7 @@ class AnimeListByStatusFragment : Fragment() { } override fun onDestroyView() { - resultsJob?.cancel() - resultsJob = null + animeListAdapter.removeLoadStateListener(loadStateListener) binding.rvAnimeByStatus.adapter = null _binding = null super.onDestroyView() @@ -56,22 +52,13 @@ class AnimeListByStatusFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - initStatus() setUpRecyclerView() - setObservers() setListeners() } - private fun initStatus() { - val status = arguments?.getString(ANIME_STATUS_KEY)?.let { status -> - AnimeStatus.valueOf(status) - } ?: AnimeStatus.all - animeViewModel.setAnimeStatus(status) - } - override fun onResume() { super.onResume() - getAnimeList() + animeViewModel.refresh() } private fun setUpRecyclerView() { @@ -80,7 +67,7 @@ class AnimeListByStatusFragment : Fragment() { val action = BottomNavGraphDirections.openAnimeById(animeId) navController.navigate(action) } - layoutManager = GridLayoutManager(context, UIConstants.AnimeAndMangaGridSpanCount) + layoutManager = UIConstants.getGridLayoutManager(context) itemAnimator = DefaultItemAnimator() adapter = animeListAdapter.withLoadStateFooter( footer = AnimeLoadStateAdapter() @@ -88,28 +75,28 @@ class AnimeListByStatusFragment : Fragment() { } } - private fun setObservers() { - getAnimeList() - lifecycleScope.launch { - animeListAdapter.addLoadStateListener { loadStates -> - if (loadStates.source.refresh is LoadState.Error) { - showToast((loadStates.source.refresh as LoadState.Error).error.message) - } - binding.progressBar.isShowing = loadStates.refresh is LoadState.Loading - binding.tvEmptyHint.isVisible = - loadStates.refresh is LoadState.NotLoading && animeListAdapter.itemCount == 0 - } - } - binding.swipeRefresh.setOnRefreshListener { - getAnimeList() - binding.swipeRefresh.isRefreshing = false + private val loadStateListener = { loadStates: CombinedLoadStates -> + if (loadStates.source.refresh is LoadState.Error) { + showToast((loadStates.source.refresh as LoadState.Error).error.message) } + binding.progressBar.isShowing = loadStates.refresh is LoadState.Loading + binding.tvEmptyHint.isVisible = + loadStates.refresh is LoadState.NotLoading && animeListAdapter.itemCount == 0 } private fun setListeners() { + animeListAdapter.addLoadStateListener(loadStateListener) + binding.swipeRefresh.setOnRefreshListener { + animeViewModel.refresh() + binding.swipeRefresh.isRefreshing = false + } binding.ibFilter.setOnClickListener { openSortMenu() } + observe(animeViewModel.animeList) { pagingData -> + lifecycleScope.launch { animeListAdapter.submitData(pagingData) } + scrollRecyclerView() + } } private fun openSortMenu() { @@ -119,33 +106,9 @@ class AnimeListByStatusFragment : Fragment() { .setTitle(R.string.sort_anime_by_hint) .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> animeViewModel.setSortType(UserAnimeSortType.values()[which]) - getAnimeList() dialog.dismiss() - } - .show() - } - - private fun getAnimeList() { - resultsJob?.cancel() - resultsJob = lifecycleScope.launch { - animeViewModel.getAnimeList().collectLatest { pagingData -> - animeListAdapter.submitData(pagingData) - scrollRecyclerView() - } - } + }.show() } fun scrollRecyclerView() = binding.rvAnimeByStatus.smoothScrollToPosition(0) - - companion object { - private const val ANIME_STATUS_KEY = "status" - - @JvmStatic - fun newInstance(status: AnimeStatus) = - AnimeListByStatusFragment().apply { - arguments = Bundle().apply { - putString(ANIME_STATUS_KEY, status.name) - } - } - } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/vm/AnimeViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/vm/AnimeViewModel.kt new file mode 100644 index 0000000..3ca462d --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_list/vm/AnimeViewModel.kt @@ -0,0 +1,74 @@ +package com.sharkaboi.mediahub.modules.anime_list.vm + +import androidx.lifecycle.* +import androidx.paging.PagingData +import androidx.paging.cachedIn +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 +import com.sharkaboi.mediahub.modules.anime_list.repository.AnimeListRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class AnimeViewModel +@Inject constructor( + private val animeListRepository: AnimeListRepository, + private val savedStateHandle: SavedStateHandle +) : ViewModel() { + private val savedAnimeStatus = savedStateHandle.get(CHOSEN_ANIME_STATUS_KEY) + private val savedSortType = savedStateHandle.get(CHOSEN_SORT_TYPE_KEY) + + private var _currentChosenAnimeStatus: AnimeStatus = + AnimeStatus.parse(savedAnimeStatus) ?: AnimeStatus.all + val currentChosenAnimeStatus: AnimeStatus get() = _currentChosenAnimeStatus + + private var _currentChosenSortType: UserAnimeSortType = + UserAnimeSortType.parse(savedSortType) ?: UserAnimeSortType.list_updated_at + val currentChosenSortType: UserAnimeSortType get() = _currentChosenSortType + + private var _animeList = MutableLiveData>() + val animeList: LiveData> = _animeList + + init { + Timber.d("Saved state anime status $savedAnimeStatus") + Timber.d("Saved state sort type $savedSortType") + } + + private fun getAnimeList(shouldEmpty: Boolean) = viewModelScope.launch { + if (shouldEmpty) { + _animeList.value = PagingData.empty() + } + val newResult: Flow> = + animeListRepository.getAnimeListFlow( + animeStatus = currentChosenAnimeStatus, + animeSortType = currentChosenSortType + ).cachedIn(viewModelScope) + _animeList.value = newResult.firstOrNull() ?: PagingData.empty() + } + + fun setSortType(userAnimeSortType: UserAnimeSortType) { + _currentChosenSortType = userAnimeSortType + savedStateHandle.set(CHOSEN_SORT_TYPE_KEY, userAnimeSortType.name) + getAnimeList(false) + } + + fun setAnimeStatus(animeStatus: AnimeStatus) { + _currentChosenAnimeStatus = animeStatus + savedStateHandle.set(CHOSEN_ANIME_STATUS_KEY, animeStatus.name) + getAnimeList(true) + } + + fun refresh() { + getAnimeList(false) + } + + companion object { + const val CHOSEN_ANIME_STATUS_KEY = "animeStatus" + private const val CHOSEN_SORT_TYPE_KEY = "sortType" + } +} 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 46fd7c8..e2d6246 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 @@ -24,8 +24,8 @@ class AnimeRankingDetailedAdapter( item?.let { animeListItemBinding.apply { ivAnimeBanner.load( - uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, - builder = UIConstants.AnimeImageBuilder + it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.TopRoundedAnimeImageBuilder ) tvAnimeName.text = it.node.title tvEpisodesWatched.text = tvEpisodesWatched.context.getString( diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/data/AnimeRankingDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/data/AnimeRankingDataSource.kt new file mode 100644 index 0000000..3100dd6 --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/data/AnimeRankingDataSource.kt @@ -0,0 +1,77 @@ +package com.sharkaboi.mediahub.modules.anime_ranking.data + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.haroldadmin.cnradapter.NetworkResponse +import com.sharkaboi.mediahub.common.extensions.getCatchingPaging +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.MHError +import timber.log.Timber + +class AnimeRankingDataSource( + private val animeService: AnimeService, + private val accessToken: String?, + private val animeRankingType: AnimeRankingType, + 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? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) + ?: anchorPage?.nextKey?.minus(ApiConstants.API_PAGE_LIMIT) + } + } + + override suspend fun load( + params: LoadParams + ): LoadResult = getCatchingPaging { + Timber.d("params : ${params.key}") + val offset = params.key ?: ApiConstants.API_START_OFFSET + val limit = ApiConstants.API_PAGE_LIMIT + if (accessToken == null) { + return@getCatchingPaging LoadResult.Error( + MHError.LoginExpiredError.getThrowable() + ) + } + + 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() + + return@getCatchingPaging when (response) { + is NetworkResponse.Success -> { + val nextOffset = if (response.body.data.isEmpty()) null else offset + limit + LoadResult.Page( + data = response.body.data, + prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, + nextKey = nextOffset + ) + } + is NetworkResponse.UnknownError -> { + LoadResult.Error(response.error) + } + is NetworkResponse.ServerError -> { + LoadResult.Error( + response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() + ) + } + is NetworkResponse.NetworkError -> { + LoadResult.Error(response.error) + } + } + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/AnimeRankingModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/di/AnimeRankingModule.kt similarity index 94% rename from app/src/main/java/com/sharkaboi/mediahub/di/AnimeRankingModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/di/AnimeRankingModule.kt index 06550b6..4b68f1c 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/AnimeRankingModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/di/AnimeRankingModule.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.anime_ranking.di import android.content.SharedPreferences import com.sharkaboi.mediahub.data.api.retrofit.AnimeService 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 3ffc5bd..9c009df 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 @@ -9,11 +9,10 @@ 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 import com.sharkaboi.mediahub.data.datastore.DataStoreRepository -import com.sharkaboi.mediahub.data.paging.AnimeRankingDataSource import com.sharkaboi.mediahub.data.sharedpref.SharedPreferencesKeys +import com.sharkaboi.mediahub.modules.anime_ranking.data.AnimeRankingDataSource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull -import timber.log.Timber class AnimeRankingRepositoryImpl( private val animeService: AnimeService, @@ -24,7 +23,6 @@ class AnimeRankingRepositoryImpl( override suspend fun getAnimeRanking(animeRankingType: AnimeRankingType): Flow> { val showNsfw = sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - Timber.d("accessToken: $accessToken") return Pager( config = PagingConfig( pageSize = ApiConstants.API_PAGE_LIMIT, 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 e95a66c..c5107ab 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 @@ -9,12 +9,13 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs +import androidx.paging.CombinedLoadStates import androidx.paging.LoadState import androidx.recyclerview.widget.DefaultItemAnimator -import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.chip.Chip +import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.common.constants.UIConstants.setMediaHubChipStyle +import com.sharkaboi.mediahub.common.extensions.observe import com.sharkaboi.mediahub.common.extensions.showToast import com.sharkaboi.mediahub.data.api.enums.AnimeRankingType import com.sharkaboi.mediahub.databinding.FragmentAnimeRankingBinding @@ -22,8 +23,6 @@ import com.sharkaboi.mediahub.modules.anime_ranking.adapters.AnimeRankingDetaile import com.sharkaboi.mediahub.modules.anime_ranking.adapters.AnimeRankingLoadStateAdapter import com.sharkaboi.mediahub.modules.anime_ranking.vm.AnimeRankingViewModel import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint @@ -33,8 +32,6 @@ class AnimeRankingFragment : Fragment() { private val navController by lazy { findNavController() } private lateinit var animeRankingDetailedAdapter: AnimeRankingDetailedAdapter private val animeRankingViewModel by viewModels() - private val args: AnimeRankingFragmentArgs by navArgs() - private var resultsJob: Job? = null override fun onCreateView( inflater: LayoutInflater, @@ -46,8 +43,7 @@ class AnimeRankingFragment : Fragment() { } override fun onDestroyView() { - resultsJob?.cancel() - resultsJob = null + animeRankingDetailedAdapter.removeLoadStateListener(loadStateListener) binding.rvAnimeRanking.adapter = null _binding = null super.onDestroyView() @@ -56,44 +52,23 @@ class AnimeRankingFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.toolbar.setNavigationOnClickListener { navController.navigateUp() } - initRanking() setupFilterChips() setUpRecyclerView() setObservers() - collectPagedList() - } - - private fun initRanking() { - animeRankingViewModel.setRankingType( - if (args.animeRankingType == null) { - AnimeRankingType.all - } else { - runCatching { - AnimeRankingType.valueOf( - args.animeRankingType?.lowercase() - ?: AnimeRankingType.all.name - ) - }.getOrElse { AnimeRankingType.all } - } - ) } private fun setupFilterChips() { binding.rankTypeChipGroup.removeAllViews() AnimeRankingType.values().forEach { rankingType -> - binding.rankTypeChipGroup.addView( - Chip(context).apply { - text = rankingType.getAnimeRanking(context) - setMediaHubChipStyle() - isCheckable = true - isChecked = rankingType == animeRankingViewModel.selectedRankingType - setOnClickListener { - animeRankingViewModel.setRankingType(rankingType) - collectPagedList() - binding.rvAnimeRanking.smoothScrollToPosition(0) - } - } - ) + val rankChip = Chip(context) + rankChip.text = rankingType.getFormattedString(rankChip.context) + rankChip.setMediaHubChipStyle() + rankChip.isCheckable = true + rankChip.isChecked = rankingType == animeRankingViewModel.rankingType + rankChip.setOnClickListener { + animeRankingViewModel.setRankingType(rankingType) + } + binding.rankTypeChipGroup.addView(rankChip) } } @@ -103,7 +78,7 @@ class AnimeRankingFragment : Fragment() { val action = AnimeRankingFragmentDirections.openAnimeById(animeId) navController.navigate(action) } - layoutManager = GridLayoutManager(context, 3) + layoutManager = UIConstants.getGridLayoutManager(context) itemAnimator = DefaultItemAnimator() adapter = animeRankingDetailedAdapter.withLoadStateFooter( footer = AnimeRankingLoadStateAdapter() @@ -111,27 +86,20 @@ class AnimeRankingFragment : Fragment() { } } - private fun setObservers() { - lifecycleScope.launch { - animeRankingDetailedAdapter.addLoadStateListener { loadStates -> - if (loadStates.source.refresh is LoadState.Error) { - showToast((loadStates.source.refresh as LoadState.Error).error.message) - } - binding.progressBar.isShowing = loadStates.refresh is LoadState.Loading - binding.tvEmptyHint.isVisible = - loadStates.refresh is LoadState.NotLoading && animeRankingDetailedAdapter.itemCount == 0 - } + private val loadStateListener = { loadStates: CombinedLoadStates -> + if (loadStates.source.refresh is LoadState.Error) { + showToast((loadStates.source.refresh as LoadState.Error).error.message) } + binding.progressBar.isShowing = loadStates.refresh is LoadState.Loading + binding.tvEmptyHint.isVisible = + loadStates.refresh is LoadState.NotLoading && animeRankingDetailedAdapter.itemCount == 0 } - private fun collectPagedList() { - resultsJob?.cancel() - resultsJob = lifecycleScope.launch { - animeRankingViewModel.getAnimeForRankingType() - .collectLatest { pagingData -> - animeRankingDetailedAdapter.submitData(pagingData) - binding.rvAnimeRanking.smoothScrollToPosition(0) - } + private fun setObservers() { + animeRankingDetailedAdapter.addLoadStateListener(loadStateListener) + observe(animeRankingViewModel.result) { pagingData -> + lifecycleScope.launch { animeRankingDetailedAdapter.submitData(pagingData) } + binding.rvAnimeRanking.smoothScrollToPosition(0) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/vm/AnimeRankingViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/vm/AnimeRankingViewModel.kt index db2c159..591fabc 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/vm/AnimeRankingViewModel.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_ranking/vm/AnimeRankingViewModel.kt @@ -1,7 +1,6 @@ package com.sharkaboi.mediahub.modules.anime_ranking.vm -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* import androidx.paging.PagingData import androidx.paging.cachedIn import com.sharkaboi.mediahub.data.api.enums.AnimeRankingType @@ -9,27 +8,46 @@ import com.sharkaboi.mediahub.data.api.models.anime.AnimeRankingResponse import com.sharkaboi.mediahub.modules.anime_ranking.repository.AnimeRankingRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel class AnimeRankingViewModel @Inject constructor( - private val animeRankingRepository: AnimeRankingRepository + private val animeRankingRepository: AnimeRankingRepository, + private val savedStateHandle: SavedStateHandle ) : ViewModel() { - private var _selectedRankingType: AnimeRankingType = AnimeRankingType.all - val selectedRankingType: AnimeRankingType get() = _selectedRankingType - private var _pagedResult: Flow>? = null + private val selectedRankingType: String? = savedStateHandle.get(ANIME_RANKING_KEY) - suspend fun getAnimeForRankingType(): Flow> { + private var _rankingType: AnimeRankingType = + AnimeRankingType.getAnimeRankingFromString(selectedRankingType) + val rankingType: AnimeRankingType get() = _rankingType + + private val _result = MutableLiveData>() + val result: LiveData> = _result + + init { + Timber.d("Saved state for anime ranking type $selectedRankingType") + getAnimeForRankingType() + } + + private fun getAnimeForRankingType() = viewModelScope.launch { val newResult: Flow> = animeRankingRepository - .getAnimeRanking(_selectedRankingType) + .getAnimeRanking(_rankingType) .cachedIn(viewModelScope) - _pagedResult = newResult - return newResult + _result.value = newResult.firstOrNull() ?: PagingData.empty() } fun setRankingType(animeRankingType: AnimeRankingType) { - _selectedRankingType = animeRankingType + _rankingType = animeRankingType + savedStateHandle.set(ANIME_RANKING_KEY, animeRankingType.name) + getAnimeForRankingType() + } + + companion object { + private const val ANIME_RANKING_KEY = "animeRankingType" } } 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 51f3915..bdde708 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 @@ -24,8 +24,8 @@ class AnimeSearchListAdapter( item?.let { animeListItemBinding.apply { ivAnimeBanner.load( - uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, - builder = UIConstants.AnimeImageBuilder + it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.TopRoundedAnimeImageBuilder ) tvAnimeName.text = it.node.title tvEpisodesWatched.isVisible = false diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/data/AnimeSearchDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/data/AnimeSearchDataSource.kt new file mode 100644 index 0000000..78b8fcb --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/data/AnimeSearchDataSource.kt @@ -0,0 +1,75 @@ +package com.sharkaboi.mediahub.modules.anime_search.data + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.haroldadmin.cnradapter.NetworkResponse +import com.sharkaboi.mediahub.common.extensions.getCatchingPaging +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.MHError +import timber.log.Timber + +class AnimeSearchDataSource( + private val animeService: AnimeService, + private val accessToken: String?, + private val query: String, + 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? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) + ?: anchorPage?.nextKey?.minus(ApiConstants.API_PAGE_LIMIT) + } + } + + override suspend fun load( + params: LoadParams + ): LoadResult = getCatchingPaging { + Timber.d("params : ${params.key}") + val offset = params.key ?: ApiConstants.API_START_OFFSET + val limit = ApiConstants.API_PAGE_LIMIT + if (accessToken == null) { + return@getCatchingPaging LoadResult.Error( + MHError.LoginExpiredError.getThrowable() + ) + } + 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() + + return@getCatchingPaging when (response) { + is NetworkResponse.Success -> { + val nextOffset = if (response.body.data.isEmpty()) null else offset + limit + LoadResult.Page( + data = response.body.data, + prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, + nextKey = nextOffset + ) + } + is NetworkResponse.UnknownError -> { + LoadResult.Error(response.error) + } + is NetworkResponse.ServerError -> { + LoadResult.Error( + response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() + ) + } + is NetworkResponse.NetworkError -> { + LoadResult.Error(response.error) + } + } + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/AnimeSearchModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/di/AnimeSearchModule.kt similarity index 94% rename from app/src/main/java/com/sharkaboi/mediahub/di/AnimeSearchModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/di/AnimeSearchModule.kt index 4542d8a..4bc8f3e 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/AnimeSearchModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/di/AnimeSearchModule.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.anime_search.di import android.content.SharedPreferences import com.sharkaboi.mediahub.data.api.retrofit.AnimeService 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 f39384f..bf76477 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 @@ -8,11 +8,10 @@ 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 -import com.sharkaboi.mediahub.data.paging.AnimeSearchDataSource import com.sharkaboi.mediahub.data.sharedpref.SharedPreferencesKeys +import com.sharkaboi.mediahub.modules.anime_search.data.AnimeSearchDataSource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull -import timber.log.Timber class AnimeSearchRepositoryImpl( private val animeService: AnimeService, @@ -23,7 +22,6 @@ class AnimeSearchRepositoryImpl( override suspend fun getAnimeByQuery(query: String): Flow> { val showNsfw = sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - Timber.d("accessToken: $accessToken") return Pager( config = PagingConfig( pageSize = ApiConstants.API_PAGE_LIMIT, 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 235b253..ed3f8c7 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 @@ -12,21 +12,21 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import androidx.paging.CombinedLoadStates 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.constants.UIConstants import com.sharkaboi.mediahub.common.extensions.debounce +import com.sharkaboi.mediahub.common.extensions.observe import com.sharkaboi.mediahub.common.extensions.showToast import com.sharkaboi.mediahub.databinding.FragmentAnimeSearchBinding import com.sharkaboi.mediahub.modules.anime_search.adapters.AnimeSearchListAdapter import com.sharkaboi.mediahub.modules.anime_search.adapters.AnimeSearchLoadStateAdapter import com.sharkaboi.mediahub.modules.anime_search.vm.AnimeSearchViewModel import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint @@ -36,7 +36,6 @@ class AnimeSearchFragment : Fragment() { private lateinit var animeSearchListAdapter: AnimeSearchListAdapter private val animeSearchViewModel by viewModels() private val navController by lazy { findNavController() } - private var searchJob: Job? = null override fun onCreateView( inflater: LayoutInflater, @@ -48,8 +47,7 @@ class AnimeSearchFragment : Fragment() { } override fun onDestroyView() { - searchJob?.cancel() - searchJob = null + animeSearchListAdapter.removeLoadStateListener(loadStateListener) binding.rvSearchResults.adapter = null _binding = null super.onDestroyView() @@ -67,7 +65,7 @@ class AnimeSearchFragment : Fragment() { val action = BottomNavGraphDirections.openAnimeById(animeId) navController.navigate(action) } - layoutManager = GridLayoutManager(context, 3) + layoutManager = UIConstants.getGridLayoutManager(context) itemAnimator = DefaultItemAnimator() adapter = animeSearchListAdapter.withLoadStateFooter( footer = AnimeSearchLoadStateAdapter() @@ -75,45 +73,41 @@ class AnimeSearchFragment : Fragment() { } } - private fun setObservers() { - lifecycleScope.launch { - animeSearchListAdapter.addLoadStateListener { loadStates -> - if (loadStates.source.refresh is LoadState.Error) { - showToast((loadStates.source.refresh as LoadState.Error).error.message) - } - binding.progress.isShowing = loadStates.refresh is LoadState.Loading - binding.searchEmptyView.root.isVisible = - loadStates.refresh is LoadState.NotLoading && animeSearchListAdapter.itemCount == 0 - binding.searchEmptyView.tvHint.text = - getString(R.string.anime_search_no_result_hint) - } + private val loadStateListener = { loadStates: CombinedLoadStates -> + if (loadStates.source.refresh is LoadState.Error) { + showToast((loadStates.source.refresh as LoadState.Error).error.message) } + binding.progress.isShowing = loadStates.refresh is LoadState.Loading + binding.searchEmptyView.root.isVisible = + loadStates.refresh is LoadState.NotLoading && animeSearchListAdapter.itemCount == 0 + binding.searchEmptyView.tvHint.text = + getString(R.string.anime_search_no_result_hint) + } + + private fun setObservers() { + animeSearchListAdapter.addLoadStateListener(loadStateListener) val debounce = debounce(scope = lifecycleScope) { searchAnime(it) } binding.svSearch.doOnTextChanged { query, _, _, _ -> debounce(query) } + observe(animeSearchViewModel.pagedSearchResult) { pagingData -> + lifecycleScope.launch { animeSearchListAdapter.submitData(pagingData) } + binding.rvSearchResults.scrollToPosition(0) + } } private fun searchAnime(query: CharSequence?) { - searchJob?.cancel() - searchJob = lifecycleScope.launch { - query?.toString()?.let { - if (it.length < 3) { - binding.searchEmptyView.root.isVisible = true - binding.searchEmptyView.tvHint.text = getString(R.string.anime_search_hint) - animeSearchListAdapter.submitData(PagingData.empty()) - return@launch - } - hideKeyboard() - animeSearchViewModel.getAnime(it.trim()) - .collectLatest { pagingData -> - animeSearchListAdapter.submitData(pagingData) - binding.rvSearchResults.scrollToPosition(0) - } - } + val trimmedText = query?.toString()?.trim() ?: return + if (trimmedText.length < 3) { + binding.searchEmptyView.root.isVisible = true + binding.searchEmptyView.tvHint.text = getString(R.string.anime_search_hint) + lifecycleScope.launch { animeSearchListAdapter.submitData(PagingData.empty()) } + return } + hideKeyboard() + animeSearchViewModel.getAnime(trimmedText) } private fun hideKeyboard() { diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/vm/AnimeSearchViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/vm/AnimeSearchViewModel.kt index 1867f1c..e235a72 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/vm/AnimeSearchViewModel.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_search/vm/AnimeSearchViewModel.kt @@ -1,31 +1,45 @@ package com.sharkaboi.mediahub.modules.anime_search.vm -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* import androidx.paging.PagingData import androidx.paging.cachedIn import com.sharkaboi.mediahub.data.api.models.anime.AnimeSearchResponse import com.sharkaboi.mediahub.modules.anime_search.repository.AnimeSearchRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel class AnimeSearchViewModel @Inject constructor( - private val animeSearchRepository: AnimeSearchRepository + private val animeSearchRepository: AnimeSearchRepository, + private val savedStateHandle: SavedStateHandle ) : ViewModel() { + private val searchedQuery = savedStateHandle.get(SEARCH_QUERY_KEY) - private var _pagedSearchResult: Flow>? = null + private val _pagedSearchResult = MutableLiveData>() + val pagedSearchResult: LiveData> = _pagedSearchResult - suspend fun getAnime( - query: String - ): Flow> { + init { + Timber.d("Saved state for anime search $searchedQuery") + if (searchedQuery != null) { + getAnime(searchedQuery) + } + } + + fun getAnime(query: String) = viewModelScope.launch { + savedStateHandle.set(SEARCH_QUERY_KEY, query) val newResult: Flow> = animeSearchRepository.getAnimeByQuery( query = query ).cachedIn(viewModelScope) - _pagedSearchResult = newResult - return newResult + _pagedSearchResult.value = newResult.firstOrNull() ?: PagingData.empty() + } + + companion object { + private const val SEARCH_QUERY_KEY = "searchQuery" } } 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 5bec8f7..fe5baef 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 @@ -24,8 +24,8 @@ class AnimeSeasonalAdapter( item?.let { animeListItemBinding.apply { ivAnimeBanner.load( - uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, - builder = UIConstants.AnimeImageBuilder + it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.TopRoundedAnimeImageBuilder ) tvAnimeName.text = it.node.title tvEpisodesWatched.isVisible = false diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/data/AnimeSeasonalDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/data/AnimeSeasonalDataSource.kt new file mode 100644 index 0000000..7d959be --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/data/AnimeSeasonalDataSource.kt @@ -0,0 +1,79 @@ +package com.sharkaboi.mediahub.modules.anime_seasonal.data + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.haroldadmin.cnradapter.NetworkResponse +import com.sharkaboi.mediahub.common.extensions.getCatchingPaging +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.MHError +import timber.log.Timber + +class AnimeSeasonalDataSource( + private val animeService: AnimeService, + private val accessToken: String?, + private val animeSeason: AnimeSeason, + private val year: Int, + 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? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) + ?: anchorPage?.nextKey?.minus(ApiConstants.API_PAGE_LIMIT) + } + } + + override suspend fun load( + params: LoadParams + ): LoadResult = getCatchingPaging { + Timber.d("params : ${params.key}") + val offset = params.key ?: ApiConstants.API_START_OFFSET + val limit = ApiConstants.API_PAGE_LIMIT + if (accessToken == null) { + return@getCatchingPaging LoadResult.Error( + MHError.LoginExpiredError.getThrowable() + ) + } + 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() + + return@getCatchingPaging when (response) { + is NetworkResponse.Success -> { + val nextOffset = if (response.body.data.isEmpty()) null else offset + limit + LoadResult.Page( + data = response.body.data, + prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, + nextKey = nextOffset + ) + } + is NetworkResponse.UnknownError -> { + LoadResult.Error(response.error) + } + is NetworkResponse.ServerError -> { + LoadResult.Error( + response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() + ) + } + is NetworkResponse.NetworkError -> { + LoadResult.Error(response.error) + } + } + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/AnimeSeasonalModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/di/AnimeSeasonalModule.kt similarity index 94% rename from app/src/main/java/com/sharkaboi/mediahub/di/AnimeSeasonalModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/di/AnimeSeasonalModule.kt index 2eeab9e..63163f8 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/AnimeSeasonalModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/di/AnimeSeasonalModule.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.anime_seasonal.di import android.content.SharedPreferences import com.sharkaboi.mediahub.data.api.retrofit.AnimeService 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 207eee0..30fe297 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 @@ -9,11 +9,10 @@ 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 import com.sharkaboi.mediahub.data.datastore.DataStoreRepository -import com.sharkaboi.mediahub.data.paging.AnimeSeasonalDataSource import com.sharkaboi.mediahub.data.sharedpref.SharedPreferencesKeys +import com.sharkaboi.mediahub.modules.anime_seasonal.data.AnimeSeasonalDataSource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull -import timber.log.Timber class AnimeSeasonalRepositoryImpl( private val animeService: AnimeService, @@ -27,7 +26,6 @@ class AnimeSeasonalRepositoryImpl( ): Flow> { val showNsfw = sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - Timber.d("accessToken: $accessToken") return Pager( config = PagingConfig( pageSize = ApiConstants.API_PAGE_LIMIT, 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 44024ad..e5ba18d 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 @@ -9,22 +9,19 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs +import androidx.paging.CombinedLoadStates import androidx.paging.LoadState import androidx.recyclerview.widget.DefaultItemAnimator -import androidx.recyclerview.widget.GridLayoutManager import com.sharkaboi.mediahub.BottomNavGraphDirections +import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.common.extensions.capitalizeFirst +import com.sharkaboi.mediahub.common.extensions.observe import com.sharkaboi.mediahub.common.extensions.showToast -import com.sharkaboi.mediahub.data.api.enums.getAnimeSeason import com.sharkaboi.mediahub.databinding.FragmentAnimeSeasonalBinding import com.sharkaboi.mediahub.modules.anime_seasonal.adapters.AnimeSeasonalAdapter import com.sharkaboi.mediahub.modules.anime_seasonal.adapters.AnimeSeasonalLoadStateAdapter -import com.sharkaboi.mediahub.modules.anime_seasonal.util.AnimeSeasonWrapper import com.sharkaboi.mediahub.modules.anime_seasonal.vm.AnimeSeasonalViewModel import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint @@ -34,8 +31,6 @@ class AnimeSeasonalFragment : Fragment() { private val navController by lazy { findNavController() } private lateinit var animeSeasonalAdapter: AnimeSeasonalAdapter private val animeSeasonalViewModel by viewModels() - private val args: AnimeSeasonalFragmentArgs by navArgs() - private var resultsJob: Job? = null override fun onCreateView( inflater: LayoutInflater, @@ -47,8 +42,7 @@ class AnimeSeasonalFragment : Fragment() { } override fun onDestroyView() { - resultsJob?.cancel() - resultsJob = null + animeSeasonalAdapter.removeLoadStateListener(loadStateListener) binding.rvAnimeSeasonal.adapter = null _binding = null super.onDestroyView() @@ -57,48 +51,24 @@ class AnimeSeasonalFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.toolbar.setNavigationOnClickListener { navController.navigateUp() } - initSeason() setupSeasonButtons() setUpRecyclerView() setObservers() - collectPagedList() - } - - private fun initSeason() { - val season = args.season.getAnimeSeason() - animeSeasonalViewModel.setAnimeSeason( - if (season == null || args.year == 0) { - AnimeSeasonWrapper.currentSeason() - } else { - AnimeSeasonWrapper( - animeSeason = season, - year = args.year - ) - } - ) } private fun setupSeasonButtons() { - binding.apply { - btnPrevSeason.setOnClickListener { - animeSeasonalViewModel.previousSeason() - setSeasonText() - collectPagedList() - } - btnNextSeason.setOnClickListener { - animeSeasonalViewModel.nextSeason() - setSeasonText() - collectPagedList() - } - setSeasonText() + binding.btnPrevSeason.setOnClickListener { + animeSeasonalViewModel.previousSeason() + } + binding.btnNextSeason.setOnClickListener { + animeSeasonalViewModel.nextSeason() } } private fun setSeasonText() { + val selectedSeason = animeSeasonalViewModel.animeSeason binding.tvSeason.text = - animeSeasonalViewModel.animeSeasonWrapper.let { - "${it.animeSeason.name.capitalizeFirst()} ${it.year}" - } + ("${selectedSeason.animeSeason.name.capitalizeFirst()} ${selectedSeason.year}") } private fun setUpRecyclerView() { @@ -107,7 +77,7 @@ class AnimeSeasonalFragment : Fragment() { val action = BottomNavGraphDirections.openAnimeById(animeId) navController.navigate(action) } - layoutManager = GridLayoutManager(context, 3) + layoutManager = UIConstants.getGridLayoutManager(context) itemAnimator = DefaultItemAnimator() adapter = animeSeasonalAdapter.withLoadStateFooter( footer = AnimeSeasonalLoadStateAdapter() @@ -115,27 +85,22 @@ class AnimeSeasonalFragment : Fragment() { } } - private fun setObservers() { - lifecycleScope.launch { - animeSeasonalAdapter.addLoadStateListener { loadStates -> - if (loadStates.source.refresh is LoadState.Error) { - showToast((loadStates.source.refresh as LoadState.Error).error.message) - } - binding.progressBar.isShowing = loadStates.refresh is LoadState.Loading - binding.tvEmptyHint.isVisible = - loadStates.refresh is LoadState.NotLoading && animeSeasonalAdapter.itemCount == 0 + private val loadStateListener + get() = { loadStates: CombinedLoadStates -> + if (loadStates.source.refresh is LoadState.Error) { + showToast((loadStates.source.refresh as LoadState.Error).error.message) } + binding.progressBar.isShowing = loadStates.refresh is LoadState.Loading + binding.tvEmptyHint.isVisible = + loadStates.refresh is LoadState.NotLoading && animeSeasonalAdapter.itemCount == 0 } - } - private fun collectPagedList() { - resultsJob?.cancel() - resultsJob = lifecycleScope.launch { - animeSeasonalViewModel.getAnimeOfSeason() - .collectLatest { pagingData -> - animeSeasonalAdapter.submitData(pagingData) - binding.rvAnimeSeasonal.smoothScrollToPosition(0) - } + private fun setObservers() { + animeSeasonalAdapter.addLoadStateListener(loadStateListener) + observe(animeSeasonalViewModel.result) { pagingData -> + setSeasonText() + lifecycleScope.launch { animeSeasonalAdapter.submitData(pagingData) } + binding.rvAnimeSeasonal.smoothScrollToPosition(0) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/util/AnimeSeasonWrapper.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/util/AnimeSeasonWrapper.kt index 47123f8..726f787 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/util/AnimeSeasonWrapper.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/util/AnimeSeasonWrapper.kt @@ -40,5 +40,14 @@ data class AnimeSeasonWrapper( year = now.year ) } + + fun parseFrom(season: AnimeSeason?, year: Int?): AnimeSeasonWrapper? { + return when { + season == null -> null + year == null -> null + year == 0 -> null + else -> AnimeSeasonWrapper(season, year) + } + } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/vm/AnimeSeasonalViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/vm/AnimeSeasonalViewModel.kt index c85eb27..1540890 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/vm/AnimeSeasonalViewModel.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_seasonal/vm/AnimeSeasonalViewModel.kt @@ -1,7 +1,6 @@ package com.sharkaboi.mediahub.modules.anime_seasonal.vm -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* import androidx.paging.PagingData import androidx.paging.cachedIn import com.sharkaboi.mediahub.data.api.enums.getAnimeSeason @@ -10,39 +9,56 @@ import com.sharkaboi.mediahub.modules.anime_seasonal.repository.AnimeSeasonalRep import com.sharkaboi.mediahub.modules.anime_seasonal.util.AnimeSeasonWrapper import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow -import java.time.LocalDate +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel class AnimeSeasonalViewModel @Inject constructor( - private val animeSeasonalRepository: AnimeSeasonalRepository + private val animeSeasonalRepository: AnimeSeasonalRepository, + savedStateHandle: SavedStateHandle ) : ViewModel() { - private var _pagedResult: Flow>? = null - private var _animeSeasonWrapper = AnimeSeasonWrapper( - animeSeason = LocalDate.now().getAnimeSeason(), - year = LocalDate.now().year - ) - val animeSeasonWrapper get() = _animeSeasonWrapper - - suspend fun getAnimeOfSeason(): Flow> { + + private val selectedSeason = + savedStateHandle.get(ANIME_SELECTED_SEASON).getAnimeSeason() + private val selectedYear = savedStateHandle.get(ANIME_SELECTED_YEAR) + + private var _animeSeason: AnimeSeasonWrapper = + AnimeSeasonWrapper.parseFrom(selectedSeason, selectedYear) + ?: AnimeSeasonWrapper.currentSeason() + val animeSeason: AnimeSeasonWrapper get() = _animeSeason + + private var _result = MutableLiveData>() + val result: LiveData> = _result + + init { + Timber.d("Saved state for anime season $selectedSeason") + Timber.d("Saved state for anime season year $selectedYear") + getAnimeOfSeason() + } + + private fun getAnimeOfSeason() = viewModelScope.launch { val newResult: Flow> = animeSeasonalRepository - .getAnimeSeasonal(_animeSeasonWrapper.animeSeason, _animeSeasonWrapper.year) + .getAnimeSeasonal(animeSeason.animeSeason, animeSeason.year) .cachedIn(viewModelScope) - _pagedResult = newResult - return newResult - } - - fun setAnimeSeason(animeSeasonWrapper: AnimeSeasonWrapper) { - _animeSeasonWrapper = animeSeasonWrapper + _result.value = newResult.firstOrNull() ?: PagingData.empty() } fun previousSeason() { - _animeSeasonWrapper = _animeSeasonWrapper.prev() + _animeSeason = _animeSeason.prev() + getAnimeOfSeason() } fun nextSeason() { - _animeSeasonWrapper = _animeSeasonWrapper.next() + _animeSeason = _animeSeason.next() + getAnimeOfSeason() + } + + companion object { + private const val ANIME_SELECTED_SEASON = "season" + private const val ANIME_SELECTED_YEAR = "year" } } 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 1ea72af..d67d0af 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 @@ -24,8 +24,8 @@ class AnimeSuggestionsAdapter( item?.let { animeListItemBinding.apply { ivAnimeBanner.load( - uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, - builder = UIConstants.AnimeImageBuilder + it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.TopRoundedAnimeImageBuilder ) tvAnimeName.text = it.node.title tvEpisodesWatched.isVisible = false diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/data/AnimeSuggestionsDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/data/AnimeSuggestionsDataSource.kt new file mode 100644 index 0000000..66d2752 --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/data/AnimeSuggestionsDataSource.kt @@ -0,0 +1,73 @@ +package com.sharkaboi.mediahub.modules.anime_suggestions.data + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.haroldadmin.cnradapter.NetworkResponse +import com.sharkaboi.mediahub.common.extensions.getCatchingPaging +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.MHError +import timber.log.Timber + +class AnimeSuggestionsDataSource( + private val animeService: AnimeService, + private val accessToken: String?, + 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? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) + ?: anchorPage?.nextKey?.minus(ApiConstants.API_PAGE_LIMIT) + } + } + + override suspend fun load( + params: LoadParams + ): LoadResult = getCatchingPaging { + Timber.d("params : ${params.key}") + val offset = params.key ?: ApiConstants.API_START_OFFSET + val limit = ApiConstants.API_PAGE_LIMIT + if (accessToken == null) { + return@getCatchingPaging LoadResult.Error( + MHError.LoginExpiredError.getThrowable() + ) + } + val response = animeService.getAnimeSuggestionsAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + offset = offset, + limit = limit, + nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY + ).await() + + return@getCatchingPaging when (response) { + is NetworkResponse.Success -> { + val nextOffset = if (response.body.data.isEmpty()) null else offset + limit + LoadResult.Page( + data = response.body.data, + prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, + nextKey = nextOffset + ) + } + is NetworkResponse.UnknownError -> { + LoadResult.Error(response.error) + } + is NetworkResponse.ServerError -> { + LoadResult.Error( + response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() + ) + } + is NetworkResponse.NetworkError -> { + LoadResult.Error(response.error) + } + } + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/AnimeSuggestionsModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/di/AnimeSuggestionsModule.kt similarity index 94% rename from app/src/main/java/com/sharkaboi/mediahub/di/AnimeSuggestionsModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/di/AnimeSuggestionsModule.kt index 36b9e50..599e981 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/AnimeSuggestionsModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/di/AnimeSuggestionsModule.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.anime_suggestions.di import android.content.SharedPreferences import com.sharkaboi.mediahub.data.api.retrofit.AnimeService 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 20240d1..6d5124f 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 @@ -8,11 +8,10 @@ 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 -import com.sharkaboi.mediahub.data.paging.AnimeSuggestionsDataSource import com.sharkaboi.mediahub.data.sharedpref.SharedPreferencesKeys +import com.sharkaboi.mediahub.modules.anime_suggestions.data.AnimeSuggestionsDataSource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull -import timber.log.Timber class AnimeSuggestionsRepositoryImpl( private val animeService: AnimeService, @@ -23,7 +22,6 @@ class AnimeSuggestionsRepositoryImpl( override suspend fun getAnimeSuggestions(): Flow> { val showNsfw = sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - Timber.d("accessToken: $accessToken") return Pager( config = PagingConfig( pageSize = ApiConstants.API_PAGE_LIMIT, 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 2721184..32a3d3a 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 @@ -9,17 +9,18 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import androidx.paging.CombinedLoadStates import androidx.paging.LoadState import androidx.recyclerview.widget.DefaultItemAnimator -import androidx.recyclerview.widget.GridLayoutManager import com.sharkaboi.mediahub.BottomNavGraphDirections +import com.sharkaboi.mediahub.common.constants.UIConstants +import com.sharkaboi.mediahub.common.extensions.observe import com.sharkaboi.mediahub.common.extensions.showToast import com.sharkaboi.mediahub.databinding.FragmentAnimeSuggestionsBinding import com.sharkaboi.mediahub.modules.anime_suggestions.adapters.AnimeSuggestionsAdapter import com.sharkaboi.mediahub.modules.anime_suggestions.adapters.AnimeSuggestionsLoadStateAdapter import com.sharkaboi.mediahub.modules.anime_suggestions.vm.AnimeSuggestionsViewModel import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint @@ -40,6 +41,7 @@ class AnimeSuggestionsFragment : Fragment() { } override fun onDestroyView() { + animeSuggestionsAdapter.removeLoadStateListener(loadStateListener) binding.rvAnimeSuggestions.adapter = null _binding = null super.onDestroyView() @@ -58,7 +60,7 @@ class AnimeSuggestionsFragment : Fragment() { val action = BottomNavGraphDirections.openAnimeById(animeId) navController.navigate(action) } - layoutManager = GridLayoutManager(context, 3) + layoutManager = UIConstants.getGridLayoutManager(context) itemAnimator = DefaultItemAnimator() adapter = animeSuggestionsAdapter.withLoadStateFooter( footer = AnimeSuggestionsLoadStateAdapter() @@ -66,22 +68,19 @@ class AnimeSuggestionsFragment : Fragment() { } } - private fun setObservers() { - lifecycleScope.launch { - animeSuggestionsAdapter.addLoadStateListener { loadStates -> - if (loadStates.source.refresh is LoadState.Error) { - showToast((loadStates.source.refresh as LoadState.Error).error.message) - } - binding.progressBar.isShowing = loadStates.refresh is LoadState.Loading - binding.tvEmptyHint.isVisible = - loadStates.refresh is LoadState.NotLoading && animeSuggestionsAdapter.itemCount == 0 - } + private val loadStateListener = { loadStates: CombinedLoadStates -> + if (loadStates.source.refresh is LoadState.Error) { + showToast((loadStates.source.refresh as LoadState.Error).error.message) } - lifecycleScope.launch { - animeSuggestionsViewModel.getAnimeSuggestions() - .collectLatest { pagingData -> - animeSuggestionsAdapter.submitData(pagingData) - } + binding.progressBar.isShowing = loadStates.refresh is LoadState.Loading + binding.tvEmptyHint.isVisible = + loadStates.refresh is LoadState.NotLoading && animeSuggestionsAdapter.itemCount == 0 + } + + private fun setObservers() { + animeSuggestionsAdapter.addLoadStateListener(loadStateListener) + observe(animeSuggestionsViewModel.result) { pagingData -> + lifecycleScope.launch { animeSuggestionsAdapter.submitData(pagingData) } } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/vm/AnimeSuggestionsViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/vm/AnimeSuggestionsViewModel.kt index 608e805..c19945b 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/vm/AnimeSuggestionsViewModel.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/anime_suggestions/vm/AnimeSuggestionsViewModel.kt @@ -1,5 +1,7 @@ package com.sharkaboi.mediahub.modules.anime_suggestions.vm +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData @@ -8,6 +10,8 @@ import com.sharkaboi.mediahub.data.api.models.anime.AnimeSuggestionsResponse import com.sharkaboi.mediahub.modules.anime_suggestions.repository.AnimeSuggestionsRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -15,12 +19,16 @@ class AnimeSuggestionsViewModel @Inject constructor( private val animeSuggestionsRepository: AnimeSuggestionsRepository ) : ViewModel() { - private var _pagedResult: Flow>? = null + private var _result = MutableLiveData>() + val result: LiveData> = _result - suspend fun getAnimeSuggestions(): Flow> { + init { + getAnimeSuggestions() + } + + private fun getAnimeSuggestions() = viewModelScope.launch { val newResult: Flow> = animeSuggestionsRepository.getAnimeSuggestions().cachedIn(viewModelScope) - _pagedResult = newResult - return newResult + _result.value = newResult.firstOrNull() ?: PagingData.empty() } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/AuthModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/auth/di/AuthModule.kt similarity index 94% rename from app/src/main/java/com/sharkaboi/mediahub/di/AuthModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/auth/di/AuthModule.kt index a638a00..b10a258 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/AuthModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/auth/di/AuthModule.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.auth.di import com.sharkaboi.mediahub.data.api.retrofit.AuthService import com.sharkaboi.mediahub.data.datastore.DataStoreRepository 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 021ae09..25b9e0d 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 @@ -2,13 +2,11 @@ package com.sharkaboi.mediahub.modules.auth.repository import com.haroldadmin.cnradapter.NetworkResponse import com.sharkaboi.mediahub.BuildConfig -import com.sharkaboi.mediahub.common.extensions.emptyString +import com.sharkaboi.mediahub.common.extensions.getCatching 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 class OAuthRepositoryImpl( @@ -16,52 +14,52 @@ class OAuthRepositoryImpl( private val dataStoreRepository: DataStoreRepository ) : OAuthRepository { - override suspend fun getAccessToken(code: String, codeVerifier: String): MHTaskState = - withContext(Dispatchers.IO) { - val response = authService.getAccessTokenAsync( - clientId = BuildConfig.clientId, - code = code, - codeVerifier = codeVerifier - ).await() - when (response) { - is NetworkResponse.Success -> { - Timber.d(response.body.toString()) - dataStoreRepository.setAccessToken(response.body.accessToken) - dataStoreRepository.setExpireIn() - dataStoreRepository.setRefreshToken(response.body.refreshToken) - return@withContext MHTaskState( - isSuccess = true, - data = null, - error = MHError.EmptyError - ) - } - is NetworkResponse.ServerError -> { - Timber.d(response.body.toString()) - 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 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 MHTaskState( - isSuccess = false, - data = null, - error = response.error.message?.let { MHError(it) } - ?: MHError.ParsingError + override suspend fun getAccessToken( + code: String, + codeVerifier: String + ): MHTaskState = getCatching { + val response = authService.getAccessTokenAsync( + clientId = BuildConfig.clientId, + code = code, + codeVerifier = codeVerifier + ).await() + Timber.d(response.toString()) + + return@getCatching when (response) { + is NetworkResponse.Success -> { + dataStoreRepository.setAccessToken(response.body.accessToken) + dataStoreRepository.setExpireIn() + dataStoreRepository.setRefreshToken(response.body.refreshToken) + MHTaskState( + isSuccess = true, + data = null, + error = MHError.EmptyError + ) + } + is NetworkResponse.ServerError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError( + response.body?.message, + MHError.apiErrorWithCode(response.code) ) - } + ) + } + is NetworkResponse.NetworkError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError(response.error.message, MHError.NetworkError) + ) + } + is NetworkResponse.UnknownError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError(response.error.message, MHError.ParsingError) + ) } } + } } 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 c01f3d9..d2f690e 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 @@ -13,7 +13,7 @@ import kotlin.random.Random class OAuthViewModel @Inject constructor( private val oAuthRepository: OAuthRepository, - savedStateHandle: SavedStateHandle, + savedStateHandle: SavedStateHandle ) : ViewModel() { private val _oAuthState = MutableLiveData().getDefault() val oAuthState: LiveData = _oAuthState 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 36f8e18..0b1323d 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 @@ -10,7 +10,7 @@ import coil.load 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 +import com.sharkaboi.mediahub.databinding.AnimeListItemHorizontalBinding class AiringAnimeAdapter(private val onClick: (Int) -> Unit) : RecyclerView.Adapter() { @@ -34,10 +34,14 @@ class AiringAnimeAdapter(private val onClick: (Int) -> Unit) : private val listDiffer = AsyncListDiffer(this, diffUtilItemCallback) - private lateinit var binding: AnimeListItemBinding + private lateinit var binding: AnimeListItemHorizontalBinding override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AiringAnimeViewHolder { - binding = AnimeListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding = AnimeListItemHorizontalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return AiringAnimeViewHolder(binding, onClick) } @@ -52,7 +56,7 @@ class AiringAnimeAdapter(private val onClick: (Int) -> Unit) : } class AiringAnimeViewHolder( - private val binding: AnimeListItemBinding, + private val binding: AnimeListItemHorizontalBinding, private val onClick: (Int) -> Unit ) : RecyclerView.ViewHolder(binding.root) { @@ -62,10 +66,11 @@ class AiringAnimeAdapter(private val onClick: (Int) -> Unit) : } binding.tvAnimeName.text = item.node.title binding.tvEpisodesWatched.isVisible = false - binding.tvScore.text = binding.tvScore.context.getRatingStringWithRating(item.node.meanScore) + binding.tvScore.text = + binding.tvScore.context.getRatingStringWithRating(item.node.meanScore) binding.ivAnimeBanner.load( - uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, - builder = UIConstants.AnimeImageBuilder + item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.TopRoundedAnimeImageBuilder ) } } 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 c50ecba..99ca4bd 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 @@ -10,7 +10,7 @@ import coil.load 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 +import com.sharkaboi.mediahub.databinding.AnimeListItemHorizontalBinding class AnimeRankingAdapter(private val onClick: (Int) -> Unit) : RecyclerView.Adapter() { @@ -34,10 +34,14 @@ class AnimeRankingAdapter(private val onClick: (Int) -> Unit) : private val listDiffer = AsyncListDiffer(this, diffUtilItemCallback) - private lateinit var binding: AnimeListItemBinding + private lateinit var binding: AnimeListItemHorizontalBinding override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AnimeRankingViewHolder { - binding = AnimeListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding = AnimeListItemHorizontalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return AnimeRankingViewHolder(binding, onClick) } @@ -52,7 +56,7 @@ class AnimeRankingAdapter(private val onClick: (Int) -> Unit) : } class AnimeRankingViewHolder( - private val binding: AnimeListItemBinding, + private val binding: AnimeListItemHorizontalBinding, private val onClick: (Int) -> Unit ) : RecyclerView.ViewHolder(binding.root) { @@ -62,10 +66,11 @@ class AnimeRankingAdapter(private val onClick: (Int) -> Unit) : } binding.tvAnimeName.text = item.node.title binding.tvEpisodesWatched.isVisible = false - binding.tvScore.text = binding.tvScore.context.getRatingStringWithRating(item.node.meanScore) + binding.tvScore.text = + binding.tvScore.context.getRatingStringWithRating(item.node.meanScore) binding.ivAnimeBanner.load( - uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, - builder = UIConstants.AnimeImageBuilder + item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.TopRoundedAnimeImageBuilder ) } } 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 6f23b0c..bf6a6cb 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 @@ -10,7 +10,7 @@ import coil.load 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 +import com.sharkaboi.mediahub.databinding.AnimeListItemHorizontalBinding class AnimeSuggestionsAdapter(private val onClick: (Int) -> Unit) : RecyclerView.Adapter() { @@ -34,10 +34,14 @@ class AnimeSuggestionsAdapter(private val onClick: (Int) -> Unit) : private val listDiffer = AsyncListDiffer(this, diffUtilItemCallback) - private lateinit var binding: AnimeListItemBinding + private lateinit var binding: AnimeListItemHorizontalBinding override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AnimeSuggestionsViewHolder { - binding = AnimeListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding = AnimeListItemHorizontalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return AnimeSuggestionsViewHolder(binding, onClick) } @@ -52,7 +56,7 @@ class AnimeSuggestionsAdapter(private val onClick: (Int) -> Unit) : } class AnimeSuggestionsViewHolder( - private val binding: AnimeListItemBinding, + private val binding: AnimeListItemHorizontalBinding, private val onClick: (Int) -> Unit ) : RecyclerView.ViewHolder(binding.root) { @@ -62,10 +66,11 @@ class AnimeSuggestionsAdapter(private val onClick: (Int) -> Unit) : } binding.tvAnimeName.text = item.node.title binding.tvEpisodesWatched.isVisible = false - binding.tvScore.text = binding.tvScore.context.getRatingStringWithRating(item.node.meanScore) + binding.tvScore.text = + binding.tvScore.context.getRatingStringWithRating(item.node.meanScore) binding.ivAnimeBanner.load( - uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, - builder = UIConstants.AnimeImageBuilder + item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.TopRoundedAnimeImageBuilder ) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/DiscoverModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/di/DiscoverModule.kt similarity index 94% rename from app/src/main/java/com/sharkaboi/mediahub/di/DiscoverModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/discover/di/DiscoverModule.kt index 78147a3..4c9723b 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/DiscoverModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/di/DiscoverModule.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.discover.di import android.content.SharedPreferences import com.sharkaboi.mediahub.data.api.retrofit.AnimeService 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 874fe0e..7fe9233 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 @@ -2,7 +2,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.getCatching 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 @@ -13,9 +13,7 @@ import com.sharkaboi.mediahub.data.datastore.DataStoreRepository import com.sharkaboi.mediahub.data.sharedpref.SharedPreferencesKeys import com.sharkaboi.mediahub.data.wrappers.MHError import com.sharkaboi.mediahub.data.wrappers.MHTaskState -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.withContext import timber.log.Timber import java.time.LocalDate @@ -26,209 +24,165 @@ class DiscoverRepositoryImpl( ) : DiscoverRepository { override suspend fun getAnimeRecommendations(): MHTaskState = - withContext(Dispatchers.IO) { - try { - val showNsfw = - sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) - val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - if (accessToken == null) { - return@withContext MHTaskState( + getCatching { + val showNsfw = + sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) + val accessToken: String = dataStoreRepository.accessTokenFlow.firstOrNull() + ?: return@getCatching MHTaskState( + isSuccess = false, + data = null, + error = MHError.LoginExpiredError + ) + val result = animeService.getAnimeSuggestionsAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + limit = ApiConstants.API_PAGE_LIMIT, + offset = ApiConstants.API_START_OFFSET, + nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY + ).await() + + Timber.d(result.toString()) + return@getCatching when (result) { + is NetworkResponse.Success -> { + MHTaskState( + isSuccess = true, + data = result.body, + error = MHError.EmptyError + ) + } + is NetworkResponse.NetworkError -> { + MHTaskState( isSuccess = false, data = null, - error = MHError.LoginExpiredError + error = MHError.getError(result.error.message, MHError.NetworkError) + ) + } + is NetworkResponse.ServerError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError( + result.body?.message, + MHError.apiErrorWithCode(result.code) + ) + ) + } + is NetworkResponse.UnknownError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError(result.error.message, MHError.ParsingError) ) - } else { - val result = animeService.getAnimeSuggestionsAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - limit = ApiConstants.API_PAGE_LIMIT, - offset = ApiConstants.API_START_OFFSET, - nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY - ).await() - when (result) { - is NetworkResponse.Success -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = true, - data = result.body, - error = MHError.EmptyError - ) - } - is NetworkResponse.NetworkError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.NetworkError - ) - } - is NetworkResponse.ServerError -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.body?.message?.let { MHError(it) } - ?: MHError.apiErrorWithCode(result.code) - ) - } - is NetworkResponse.UnknownError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.ParsingError - ) - } - } } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = e.message?.let { MHError(it) } ?: MHError.UnknownError - ) } } override suspend fun getAnimeSeasonals(): MHTaskState = - withContext(Dispatchers.IO) { - try { - val showNsfw = - sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) - val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - if (accessToken == null) { - return@withContext MHTaskState( + getCatching { + val showNsfw = + sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) + val accessToken: String = dataStoreRepository.accessTokenFlow.firstOrNull() + ?: return@getCatching MHTaskState( + isSuccess = false, + data = null, + error = MHError.LoginExpiredError + ) + + val today = LocalDate.now() + val result = animeService.getAnimeBySeasonAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + year = today.year, + season = today.getAnimeSeason().name, + limit = ApiConstants.API_PAGE_LIMIT, + offset = ApiConstants.API_START_OFFSET, + nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY + ).await() + + Timber.d(result.toString()) + return@getCatching when (result) { + is NetworkResponse.Success -> { + MHTaskState( + isSuccess = true, + data = result.body, + error = MHError.EmptyError + ) + } + is NetworkResponse.NetworkError -> { + MHTaskState( isSuccess = false, data = null, - error = MHError.LoginExpiredError + error = MHError.getError(result.error.message, MHError.NetworkError) + ) + } + is NetworkResponse.ServerError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError( + result.body?.message, + MHError.apiErrorWithCode(result.code) + ) + ) + } + is NetworkResponse.UnknownError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError(result.error.message, MHError.ParsingError) ) - } else { - val today = LocalDate.now() - val result = animeService.getAnimeBySeasonAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - year = today.year, - season = today.getAnimeSeason().name, - limit = ApiConstants.API_PAGE_LIMIT, - offset = ApiConstants.API_START_OFFSET, - nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY - ).await() - when (result) { - is NetworkResponse.Success -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = true, - data = result.body, - error = MHError.EmptyError - ) - } - is NetworkResponse.NetworkError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.NetworkError - ) - } - is NetworkResponse.ServerError -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.body?.message?.let { MHError(it) } - ?: MHError.apiErrorWithCode(result.code) - ) - } - is NetworkResponse.UnknownError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.ParsingError - ) - } - } } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = e.message?.let { MHError(it) } ?: MHError.UnknownError - ) } } override suspend fun getAnimeRankings(): MHTaskState = - withContext(Dispatchers.IO) { - try { - val showNsfw = - sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) - val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - if (accessToken == null) { - return@withContext MHTaskState( + getCatching { + val showNsfw = + sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) + val accessToken: String = dataStoreRepository.accessTokenFlow.firstOrNull() + ?: return@getCatching MHTaskState( + isSuccess = false, + data = null, + error = MHError.LoginExpiredError + ) + val result = animeService.getAnimeRankingAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + limit = ApiConstants.API_PAGE_LIMIT, + offset = ApiConstants.API_START_OFFSET, + nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY + ).await() + + Timber.d(result.toString()) + return@getCatching when (result) { + is NetworkResponse.Success -> { + MHTaskState( + isSuccess = true, + data = result.body, + error = MHError.EmptyError + ) + } + is NetworkResponse.NetworkError -> { + MHTaskState( isSuccess = false, data = null, - error = MHError.LoginExpiredError + error = MHError.getError(result.error.message, MHError.NetworkError) + ) + } + is NetworkResponse.ServerError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError( + result.body?.message, + MHError.apiErrorWithCode(result.code) + ) + ) + } + is NetworkResponse.UnknownError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError(result.error.message, MHError.ParsingError) ) - } else { - val result = animeService.getAnimeRankingAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - limit = ApiConstants.API_PAGE_LIMIT, - offset = ApiConstants.API_START_OFFSET, - nsfw = if (showNsfw) ApiConstants.NSFW_ALSO else ApiConstants.SFW_ONLY - ).await() - when (result) { - is NetworkResponse.Success -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = true, - data = result.body, - error = MHError.EmptyError - ) - } - is NetworkResponse.NetworkError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.NetworkError - ) - } - is NetworkResponse.ServerError -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.body?.message?.let { MHError(it) } - ?: MHError.apiErrorWithCode(result.code) - ) - } - is NetworkResponse.UnknownError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.ParsingError - ) - } - } } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - 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 40313c2..4d943d9 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 @@ -22,7 +22,6 @@ import com.sharkaboi.mediahub.modules.discover.adapters.AiringAnimeAdapter import com.sharkaboi.mediahub.modules.discover.adapters.AnimeRankingAdapter import com.sharkaboi.mediahub.modules.discover.adapters.AnimeSuggestionsAdapter import com.sharkaboi.mediahub.modules.discover.adapters.LoadMoreAdapter -import com.sharkaboi.mediahub.modules.discover.util.DiscoverAnimeListWrapper import com.sharkaboi.mediahub.modules.discover.vm.DiscoverState import com.sharkaboi.mediahub.modules.discover.vm.DiscoverViewModel import dagger.hilt.android.AndroidEntryPoint @@ -83,16 +82,18 @@ class DiscoverFragment : Fragment() { binding.progress.isVisible = uiState is DiscoverState.Loading when (uiState) { is DiscoverState.AnimeDetailsFailure -> showToast(uiState.message) - is DiscoverState.AnimeDetailsSuccess -> setupRecyclerViews(uiState.data) else -> Unit } } - } - - private fun setupRecyclerViews(discoverAnimeListWrapper: DiscoverAnimeListWrapper) { - setupAnimeRecommendationsList(discoverAnimeListWrapper.animeSuggestions) - setupAnimeAiringList(discoverAnimeListWrapper.animeOfCurrentSeason) - setupAnimeRankingList(discoverAnimeListWrapper.animeRankings) + observe(discoverDetailsViewModel.animeOfCurrentSeason) { + setupAnimeAiringList(it) + } + observe(discoverDetailsViewModel.animeRankings) { + setupAnimeRankingList(it) + } + observe(discoverDetailsViewModel.animeSuggestions) { + setupAnimeRecommendationsList(it) + } } private fun setupAnimeRankingList(animeRankings: List) { diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/util/DiscoverAnimeListWrapper.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/util/DiscoverAnimeListWrapper.kt deleted file mode 100644 index e37b533..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/util/DiscoverAnimeListWrapper.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.sharkaboi.mediahub.modules.discover.util - -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 - -data class DiscoverAnimeListWrapper( - val animeSuggestions: List, - val animeRankings: List, - val animeOfCurrentSeason: List, -) diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/vm/DiscoverState.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/vm/DiscoverState.kt index e94bd14..cc677f5 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/vm/DiscoverState.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/vm/DiscoverState.kt @@ -1,12 +1,10 @@ package com.sharkaboi.mediahub.modules.discover.vm import androidx.lifecycle.MutableLiveData -import com.sharkaboi.mediahub.modules.discover.util.DiscoverAnimeListWrapper sealed class DiscoverState { object Idle : DiscoverState() object Loading : DiscoverState() - data class AnimeDetailsSuccess(val data: DiscoverAnimeListWrapper) : DiscoverState() data class AnimeDetailsFailure(val message: String) : DiscoverState() } @@ -18,11 +16,6 @@ fun MutableLiveData.setIdle() = this.apply { value = DiscoverState.Idle } -fun MutableLiveData.setFetchSuccess(data: DiscoverAnimeListWrapper) = - this.apply { - value = DiscoverState.AnimeDetailsSuccess(data) - } - fun MutableLiveData.setFailure(message: String) = this.apply { value = DiscoverState.AnimeDetailsFailure(message) } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/vm/DiscoverViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/vm/DiscoverViewModel.kt index f602b20..cb910cc 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/discover/vm/DiscoverViewModel.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/discover/vm/DiscoverViewModel.kt @@ -4,9 +4,12 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +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.modules.discover.repository.DiscoverRepository -import com.sharkaboi.mediahub.modules.discover.util.DiscoverAnimeListWrapper import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async import kotlinx.coroutines.launch import javax.inject.Inject @@ -18,40 +21,47 @@ class DiscoverViewModel private val _uiState = MutableLiveData().setIdle() val uiState: LiveData = _uiState + private val _animeSuggestions = MutableLiveData>() + val animeSuggestions: LiveData> = _animeSuggestions + + private val _animeRankings = MutableLiveData>() + val animeRankings: LiveData> = _animeRankings + + private val _animeOfCurrentSeason = MutableLiveData>() + val animeOfCurrentSeason: LiveData> = _animeOfCurrentSeason + init { getAnimeRecommendations() } - private fun getAnimeRecommendations() { + private fun getAnimeRecommendations() = viewModelScope.launch { _uiState.setLoading() - viewModelScope.launch { - val animeRecommendationsResult = discoverRepository.getAnimeRecommendations() - if (animeRecommendationsResult.isSuccess) { - val animeRankingsResult = discoverRepository.getAnimeRankings() - if (animeRankingsResult.isSuccess) { - val animeSeasonalsResult = discoverRepository.getAnimeSeasonals() - if (animeSeasonalsResult.isSuccess) { - if (animeRecommendationsResult.data?.data != null && - animeRankingsResult.data?.data != null && - animeSeasonalsResult.data?.data != null - ) { - _uiState.setFetchSuccess( - DiscoverAnimeListWrapper( - animeOfCurrentSeason = animeSeasonalsResult.data.data, - animeRankings = animeRankingsResult.data.data, - animeSuggestions = animeRecommendationsResult.data.data - ) - ) - } - } else { - _uiState.setFailure(animeSeasonalsResult.error.errorMessage) - } - } else { - _uiState.setFailure(animeRankingsResult.error.errorMessage) - } - } else { - _uiState.setFailure(animeRecommendationsResult.error.errorMessage) - } + + val animeRecommendationsDeferred = async { discoverRepository.getAnimeRecommendations() } + val animeRankingsDeferred = async { discoverRepository.getAnimeRankings() } + val animeSeasonalsDeferred = async { discoverRepository.getAnimeSeasonals() } + val animeRecommendationsResult = animeRecommendationsDeferred.await() + val animeRankingsResult = animeRankingsDeferred.await() + val animeSeasonalsResult = animeSeasonalsDeferred.await() + + if (animeRecommendationsResult.isSuccess.not()) { + _uiState.setFailure(animeRecommendationsResult.error.errorMessage) + } else { + _animeSuggestions.value = animeRecommendationsResult.data.data } + + if (animeRankingsResult.isSuccess.not()) { + _uiState.setFailure(animeRankingsResult.error.errorMessage) + } else { + _animeRankings.value = animeRankingsResult.data.data + } + + if (animeSeasonalsResult.isSuccess.not()) { + _uiState.setFailure(animeSeasonalsResult.error.errorMessage) + } else { + _animeOfCurrentSeason.value = animeSeasonalsResult.data.data + } + + _uiState.setIdle() } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/views/image_slider/FullScreenImageFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/image_slider/FullScreenImageFragment.kt similarity index 96% rename from app/src/main/java/com/sharkaboi/mediahub/common/views/image_slider/FullScreenImageFragment.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/image_slider/FullScreenImageFragment.kt index f4b44af..6be8c9d 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/views/image_slider/FullScreenImageFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/image_slider/FullScreenImageFragment.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.common.views.image_slider +package com.sharkaboi.mediahub.modules.image_slider import android.os.Bundle import android.view.LayoutInflater diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/views/image_slider/ImageSliderAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/image_slider/ImageSliderAdapter.kt similarity index 90% rename from app/src/main/java/com/sharkaboi/mediahub/common/views/image_slider/ImageSliderAdapter.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/image_slider/ImageSliderAdapter.kt index 8f08bd4..e1f6e29 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/views/image_slider/ImageSliderAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/image_slider/ImageSliderAdapter.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.common.views.image_slider +package com.sharkaboi.mediahub.modules.image_slider import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/views/image_slider/ImageSliderFragment.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/image_slider/ImageSliderFragment.kt similarity index 97% rename from app/src/main/java/com/sharkaboi/mediahub/common/views/image_slider/ImageSliderFragment.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/image_slider/ImageSliderFragment.kt index 9ff7143..5b401bb 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/views/image_slider/ImageSliderFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/image_slider/ImageSliderFragment.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.common.views.image_slider +package com.sharkaboi.mediahub.modules.image_slider import android.app.Dialog import android.graphics.Color 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 e4147c7..98548d0 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 @@ -2,18 +2,13 @@ package com.sharkaboi.mediahub.modules.main.ui import android.content.Intent import android.os.Bundle -import android.view.animation.AccelerateDecelerateInterpolator -import android.view.animation.AnimationUtils import androidx.activity.viewModels -import androidx.annotation.IdRes import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.isVisible import androidx.navigation.NavController import androidx.navigation.findNavController 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 @@ -28,11 +23,6 @@ class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var navController: NavController private val mainViewModel by viewModels() - private val anim by lazy { - AnimationUtils.loadAnimation(this, R.anim.fab_explode).apply { - interpolator = AccelerateDecelerateInterpolator() - } - } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -67,13 +57,8 @@ class MainActivity : AppCompatActivity() { } private fun setListeners() { - setVisibilityAndListeners(binding.bottomNav.selectedItemId) - navController.addOnDestinationChangedListener { _, destination, _ -> - setVisibilityAndListeners(destination.id) - } binding.bottomNav.setOnItemReselectedListener { navController.popBackStack(it.itemId, false) - setVisibilityAndListeners(it.itemId) } } @@ -86,26 +71,6 @@ class MainActivity : AppCompatActivity() { } } - private fun setVisibilityAndListeners(@IdRes id: Int) { - val isAnimeItem = - id == R.id.anime_item && navController.currentDestination?.id == R.id.anime_item - val isMangaItem = - id == R.id.manga_item && navController.currentDestination?.id == R.id.manga_item - binding.fabSearch.isVisible = isAnimeItem || isMangaItem - binding.fabSearch.setOnClickListener { - binding.fabSearch.isVisible = false - binding.circleAnimeView.isVisible = true - binding.circleAnimeView.startAnim(anim) { - binding.fabSearch.isVisible = isAnimeItem || isMangaItem - when { - isAnimeItem -> navController.navigate(R.id.openAnimeSearch) - isMangaItem -> navController.navigate(R.id.openMangaSearch) - } - binding.circleAnimeView.isVisible = false - } - } - } - private fun redirectToOAuthFlow() { startActivity(Intent(this, OAuthActivity::class.java)) overridePendingTransition(R.anim.fade_in, R.anim.fade_out) 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 deleted file mode 100644 index f0b1ba4..0000000 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/vm/MangaViewModel.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.sharkaboi.mediahub.modules.manga.vm - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.paging.PagingData -import androidx.paging.cachedIn -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 -import com.sharkaboi.mediahub.modules.manga.repository.MangaRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -@HiltViewModel -class MangaViewModel -@Inject constructor( - private val mangaRepository: MangaRepository -) : ViewModel() { - private var _currentChosenMangaStatus: MangaStatus = MangaStatus.all - private val currentChosenMangaStatus get() = _currentChosenMangaStatus - private var _currentChosenSortType: UserMangaSortType = UserMangaSortType.list_updated_at - val currentChosenSortType get() = _currentChosenSortType - private var _pagedMangaList: Flow>? = null - - suspend fun getMangaList(): Flow> { - val newResult: Flow> = - mangaRepository.getMangaListFlow( - mangaStatus = currentChosenMangaStatus, - mangaSortType = currentChosenSortType - ).cachedIn(viewModelScope) - _pagedMangaList = newResult - return newResult - } - - fun setMangaStatus(status: MangaStatus) { - _currentChosenMangaStatus = status - } - - fun setSortType(userMangaSortType: UserMangaSortType) { - _currentChosenSortType = userMangaSortType - } -} 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 92cb9b2..7a866d2 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 @@ -11,7 +11,7 @@ import coil.load 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 +import com.sharkaboi.mediahub.databinding.MangaListItemHorizontalBinding class RecommendedMangaAdapter(private val onClick: (Int) -> Unit) : RecyclerView.Adapter() { @@ -35,10 +35,14 @@ class RecommendedMangaAdapter(private val onClick: (Int) -> Unit) : private val listDiffer = AsyncListDiffer(this, diffUtilItemCallback) - private lateinit var binding: MangaListItemBinding + private lateinit var binding: MangaListItemHorizontalBinding override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecommendedMangaViewHolder { - binding = MangaListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding = MangaListItemHorizontalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return RecommendedMangaViewHolder(binding, onClick) } @@ -53,7 +57,7 @@ class RecommendedMangaAdapter(private val onClick: (Int) -> Unit) : } class RecommendedMangaViewHolder( - private val binding: MangaListItemBinding, + private val binding: MangaListItemHorizontalBinding, private val onClick: (Int) -> Unit ) : RecyclerView.ViewHolder(binding.root) { @@ -71,8 +75,8 @@ class RecommendedMangaAdapter(private val onClick: (Int) -> Unit) : ) binding.cardRating.isGone = true binding.ivMangaBanner.load( - uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, - builder = UIConstants.MangaImageBuilder + item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.TopRoundedMangaImageBuilder ) } } 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 f936cda..899f31a 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 @@ -9,7 +9,7 @@ import androidx.recyclerview.widget.RecyclerView import coil.load import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse -import com.sharkaboi.mediahub.databinding.AnimeListItemBinding +import com.sharkaboi.mediahub.databinding.AnimeListItemHorizontalBinding class RelatedAnimeAdapter(private val onClick: (Int) -> Unit) : RecyclerView.Adapter() { @@ -33,10 +33,14 @@ class RelatedAnimeAdapter(private val onClick: (Int) -> Unit) : private val listDiffer = AsyncListDiffer(this, diffUtilItemCallback) - private lateinit var binding: AnimeListItemBinding + private lateinit var binding: AnimeListItemHorizontalBinding override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RelatedAnimeViewHolder { - binding = AnimeListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding = AnimeListItemHorizontalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return RelatedAnimeViewHolder(binding, onClick) } @@ -51,7 +55,7 @@ class RelatedAnimeAdapter(private val onClick: (Int) -> Unit) : } class RelatedAnimeViewHolder( - private val binding: AnimeListItemBinding, + private val binding: AnimeListItemHorizontalBinding, private val onClick: (Int) -> Unit ) : RecyclerView.ViewHolder(binding.root) { @@ -63,8 +67,8 @@ class RelatedAnimeAdapter(private val onClick: (Int) -> Unit) : binding.tvEpisodesWatched.text = item.relationTypeFormatted binding.cardRating.isGone = true binding.ivAnimeBanner.load( - uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, - builder = UIConstants.AnimeImageBuilder + item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.TopRoundedAnimeImageBuilder ) } } 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 b784549..eebe5d4 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 @@ -10,7 +10,7 @@ import androidx.recyclerview.widget.RecyclerView import coil.load import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse -import com.sharkaboi.mediahub.databinding.MangaListItemBinding +import com.sharkaboi.mediahub.databinding.MangaListItemHorizontalBinding class RelatedMangaAdapter(private val onClick: (Int) -> Unit) : RecyclerView.Adapter() { @@ -34,10 +34,14 @@ class RelatedMangaAdapter(private val onClick: (Int) -> Unit) : private val listDiffer = AsyncListDiffer(this, diffUtilItemCallback) - private lateinit var binding: MangaListItemBinding + private lateinit var binding: MangaListItemHorizontalBinding override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RelatedMangaViewHolder { - binding = MangaListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding = MangaListItemHorizontalBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return RelatedMangaViewHolder(binding, onClick) } @@ -52,7 +56,7 @@ class RelatedMangaAdapter(private val onClick: (Int) -> Unit) : } class RelatedMangaViewHolder( - private val binding: MangaListItemBinding, + private val binding: MangaListItemHorizontalBinding, private val onClick: (Int) -> Unit ) : RecyclerView.ViewHolder(binding.root) { @@ -65,8 +69,8 @@ class RelatedMangaAdapter(private val onClick: (Int) -> Unit) : binding.cardRating.isGone = true binding.tvVolumesRead.isVisible = false binding.ivMangaBanner.load( - uri = item.node.mainPicture?.large ?: item.node.mainPicture?.medium, - builder = UIConstants.MangaImageBuilder + item.node.mainPicture?.large ?: item.node.mainPicture?.medium, + builder = UIConstants.TopRoundedMangaImageBuilder ) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/MangaDetailsModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/di/MangaDetailsModule.kt similarity index 94% rename from app/src/main/java/com/sharkaboi/mediahub/di/MangaDetailsModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/di/MangaDetailsModule.kt index dbd3e47..c410456 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/MangaDetailsModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/di/MangaDetailsModule.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.manga_details.di import com.sharkaboi.mediahub.data.api.retrofit.MangaService import com.sharkaboi.mediahub.data.api.retrofit.UserMangaService 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 78509eb..0ce4d30 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 @@ -1,7 +1,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.getCatching 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 @@ -10,9 +10,7 @@ import com.sharkaboi.mediahub.data.datastore.DataStoreRepository import com.sharkaboi.mediahub.data.wrappers.MHError import com.sharkaboi.mediahub.data.wrappers.MHTaskState import com.sharkaboi.mediahub.modules.manga_details.util.MangaDetailsUpdateClass -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.withContext import timber.log.Timber class MangaDetailsRepositoryImpl( @@ -20,208 +18,167 @@ class MangaDetailsRepositoryImpl( private val userMangaService: UserMangaService, private val dataStoreRepository: DataStoreRepository ) : MangaDetailsRepository { - override suspend fun getMangaById(mangaId: Int): MHTaskState = - withContext(Dispatchers.IO) { - try { - val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - if (accessToken == null) { - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = MHError.LoginExpiredError + override suspend fun getMangaById( + mangaId: Int + ): MHTaskState = getCatching { + val accessToken: String = dataStoreRepository.accessTokenFlow.firstOrNull() + ?: return@getCatching MHTaskState( + isSuccess = false, + data = null, + error = MHError.LoginExpiredError + ) + + val result = mangaService.getMangaByIdAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + mangaId = mangaId + ).await() + Timber.d(result.toString()) + + return@getCatching when (result) { + is NetworkResponse.Success -> { + MHTaskState( + isSuccess = true, + data = result.body, + error = MHError.EmptyError + ) + } + is NetworkResponse.NetworkError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError(result.error.message, MHError.NetworkError) + ) + } + is NetworkResponse.ServerError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError( + result.body?.message, + MHError.apiErrorWithCode(result.code) ) - } else { - val result = mangaService.getMangaByIdAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - mangaId = mangaId - ).await() - when (result) { - is NetworkResponse.Success -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = true, - data = result.body, - error = MHError.EmptyError - ) - } - is NetworkResponse.NetworkError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.NetworkError - ) - } - is NetworkResponse.ServerError -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.body?.message?.let { MHError(it) } - ?: MHError.apiErrorWithCode(result.code) - ) - } - is NetworkResponse.UnknownError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.ParsingError - ) - } - } - } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return@withContext MHTaskState( + ) + } + is NetworkResponse.UnknownError -> { + MHTaskState( isSuccess = false, data = null, - error = e.message?.let { MHError(it) } ?: MHError.UnknownError + error = MHError.getError(result.error.message, MHError.ParsingError) ) } } + } override suspend fun updateMangaStatus( mangaDetailsUpdateClass: MangaDetailsUpdateClass - ): MHTaskState = - withContext(Dispatchers.IO) { - try { - val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - if (accessToken == null) { - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = MHError.LoginExpiredError + ): MHTaskState = getCatching { + val accessToken: String = dataStoreRepository.accessTokenFlow.firstOrNull() + ?: return@getCatching MHTaskState( + isSuccess = false, + data = null, + error = MHError.LoginExpiredError + ) + + val result = userMangaService.updateMangaStatusAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + mangaId = mangaDetailsUpdateClass.mangaId, + mangaStatus = mangaDetailsUpdateClass.mangaStatus?.name, + score = mangaDetailsUpdateClass.score, + numChaptersRead = mangaDetailsUpdateClass.numReadChapters, + numVolumesRead = mangaDetailsUpdateClass.numReadVolumes + ).await() + Timber.d(result.toString()) + + return@getCatching when (result) { + is NetworkResponse.Success -> { + MHTaskState( + isSuccess = true, + data = Unit, + error = MHError.EmptyError + ) + } + is NetworkResponse.NetworkError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError(result.error.message, MHError.NetworkError) + ) + } + is NetworkResponse.ServerError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError( + result.body?.message, + MHError.apiErrorWithCode(result.code) ) - } else { - val result = userMangaService.updateMangaStatusAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - mangaId = mangaDetailsUpdateClass.mangaId, - mangaStatus = mangaDetailsUpdateClass.mangaStatus?.name, - score = mangaDetailsUpdateClass.score, - numChaptersRead = mangaDetailsUpdateClass.numReadChapters, - numVolumesRead = mangaDetailsUpdateClass.numReadVolumes - ).await() - when (result) { - is NetworkResponse.Success -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = true, - data = Unit, - error = MHError.EmptyError - ) - } - is NetworkResponse.NetworkError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.NetworkError - ) - } - is NetworkResponse.ServerError -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.body?.message?.let { MHError(it) } - ?: MHError.apiErrorWithCode(result.code) - ) - } - is NetworkResponse.UnknownError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.ParsingError - ) - } - } - } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return@withContext MHTaskState( + ) + } + is NetworkResponse.UnknownError -> { + MHTaskState( isSuccess = false, data = null, - error = e.message?.let { MHError(it) } ?: MHError.UnknownError + error = MHError.getError(result.error.message, MHError.ParsingError) ) } } + } - override suspend fun removeMangaFromList(mangaId: Int): MHTaskState = - withContext(Dispatchers.IO) { - try { - val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - if (accessToken == null) { - return@withContext MHTaskState( + override suspend fun removeMangaFromList( + mangaId: Int + ): MHTaskState = + getCatching { + val accessToken: String = dataStoreRepository.accessTokenFlow.firstOrNull() + ?: return@getCatching MHTaskState( + isSuccess = false, + data = null, + error = MHError.LoginExpiredError + ) + + val result = userMangaService.deleteMangaFromListAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, + mangaId = mangaId + ).await() + Timber.d(result.toString()) + + return@getCatching when (result) { + is NetworkResponse.Success -> { + MHTaskState( + isSuccess = true, + data = Unit, + error = MHError.EmptyError + ) + } + is NetworkResponse.NetworkError -> { + MHTaskState( isSuccess = false, data = null, - error = MHError.LoginExpiredError + error = MHError.getError(result.error.message, MHError.NetworkError) ) - } else { - val result = userMangaService.deleteMangaFromListAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken, - mangaId = mangaId - ).await() - when (result) { - is NetworkResponse.Success -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = true, - data = Unit, - error = MHError.EmptyError - ) - } - is NetworkResponse.NetworkError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.NetworkError - ) - } - is NetworkResponse.ServerError -> { - Timber.d(result.body.toString()) - if (result.code == 404) { - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = MHError.MangaNotFoundError - ) - } - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.body?.message?.let { MHError(it) } - ?: MHError.apiErrorWithCode(result.code) - ) - } - is NetworkResponse.UnknownError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.ParsingError - ) - } + } + is NetworkResponse.ServerError -> { + val error = if (result.code == 404) { + MHError.MangaNotFoundError + } else { + MHError.getError( + result.body?.message, + MHError.apiErrorWithCode(result.code) + ) } + + MHTaskState( + isSuccess = false, + data = null, + error = error + ) + } + is NetworkResponse.UnknownError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError(result.error.message, MHError.ParsingError) + ) } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - 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 ee83693..7372f81 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 @@ -13,7 +13,6 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import coil.load @@ -24,6 +23,7 @@ import com.sharkaboi.mediahub.R 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.openShareChooser import com.sharkaboi.mediahub.common.util.openUrl import com.sharkaboi.mediahub.data.api.constants.MALExternalLinks import com.sharkaboi.mediahub.data.api.enums.MangaStatus @@ -35,7 +35,7 @@ import com.sharkaboi.mediahub.databinding.FragmentMangaDetailsBinding import com.sharkaboi.mediahub.modules.manga_details.adapters.RecommendedMangaAdapter import com.sharkaboi.mediahub.modules.manga_details.adapters.RelatedAnimeAdapter import com.sharkaboi.mediahub.modules.manga_details.adapters.RelatedMangaAdapter -import com.sharkaboi.mediahub.modules.manga_details.util.MangaDetailsUpdateClass +import com.sharkaboi.mediahub.modules.manga_details.util.* import com.sharkaboi.mediahub.modules.manga_details.vm.MangaDetailsState import com.sharkaboi.mediahub.modules.manga_details.vm.MangaDetailsViewModel import dagger.hilt.android.AndroidEntryPoint @@ -45,7 +45,6 @@ class MangaDetailsFragment : Fragment() { private var _binding: FragmentMangaDetailsBinding? = null private val binding get() = _binding!! private val navController by lazy { findNavController() } - private val args: MangaDetailsFragmentArgs by navArgs() private val mangaDetailsViewModel by viewModels() override fun onCreateView( @@ -74,7 +73,7 @@ class MangaDetailsFragment : Fragment() { } private val handleSwipeRefresh = { - mangaDetailsViewModel.getMangaDetails(args.mangaId) + mangaDetailsViewModel.refreshDetails() binding.swipeRefresh.isRefreshing = false } @@ -86,7 +85,6 @@ class MangaDetailsFragment : Fragment() { private val handleMangaDetailsUpdate = { state: MangaDetailsState -> binding.progressBar.isShowing = state is MangaDetailsState.Loading when (state) { - is MangaDetailsState.Idle -> mangaDetailsViewModel.getMangaDetails(args.mangaId) is MangaDetailsState.FetchSuccess -> setData(state.mangaByIDResponse) is MangaDetailsState.MangaDetailsFailure -> showToast(state.message) else -> Unit @@ -107,7 +105,7 @@ class MangaDetailsFragment : Fragment() { openScoreDialog(state.score) } btnStatus.setOnClickListener { - openStatusDialog(state.mangaStatus?.name, state.mangaId) + openStatusDialog(state.mangaStatus?.name) } btnCountVolumes.setOnClickListener { openMangaVolumeCountDialog( @@ -285,10 +283,10 @@ class MangaDetailsFragment : Fragment() { setupGenresChipGroup(mangaByIDResponse.genres) } - private fun setupGenresChipGroup(genres: List) { + private fun setupGenresChipGroup(genres: List?) { val chipGroup = binding.otherDetails.genresChipGroup chipGroup.removeAllViews() - if (genres.isEmpty()) { + if (genres.isNullOrEmpty()) { val naChip = Chip(context) naChip.setMediaHubChipStyle() naChip.text = getString(R.string.n_a) @@ -312,7 +310,7 @@ class MangaDetailsFragment : Fragment() { openScoreDialog(mangaByIDResponse.myListStatus?.score) } btnStatus.setOnClickListener { - openStatusDialog(mangaByIDResponse.myListStatus?.status, mangaByIDResponse.id) + openStatusDialog(mangaByIDResponse.myListStatus?.status) } btnCountVolumes.setOnClickListener { openMangaVolumeCountDialog( @@ -327,7 +325,7 @@ class MangaDetailsFragment : Fragment() { ) } btnConfirm.setOnClickListener { - mangaDetailsViewModel.submitStatusUpdate(mangaByIDResponse.id) + mangaDetailsViewModel.submitStatusUpdate() } } @@ -347,6 +345,9 @@ class MangaDetailsFragment : Fragment() { tvRank.text = mangaByIDResponse.rank?.toString() ?: getString(R.string.n_a) tvPopularityRank.text = mangaByIDResponse.popularity?.toString() ?: getString(R.string.n_a) setupAuthorsChipGroup(mangaByIDResponse.authors) + ibShare.setOnClickListener { + openShareChooser(MALExternalLinks.getMangaLink(mangaByIDResponse)) + } } private fun setupAuthorsChipGroup(authors: List) { @@ -378,8 +379,8 @@ class MangaDetailsFragment : Fragment() { private fun setupMangaImagePreview(mangaByIDResponse: MangaByIDResponse) = binding.apply { ivMangaMainPicture.load( - uri = mangaByIDResponse.mainPicture?.large ?: mangaByIDResponse.mainPicture?.medium, - builder = UIConstants.MangaImageBuilder + mangaByIDResponse.mainPicture?.large ?: mangaByIDResponse.mainPicture?.medium, + builder = UIConstants.AllRoundedMangaImageBuilder ) ivMangaMainPicture.setOnClickListener { openImagesViewPager(mangaByIDResponse.pictures) @@ -395,7 +396,7 @@ class MangaDetailsFragment : Fragment() { private fun showFullSynopsisDialog(synopsis: String) = requireContext().showNoActionOkDialog(R.string.synopsis, synopsis) - private fun openStatusDialog(status: String?, mangaId: Int) { + private fun openStatusDialog(status: String?) { val singleItems = arrayOf(getString(R.string.not_added)) + MangaStatus.malStatuses.map { it.getFormattedString(requireContext()) @@ -407,7 +408,7 @@ class MangaDetailsFragment : Fragment() { .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> when (which) { checkedItem -> Unit - 0 -> mangaDetailsViewModel.removeFromList(mangaId) + 0 -> mangaDetailsViewModel.removeFromList() else -> mangaDetailsViewModel.setStatus(MangaStatus.malStatuses[which - 1]) } dialog.dismiss() diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/util/MangaDetailsUpdateClass.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/util/MangaDetailsUpdateClass.kt index df03c78..0a2d459 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/util/MangaDetailsUpdateClass.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/util/MangaDetailsUpdateClass.kt @@ -1,5 +1,6 @@ package com.sharkaboi.mediahub.modules.manga_details.util +import androidx.lifecycle.MutableLiveData import com.sharkaboi.mediahub.data.api.enums.MangaStatus data class MangaDetailsUpdateClass( @@ -11,3 +12,61 @@ data class MangaDetailsUpdateClass( val totalChapters: Int, val mangaId: Int ) + +fun MutableLiveData.setStatus(mangaStatus: MangaStatus?) { + value = value?.copy(mangaStatus = mangaStatus) +} + +// WARN : Doesnt check with total vol count +fun MutableLiveData.setReadVolumes(numReadVolumes: Int?) { + value = value?.copy(numReadVolumes = numReadVolumes) +} + +// WARN : Doesnt check with total chap count +fun MutableLiveData.setReadChapters(numReadChapters: Int?) { + value = value?.copy(numReadChapters = numReadChapters) +} + +// WARN : Doesnt check if > 10 +fun MutableLiveData.setScore(score: Int) { + value = value?.copy(score = score) +} + +fun MutableLiveData.setReadAsTotalChaps() { + // If total chaps not available (??), keep read count as same + if (value?.totalChapters == 0) { + return + } + + setReadChapters(value?.totalChapters) +} + +fun MutableLiveData.setReadAsTotalVols() { + // If total vols not available (??), keep read count as same + if (value?.totalVolumes == 0) { + return + } + + setReadChapters(value?.totalVolumes) +} + +fun MutableLiveData.setReadAsTotal() { + setReadAsTotalVols() + setReadAsTotalChaps() +} + +fun MutableLiveData.isNotOfStatus(vararg mangaStatuses: MangaStatus): Boolean { + return value?.mangaStatus == null || value?.mangaStatus !in mangaStatuses +} + +fun MutableLiveData.isMoreOrEqualToTotalVols(count: Int): Boolean { + val isTotalVolsAvailable = value?.totalVolumes != 0 + val totalVols = value?.totalVolumes ?: 0 + return isTotalVolsAvailable && count >= totalVols +} + +fun MutableLiveData.isMoreOrEqualToTotalChaps(count: Int): Boolean { + val isTotalChapsAvailable = value?.totalChapters != 0 + val totalChaps = value?.totalChapters ?: 0 + return isTotalChapsAvailable && count >= totalChaps +} \ No newline at end of file diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/util/PresentationExtensions.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/util/PresentationExtensions.kt new file mode 100644 index 0000000..14773ba --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_details/util/PresentationExtensions.kt @@ -0,0 +1,47 @@ +package com.sharkaboi.mediahub.modules.manga_details.util + +import android.content.Context +import android.text.Spanned +import androidx.core.text.HtmlCompat +import androidx.core.text.toSpanned +import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.extensions.ifNullOrBlank +import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse + +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 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.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 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) +} \ No newline at end of file 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 34a6141..cbb8610 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 @@ -1,124 +1,131 @@ package com.sharkaboi.mediahub.modules.manga_details.vm -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* 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 com.sharkaboi.mediahub.modules.manga_details.util.* import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel class MangaDetailsViewModel @Inject constructor( - private val mangaDetailsRepository: MangaDetailsRepository + private val mangaDetailsRepository: MangaDetailsRepository, + savedStateHandle: SavedStateHandle ) : ViewModel() { + private val mangaId = savedStateHandle.get(MANGA_ID_KEY) ?: 0 private val _uiState = MutableLiveData().getDefault() val uiState: LiveData = _uiState + private var _mangaDetailsUpdate: MutableLiveData = MutableLiveData() val mangaDetailsUpdate: LiveData = _mangaDetailsUpdate - fun getMangaDetails(mangaId: Int) { - viewModelScope.launch { - _uiState.setLoading() - val result = mangaDetailsRepository.getMangaById(mangaId) - if (result.isSuccess) { - result.data?.let { - _mangaDetailsUpdate.value = MangaDetailsUpdateClass( - mangaStatus = it.myListStatus?.status?.mangaStatusFromString(), - mangaId = it.id, - score = it.myListStatus?.score, - numReadChapters = it.myListStatus?.numChaptersRead, - numReadVolumes = it.myListStatus?.numVolumesRead, - totalVolumes = it.numVolumes, - totalChapters = it.numChapters - ) - _uiState.setFetchSuccess(result.data) - } - } else { - _uiState.setFailure(result.error.errorMessage) - } + init { + Timber.d("Saved state manga id $mangaId") + getMangaDetails(mangaId) + } + + private fun getMangaDetails(mangaId: Int) = viewModelScope.launch { + _uiState.setLoading() + val result = mangaDetailsRepository.getMangaById(mangaId) + if (!result.isSuccess) { + _uiState.setFailure(result.error.errorMessage) + return@launch } + + Timber.d("getMangaDetails: ${result.data}") + _mangaDetailsUpdate.value = MangaDetailsUpdateClass( + mangaStatus = MangaStatus.parse(result.data.myListStatus?.status), + mangaId = result.data.id, + score = result.data.myListStatus?.score, + numReadChapters = result.data.myListStatus?.numChaptersRead, + numReadVolumes = result.data.myListStatus?.numVolumesRead, + totalVolumes = result.data.numVolumes, + totalChapters = result.data.numChapters + ) + _uiState.setFetchSuccess(result.data) } fun setStatus(mangaStatus: MangaStatus) { - _mangaDetailsUpdate.apply { - value = value?.copy(mangaStatus = mangaStatus) - if (mangaStatus == MangaStatus.completed) { - value = value?.copy( - numReadVolumes = value?.totalVolumes, - numReadChapters = value?.totalChapters - ) - } + _mangaDetailsUpdate.setStatus(mangaStatus) + + if (mangaStatus == MangaStatus.completed) { + _mangaDetailsUpdate.setReadAsTotal() } } - fun setReadChapterCount(numReadChapter: Int) { - _mangaDetailsUpdate.apply { - if (this.value?.mangaStatus == null || - ( - this.value?.mangaStatus != MangaStatus.reading && - this.value?.mangaStatus != MangaStatus.completed - ) - ) { - value = this.value?.copy(mangaStatus = MangaStatus.reading) - } - value = value?.copy(numReadChapters = numReadChapter) + fun setReadChapterCount(numReadChapters: Int) { + if (_mangaDetailsUpdate.isNotOfStatus(MangaStatus.reading, MangaStatus.completed)) { + _mangaDetailsUpdate.setStatus(MangaStatus.reading) } + + if (_mangaDetailsUpdate.isMoreOrEqualToTotalChaps(numReadChapters)) { + _mangaDetailsUpdate.setReadAsTotalChaps() + _mangaDetailsUpdate.setStatus(MangaStatus.completed) + return + } + + _mangaDetailsUpdate.setReadChapters(numReadChapters) } - fun setReadVolumeCount(numReadVolume: Int) { - _mangaDetailsUpdate.apply { - if (this.value?.mangaStatus == null || - ( - this.value?.mangaStatus != MangaStatus.reading && - this.value?.mangaStatus != MangaStatus.completed - ) - ) { - value = this.value?.copy(mangaStatus = MangaStatus.reading) - } - value = value?.copy(numReadVolumes = numReadVolume) + fun setReadVolumeCount(numReadVolumes: Int) { + if (_mangaDetailsUpdate.isNotOfStatus(MangaStatus.reading, MangaStatus.completed)) { + _mangaDetailsUpdate.setStatus(MangaStatus.reading) } + + if (_mangaDetailsUpdate.isMoreOrEqualToTotalVols(numReadVolumes)) { + _mangaDetailsUpdate.setReadAsTotalVols() + _mangaDetailsUpdate.setStatus(MangaStatus.completed) + return + } + + _mangaDetailsUpdate.setReadVolumes(numReadVolumes) } fun setScore(score: Int) { - _mangaDetailsUpdate.apply { - value = value?.copy(score = score) - } + _mangaDetailsUpdate.setScore(score) } - fun submitStatusUpdate(mangaId: Int) { - viewModelScope.launch { - _uiState.setLoading() - _mangaDetailsUpdate.value?.let { - val result = mangaDetailsRepository.updateMangaStatus(it) - if (result.isSuccess) { - getMangaDetails(mangaId) - } else { - _uiState.setFailure(result.error.errorMessage) - } - } ?: run { - _uiState.setFailure(MHError.InvalidStateError.errorMessage) - } + fun submitStatusUpdate() = viewModelScope.launch { + _uiState.setLoading() + val details = _mangaDetailsUpdate.value + if (details == null) { + _uiState.setFailure(MHError.InvalidStateError.errorMessage) + return@launch } + + val result = mangaDetailsRepository.updateMangaStatus(details) + + if (!result.isSuccess) { + _uiState.setFailure(result.error.errorMessage) + return@launch + } + + refreshDetails() } - fun removeFromList(mangaId: Int) { - viewModelScope.launch { - _uiState.setLoading() - val result = mangaDetailsRepository.removeMangaFromList(mangaId = mangaId) - if (result.isSuccess) { - getMangaDetails(mangaId) - } else { - _uiState.setFailure(result.error.errorMessage) - } + fun removeFromList() = viewModelScope.launch { + val id = mangaDetailsUpdate.value?.mangaId ?: return@launch + _uiState.setLoading() + val result = mangaDetailsRepository.removeMangaFromList(mangaId = id) + if (!result.isSuccess) { + _uiState.setFailure(result.error.errorMessage) + return@launch } + + refreshDetails() + } + + fun refreshDetails() { + getMangaDetails(mangaId) + } + + companion object { + private const val MANGA_ID_KEY = "mangaId" } } 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_list/adapters/MangaListAdapter.kt similarity index 92% rename from app/src/main/java/com/sharkaboi/mediahub/modules/manga/adapters/MangaListAdapter.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/adapters/MangaListAdapter.kt index 737cce4..bc67d4e 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/adapters/MangaListAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/adapters/MangaListAdapter.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.modules.manga.adapters +package com.sharkaboi.mediahub.modules.manga_list.adapters import android.view.LayoutInflater import android.view.ViewGroup @@ -24,8 +24,8 @@ class MangaListAdapter( item?.let { mangaListItemBinding.apply { ivMangaBanner.load( - uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, - builder = UIConstants.MangaImageBuilder + it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.TopRoundedMangaImageBuilder ) tvMangaName.text = it.node.title tvChapsRead.text = tvChapsRead.context.getProgressStringWith( diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/adapters/MangaLoadStateAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/adapters/MangaLoadStateAdapter.kt similarity index 95% rename from app/src/main/java/com/sharkaboi/mediahub/modules/manga/adapters/MangaLoadStateAdapter.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/adapters/MangaLoadStateAdapter.kt index 6a9f397..be359bf 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/adapters/MangaLoadStateAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/adapters/MangaLoadStateAdapter.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.modules.manga.adapters +package com.sharkaboi.mediahub.modules.manga_list.adapters import android.view.LayoutInflater import android.view.ViewGroup diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/adapters/MangaPagerAdapter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/adapters/MangaPagerAdapter.kt similarity index 59% rename from app/src/main/java/com/sharkaboi/mediahub/modules/manga/adapters/MangaPagerAdapter.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/adapters/MangaPagerAdapter.kt index eaf6b37..26908d3 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/adapters/MangaPagerAdapter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/adapters/MangaPagerAdapter.kt @@ -1,17 +1,16 @@ -package com.sharkaboi.mediahub.modules.manga.adapters +package com.sharkaboi.mediahub.modules.manga_list.adapters import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.viewpager2.adapter.FragmentStateAdapter -import com.sharkaboi.mediahub.data.api.enums.MangaStatus -import com.sharkaboi.mediahub.modules.manga.ui.MangaListByStatusFragment +import com.sharkaboi.mediahub.modules.manga_list.ui.MangaListByStatusFragment class MangaPagerAdapter(fm: FragmentManager, lifecycle: Lifecycle) : FragmentStateAdapter(fm, lifecycle) { override fun getItemCount(): Int = 6 override fun createFragment(position: Int): Fragment { - return MangaListByStatusFragment.newInstance(MangaStatus.values()[position]) + return MangaListByStatusFragment() } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/data/UserMangaListDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/data/UserMangaListDataSource.kt new file mode 100644 index 0000000..d0f9c23 --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/data/UserMangaListDataSource.kt @@ -0,0 +1,85 @@ +package com.sharkaboi.mediahub.modules.manga_list.data + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.haroldadmin.cnradapter.NetworkResponse +import com.sharkaboi.mediahub.common.extensions.getCatchingPaging +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.MHError +import timber.log.Timber + +class UserMangaListDataSource( + private val userMangaService: UserMangaService, + private val accessToken: String?, + private val mangaStatus: MangaStatus, + private val mangaSortType: UserMangaSortType = UserMangaSortType.list_updated_at, + 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? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) + ?: anchorPage?.nextKey?.minus(ApiConstants.API_PAGE_LIMIT) + } + } + + override suspend fun load( + params: LoadParams + ): LoadResult = getCatchingPaging { + Timber.d("params : ${params.key}") + val offset = params.key ?: ApiConstants.API_START_OFFSET + val limit = ApiConstants.API_PAGE_LIMIT + if (accessToken == null) { + return@getCatchingPaging LoadResult.Error( + MHError.LoginExpiredError.getThrowable() + ) + } + 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() + + return@getCatchingPaging when (response) { + is NetworkResponse.Success -> { + val nextOffset = if (response.body.data.isEmpty()) null else offset + limit + LoadResult.Page( + data = response.body.data, + prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, + nextKey = nextOffset + ) + } + is NetworkResponse.UnknownError -> { + LoadResult.Error(response.error) + } + is NetworkResponse.ServerError -> { + LoadResult.Error( + response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() + ) + } + is NetworkResponse.NetworkError -> { + LoadResult.Error(response.error) + } + } + } + + private fun getStatus(): String? = if (mangaStatus == MangaStatus.all) { + null + } else { + mangaStatus.name + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/MangaModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/di/MangaListModule.kt similarity index 63% rename from app/src/main/java/com/sharkaboi/mediahub/di/MangaModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/di/MangaListModule.kt index 73e5b21..cfae25a 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/MangaModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/di/MangaListModule.kt @@ -1,10 +1,10 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.manga_list.di import android.content.SharedPreferences import com.sharkaboi.mediahub.data.api.retrofit.UserMangaService import com.sharkaboi.mediahub.data.datastore.DataStoreRepository -import com.sharkaboi.mediahub.modules.manga.repository.MangaRepository -import com.sharkaboi.mediahub.modules.manga.repository.MangaRepositoryImpl +import com.sharkaboi.mediahub.modules.manga_list.repository.MangaListRepository +import com.sharkaboi.mediahub.modules.manga_list.repository.MangaListRepositoryImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -13,7 +13,7 @@ import dagger.hilt.android.scopes.ActivityRetainedScoped @InstallIn(ActivityRetainedComponent::class) @Module -object MangaModule { +object MangaListModule { @Provides @ActivityRetainedScoped @@ -21,6 +21,6 @@ object MangaModule { userMangaService: UserMangaService, dataStoreRepository: DataStoreRepository, sharedPreferences: SharedPreferences - ): MangaRepository = - MangaRepositoryImpl(userMangaService, dataStoreRepository, sharedPreferences) + ): MangaListRepository = + MangaListRepositoryImpl(userMangaService, dataStoreRepository, sharedPreferences) } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/repository/MangaRepository.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/repository/MangaListRepository.kt similarity index 83% rename from app/src/main/java/com/sharkaboi/mediahub/modules/manga/repository/MangaRepository.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/repository/MangaListRepository.kt index 5c160e2..dc42d8f 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/repository/MangaRepository.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/repository/MangaListRepository.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.modules.manga.repository +package com.sharkaboi.mediahub.modules.manga_list.repository import androidx.paging.PagingData import com.sharkaboi.mediahub.data.api.enums.MangaStatus @@ -6,7 +6,7 @@ import com.sharkaboi.mediahub.data.api.enums.UserMangaSortType import com.sharkaboi.mediahub.data.api.models.usermanga.UserMangaListResponse import kotlinx.coroutines.flow.Flow -interface MangaRepository { +interface MangaListRepository { suspend fun getMangaListFlow( mangaStatus: MangaStatus, 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_list/repository/MangaListRepositoryImpl.kt similarity index 87% rename from app/src/main/java/com/sharkaboi/mediahub/modules/manga/repository/MangaRepositoryImpl.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/repository/MangaListRepositoryImpl.kt index f7f6edf..91df29f 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/repository/MangaRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/repository/MangaListRepositoryImpl.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.modules.manga.repository +package com.sharkaboi.mediahub.modules.manga_list.repository import android.content.SharedPreferences import androidx.paging.Pager @@ -10,17 +10,16 @@ import com.sharkaboi.mediahub.data.api.enums.UserMangaSortType import com.sharkaboi.mediahub.data.api.models.usermanga.UserMangaListResponse import com.sharkaboi.mediahub.data.api.retrofit.UserMangaService import com.sharkaboi.mediahub.data.datastore.DataStoreRepository -import com.sharkaboi.mediahub.data.paging.UserMangaListDataSource import com.sharkaboi.mediahub.data.sharedpref.SharedPreferencesKeys +import com.sharkaboi.mediahub.modules.manga_list.data.UserMangaListDataSource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull -import timber.log.Timber -class MangaRepositoryImpl( +class MangaListRepositoryImpl( private val userMangaService: UserMangaService, private val dataStoreRepository: DataStoreRepository, private val sharedPreferences: SharedPreferences -) : MangaRepository { +) : MangaListRepository { override suspend fun getMangaListFlow( mangaStatus: MangaStatus, @@ -28,7 +27,6 @@ class MangaRepositoryImpl( ): Flow> { val showNsfw = sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - Timber.d("accessToken: $accessToken") return Pager( config = PagingConfig( pageSize = ApiConstants.API_PAGE_LIMIT, 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_list/ui/MangaFragment.kt similarity index 61% rename from app/src/main/java/com/sharkaboi/mediahub/modules/manga/ui/MangaFragment.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/ui/MangaFragment.kt index c737f25..8608029 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/ui/MangaFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/ui/MangaFragment.kt @@ -1,27 +1,40 @@ -package com.sharkaboi.mediahub.modules.manga.ui +package com.sharkaboi.mediahub.modules.manga_list.ui import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.coordinatorlayout.widget.CoordinatorLayout +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.AnimationUtils +import androidx.core.view.isInvisible +import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.viewpager2.widget.ViewPager2 -import com.google.android.material.behavior.HideBottomViewOnScrollBehavior -import com.google.android.material.bottomnavigation.BottomNavigationView +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import com.google.android.material.tabs.TabLayout import com.sharkaboi.mediahub.R +import com.sharkaboi.mediahub.common.extensions.startAnim import com.sharkaboi.mediahub.data.api.enums.MangaStatus import com.sharkaboi.mediahub.databinding.FragmentMangaBinding -import com.sharkaboi.mediahub.modules.manga.adapters.MangaPagerAdapter +import com.sharkaboi.mediahub.modules.manga_list.adapters.MangaPagerAdapter +import com.sharkaboi.mediahub.modules.manga_list.vm.MangaViewModel +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class MangaFragment : Fragment() { - private lateinit var vpMangaAdapter: MangaPagerAdapter private var _binding: FragmentMangaBinding? = null private val binding get() = _binding!! private lateinit var onTabChanged: TabLayout.OnTabSelectedListener - private lateinit var onPageChanged: ViewPager2.OnPageChangeCallback + private lateinit var onPageChanged: OnPageChangeCallback + private val mangaViewModel by activityViewModels() + private val navController by lazy { findNavController() } + private val anim by lazy { + AnimationUtils.loadAnimation(requireContext(), R.anim.fab_explode).apply { + interpolator = AccelerateDecelerateInterpolator() + } + } override fun onCreateView( inflater: LayoutInflater, @@ -62,26 +75,31 @@ class MangaFragment : Fragment() { childFragmentManager.findFragmentByTag("f${tab?.position ?: 0}")?.let { if (it is MangaListByStatusFragment) { it.scrollRecyclerView() - activity?.findViewById(R.id.bottomNav) - ?.let { bottomNav -> - val layoutParams = - bottomNav.layoutParams as CoordinatorLayout.LayoutParams? - val bottomViewNavigationBehavior = - layoutParams?.behavior as HideBottomViewOnScrollBehavior? - bottomViewNavigationBehavior?.slideUp(bottomNav) - } } } } } - onPageChanged = object : ViewPager2.OnPageChangeCallback() { + onPageChanged = object : OnPageChangeCallback() { override fun onPageSelected(position: Int) { + mangaViewModel.setMangaStatus(MangaStatus.values()[position]) binding.mangaTabLayout.apply { selectTab(getTabAt(position)) } } } - binding.mangaTabLayout.addOnTabSelectedListener(onTabChanged) binding.vpManga.registerOnPageChangeCallback(onPageChanged) + binding.mangaTabLayout.addOnTabSelectedListener(onTabChanged) + binding.fabSearch.setOnClickListener { + binding.fabSearch.isVisible = false + binding.circleAnimeView.isVisible = true + binding.circleAnimeView.startAnim( + anim, + onEnd = { + binding.circleAnimeView.isInvisible = true + navController.navigate(R.id.openMangaSearch) + binding.fabSearch.isVisible = true + } + ) + } } } 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_list/ui/MangaListByStatusFragment.kt similarity index 56% rename from app/src/main/java/com/sharkaboi/mediahub/modules/manga/ui/MangaListByStatusFragment.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/ui/MangaListByStatusFragment.kt index 2ef5507..10fa3b3 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga/ui/MangaListByStatusFragment.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/ui/MangaListByStatusFragment.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.modules.manga.ui +package com.sharkaboi.mediahub.modules.manga_list.ui import android.os.Bundle import android.view.LayoutInflater @@ -6,26 +6,24 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels +import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import androidx.paging.CombinedLoadStates 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.observe import com.sharkaboi.mediahub.common.extensions.showToast -import com.sharkaboi.mediahub.data.api.enums.MangaStatus import com.sharkaboi.mediahub.data.api.enums.UserMangaSortType import com.sharkaboi.mediahub.databinding.FragmentMangaListByStatusBinding -import com.sharkaboi.mediahub.modules.manga.adapters.MangaListAdapter -import com.sharkaboi.mediahub.modules.manga.adapters.MangaLoadStateAdapter -import com.sharkaboi.mediahub.modules.manga.vm.MangaViewModel +import com.sharkaboi.mediahub.modules.manga_list.adapters.MangaListAdapter +import com.sharkaboi.mediahub.modules.manga_list.adapters.MangaLoadStateAdapter +import com.sharkaboi.mediahub.modules.manga_list.vm.MangaViewModel import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import timber.log.Timber @@ -33,10 +31,9 @@ import timber.log.Timber class MangaListByStatusFragment : Fragment() { private var _binding: FragmentMangaListByStatusBinding? = null private val binding get() = _binding!! - private val mangaViewModel by viewModels() + private val mangaViewModel by activityViewModels() private lateinit var mangaListAdapter: MangaListAdapter private val navController by lazy { findNavController() } - private var resultsJob: Job? = null override fun onCreateView( inflater: LayoutInflater, @@ -48,8 +45,7 @@ class MangaListByStatusFragment : Fragment() { } override fun onDestroyView() { - resultsJob?.cancel() - resultsJob = null + mangaListAdapter.removeLoadStateListener(loadStateListener) binding.rvMangaByStatus.adapter = null _binding = null super.onDestroyView() @@ -57,22 +53,13 @@ class MangaListByStatusFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - initStatus() setUpRecyclerView() - setObservers() setListeners() } override fun onResume() { super.onResume() - getMangaList() - } - - private fun initStatus() { - val status = arguments?.getString(MANGA_STATUS_KEY)?.let { status -> - MangaStatus.valueOf(status) - } ?: MangaStatus.all - mangaViewModel.setMangaStatus(status) + mangaViewModel.refresh() } private fun setUpRecyclerView() { @@ -81,7 +68,7 @@ class MangaListByStatusFragment : Fragment() { val action = BottomNavGraphDirections.openMangaById(mangaId) navController.navigate(action) } - layoutManager = GridLayoutManager(context, UIConstants.AnimeAndMangaGridSpanCount) + layoutManager = UIConstants.getGridLayoutManager(context) itemAnimator = DefaultItemAnimator() adapter = mangaListAdapter.withLoadStateFooter( footer = MangaLoadStateAdapter() @@ -89,30 +76,30 @@ class MangaListByStatusFragment : Fragment() { } } - private fun setObservers() { - getMangaList() - lifecycleScope.launch { - mangaListAdapter.addLoadStateListener { loadStates -> - if (loadStates.source.refresh is LoadState.Error) { - val errorMessage = (loadStates.source.refresh as LoadState.Error).error.message - Timber.d("setObservers: $errorMessage") - showToast(errorMessage) - } - binding.progressBar.isShowing = loadStates.refresh is LoadState.Loading - binding.tvEmptyHint.isVisible = - loadStates.refresh is LoadState.NotLoading && mangaListAdapter.itemCount == 0 - } - } - binding.swipeRefresh.setOnRefreshListener { - getMangaList() - binding.swipeRefresh.isRefreshing = false + private val loadStateListener = { loadStates: CombinedLoadStates -> + if (loadStates.source.refresh is LoadState.Error) { + val errorMessage = (loadStates.source.refresh as LoadState.Error).error.message + Timber.d("setObservers: $errorMessage") + showToast(errorMessage) } + binding.progressBar.isShowing = loadStates.refresh is LoadState.Loading + binding.tvEmptyHint.isVisible = + loadStates.refresh is LoadState.NotLoading && mangaListAdapter.itemCount == 0 } private fun setListeners() { + mangaListAdapter.addLoadStateListener(loadStateListener) + binding.swipeRefresh.setOnRefreshListener { + mangaViewModel.refresh() + binding.swipeRefresh.isRefreshing = false + } binding.ibFilter.setOnClickListener { openSortMenu() } + observe(mangaViewModel.mangaList) { pagingData -> + lifecycleScope.launch { mangaListAdapter.submitData(pagingData) } + scrollRecyclerView() + } } private fun openSortMenu() { @@ -122,33 +109,9 @@ class MangaListByStatusFragment : Fragment() { .setTitle(R.string.sort_manga_by_hint) .setSingleChoiceItems(singleItems, checkedItem) { dialog, which -> mangaViewModel.setSortType(UserMangaSortType.values()[which]) - getMangaList() dialog.dismiss() }.show() } - private fun getMangaList() { - resultsJob?.cancel() - resultsJob = lifecycleScope.launch { - mangaViewModel.getMangaList() - .collectLatest { pagingData -> - mangaListAdapter.submitData(pagingData) - scrollRecyclerView() - } - } - } - fun scrollRecyclerView() = binding.rvMangaByStatus.smoothScrollToPosition(0) - - companion object { - private const val MANGA_STATUS_KEY = "status" - - @JvmStatic - fun newInstance(status: MangaStatus) = - MangaListByStatusFragment().apply { - arguments = Bundle().apply { - putString(MANGA_STATUS_KEY, status.name) - } - } - } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/vm/MangaViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/vm/MangaViewModel.kt new file mode 100644 index 0000000..67def31 --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_list/vm/MangaViewModel.kt @@ -0,0 +1,74 @@ +package com.sharkaboi.mediahub.modules.manga_list.vm + +import androidx.lifecycle.* +import androidx.paging.PagingData +import androidx.paging.cachedIn +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 +import com.sharkaboi.mediahub.modules.manga_list.repository.MangaListRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class MangaViewModel +@Inject constructor( + private val mangaListRepository: MangaListRepository, + private val savedStateHandle: SavedStateHandle +) : ViewModel() { + private val savedMangaStatus = savedStateHandle.get(CHOSEN_MANGA_STATUS_KEY) + private val savedSortType = savedStateHandle.get(CHOSEN_SORT_TYPE_KEY) + + private var _currentChosenMangaStatus: MangaStatus = + MangaStatus.parse(savedMangaStatus) ?: MangaStatus.all + private val currentChosenMangaStatus get() = _currentChosenMangaStatus + + private var _currentChosenSortType: UserMangaSortType = + UserMangaSortType.parse(savedSortType) ?: UserMangaSortType.list_updated_at + val currentChosenSortType get() = _currentChosenSortType + + private var _mangaList = MutableLiveData>() + val mangaList: LiveData> = _mangaList + + init { + Timber.d("Saved state manga status $savedMangaStatus") + Timber.d("Saved state sort type $savedSortType") + } + + private fun getMangaList(shouldEmpty: Boolean) = viewModelScope.launch { + if (shouldEmpty) { + _mangaList.value = PagingData.empty() + } + val newResult: Flow> = + mangaListRepository.getMangaListFlow( + mangaStatus = currentChosenMangaStatus, + mangaSortType = currentChosenSortType + ).cachedIn(viewModelScope) + _mangaList.value = newResult.firstOrNull() ?: PagingData.empty() + } + + fun setMangaStatus(status: MangaStatus) { + _currentChosenMangaStatus = status + savedStateHandle.set(CHOSEN_MANGA_STATUS_KEY, status.name) + getMangaList(false) + } + + fun setSortType(userMangaSortType: UserMangaSortType) { + _currentChosenSortType = userMangaSortType + savedStateHandle.set(CHOSEN_SORT_TYPE_KEY, userMangaSortType.name) + getMangaList(false) + } + + fun refresh() { + getMangaList(false) + } + + companion object { + const val CHOSEN_MANGA_STATUS_KEY = "mangaStatus" + private const val CHOSEN_SORT_TYPE_KEY = "sortType" + } +} 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 caf46c1..60b4534 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 @@ -25,8 +25,8 @@ class MangaRankingDetailedAdapter( item?.let { mangaListItemBinding.apply { ivMangaBanner.load( - uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, - builder = UIConstants.MangaImageBuilder + it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.TopRoundedMangaImageBuilder ) tvMangaName.text = it.node.title tvChapsRead.text = tvChapsRead.context?.getString( diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/data/MangaRankingDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/data/MangaRankingDataSource.kt new file mode 100644 index 0000000..bc2fbe9 --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/data/MangaRankingDataSource.kt @@ -0,0 +1,77 @@ +package com.sharkaboi.mediahub.modules.manga_ranking.data + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.haroldadmin.cnradapter.NetworkResponse +import com.sharkaboi.mediahub.common.extensions.getCatchingPaging +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.MHError +import timber.log.Timber + +class MangaRankingDataSource( + private val mangaService: MangaService, + private val accessToken: String?, + private val mangaRankingType: MangaRankingType, + 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? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) + ?: anchorPage?.nextKey?.minus(ApiConstants.API_PAGE_LIMIT) + } + } + + override suspend fun load( + params: LoadParams + ): LoadResult = getCatchingPaging { + Timber.d("params : ${params.key}") + val offset = params.key ?: ApiConstants.API_START_OFFSET + val limit = ApiConstants.API_PAGE_LIMIT + if (accessToken == null) { + return@getCatchingPaging LoadResult.Error( + MHError.LoginExpiredError.getThrowable() + ) + } + + 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() + + return@getCatchingPaging when (response) { + is NetworkResponse.Success -> { + val nextOffset = if (response.body.data.isEmpty()) null else offset + limit + LoadResult.Page( + data = response.body.data, + prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, + nextKey = nextOffset + ) + } + is NetworkResponse.UnknownError -> { + LoadResult.Error(response.error) + } + is NetworkResponse.ServerError -> { + LoadResult.Error( + response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() + ) + } + is NetworkResponse.NetworkError -> { + LoadResult.Error(response.error) + } + } + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/MangaRankingModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/di/MangaRankingModule.kt similarity index 94% rename from app/src/main/java/com/sharkaboi/mediahub/di/MangaRankingModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/di/MangaRankingModule.kt index 418729c..4f97db7 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/MangaRankingModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/di/MangaRankingModule.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.manga_ranking.di import android.content.SharedPreferences import com.sharkaboi.mediahub.data.api.retrofit.MangaService 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 22425c0..e8879ca 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 @@ -9,11 +9,10 @@ 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 import com.sharkaboi.mediahub.data.datastore.DataStoreRepository -import com.sharkaboi.mediahub.data.paging.MangaRankingDataSource import com.sharkaboi.mediahub.data.sharedpref.SharedPreferencesKeys +import com.sharkaboi.mediahub.modules.manga_ranking.data.MangaRankingDataSource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull -import timber.log.Timber class MangaRankingRepositoryImpl( private val mangaService: MangaService, @@ -24,7 +23,6 @@ class MangaRankingRepositoryImpl( override suspend fun getMangaRanking(mangaRankingType: MangaRankingType): Flow> { val showNsfw = sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - Timber.d("accessToken: $accessToken") return Pager( config = PagingConfig( pageSize = ApiConstants.API_PAGE_LIMIT, 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 979da98..639da0e 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 @@ -9,13 +9,14 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs +import androidx.paging.CombinedLoadStates import androidx.paging.LoadState import androidx.recyclerview.widget.DefaultItemAnimator -import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.chip.Chip import com.sharkaboi.mediahub.BottomNavGraphDirections +import com.sharkaboi.mediahub.common.constants.UIConstants import com.sharkaboi.mediahub.common.constants.UIConstants.setMediaHubChipStyle +import com.sharkaboi.mediahub.common.extensions.observe import com.sharkaboi.mediahub.common.extensions.showToast import com.sharkaboi.mediahub.data.api.enums.MangaRankingType import com.sharkaboi.mediahub.databinding.FragmentMangaRankingBinding @@ -23,8 +24,6 @@ import com.sharkaboi.mediahub.modules.manga_ranking.adapters.MangaRankingDetaile import com.sharkaboi.mediahub.modules.manga_ranking.adapters.MangaRankingLoadStateAdapter import com.sharkaboi.mediahub.modules.manga_ranking.vm.MangaRankingViewModel import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint @@ -34,8 +33,6 @@ class MangaRankingFragment : Fragment() { private val navController by lazy { findNavController() } private lateinit var mangaRankingDetailedAdapter: MangaRankingDetailedAdapter private val mangaRankingViewModel by viewModels() - private val args: MangaRankingFragmentArgs by navArgs() - private var resultsJob: Job? = null override fun onCreateView( inflater: LayoutInflater, @@ -47,8 +44,7 @@ class MangaRankingFragment : Fragment() { } override fun onDestroyView() { - resultsJob?.cancel() - resultsJob = null + mangaRankingDetailedAdapter.removeLoadStateListener(loadStateListener) binding.rvMangaRanking.adapter = null _binding = null super.onDestroyView() @@ -57,17 +53,9 @@ class MangaRankingFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.toolbar.setNavigationOnClickListener { navController.navigateUp() } - initRanking() setupFilterChips() setUpRecyclerView() setObservers() - getMangaRankingList() - } - - private fun initRanking() { - mangaRankingViewModel.setRankingType( - MangaRankingType.getMangaRankingFromString(args.mangaRankingType) - ) } private fun setupFilterChips() { @@ -77,11 +65,9 @@ class MangaRankingFragment : Fragment() { rankChip.text = rankingType.getFormattedString(rankChip.context) rankChip.setMediaHubChipStyle() rankChip.isCheckable = true - rankChip.isChecked = rankingType == mangaRankingViewModel.selectedRankingType + rankChip.isChecked = rankingType == mangaRankingViewModel.rankingType rankChip.setOnClickListener { mangaRankingViewModel.setRankingType(rankingType) - getMangaRankingList() - binding.rvMangaRanking.smoothScrollToPosition(0) } binding.rankTypeChipGroup.addView(rankChip) } @@ -93,7 +79,7 @@ class MangaRankingFragment : Fragment() { val action = BottomNavGraphDirections.openMangaById(mangaId) navController.navigate(action) } - layoutManager = GridLayoutManager(context, 3) + layoutManager = UIConstants.getGridLayoutManager(context) itemAnimator = DefaultItemAnimator() adapter = mangaRankingDetailedAdapter.withLoadStateFooter( footer = MangaRankingLoadStateAdapter() @@ -101,27 +87,20 @@ class MangaRankingFragment : Fragment() { } } - private fun setObservers() { - lifecycleScope.launch { - mangaRankingDetailedAdapter.addLoadStateListener { loadStates -> - if (loadStates.source.refresh is LoadState.Error) { - showToast((loadStates.source.refresh as LoadState.Error).error.message) - } - binding.progressBar.isShowing = loadStates.refresh is LoadState.Loading - binding.tvEmptyHint.isVisible = - loadStates.refresh is LoadState.NotLoading && mangaRankingDetailedAdapter.itemCount == 0 - } + private val loadStateListener = { loadStates: CombinedLoadStates -> + if (loadStates.source.refresh is LoadState.Error) { + showToast((loadStates.source.refresh as LoadState.Error).error.message) } + binding.progressBar.isShowing = loadStates.refresh is LoadState.Loading + binding.tvEmptyHint.isVisible = + loadStates.refresh is LoadState.NotLoading && mangaRankingDetailedAdapter.itemCount == 0 } - private fun getMangaRankingList() { - resultsJob?.cancel() - resultsJob = lifecycleScope.launch { - mangaRankingViewModel.getMangaRankingOfFilter() - .collectLatest { pagingData -> - mangaRankingDetailedAdapter.submitData(pagingData) - binding.rvMangaRanking.smoothScrollToPosition(0) - } + private fun setObservers() { + mangaRankingDetailedAdapter.addLoadStateListener(loadStateListener) + observe(mangaRankingViewModel.result) { pagingData -> + lifecycleScope.launch { mangaRankingDetailedAdapter.submitData(pagingData) } + binding.rvMangaRanking.smoothScrollToPosition(0) } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/vm/MangaRankingViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/vm/MangaRankingViewModel.kt index 80061b3..7e21da3 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/vm/MangaRankingViewModel.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_ranking/vm/MangaRankingViewModel.kt @@ -1,7 +1,6 @@ package com.sharkaboi.mediahub.modules.manga_ranking.vm -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* import androidx.paging.PagingData import androidx.paging.cachedIn import com.sharkaboi.mediahub.data.api.enums.MangaRankingType @@ -9,27 +8,47 @@ import com.sharkaboi.mediahub.data.api.models.manga.MangaRankingResponse import com.sharkaboi.mediahub.modules.manga_ranking.repository.MangaRankingRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel class MangaRankingViewModel @Inject constructor( - private val mangaRankingRepository: MangaRankingRepository + private val mangaRankingRepository: MangaRankingRepository, + private val savedStateHandle: SavedStateHandle ) : ViewModel() { - private var _selectedRankingType: MangaRankingType = MangaRankingType.all - val selectedRankingType: MangaRankingType get() = _selectedRankingType - private var _pagedResult: Flow>? = null + private val selectedRankingType: String? = + savedStateHandle.get(MANGA_RANKING_KEY) - suspend fun getMangaRankingOfFilter(): Flow> { + private var _rankingType: MangaRankingType = + MangaRankingType.getMangaRankingFromString(selectedRankingType) + val rankingType: MangaRankingType get() = _rankingType + + private val _result = MutableLiveData>() + val result: LiveData> = _result + + init { + Timber.d("Saved state for manga ranking type $selectedRankingType") + getMangaRankingOfFilter() + } + + private fun getMangaRankingOfFilter() = viewModelScope.launch { val newResult: Flow> = mangaRankingRepository - .getMangaRanking(_selectedRankingType) + .getMangaRanking(_rankingType) .cachedIn(viewModelScope) - _pagedResult = newResult - return newResult + _result.value = newResult.firstOrNull() ?: PagingData.empty() } fun setRankingType(mangaRankingType: MangaRankingType) { - _selectedRankingType = mangaRankingType + _rankingType = mangaRankingType + savedStateHandle.set(MANGA_RANKING_KEY, mangaRankingType.name) + getMangaRankingOfFilter() + } + + companion object { + private const val MANGA_RANKING_KEY = "mangaRankingType" } } 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 a0918aa..7662204 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 @@ -24,8 +24,8 @@ class MangaSearchListAdapter( item?.let { mangaListItemBinding.apply { ivMangaBanner.load( - uri = it.node.mainPicture?.large ?: it.node.mainPicture?.medium, - builder = UIConstants.MangaImageBuilder + it.node.mainPicture?.large ?: it.node.mainPicture?.medium, + builder = UIConstants.TopRoundedMangaImageBuilder ) tvMangaName.text = it.node.title tvChapsRead.isVisible = false diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/data/MangaSearchDataSource.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/data/MangaSearchDataSource.kt new file mode 100644 index 0000000..8d2d1af --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/data/MangaSearchDataSource.kt @@ -0,0 +1,75 @@ +package com.sharkaboi.mediahub.modules.manga_search.data + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.haroldadmin.cnradapter.NetworkResponse +import com.sharkaboi.mediahub.common.extensions.getCatchingPaging +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.MHError +import timber.log.Timber + +class MangaSearchDataSource( + private val mangaService: MangaService, + private val accessToken: String?, + private val query: String, + 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? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey?.plus(ApiConstants.API_PAGE_LIMIT) + ?: anchorPage?.nextKey?.minus(ApiConstants.API_PAGE_LIMIT) + } + } + + override suspend fun load( + params: LoadParams + ): LoadResult = getCatchingPaging { + Timber.d("params : ${params.key}") + val offset = params.key ?: ApiConstants.API_START_OFFSET + val limit = ApiConstants.API_PAGE_LIMIT + if (accessToken == null) { + return@getCatchingPaging LoadResult.Error( + MHError.LoginExpiredError.getThrowable() + ) + } + 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() + + return@getCatchingPaging when (response) { + is NetworkResponse.Success -> { + val nextOffset = if (response.body.data.isEmpty()) null else offset + limit + LoadResult.Page( + data = response.body.data, + prevKey = if (offset == ApiConstants.API_START_OFFSET) null else offset - limit, + nextKey = nextOffset + ) + } + is NetworkResponse.UnknownError -> { + LoadResult.Error(response.error) + } + is NetworkResponse.ServerError -> { + LoadResult.Error( + response.body?.getThrowable() ?: ApiError.DefaultError.getThrowable() + ) + } + is NetworkResponse.NetworkError -> { + LoadResult.Error(response.error) + } + } + } +} diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/MangaSearchModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/di/MangaSearchModule.kt similarity index 94% rename from app/src/main/java/com/sharkaboi/mediahub/di/MangaSearchModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/di/MangaSearchModule.kt index c2b74c9..eb60ef6 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/MangaSearchModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/di/MangaSearchModule.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.manga_search.di import android.content.SharedPreferences import com.sharkaboi.mediahub.data.api.retrofit.MangaService 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 0cb30fa..e76ff41 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 @@ -8,11 +8,10 @@ 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 -import com.sharkaboi.mediahub.data.paging.MangaSearchDataSource import com.sharkaboi.mediahub.data.sharedpref.SharedPreferencesKeys +import com.sharkaboi.mediahub.modules.manga_search.data.MangaSearchDataSource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull -import timber.log.Timber class MangaSearchRepositoryImpl( private val mangaService: MangaService, @@ -23,7 +22,6 @@ class MangaSearchRepositoryImpl( override suspend fun getMangaByQuery(query: String): Flow> { val showNsfw = sharedPreferences.getBoolean(SharedPreferencesKeys.NSFW_OPTION, false) val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - Timber.d("accessToken: $accessToken") return Pager( config = PagingConfig( pageSize = ApiConstants.API_PAGE_LIMIT, 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 3588750..bbcd444 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 @@ -12,21 +12,21 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import androidx.paging.CombinedLoadStates 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.constants.UIConstants import com.sharkaboi.mediahub.common.extensions.debounce +import com.sharkaboi.mediahub.common.extensions.observe import com.sharkaboi.mediahub.common.extensions.showToast import com.sharkaboi.mediahub.databinding.FragmentMangaSearchBinding import com.sharkaboi.mediahub.modules.manga_search.adapters.MangaSearchListAdapter import com.sharkaboi.mediahub.modules.manga_search.adapters.MangaSearchLoadStateAdapter import com.sharkaboi.mediahub.modules.manga_search.vm.MangaSearchViewModel import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint @@ -36,7 +36,6 @@ class MangaSearchFragment : Fragment() { private lateinit var mangaSearchListAdapter: MangaSearchListAdapter private val mangaSearchViewModel by viewModels() private val navController by lazy { findNavController() } - private var searchJob: Job? = null override fun onCreateView( inflater: LayoutInflater, @@ -48,8 +47,7 @@ class MangaSearchFragment : Fragment() { } override fun onDestroyView() { - searchJob?.cancel() - searchJob = null + mangaSearchListAdapter.removeLoadStateListener(loadStateListener) binding.rvSearchResults.adapter = null _binding = null super.onDestroyView() @@ -67,7 +65,7 @@ class MangaSearchFragment : Fragment() { val action = BottomNavGraphDirections.openMangaById(mangaId) navController.navigate(action) } - layoutManager = GridLayoutManager(context, 3) + layoutManager = UIConstants.getGridLayoutManager(context) setHasFixedSize(true) itemAnimator = DefaultItemAnimator() adapter = mangaSearchListAdapter.withLoadStateFooter( @@ -76,45 +74,41 @@ class MangaSearchFragment : Fragment() { } } - private fun setObservers() { - lifecycleScope.launch { - mangaSearchListAdapter.addLoadStateListener { loadStates -> - if (loadStates.source.refresh is LoadState.Error) { - showToast((loadStates.source.refresh as LoadState.Error).error.message) - } - binding.progress.isShowing = loadStates.refresh is LoadState.Loading - binding.searchEmptyView.root.isVisible = - loadStates.refresh is LoadState.NotLoading && mangaSearchListAdapter.itemCount == 0 - binding.searchEmptyView.tvHint.text = - getString(R.string.manga_search_no_result_hint) - } + private val loadStateListener = { loadStates: CombinedLoadStates -> + if (loadStates.source.refresh is LoadState.Error) { + showToast((loadStates.source.refresh as LoadState.Error).error.message) } + binding.progress.isShowing = loadStates.refresh is LoadState.Loading + binding.searchEmptyView.root.isVisible = + loadStates.refresh is LoadState.NotLoading && mangaSearchListAdapter.itemCount == 0 + binding.searchEmptyView.tvHint.text = + getString(R.string.manga_search_no_result_hint) + } + + private fun setObservers() { + mangaSearchListAdapter.addLoadStateListener(loadStateListener) val debounce = debounce(scope = lifecycleScope) { searchAnime(it) } binding.svSearch.doOnTextChanged { query, _, _, _ -> debounce(query) } + observe(mangaSearchViewModel.pagedSearchResult) { pagingData -> + lifecycleScope.launch { mangaSearchListAdapter.submitData(pagingData) } + binding.rvSearchResults.scrollToPosition(0) + } } private fun searchAnime(query: CharSequence?) { - searchJob?.cancel() - searchJob = lifecycleScope.launch { - query?.toString()?.let { - if (it.length < 3) { - binding.searchEmptyView.root.isVisible = true - binding.searchEmptyView.tvHint.text = getString(R.string.manga_search_hint) - mangaSearchListAdapter.submitData(PagingData.empty()) - return@launch - } - hideKeyboard() - mangaSearchViewModel.getManga(it.trim()) - .collectLatest { pagingData -> - mangaSearchListAdapter.submitData(pagingData) - binding.rvSearchResults.scrollToPosition(0) - } - } + val trimmedText = query?.toString()?.trim() ?: return + if (trimmedText.length < 3) { + binding.searchEmptyView.root.isVisible = true + binding.searchEmptyView.tvHint.text = getString(R.string.manga_search_hint) + lifecycleScope.launch { mangaSearchListAdapter.submitData(PagingData.empty()) } + return } + hideKeyboard() + mangaSearchViewModel.getManga(trimmedText) } private fun hideKeyboard() { diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/vm/MangaSearchViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/vm/MangaSearchViewModel.kt index 295cfa3..fa1e838 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/vm/MangaSearchViewModel.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/manga_search/vm/MangaSearchViewModel.kt @@ -1,31 +1,45 @@ package com.sharkaboi.mediahub.modules.manga_search.vm -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* import androidx.paging.PagingData import androidx.paging.cachedIn import com.sharkaboi.mediahub.data.api.models.manga.MangaSearchResponse import com.sharkaboi.mediahub.modules.manga_search.repository.MangaSearchRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel class MangaSearchViewModel @Inject constructor( - private val mangaSearchRepository: MangaSearchRepository + private val mangaSearchRepository: MangaSearchRepository, + private val savedStateHandle: SavedStateHandle ) : ViewModel() { + private val searchedQuery = savedStateHandle.get(SEARCH_QUERY_KEY) - private var _pagedSearchResult: Flow>? = null + private val _pagedSearchResult = MutableLiveData>() + val pagedSearchResult: LiveData> = _pagedSearchResult - suspend fun getManga( - query: String - ): Flow> { + init { + Timber.d("Saved state for manga search $searchedQuery") + if (searchedQuery != null) { + getManga(searchedQuery) + } + } + + fun getManga(query: String) = viewModelScope.launch { + savedStateHandle.set(SEARCH_QUERY_KEY, query) val newResult: Flow> = mangaSearchRepository.getMangaByQuery( query = query ).cachedIn(viewModelScope) - _pagedSearchResult = newResult - return newResult + _pagedSearchResult.value = newResult.firstOrNull() ?: PagingData.empty() + } + + companion object { + private const val SEARCH_QUERY_KEY = "searchQuery" } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/ProfileModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/profile/di/ProfileModule.kt similarity index 94% rename from app/src/main/java/com/sharkaboi/mediahub/di/ProfileModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/profile/di/ProfileModule.kt index 894e09b..1204ef0 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/ProfileModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/profile/di/ProfileModule.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.profile.di import com.sharkaboi.mediahub.data.api.retrofit.UserService import com.sharkaboi.mediahub.data.datastore.DataStoreRepository 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 b5b1f29..5bf9168 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 @@ -1,16 +1,14 @@ 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.getCatching 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 import com.sharkaboi.mediahub.data.wrappers.MHError import com.sharkaboi.mediahub.data.wrappers.MHTaskState -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.withContext import timber.log.Timber class ProfileRepositoryImpl( @@ -19,65 +17,51 @@ class ProfileRepositoryImpl( ) : ProfileRepository { override suspend fun getUserDetails(): MHTaskState = - withContext(Dispatchers.IO) { - try { - val accessToken: String? = dataStoreRepository.accessTokenFlow.firstOrNull() - if (accessToken == null) { - return@withContext MHTaskState( + getCatching { + val accessToken: String = dataStoreRepository.accessTokenFlow.firstOrNull() + ?: return@getCatching MHTaskState( + isSuccess = false, + data = null, + error = MHError.LoginExpiredError + ) + + val result = userService.getUserDetailsAsync( + authHeader = ApiConstants.BEARER_SEPARATOR + accessToken + ).await() + + Timber.d(result.toString()) + return@getCatching when (result) { + is NetworkResponse.Success -> { + MHTaskState( + isSuccess = true, + data = result.body, + error = MHError.EmptyError + ) + } + is NetworkResponse.NetworkError -> { + MHTaskState( isSuccess = false, data = null, - error = MHError.LoginExpiredError + error = MHError.getError(result.error.message, MHError.NetworkError) + ) + } + is NetworkResponse.ServerError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError( + result.body?.message, + MHError.apiErrorWithCode(result.code) + ) + ) + } + is NetworkResponse.UnknownError -> { + MHTaskState( + isSuccess = false, + data = null, + error = MHError.getError(result.error.message, MHError.ParsingError) ) - } else { - val result = userService.getUserDetailsAsync( - authHeader = ApiConstants.BEARER_SEPARATOR + accessToken - ).await() - when (result) { - is NetworkResponse.Success -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = true, - data = result.body, - error = MHError.EmptyError - ) - } - is NetworkResponse.NetworkError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.NetworkError - ) - } - is NetworkResponse.ServerError -> { - Timber.d(result.body.toString()) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.body?.message?.let { MHError(it) } - ?: MHError.apiErrorWithCode(result.code) - ) - } - is NetworkResponse.UnknownError -> { - Timber.d(result.error.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = result.error.message?.let { MHError(it) } - ?: MHError.ParsingError - ) - } - } } - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - 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 780c0a0..14be466 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 @@ -23,12 +23,14 @@ import com.sharkaboi.mediahub.R 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.profile.util.MPAndroidChartValueFormatter +import com.sharkaboi.mediahub.modules.profile.util.getDaysCountString +import com.sharkaboi.mediahub.modules.profile.util.getEpisodesOfAnimeFullString import com.sharkaboi.mediahub.modules.profile.vm.ProfileStates import com.sharkaboi.mediahub.modules.profile.vm.ProfileViewModel import dagger.hilt.android.AndroidEntryPoint @@ -63,7 +65,7 @@ class ProfileFragment : Fragment() { private fun setListeners() { binding.apply { profileContent.ivProfileImage.load( - drawableResId = R.drawable.ic_profile_placeholder, + R.drawable.ic_profile_placeholder, builder = UIConstants.ProfileImageBuilder ) profileContent.chipGroupOptions.forEach { @@ -79,9 +81,6 @@ class ProfileFragment : Fragment() { observe(profileViewModel.uiState) { uiState -> binding.progressBar.isShowing = uiState is ProfileStates.Loading when (uiState) { - is ProfileStates.Idle -> { - profileViewModel.getUserDetails() - } is ProfileStates.FetchSuccess -> { setData(uiState.userDetailsResponse) } @@ -222,7 +221,7 @@ class ProfileFragment : Fragment() { private fun setUpBannerSection(userDetailsResponse: UserDetailsResponse) = binding.profileContent.apply { ivProfileImage.load( - uri = userDetailsResponse.profilePicUrl, + userDetailsResponse.profilePicUrl, builder = UIConstants.ProfileImageBuilder ) ivProfileImage.setOnClickListener { diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/util/MPAndroidChartValueFormatter.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/profile/util/MPAndroidChartValueFormatter.kt similarity index 95% rename from app/src/main/java/com/sharkaboi/mediahub/common/util/MPAndroidChartValueFormatter.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/profile/util/MPAndroidChartValueFormatter.kt index a0c12b0..8f1daed 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/util/MPAndroidChartValueFormatter.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/profile/util/MPAndroidChartValueFormatter.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.common.util +package com.sharkaboi.mediahub.modules.profile.util import com.github.mikephil.charting.formatter.ValueFormatter import com.sharkaboi.mediahub.common.extensions.emptyString diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/profile/util/PresentationExtensions.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/profile/util/PresentationExtensions.kt new file mode 100644 index 0000000..e02dc6f --- /dev/null +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/profile/util/PresentationExtensions.kt @@ -0,0 +1,20 @@ +package com.sharkaboi.mediahub.modules.profile.util + +import android.content.Context +import com.sharkaboi.mediahub.R + +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 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() + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/profile/vm/ProfileViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/profile/vm/ProfileViewModel.kt index 75349cf..d1bb4c8 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/profile/vm/ProfileViewModel.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/profile/vm/ProfileViewModel.kt @@ -16,17 +16,20 @@ class ProfileViewModel @Inject constructor( private val profileRepository: ProfileRepository ) : ViewModel() { - private val _uiState: MutableLiveData = MutableLiveData().getDefault() val uiState: LiveData = _uiState - fun getUserDetails() { + init { + getUserDetails() + } + + private fun getUserDetails() { viewModelScope.launch { _uiState.setLoading() val result: MHTaskState = profileRepository.getUserDetails() if (result.isSuccess) { - _uiState.setFetchSuccess(result.data!!) + _uiState.setFetchSuccess(result.data) } else { _uiState.setProfileFailure(result.error.errorMessage) } diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/SettingsModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/settings/di/SettingsModule.kt similarity index 93% rename from app/src/main/java/com/sharkaboi/mediahub/di/SettingsModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/settings/di/SettingsModule.kt index 12e76c7..3653451 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/SettingsModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/settings/di/SettingsModule.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.settings.di import com.sharkaboi.mediahub.data.datastore.DataStoreRepository import com.sharkaboi.mediahub.modules.settings.repository.SettingsRepository 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 70c470c..d2c1473 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,33 +1,20 @@ package com.sharkaboi.mediahub.modules.settings.repository -import com.sharkaboi.mediahub.common.extensions.emptyString +import com.sharkaboi.mediahub.common.extensions.getCatching 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 class SettingsRepositoryImpl( private val dataStoreRepository: DataStoreRepository ) : SettingsRepository { - override suspend fun logOutUser(): MHTaskState = withContext(Dispatchers.IO) { - try { - dataStoreRepository.clearDataStore() - return@withContext MHTaskState( - isSuccess = true, - data = null, - error = MHError.EmptyError - ) - } catch (e: Exception) { - e.printStackTrace() - Timber.d(e.message ?: String.emptyString) - return@withContext MHTaskState( - isSuccess = false, - data = null, - error = e.message?.let { MHError(it) } ?: MHError.UnknownError - ) - } + override suspend fun logOutUser(): MHTaskState = getCatching { + dataStoreRepository.clearDataStore() + return@getCatching MHTaskState( + isSuccess = true, + data = null, + error = MHError.EmptyError + ) } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/common/views/MaterialToolBarPreference.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/settings/ui/MaterialToolBarPreference.kt similarity index 95% rename from app/src/main/java/com/sharkaboi/mediahub/common/views/MaterialToolBarPreference.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/settings/ui/MaterialToolBarPreference.kt index f31ef16..d9fc08e 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/common/views/MaterialToolBarPreference.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/settings/ui/MaterialToolBarPreference.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.common.views +package com.sharkaboi.mediahub.modules.settings.ui import android.content.Context import android.util.AttributeSet 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 1c71893..383b08d 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 @@ -21,7 +21,6 @@ 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 import com.sharkaboi.mediahub.data.sharedpref.SharedPreferencesKeys import com.sharkaboi.mediahub.modules.auth.ui.OAuthActivity import com.sharkaboi.mediahub.modules.settings.vm.SettingsStates diff --git a/app/src/main/java/com/sharkaboi/mediahub/di/SplashModule.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/splash/di/SplashModule.kt similarity index 94% rename from app/src/main/java/com/sharkaboi/mediahub/di/SplashModule.kt rename to app/src/main/java/com/sharkaboi/mediahub/modules/splash/di/SplashModule.kt index c03d205..913d09a 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/di/SplashModule.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/splash/di/SplashModule.kt @@ -1,4 +1,4 @@ -package com.sharkaboi.mediahub.di +package com.sharkaboi.mediahub.modules.splash.di import com.sharkaboi.mediahub.data.api.retrofit.AuthService import com.sharkaboi.mediahub.data.datastore.DataStoreRepository diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/splash/repository/SplashRepositoryImpl.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/splash/repository/SplashRepositoryImpl.kt index b6e363a..e486034 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/splash/repository/SplashRepositoryImpl.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/splash/repository/SplashRepositoryImpl.kt @@ -2,7 +2,6 @@ package com.sharkaboi.mediahub.modules.splash.repository import com.haroldadmin.cnradapter.NetworkResponse 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 kotlinx.coroutines.Dispatchers @@ -21,35 +20,22 @@ class SplashRepositoryImpl( override val refreshTokenFlow: Flow = dataStoreRepository.refreshTokenFlow override suspend fun refreshToken(): Boolean = withContext(Dispatchers.IO) { - val refreshToken: String? = refreshTokenFlow.firstOrNull() - if (refreshToken == null) { - return@withContext false - } else { - val response = authService.refreshTokenAsync( - refreshToken = refreshToken, - clientId = BuildConfig.clientId - ).await() - when (response) { - is NetworkResponse.Success -> { - Timber.d(response.body.toString()) - dataStoreRepository.setAccessToken(response.body.accessToken) - dataStoreRepository.setExpireIn() - dataStoreRepository.setRefreshToken(response.body.refreshToken) - return@withContext true - } - is NetworkResponse.ServerError -> { - Timber.d(response.body.toString()) - return@withContext false - } - is NetworkResponse.NetworkError -> { - Timber.d(response.error.message ?: String.emptyString) - return@withContext false - } - is NetworkResponse.UnknownError -> { - Timber.d(response.error.message ?: String.emptyString) - return@withContext false - } + val refreshToken: String = refreshTokenFlow.firstOrNull() ?: return@withContext false + + val response = authService.refreshTokenAsync( + refreshToken = refreshToken, + clientId = BuildConfig.clientId + ).await() + + Timber.d(response.toString()) + return@withContext when (response) { + is NetworkResponse.Success -> { + dataStoreRepository.setAccessToken(response.body.accessToken) + dataStoreRepository.setExpireIn() + dataStoreRepository.setRefreshToken(response.body.refreshToken) + true } + else -> false } } } diff --git a/app/src/main/java/com/sharkaboi/mediahub/modules/splash/vm/SplashViewModel.kt b/app/src/main/java/com/sharkaboi/mediahub/modules/splash/vm/SplashViewModel.kt index 5817988..778145c 100644 --- a/app/src/main/java/com/sharkaboi/mediahub/modules/splash/vm/SplashViewModel.kt +++ b/app/src/main/java/com/sharkaboi/mediahub/modules/splash/vm/SplashViewModel.kt @@ -27,6 +27,10 @@ class SplashViewModel val splashState: LiveData = _splashState init { + checkIfTokenExpired() + } + + private fun checkIfTokenExpired() { _splashState.setLoading() viewModelScope.launch { val accessToken: String? = accessTokenFlow.firstOrNull() diff --git a/app/src/main/res/anim/fab_explode.xml b/app/src/main/res/anim/fab_explode.xml index 8b05af6..f9b2fe2 100644 --- a/app/src/main/res/anim/fab_explode.xml +++ b/app/src/main/res/anim/fab_explode.xml @@ -1,6 +1,6 @@ + android:duration="300"> - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/anime_list_item.xml b/app/src/main/res/layout/anime_list_item.xml index e2a6336..615917f 100644 --- a/app/src/main/res/layout/anime_list_item.xml +++ b/app/src/main/res/layout/anime_list_item.xml @@ -2,7 +2,7 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/anime_search_empty.xml b/app/src/main/res/layout/anime_search_empty.xml index 4be2b88..b739717 100644 --- a/app/src/main/res/layout/anime_search_empty.xml +++ b/app/src/main/res/layout/anime_search_empty.xml @@ -2,17 +2,19 @@ + android:layout_height="match_parent"> diff --git a/app/src/main/res/layout/fragment_anime.xml b/app/src/main/res/layout/fragment_anime.xml index 95c05b2..97c00ff 100644 --- a/app/src/main/res/layout/fragment_anime.xml +++ b/app/src/main/res/layout/fragment_anime.xml @@ -24,4 +24,32 @@ app:layout_constraintTop_toBottomOf="@id/animeTabLayout" tools:layout="@layout/fragment_anime_list_by_status" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_anime_details.xml b/app/src/main/res/layout/fragment_anime_details.xml index a5752e2..a838eea 100644 --- a/app/src/main/res/layout/fragment_anime_details.xml +++ b/app/src/main/res/layout/fragment_anime_details.xml @@ -21,17 +21,30 @@ + + + tools:context=".modules.anime_list.ui.AnimeListByStatusFragment"> + android:maxLines="1" + android:singleLine="true" /> @@ -53,8 +54,7 @@ android:id="@+id/searchEmptyView" layout="@layout/anime_search_empty" android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginBottom="54dp" + android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/fragment_full_screen_image.xml b/app/src/main/res/layout/fragment_full_screen_image.xml index a7ca78d..558f96e 100644 --- a/app/src/main/res/layout/fragment_full_screen_image.xml +++ b/app/src/main/res/layout/fragment_full_screen_image.xml @@ -3,7 +3,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".common.views.image_slider.FullScreenImageFragment"> + tools:context=".modules.image_slider.FullScreenImageFragment"> + tools:context=".modules.manga_list.ui.MangaFragment"> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_manga_details.xml b/app/src/main/res/layout/fragment_manga_details.xml index 4a901d3..d961b82 100644 --- a/app/src/main/res/layout/fragment_manga_details.xml +++ b/app/src/main/res/layout/fragment_manga_details.xml @@ -21,17 +21,30 @@ + + + tools:context=".modules.manga_list.ui.MangaListByStatusFragment"> + android:maxLines="1" + android:singleLine="true" /> @@ -53,8 +54,7 @@ android:id="@+id/searchEmptyView" layout="@layout/manga_search_empty" android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginBottom="54dp" + android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/manga_list_item.xml b/app/src/main/res/layout/manga_list_item.xml index c800000..1e6e9f6 100644 --- a/app/src/main/res/layout/manga_list_item.xml +++ b/app/src/main/res/layout/manga_list_item.xml @@ -2,7 +2,7 @@ diff --git a/app/src/main/res/layout/manga_list_item_horizontal.xml b/app/src/main/res/layout/manga_list_item_horizontal.xml new file mode 100644 index 0000000..e1ea8a1 --- /dev/null +++ b/app/src/main/res/layout/manga_list_item_horizontal.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/manga_search_empty.xml b/app/src/main/res/layout/manga_search_empty.xml index 6c696a3..b14dbcf 100644 --- a/app/src/main/res/layout/manga_search_empty.xml +++ b/app/src/main/res/layout/manga_search_empty.xml @@ -2,17 +2,19 @@ + android:layout_height="match_parent"> diff --git a/app/src/main/res/navigation/bottom_nav_graph.xml b/app/src/main/res/navigation/bottom_nav_graph.xml index a1f4de6..1af2bd9 100644 --- a/app/src/main/res/navigation/bottom_nav_graph.xml +++ b/app/src/main/res/navigation/bottom_nav_graph.xml @@ -7,27 +7,27 @@ @@ -127,7 +127,7 @@ + + 5 + \ No newline at end of file diff --git a/app/src/main/res/values-w840dp/constants.xml b/app/src/main/res/values-w840dp/constants.xml new file mode 100644 index 0000000..fb6ddf5 --- /dev/null +++ b/app/src/main/res/values-w840dp/constants.xml @@ -0,0 +1,4 @@ + + + 7 + \ No newline at end of file diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml new file mode 100644 index 0000000..9109874 --- /dev/null +++ b/app/src/main/res/values/constants.xml @@ -0,0 +1,4 @@ + + + 3 + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2161472..c2225b8 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -10,10 +10,15 @@ @color/colorAccent @color/white - ?colorOnPrimary + ?colorOnPrimary true + + diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml index 3be5b0c..3742294 100644 --- a/app/src/main/res/xml/root_preferences.xml +++ b/app/src/main/res/xml/root_preferences.xml @@ -1,7 +1,7 @@ - diff --git a/app/src/androidTest/java/EnumTest.kt b/app/src/test/java/com/sharkaboi/mediahub/EnumTest.kt similarity index 94% rename from app/src/androidTest/java/EnumTest.kt rename to app/src/test/java/com/sharkaboi/mediahub/EnumTest.kt index 9eb52e2..4e5a52b 100644 --- a/app/src/androidTest/java/EnumTest.kt +++ b/app/src/test/java/com/sharkaboi/mediahub/EnumTest.kt @@ -1,12 +1,19 @@ -import androidx.test.platform.app.InstrumentationRegistry +package com.sharkaboi.mediahub + +import androidx.test.core.app.ApplicationProvider import com.sharkaboi.mediahub.data.api.enums.* import com.sharkaboi.mediahub.data.api.enums.AnimeRating.getAnimeRating import org.junit.Assert import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [30]) class EnumTest { - private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val context = ApplicationProvider.getApplicationContext() @Test fun formattedStringOfAnimeMustContainSameElementsAsEnumValues() { @@ -92,7 +99,7 @@ class EnumTest { AnimeRankingType.favorite to "In your list", ) inputToExpectedMap.forEach { (input, expectedString) -> - Assert.assertEquals(input.getAnimeRanking(context), expectedString) + Assert.assertEquals(input.getFormattedString(context), expectedString) } } diff --git a/app/src/androidTest/java/PresentationExtensionsTest.kt b/app/src/test/java/com/sharkaboi/mediahub/PresentationExtensionsTest.kt similarity index 94% rename from app/src/androidTest/java/PresentationExtensionsTest.kt rename to app/src/test/java/com/sharkaboi/mediahub/PresentationExtensionsTest.kt index 32d8546..c193d9f 100644 --- a/app/src/androidTest/java/PresentationExtensionsTest.kt +++ b/app/src/test/java/com/sharkaboi/mediahub/PresentationExtensionsTest.kt @@ -1,15 +1,30 @@ +package com.sharkaboi.mediahub + import androidx.core.text.toSpanned -import androidx.test.platform.app.InstrumentationRegistry -import com.sharkaboi.mediahub.common.extensions.* +import androidx.test.core.app.ApplicationProvider +import com.sharkaboi.mediahub.common.extensions.getMediaTypeStringWith +import com.sharkaboi.mediahub.common.extensions.getProgressStringWith +import com.sharkaboi.mediahub.common.extensions.getRatingStringWithRating import com.sharkaboi.mediahub.data.api.models.anime.AnimeByIDResponse import com.sharkaboi.mediahub.data.api.models.manga.MangaByIDResponse +import com.sharkaboi.mediahub.modules.anime_details.util.* +import com.sharkaboi.mediahub.modules.manga_details.util.getChaptersOfMangaString +import com.sharkaboi.mediahub.modules.manga_details.util.getFormattedMangaTitlesString +import com.sharkaboi.mediahub.modules.manga_details.util.getMangaStats +import com.sharkaboi.mediahub.modules.manga_details.util.getVolumesOfMangaString +import com.sharkaboi.mediahub.modules.profile.util.getDaysCountString +import com.sharkaboi.mediahub.modules.profile.util.getEpisodesOfAnimeFullString import org.junit.Assert import org.junit.Test -import kotlin.time.ExperimentalTime +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [30]) class PresentationExtensionsTest { - private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val context = ApplicationProvider.getApplicationContext() @Test fun getProgressStringWithNullProgressAndNullTotalReturnsValidString() { @@ -464,7 +479,6 @@ class PresentationExtensionsTest { Assert.assertFalse(resultString.contains("N/A")) } - @ExperimentalTime @Test fun getEpisodeLengthFromSecondsWithNullSecondsReturnsValidString() { val seconds = null @@ -473,7 +487,6 @@ class PresentationExtensionsTest { Assert.assertEquals(expectedString, resultString) } - @ExperimentalTime @Test fun getEpisodeLengthFromSecondsWith0SecondsReturnsValidString() { val seconds = 0 @@ -482,7 +495,6 @@ class PresentationExtensionsTest { Assert.assertEquals(expectedString, resultString) } - @ExperimentalTime @Test fun getEpisodeLengthFromSecondsWithNegativeSecondsReturnsValidString() { val seconds = -1 @@ -491,7 +503,6 @@ class PresentationExtensionsTest { Assert.assertEquals(expectedString, resultString) } - @ExperimentalTime @Test fun getEpisodeLengthFromSecondsWithValidHourSecondsReturnsValidString() { val seconds = 90 * 60 @@ -500,7 +511,6 @@ class PresentationExtensionsTest { Assert.assertEquals(expectedString, resultString) } - @ExperimentalTime @Test fun getEpisodeLengthFromSecondsWithValidMinutesSecondsReturnsValidString() { val seconds = 30 * 60 @@ -509,7 +519,6 @@ class PresentationExtensionsTest { Assert.assertEquals(expectedString, resultString) } - @ExperimentalTime @Test fun getAiringTimeFormattedWith0TimeReturnsValidString() { val time = GetNextAiringAnimeEpisodeQuery.NextAiringEpisode( @@ -521,7 +530,6 @@ class PresentationExtensionsTest { Assert.assertEquals(expectedString, resultString) } - @ExperimentalTime @Test fun getAiringTimeFormattedWithMinutesTimeReturnsValidString() { val time = GetNextAiringAnimeEpisodeQuery.NextAiringEpisode( @@ -533,7 +541,6 @@ class PresentationExtensionsTest { Assert.assertEquals(expectedString, resultString) } - @ExperimentalTime @Test fun getAiringTimeFormattedWithHoursTimeReturnsValidString() { val time = GetNextAiringAnimeEpisodeQuery.NextAiringEpisode( @@ -545,7 +552,6 @@ class PresentationExtensionsTest { Assert.assertEquals(expectedString, resultString) } - @ExperimentalTime @Test fun getAiringTimeFormattedWithDaysTimeReturnsValidString() { val time = GetNextAiringAnimeEpisodeQuery.NextAiringEpisode( diff --git a/app/src/test/java/com/sharkaboi/mediahub/UtilsTest.kt b/app/src/test/java/com/sharkaboi/mediahub/UtilsTest.kt index b1b76e6..2dc3fd3 100644 --- a/app/src/test/java/com/sharkaboi/mediahub/UtilsTest.kt +++ b/app/src/test/java/com/sharkaboi/mediahub/UtilsTest.kt @@ -1,6 +1,6 @@ package com.sharkaboi.mediahub -import com.sharkaboi.mediahub.common.util.getLocalDateFromDayAndTime +import com.sharkaboi.mediahub.common.util.DateUtils import org.junit.Assert.assertEquals import org.junit.Test import java.time.DayOfWeek @@ -15,7 +15,7 @@ class UtilsTest { TimeZone.setDefault(TimeZone.getTimeZone("UTC")) val inputStartTime = "17:30" val inputDayOfWeek = "saturday" - val result = getLocalDateFromDayAndTime(inputDayOfWeek, inputStartTime) + val result = DateUtils.getLocalDateFromDayAndTime(inputDayOfWeek, inputStartTime) val expectedResult = ZonedDateTime.now().withHour(8).withMinute(30) .with(WeekFields.ISO.dayOfWeek(), DayOfWeek.SATURDAY.value.toLong()) .withSecond(0).withNano(0) diff --git a/app/src/test/java/com/sharkaboi/mediahub/enum_tests/AnimeStatusTest.kt b/app/src/test/java/com/sharkaboi/mediahub/enum_tests/AnimeStatusTest.kt index 7d26b90..57c8362 100644 --- a/app/src/test/java/com/sharkaboi/mediahub/enum_tests/AnimeStatusTest.kt +++ b/app/src/test/java/com/sharkaboi/mediahub/enum_tests/AnimeStatusTest.kt @@ -1,10 +1,7 @@ package com.sharkaboi.mediahub.enum_tests import com.sharkaboi.mediahub.data.api.enums.AnimeStatus -import com.sharkaboi.mediahub.data.api.enums.animeStatusFromString -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue +import org.junit.Assert.* import org.junit.Test class AnimeStatusTest { @@ -13,14 +10,14 @@ class AnimeStatusTest { fun `Calling animeStatusFromString on valid string returns expected Anime status`() { val testString = "plan_to_watch" val expectedAnimeStatus = AnimeStatus.plan_to_watch - val result = testString.animeStatusFromString() + val result = AnimeStatus.parse(testString) assertTrue(expectedAnimeStatus == result) } @Test fun `Calling animeStatusFromString on invalid string returns null`() { val testString = "invalid string" - val result = testString.animeStatusFromString() + val result = AnimeStatus.parse(testString) assertNull(result) } diff --git a/app/src/test/java/com/sharkaboi/mediahub/enum_tests/MangaStatusTest.kt b/app/src/test/java/com/sharkaboi/mediahub/enum_tests/MangaStatusTest.kt index 8b98ff2..3829d37 100644 --- a/app/src/test/java/com/sharkaboi/mediahub/enum_tests/MangaStatusTest.kt +++ b/app/src/test/java/com/sharkaboi/mediahub/enum_tests/MangaStatusTest.kt @@ -1,10 +1,7 @@ package com.sharkaboi.mediahub.enum_tests import com.sharkaboi.mediahub.data.api.enums.MangaStatus -import com.sharkaboi.mediahub.data.api.enums.mangaStatusFromString -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue +import org.junit.Assert.* import org.junit.Test class MangaStatusTest { @@ -13,14 +10,14 @@ class MangaStatusTest { fun `Calling mangaStatusFromString on valid string returns expected Manga status`() { val testString = "plan_to_read" val expectedAnimeStatus = MangaStatus.plan_to_read - val result = testString.mangaStatusFromString() + val result = MangaStatus.parse(testString) assertTrue(expectedAnimeStatus == result) } @Test fun `Calling mangaStatusFromString on invalid string returns null`() { val testString = "invalid string" - val result = testString.mangaStatusFromString() + val result = MangaStatus.parse(testString) assertNull(result) } diff --git a/assets/screenshots/anime.png b/assets/screenshots/anime.png deleted file mode 100644 index cc5f643..0000000 Binary files a/assets/screenshots/anime.png and /dev/null differ diff --git a/assets/screenshots/anime_details.png b/assets/screenshots/anime_details.png deleted file mode 100644 index ca89244..0000000 Binary files a/assets/screenshots/anime_details.png and /dev/null differ diff --git a/assets/screenshots/anime_details2.png b/assets/screenshots/anime_details2.png deleted file mode 100644 index f8d954f..0000000 Binary files a/assets/screenshots/anime_details2.png and /dev/null differ diff --git a/assets/screenshots/anime_ranking.png b/assets/screenshots/anime_ranking.png deleted file mode 100644 index 23e7a06..0000000 Binary files a/assets/screenshots/anime_ranking.png and /dev/null differ diff --git a/assets/screenshots/anime_search.png b/assets/screenshots/anime_search.png deleted file mode 100644 index 996760f..0000000 Binary files a/assets/screenshots/anime_search.png and /dev/null differ diff --git a/assets/screenshots/anime_seasonal.png b/assets/screenshots/anime_seasonal.png deleted file mode 100644 index e127640..0000000 Binary files a/assets/screenshots/anime_seasonal.png and /dev/null differ diff --git a/assets/screenshots/anime_suggestions.png b/assets/screenshots/anime_suggestions.png deleted file mode 100644 index c5c8f51..0000000 Binary files a/assets/screenshots/anime_suggestions.png and /dev/null differ diff --git a/assets/screenshots/discover.png b/assets/screenshots/discover.png deleted file mode 100644 index fbded83..0000000 Binary files a/assets/screenshots/discover.png and /dev/null differ diff --git a/assets/screenshots/manga.png b/assets/screenshots/manga.png deleted file mode 100644 index 5eb2070..0000000 Binary files a/assets/screenshots/manga.png and /dev/null differ diff --git a/assets/screenshots/manga_details.png b/assets/screenshots/manga_details.png deleted file mode 100644 index 4ab2464..0000000 Binary files a/assets/screenshots/manga_details.png and /dev/null differ diff --git a/assets/screenshots/manga_details2.png b/assets/screenshots/manga_details2.png deleted file mode 100644 index 3fca3ae..0000000 Binary files a/assets/screenshots/manga_details2.png and /dev/null differ diff --git a/assets/screenshots/manga_ranking.png b/assets/screenshots/manga_ranking.png deleted file mode 100644 index 4452b11..0000000 Binary files a/assets/screenshots/manga_ranking.png and /dev/null differ diff --git a/assets/screenshots/manga_search.png b/assets/screenshots/manga_search.png deleted file mode 100644 index 6747583..0000000 Binary files a/assets/screenshots/manga_search.png and /dev/null differ diff --git a/assets/screenshots/profile.png b/assets/screenshots/profile.png deleted file mode 100644 index 94ec0b5..0000000 Binary files a/assets/screenshots/profile.png and /dev/null differ diff --git a/assets/screenshots/profile2.png b/assets/screenshots/profile2.png deleted file mode 100644 index 36d5e0c..0000000 Binary files a/assets/screenshots/profile2.png and /dev/null differ diff --git a/assets/screenshots/settings.png b/assets/screenshots/settings.png deleted file mode 100644 index 5e6e439..0000000 Binary files a/assets/screenshots/settings.png and /dev/null differ diff --git a/assets/screenshots/share.png b/assets/screenshots/share.png deleted file mode 100644 index b4b8469..0000000 Binary files a/assets/screenshots/share.png and /dev/null differ diff --git a/assets/screenshots/update.png b/assets/screenshots/update.png deleted file mode 100644 index ca3d644..0000000 Binary files a/assets/screenshots/update.png and /dev/null differ diff --git a/build.gradle b/build.gradle index d5f375c..0870fbe 100644 --- a/build.gradle +++ b/build.gradle @@ -1,40 +1,44 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { ext { - kotlin_version = '1.5.21' - hilt_version = '2.38.1' - paging_version = '3.0.1' - nav_version = '2.4.0-alpha06' + acra_version = '5.9.3' agp_version = '7.0.0' - oss_plugin_version = '0.10.4' - oss_version = '17.0.0' - secrets_version = '1.3.0' - ktlint_version = '10.1.0' - apollo_version = '2.5.9' - core_version = '1.6.0' - appcompat_version = '1.3.1' - mdc_version = '1.4.0' - multidex_version = '2.0.1' - pref_version = '1.1.1' - fragment_version = '1.4.0-alpha05' - swiperefresh_version = '1.1.0' - constraint_version = '2.1.0' - retrofit_version = '2.9.0' - logging_version = '4.9.1' - reponse_version = '4.1.0' - moshi_version = '1.12.0' - datastore_version = '1.0.0' - coil_version = '1.3.2' - progress_version = '2.1.0' + apollo_version = '3.3.0' + appcompat_version = '1.4.2' + appupdater_version = '2.7' chart_version = 'v3.1.0' - lottie_version = '4.0.0' + coil_version = '2.1.0' + constraint_version = '2.1.4' + core_text_ktx_version = '1.4.0' + core_version = '1.8.0' + datastore_version = '1.0.0' desugar_version = '1.1.5' - appupdater_version = '2.7' - timber_version = '5.0.0' - leak_version = '2.7' - junit_version = '4.13.2' - junit_ext_version = '1.1.3' expresso_version = '3.4.0' + fragment_version = '1.4.0-alpha05' + hilt_version = '2.42' + junit_ext_version = '1.1.3' + junit_version = '4.13.2' + kotlin_version = '1.7.0' + ktlint_version = '10.1.0' + leak_version = '2.9.1' + logging_version = '5.0.0-alpha.8' + lottie_version = '5.2.0' + mdc_version = '1.6.1' + moshi_version = '1.13.0' + multidex_version = '2.0.1' + nav_version = '2.4.0-alpha06' + oss_plugin_version = '0.10.4' + oss_version = '17.0.0' + paging_version = '3.1.1' + pref_version = '1.2.0' + progress_version = '2.1.0' + reponse_version = '4.1.0' + retrofit_version = '2.9.0' + robolectric_version = '4.8.1' + room_version = '2.4.2' + secrets_version = '2.0.1' + swiperefresh_version = '1.1.0' + timber_version = '5.0.1' } repositories { @@ -49,7 +53,7 @@ buildscript { classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version" classpath "com.google.android.gms:oss-licenses-plugin:$oss_plugin_version" - classpath "com.apollographql.apollo:apollo-gradle-plugin:$apollo_version" + classpath "com.apollographql.apollo3:apollo-gradle-plugin:$apollo_version" classpath "org.jlleitschuh.gradle:ktlint-gradle:$ktlint_version" classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:$secrets_version" } diff --git a/gradle.properties b/gradle.properties index da95c75..e306544 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,4 +19,6 @@ android.useAndroidX=true android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -org.gradle.parallel=true \ No newline at end of file +org.gradle.parallel=true +# https://github.com/square/moshi/issues/1463#issuecomment-994576201 +android.jetifier.ignorelist=moshi-1.13.0 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 796c50a..a5e969d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Mon Jun 21 17:32:07 IST 2021 +#Mon May 23 16:52:27 IST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME