mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 06:17:57 +01:00 
			
		
		
		
	Unix line endings
This commit is contained in:
		| @@ -1,23 +1,23 @@ | ||||
| package eu.kanade.tachiyomi.data.backup.models | ||||
|  | ||||
| import java.text.SimpleDateFormat | ||||
| import java.util.* | ||||
|  | ||||
| /** | ||||
|  * Json values | ||||
|  */ | ||||
| object Backup { | ||||
|     const val CURRENT_VERSION = 2 | ||||
|     const val MANGA = "manga" | ||||
|     const val MANGAS = "mangas" | ||||
|     const val TRACK = "track" | ||||
|     const val CHAPTERS = "chapters" | ||||
|     const val CATEGORIES = "categories" | ||||
|     const val HISTORY = "history" | ||||
|     const val VERSION = "version" | ||||
|  | ||||
|     fun getDefaultFilename(): String { | ||||
|         val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) | ||||
|         return "tachiyomi_$date.json" | ||||
|     } | ||||
| package eu.kanade.tachiyomi.data.backup.models | ||||
|  | ||||
| import java.text.SimpleDateFormat | ||||
| import java.util.* | ||||
|  | ||||
| /** | ||||
|  * Json values | ||||
|  */ | ||||
| object Backup { | ||||
|     const val CURRENT_VERSION = 2 | ||||
|     const val MANGA = "manga" | ||||
|     const val MANGAS = "mangas" | ||||
|     const val TRACK = "track" | ||||
|     const val CHAPTERS = "chapters" | ||||
|     const val CATEGORIES = "categories" | ||||
|     const val HISTORY = "history" | ||||
|     const val VERSION = "version" | ||||
|  | ||||
|     fun getDefaultFilename(): String { | ||||
|         val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) | ||||
|         return "tachiyomi_$date.json" | ||||
|     } | ||||
| } | ||||
| @@ -1,34 +1,34 @@ | ||||
| package eu.kanade.tachiyomi.data.database.queries | ||||
|  | ||||
| import com.pushtorefresh.storio.sqlite.queries.DeleteQuery | ||||
| import com.pushtorefresh.storio.sqlite.queries.Query | ||||
| import eu.kanade.tachiyomi.data.database.DbProvider | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
|  | ||||
| interface TrackQueries : DbProvider { | ||||
|  | ||||
|     fun getTracks(manga: Manga) = db.get() | ||||
|             .listOfObjects(Track::class.java) | ||||
|             .withQuery(Query.builder() | ||||
|                     .table(TrackTable.TABLE) | ||||
|                     .where("${TrackTable.COL_MANGA_ID} = ?") | ||||
|                     .whereArgs(manga.id) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|  | ||||
|     fun insertTrack(track: Track) = db.put().`object`(track).prepare() | ||||
|  | ||||
|     fun insertTracks(tracks: List<Track>) = db.put().objects(tracks).prepare() | ||||
|  | ||||
|     fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete() | ||||
|             .byQuery(DeleteQuery.builder() | ||||
|                     .table(TrackTable.TABLE) | ||||
|                     .where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?") | ||||
|                     .whereArgs(manga.id, sync.id) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|  | ||||
| package eu.kanade.tachiyomi.data.database.queries | ||||
|  | ||||
| import com.pushtorefresh.storio.sqlite.queries.DeleteQuery | ||||
| import com.pushtorefresh.storio.sqlite.queries.Query | ||||
| import eu.kanade.tachiyomi.data.database.DbProvider | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
|  | ||||
| interface TrackQueries : DbProvider { | ||||
|  | ||||
|     fun getTracks(manga: Manga) = db.get() | ||||
|             .listOfObjects(Track::class.java) | ||||
|             .withQuery(Query.builder() | ||||
|                     .table(TrackTable.TABLE) | ||||
|                     .where("${TrackTable.COL_MANGA_ID} = ?") | ||||
|                     .whereArgs(manga.id) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|  | ||||
|     fun insertTrack(track: Track) = db.put().`object`(track).prepare() | ||||
|  | ||||
|     fun insertTracks(tracks: List<Track>) = db.put().objects(tracks).prepare() | ||||
|  | ||||
|     fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete() | ||||
|             .byQuery(DeleteQuery.builder() | ||||
|                     .table(TrackTable.TABLE) | ||||
|                     .where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?") | ||||
|                     .whereArgs(manga.id, sync.id) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|  | ||||
| } | ||||
| @@ -1,132 +1,132 @@ | ||||
| package eu.kanade.tachiyomi.data.preference | ||||
|  | ||||
| /** | ||||
|  * This class stores the keys for the preferences in the application. | ||||
|  */ | ||||
| object PreferenceKeys { | ||||
|  | ||||
|     const val theme = "pref_theme_key" | ||||
|  | ||||
|     const val rotation = "pref_rotation_type_key" | ||||
|  | ||||
|     const val enableTransitions = "pref_enable_transitions_key" | ||||
|  | ||||
|     const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed" | ||||
|  | ||||
|     const val showPageNumber = "pref_show_page_number_key" | ||||
|  | ||||
|     const val trueColor = "pref_true_color_key" | ||||
|  | ||||
|     const val fullscreen = "fullscreen" | ||||
|  | ||||
|     const val keepScreenOn = "pref_keep_screen_on_key" | ||||
|  | ||||
|     const val customBrightness = "pref_custom_brightness_key" | ||||
|  | ||||
|     const val customBrightnessValue = "custom_brightness_value" | ||||
|  | ||||
|     const val colorFilter = "pref_color_filter_key" | ||||
|  | ||||
|     const val colorFilterValue = "color_filter_value" | ||||
|  | ||||
|     const val colorFilterMode = "color_filter_mode" | ||||
|  | ||||
|     const val defaultViewer = "pref_default_viewer_key" | ||||
|  | ||||
|     const val imageScaleType = "pref_image_scale_type_key" | ||||
|  | ||||
|     const val zoomStart = "pref_zoom_start_key" | ||||
|  | ||||
|     const val readerTheme = "pref_reader_theme_key" | ||||
|  | ||||
|     const val cropBorders = "crop_borders" | ||||
|  | ||||
|     const val cropBordersWebtoon = "crop_borders_webtoon" | ||||
|  | ||||
|     const val readWithTapping = "reader_tap" | ||||
|  | ||||
|     const val readWithLongTap = "reader_long_tap" | ||||
|  | ||||
|     const val readWithVolumeKeys = "reader_volume_keys" | ||||
|  | ||||
|     const val readWithVolumeKeysInverted = "reader_volume_keys_inverted" | ||||
|  | ||||
|     const val portraitColumns = "pref_library_columns_portrait_key" | ||||
|  | ||||
|     const val landscapeColumns = "pref_library_columns_landscape_key" | ||||
|  | ||||
|     const val updateOnlyNonCompleted = "pref_update_only_non_completed_key" | ||||
|  | ||||
|     const val autoUpdateTrack = "pref_auto_update_manga_sync_key" | ||||
|  | ||||
|     const val lastUsedCatalogueSource = "last_catalogue_source" | ||||
|  | ||||
|     const val lastUsedCategory = "last_used_category" | ||||
|  | ||||
|     const val catalogueAsList = "pref_display_catalogue_as_list" | ||||
|  | ||||
|     const val enabledLanguages = "source_languages" | ||||
|  | ||||
|     const val backupDirectory = "backup_directory" | ||||
|  | ||||
|     const val downloadsDirectory = "download_directory" | ||||
|  | ||||
|     const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key" | ||||
|  | ||||
|     const val numberOfBackups = "backup_slots" | ||||
|  | ||||
|     const val backupInterval = "backup_interval" | ||||
|  | ||||
|     const val removeAfterReadSlots = "remove_after_read_slots" | ||||
|  | ||||
|     const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key" | ||||
|  | ||||
|     const val libraryUpdateInterval = "pref_library_update_interval_key" | ||||
|  | ||||
|     const val libraryUpdateRestriction = "library_update_restriction" | ||||
|  | ||||
|     const val libraryUpdateCategories = "library_update_categories" | ||||
|  | ||||
|     const val libraryUpdatePrioritization = "library_update_prioritization" | ||||
|  | ||||
|     const val filterDownloaded = "pref_filter_downloaded_key" | ||||
|  | ||||
|     const val filterUnread = "pref_filter_unread_key" | ||||
|  | ||||
|     const val filterCompleted = "pref_filter_completed_key" | ||||
|  | ||||
|     const val librarySortingMode = "library_sorting_mode" | ||||
|  | ||||
|     const val automaticUpdates = "automatic_updates" | ||||
|  | ||||
|     const val startScreen = "start_screen" | ||||
|  | ||||
|     const val downloadNew = "download_new" | ||||
|  | ||||
|     const val downloadNewCategories = "download_new_categories" | ||||
|  | ||||
|     const val libraryAsList = "pref_display_library_as_list" | ||||
|  | ||||
|     const val lang = "app_language" | ||||
|  | ||||
|     const val defaultCategory = "default_category" | ||||
|  | ||||
|     const val skipRead = "skip_read" | ||||
|  | ||||
|     const val downloadBadge = "display_download_badge" | ||||
|  | ||||
|     @Deprecated("Use the preferences of the source") | ||||
|     fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId" | ||||
|  | ||||
|     @Deprecated("Use the preferences of the source") | ||||
|     fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId" | ||||
|  | ||||
|     fun sourceSharedPref(sourceId: Long) = "source_$sourceId" | ||||
|  | ||||
|     fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" | ||||
|  | ||||
|     fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" | ||||
|  | ||||
|     fun trackToken(syncId: Int) = "track_token_$syncId" | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.data.preference | ||||
|  | ||||
| /** | ||||
|  * This class stores the keys for the preferences in the application. | ||||
|  */ | ||||
| object PreferenceKeys { | ||||
|  | ||||
|     const val theme = "pref_theme_key" | ||||
|  | ||||
|     const val rotation = "pref_rotation_type_key" | ||||
|  | ||||
|     const val enableTransitions = "pref_enable_transitions_key" | ||||
|  | ||||
|     const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed" | ||||
|  | ||||
|     const val showPageNumber = "pref_show_page_number_key" | ||||
|  | ||||
|     const val trueColor = "pref_true_color_key" | ||||
|  | ||||
|     const val fullscreen = "fullscreen" | ||||
|  | ||||
|     const val keepScreenOn = "pref_keep_screen_on_key" | ||||
|  | ||||
|     const val customBrightness = "pref_custom_brightness_key" | ||||
|  | ||||
|     const val customBrightnessValue = "custom_brightness_value" | ||||
|  | ||||
|     const val colorFilter = "pref_color_filter_key" | ||||
|  | ||||
|     const val colorFilterValue = "color_filter_value" | ||||
|  | ||||
|     const val colorFilterMode = "color_filter_mode" | ||||
|  | ||||
|     const val defaultViewer = "pref_default_viewer_key" | ||||
|  | ||||
|     const val imageScaleType = "pref_image_scale_type_key" | ||||
|  | ||||
|     const val zoomStart = "pref_zoom_start_key" | ||||
|  | ||||
|     const val readerTheme = "pref_reader_theme_key" | ||||
|  | ||||
|     const val cropBorders = "crop_borders" | ||||
|  | ||||
|     const val cropBordersWebtoon = "crop_borders_webtoon" | ||||
|  | ||||
|     const val readWithTapping = "reader_tap" | ||||
|  | ||||
|     const val readWithLongTap = "reader_long_tap" | ||||
|  | ||||
|     const val readWithVolumeKeys = "reader_volume_keys" | ||||
|  | ||||
|     const val readWithVolumeKeysInverted = "reader_volume_keys_inverted" | ||||
|  | ||||
|     const val portraitColumns = "pref_library_columns_portrait_key" | ||||
|  | ||||
|     const val landscapeColumns = "pref_library_columns_landscape_key" | ||||
|  | ||||
|     const val updateOnlyNonCompleted = "pref_update_only_non_completed_key" | ||||
|  | ||||
|     const val autoUpdateTrack = "pref_auto_update_manga_sync_key" | ||||
|  | ||||
|     const val lastUsedCatalogueSource = "last_catalogue_source" | ||||
|  | ||||
|     const val lastUsedCategory = "last_used_category" | ||||
|  | ||||
|     const val catalogueAsList = "pref_display_catalogue_as_list" | ||||
|  | ||||
|     const val enabledLanguages = "source_languages" | ||||
|  | ||||
|     const val backupDirectory = "backup_directory" | ||||
|  | ||||
|     const val downloadsDirectory = "download_directory" | ||||
|  | ||||
|     const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key" | ||||
|  | ||||
|     const val numberOfBackups = "backup_slots" | ||||
|  | ||||
|     const val backupInterval = "backup_interval" | ||||
|  | ||||
|     const val removeAfterReadSlots = "remove_after_read_slots" | ||||
|  | ||||
|     const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key" | ||||
|  | ||||
|     const val libraryUpdateInterval = "pref_library_update_interval_key" | ||||
|  | ||||
|     const val libraryUpdateRestriction = "library_update_restriction" | ||||
|  | ||||
|     const val libraryUpdateCategories = "library_update_categories" | ||||
|  | ||||
|     const val libraryUpdatePrioritization = "library_update_prioritization" | ||||
|  | ||||
|     const val filterDownloaded = "pref_filter_downloaded_key" | ||||
|  | ||||
|     const val filterUnread = "pref_filter_unread_key" | ||||
|  | ||||
|     const val filterCompleted = "pref_filter_completed_key" | ||||
|  | ||||
|     const val librarySortingMode = "library_sorting_mode" | ||||
|  | ||||
|     const val automaticUpdates = "automatic_updates" | ||||
|  | ||||
|     const val startScreen = "start_screen" | ||||
|  | ||||
|     const val downloadNew = "download_new" | ||||
|  | ||||
|     const val downloadNewCategories = "download_new_categories" | ||||
|  | ||||
|     const val libraryAsList = "pref_display_library_as_list" | ||||
|  | ||||
|     const val lang = "app_language" | ||||
|  | ||||
|     const val defaultCategory = "default_category" | ||||
|  | ||||
|     const val skipRead = "skip_read" | ||||
|  | ||||
|     const val downloadBadge = "display_download_badge" | ||||
|  | ||||
|     @Deprecated("Use the preferences of the source") | ||||
|     fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId" | ||||
|  | ||||
|     @Deprecated("Use the preferences of the source") | ||||
|     fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId" | ||||
|  | ||||
|     fun sourceSharedPref(sourceId: Long) = "source_$sourceId" | ||||
|  | ||||
|     fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" | ||||
|  | ||||
|     fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" | ||||
|  | ||||
|     fun trackToken(syncId: Int) = "track_token_$syncId" | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,36 +1,36 @@ | ||||
| package eu.kanade.tachiyomi.data.track | ||||
|  | ||||
| import android.content.Context | ||||
| import eu.kanade.tachiyomi.data.track.anilist.Anilist | ||||
| import eu.kanade.tachiyomi.data.track.kitsu.Kitsu | ||||
| import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist | ||||
| import eu.kanade.tachiyomi.data.track.shikimori.Shikimori | ||||
| import eu.kanade.tachiyomi.data.track.bangumi.Bangumi | ||||
|  | ||||
| class TrackManager(private val context: Context) { | ||||
|  | ||||
|     companion object { | ||||
|         const val MYANIMELIST = 1 | ||||
|         const val ANILIST = 2 | ||||
|         const val KITSU = 3 | ||||
|         const val SHIKIMORI = 4 | ||||
|         const val BANGUMI = 5 | ||||
|     } | ||||
|  | ||||
|     val myAnimeList = Myanimelist(context, MYANIMELIST) | ||||
|  | ||||
|     val aniList = Anilist(context, ANILIST) | ||||
|  | ||||
|     val kitsu = Kitsu(context, KITSU) | ||||
|  | ||||
|     val shikimori = Shikimori(context, SHIKIMORI) | ||||
|  | ||||
|     val bangumi = Bangumi(context, BANGUMI) | ||||
|  | ||||
|     val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi) | ||||
|  | ||||
|     fun getService(id: Int) = services.find { it.id == id } | ||||
|  | ||||
|     fun hasLoggedServices() = services.any { it.isLogged } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.data.track | ||||
|  | ||||
| import android.content.Context | ||||
| import eu.kanade.tachiyomi.data.track.anilist.Anilist | ||||
| import eu.kanade.tachiyomi.data.track.kitsu.Kitsu | ||||
| import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist | ||||
| import eu.kanade.tachiyomi.data.track.shikimori.Shikimori | ||||
| import eu.kanade.tachiyomi.data.track.bangumi.Bangumi | ||||
|  | ||||
| class TrackManager(private val context: Context) { | ||||
|  | ||||
|     companion object { | ||||
|         const val MYANIMELIST = 1 | ||||
|         const val ANILIST = 2 | ||||
|         const val KITSU = 3 | ||||
|         const val SHIKIMORI = 4 | ||||
|         const val BANGUMI = 5 | ||||
|     } | ||||
|  | ||||
|     val myAnimeList = Myanimelist(context, MYANIMELIST) | ||||
|  | ||||
|     val aniList = Anilist(context, ANILIST) | ||||
|  | ||||
|     val kitsu = Kitsu(context, KITSU) | ||||
|  | ||||
|     val shikimori = Shikimori(context, SHIKIMORI) | ||||
|  | ||||
|     val bangumi = Bangumi(context, BANGUMI) | ||||
|  | ||||
|     val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi) | ||||
|  | ||||
|     fun getService(id: Int) = services.find { it.id == id } | ||||
|  | ||||
|     fun hasLoggedServices() = services.any { it.isLogged } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,70 +1,70 @@ | ||||
| package eu.kanade.tachiyomi.data.track | ||||
|  | ||||
| import android.support.annotation.CallSuper | ||||
| import android.support.annotation.DrawableRes | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| import okhttp3.OkHttpClient | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| abstract class TrackService(val id: Int) { | ||||
|  | ||||
|     val preferences: PreferencesHelper by injectLazy() | ||||
|     val networkService: NetworkHelper by injectLazy() | ||||
|  | ||||
|     open val client: OkHttpClient | ||||
|         get() = networkService.client | ||||
|  | ||||
|     // Name of the manga sync service to display | ||||
|     abstract val name: String | ||||
|  | ||||
|     @DrawableRes | ||||
|     abstract fun getLogo(): Int | ||||
|  | ||||
|     abstract fun getLogoColor(): Int | ||||
|  | ||||
|     abstract fun getStatusList(): List<Int> | ||||
|  | ||||
|     abstract fun getStatus(status: Int): String | ||||
|  | ||||
|     abstract fun getScoreList(): List<String> | ||||
|  | ||||
|     open fun indexToScore(index: Int): Float { | ||||
|         return index.toFloat() | ||||
|     } | ||||
|  | ||||
|     abstract fun displayScore(track: Track): String | ||||
|  | ||||
|     abstract fun add(track: Track): Observable<Track> | ||||
|  | ||||
|     abstract fun update(track: Track): Observable<Track> | ||||
|  | ||||
|     abstract fun bind(track: Track): Observable<Track> | ||||
|  | ||||
|     abstract fun search(query: String): Observable<List<TrackSearch>> | ||||
|  | ||||
|     abstract fun refresh(track: Track): Observable<Track> | ||||
|  | ||||
|     abstract fun login(username: String, password: String): Completable | ||||
|  | ||||
|     @CallSuper | ||||
|     open fun logout() { | ||||
|         preferences.setTrackCredentials(this, "", "") | ||||
|     } | ||||
|  | ||||
|     open val isLogged: Boolean | ||||
|         get() = !getUsername().isEmpty() && | ||||
|                 !getPassword().isEmpty() | ||||
|  | ||||
|     fun getUsername() = preferences.trackUsername(this)!! | ||||
|  | ||||
|     fun getPassword() = preferences.trackPassword(this)!! | ||||
|  | ||||
|     fun saveCredentials(username: String, password: String) { | ||||
|         preferences.setTrackCredentials(this, username, password) | ||||
|     } | ||||
| } | ||||
| package eu.kanade.tachiyomi.data.track | ||||
|  | ||||
| import android.support.annotation.CallSuper | ||||
| import android.support.annotation.DrawableRes | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| import okhttp3.OkHttpClient | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| abstract class TrackService(val id: Int) { | ||||
|  | ||||
|     val preferences: PreferencesHelper by injectLazy() | ||||
|     val networkService: NetworkHelper by injectLazy() | ||||
|  | ||||
|     open val client: OkHttpClient | ||||
|         get() = networkService.client | ||||
|  | ||||
|     // Name of the manga sync service to display | ||||
|     abstract val name: String | ||||
|  | ||||
|     @DrawableRes | ||||
|     abstract fun getLogo(): Int | ||||
|  | ||||
|     abstract fun getLogoColor(): Int | ||||
|  | ||||
|     abstract fun getStatusList(): List<Int> | ||||
|  | ||||
|     abstract fun getStatus(status: Int): String | ||||
|  | ||||
|     abstract fun getScoreList(): List<String> | ||||
|  | ||||
|     open fun indexToScore(index: Int): Float { | ||||
|         return index.toFloat() | ||||
|     } | ||||
|  | ||||
|     abstract fun displayScore(track: Track): String | ||||
|  | ||||
|     abstract fun add(track: Track): Observable<Track> | ||||
|  | ||||
|     abstract fun update(track: Track): Observable<Track> | ||||
|  | ||||
|     abstract fun bind(track: Track): Observable<Track> | ||||
|  | ||||
|     abstract fun search(query: String): Observable<List<TrackSearch>> | ||||
|  | ||||
|     abstract fun refresh(track: Track): Observable<Track> | ||||
|  | ||||
|     abstract fun login(username: String, password: String): Completable | ||||
|  | ||||
|     @CallSuper | ||||
|     open fun logout() { | ||||
|         preferences.setTrackCredentials(this, "", "") | ||||
|     } | ||||
|  | ||||
|     open val isLogged: Boolean | ||||
|         get() = !getUsername().isEmpty() && | ||||
|                 !getPassword().isEmpty() | ||||
|  | ||||
|     fun getUsername() = preferences.trackUsername(this)!! | ||||
|  | ||||
|     fun getPassword() = preferences.trackPassword(this)!! | ||||
|  | ||||
|     fun saveCredentials(username: String, password: String) { | ||||
|         preferences.setTrackCredentials(this, username, password) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,214 +1,214 @@ | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class Anilist(private val context: Context, id: Int) : TrackService(id) { | ||||
|  | ||||
|     companion object { | ||||
|         const val READING = 1 | ||||
|         const val COMPLETED = 2 | ||||
|         const val ON_HOLD = 3 | ||||
|         const val DROPPED = 4 | ||||
|         const val PLANNING = 5 | ||||
|         const val REPEATING = 6 | ||||
|  | ||||
|         const val DEFAULT_STATUS = READING | ||||
|         const val DEFAULT_SCORE = 0 | ||||
|  | ||||
|         const val POINT_100 = "POINT_100" | ||||
|         const val POINT_10 = "POINT_10" | ||||
|         const val POINT_10_DECIMAL = "POINT_10_DECIMAL" | ||||
|         const val POINT_5 = "POINT_5" | ||||
|         const val POINT_3 = "POINT_3" | ||||
|     } | ||||
|  | ||||
|     override val name = "AniList" | ||||
|  | ||||
|     private val gson: Gson by injectLazy() | ||||
|  | ||||
|     private val interceptor by lazy { AnilistInterceptor(this, getPassword()) } | ||||
|  | ||||
|     private val api by lazy { AnilistApi(client, interceptor) } | ||||
|  | ||||
|     private val scorePreference = preferences.anilistScoreType() | ||||
|  | ||||
|     init { | ||||
|         // If the preference is an int from APIv1, logout user to force using APIv2 | ||||
|         try { | ||||
|             scorePreference.get() | ||||
|         } catch (e: ClassCastException) { | ||||
|             logout() | ||||
|             scorePreference.delete() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getLogo() = R.drawable.al | ||||
|  | ||||
|     override fun getLogoColor() = Color.rgb(18, 25, 35) | ||||
|  | ||||
|     override fun getStatusList(): List<Int> { | ||||
|         return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING) | ||||
|     } | ||||
|  | ||||
|     override fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             READING -> getString(R.string.reading) | ||||
|             COMPLETED -> getString(R.string.completed) | ||||
|             ON_HOLD -> getString(R.string.on_hold) | ||||
|             DROPPED -> getString(R.string.dropped) | ||||
|             PLANNING -> getString(R.string.plan_to_read) | ||||
|             REPEATING -> getString(R.string.repeating) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getScoreList(): List<String> { | ||||
|         return when (scorePreference.getOrDefault()) { | ||||
|             // 10 point | ||||
|             POINT_10 -> IntRange(0, 10).map(Int::toString) | ||||
|             // 100 point | ||||
|             POINT_100 -> IntRange(0, 100).map(Int::toString) | ||||
|             // 5 stars | ||||
|             POINT_5 -> IntRange(0, 5).map { "$it ★" } | ||||
|             // Smiley | ||||
|             POINT_3 -> listOf("-", "😦", "😐", "😊") | ||||
|             // 10 point decimal | ||||
|             POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() } | ||||
|             else -> throw Exception("Unknown score type") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun indexToScore(index: Int): Float { | ||||
|         return when (scorePreference.getOrDefault()) { | ||||
|             // 10 point | ||||
|             POINT_10 -> index * 10f | ||||
|             // 100 point | ||||
|             POINT_100 -> index.toFloat() | ||||
|             // 5 stars | ||||
|             POINT_5 -> when { | ||||
|                 index == 0 -> 0f | ||||
|                 else -> index * 20f - 10f | ||||
|             } | ||||
|             // Smiley | ||||
|             POINT_3 -> when { | ||||
|                 index == 0 -> 0f | ||||
|                 else -> index * 25f + 10f | ||||
|             } | ||||
|             // 10 point decimal | ||||
|             POINT_10_DECIMAL -> index.toFloat() | ||||
|             else -> throw Exception("Unknown score type") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun displayScore(track: Track): String { | ||||
|         val score = track.score | ||||
|  | ||||
|         return when (scorePreference.getOrDefault()) { | ||||
|             POINT_5 -> when { | ||||
|                 score == 0f -> "0 ★" | ||||
|                 else -> "${((score + 10) / 20).toInt()} ★" | ||||
|             } | ||||
|             POINT_3 -> when { | ||||
|                 score == 0f -> "0" | ||||
|                 score <= 35 -> "😦" | ||||
|                 score <= 60 -> "😐" | ||||
|                 else -> "😊" | ||||
|             } | ||||
|             else -> track.toAnilistScore() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun add(track: Track): Observable<Track> { | ||||
|         return api.addLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun update(track: Track): Observable<Track> { | ||||
|         if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { | ||||
|             track.status = COMPLETED | ||||
|         } | ||||
|         // If user was using API v1 fetch library_id | ||||
|         if (track.library_id == null || track.library_id!! == 0L){ | ||||
|             return api.findLibManga(track, getUsername().toInt()).flatMap { | ||||
|                 if (it == null) { | ||||
|                     throw Exception("$track not found on user library") | ||||
|                 } | ||||
|                 track.library_id = it.library_id | ||||
|                 api.updateLibManga(track) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return api.updateLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun bind(track: Track): Observable<Track> { | ||||
|         return api.findLibManga(track, getUsername().toInt()) | ||||
|                 .flatMap { remoteTrack -> | ||||
|                     if (remoteTrack != null) { | ||||
|                         track.copyPersonalFrom(remoteTrack) | ||||
|                         track.library_id = remoteTrack.library_id | ||||
|                         update(track) | ||||
|                     } else { | ||||
|                         // Set default fields if it's not found in the list | ||||
|                         track.score = DEFAULT_SCORE.toFloat() | ||||
|                         track.status = DEFAULT_STATUS | ||||
|                         add(track) | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|         return api.search(query) | ||||
|     } | ||||
|  | ||||
|     override fun refresh(track: Track): Observable<Track> { | ||||
|         return api.getLibManga(track, getUsername().toInt()) | ||||
|                 .map { remoteTrack -> | ||||
|                     track.copyPersonalFrom(remoteTrack) | ||||
|                     track.total_chapters = remoteTrack.total_chapters | ||||
|                     track | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun login(username: String, password: String) = login(password) | ||||
|  | ||||
|     fun login(token: String): Completable { | ||||
|         val oauth = api.createOAuth(token) | ||||
|         interceptor.setAuth(oauth) | ||||
|         return api.getCurrentUser().map { (username, scoreType) -> | ||||
|             scorePreference.set(scoreType) | ||||
|             saveCredentials(username.toString(), oauth.access_token) | ||||
|          }.doOnError{ | ||||
|             logout() | ||||
|         }.toCompletable() | ||||
|     } | ||||
|  | ||||
|     override fun logout() { | ||||
|         super.logout() | ||||
|         preferences.trackToken(this).set(null) | ||||
|         interceptor.setAuth(null) | ||||
|     } | ||||
|  | ||||
|     fun saveOAuth(oAuth: OAuth?) { | ||||
|         preferences.trackToken(this).set(gson.toJson(oAuth)) | ||||
|     } | ||||
|  | ||||
|     fun loadOAuth(): OAuth? { | ||||
|         return try { | ||||
|             gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) | ||||
|         } catch (e: Exception) { | ||||
|             null | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class Anilist(private val context: Context, id: Int) : TrackService(id) { | ||||
|  | ||||
|     companion object { | ||||
|         const val READING = 1 | ||||
|         const val COMPLETED = 2 | ||||
|         const val ON_HOLD = 3 | ||||
|         const val DROPPED = 4 | ||||
|         const val PLANNING = 5 | ||||
|         const val REPEATING = 6 | ||||
|  | ||||
|         const val DEFAULT_STATUS = READING | ||||
|         const val DEFAULT_SCORE = 0 | ||||
|  | ||||
|         const val POINT_100 = "POINT_100" | ||||
|         const val POINT_10 = "POINT_10" | ||||
|         const val POINT_10_DECIMAL = "POINT_10_DECIMAL" | ||||
|         const val POINT_5 = "POINT_5" | ||||
|         const val POINT_3 = "POINT_3" | ||||
|     } | ||||
|  | ||||
|     override val name = "AniList" | ||||
|  | ||||
|     private val gson: Gson by injectLazy() | ||||
|  | ||||
|     private val interceptor by lazy { AnilistInterceptor(this, getPassword()) } | ||||
|  | ||||
|     private val api by lazy { AnilistApi(client, interceptor) } | ||||
|  | ||||
|     private val scorePreference = preferences.anilistScoreType() | ||||
|  | ||||
|     init { | ||||
|         // If the preference is an int from APIv1, logout user to force using APIv2 | ||||
|         try { | ||||
|             scorePreference.get() | ||||
|         } catch (e: ClassCastException) { | ||||
|             logout() | ||||
|             scorePreference.delete() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getLogo() = R.drawable.al | ||||
|  | ||||
|     override fun getLogoColor() = Color.rgb(18, 25, 35) | ||||
|  | ||||
|     override fun getStatusList(): List<Int> { | ||||
|         return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING) | ||||
|     } | ||||
|  | ||||
|     override fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             READING -> getString(R.string.reading) | ||||
|             COMPLETED -> getString(R.string.completed) | ||||
|             ON_HOLD -> getString(R.string.on_hold) | ||||
|             DROPPED -> getString(R.string.dropped) | ||||
|             PLANNING -> getString(R.string.plan_to_read) | ||||
|             REPEATING -> getString(R.string.repeating) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getScoreList(): List<String> { | ||||
|         return when (scorePreference.getOrDefault()) { | ||||
|             // 10 point | ||||
|             POINT_10 -> IntRange(0, 10).map(Int::toString) | ||||
|             // 100 point | ||||
|             POINT_100 -> IntRange(0, 100).map(Int::toString) | ||||
|             // 5 stars | ||||
|             POINT_5 -> IntRange(0, 5).map { "$it ★" } | ||||
|             // Smiley | ||||
|             POINT_3 -> listOf("-", "😦", "😐", "😊") | ||||
|             // 10 point decimal | ||||
|             POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() } | ||||
|             else -> throw Exception("Unknown score type") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun indexToScore(index: Int): Float { | ||||
|         return when (scorePreference.getOrDefault()) { | ||||
|             // 10 point | ||||
|             POINT_10 -> index * 10f | ||||
|             // 100 point | ||||
|             POINT_100 -> index.toFloat() | ||||
|             // 5 stars | ||||
|             POINT_5 -> when { | ||||
|                 index == 0 -> 0f | ||||
|                 else -> index * 20f - 10f | ||||
|             } | ||||
|             // Smiley | ||||
|             POINT_3 -> when { | ||||
|                 index == 0 -> 0f | ||||
|                 else -> index * 25f + 10f | ||||
|             } | ||||
|             // 10 point decimal | ||||
|             POINT_10_DECIMAL -> index.toFloat() | ||||
|             else -> throw Exception("Unknown score type") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun displayScore(track: Track): String { | ||||
|         val score = track.score | ||||
|  | ||||
|         return when (scorePreference.getOrDefault()) { | ||||
|             POINT_5 -> when { | ||||
|                 score == 0f -> "0 ★" | ||||
|                 else -> "${((score + 10) / 20).toInt()} ★" | ||||
|             } | ||||
|             POINT_3 -> when { | ||||
|                 score == 0f -> "0" | ||||
|                 score <= 35 -> "😦" | ||||
|                 score <= 60 -> "😐" | ||||
|                 else -> "😊" | ||||
|             } | ||||
|             else -> track.toAnilistScore() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun add(track: Track): Observable<Track> { | ||||
|         return api.addLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun update(track: Track): Observable<Track> { | ||||
|         if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { | ||||
|             track.status = COMPLETED | ||||
|         } | ||||
|         // If user was using API v1 fetch library_id | ||||
|         if (track.library_id == null || track.library_id!! == 0L){ | ||||
|             return api.findLibManga(track, getUsername().toInt()).flatMap { | ||||
|                 if (it == null) { | ||||
|                     throw Exception("$track not found on user library") | ||||
|                 } | ||||
|                 track.library_id = it.library_id | ||||
|                 api.updateLibManga(track) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return api.updateLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun bind(track: Track): Observable<Track> { | ||||
|         return api.findLibManga(track, getUsername().toInt()) | ||||
|                 .flatMap { remoteTrack -> | ||||
|                     if (remoteTrack != null) { | ||||
|                         track.copyPersonalFrom(remoteTrack) | ||||
|                         track.library_id = remoteTrack.library_id | ||||
|                         update(track) | ||||
|                     } else { | ||||
|                         // Set default fields if it's not found in the list | ||||
|                         track.score = DEFAULT_SCORE.toFloat() | ||||
|                         track.status = DEFAULT_STATUS | ||||
|                         add(track) | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|         return api.search(query) | ||||
|     } | ||||
|  | ||||
|     override fun refresh(track: Track): Observable<Track> { | ||||
|         return api.getLibManga(track, getUsername().toInt()) | ||||
|                 .map { remoteTrack -> | ||||
|                     track.copyPersonalFrom(remoteTrack) | ||||
|                     track.total_chapters = remoteTrack.total_chapters | ||||
|                     track | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun login(username: String, password: String) = login(password) | ||||
|  | ||||
|     fun login(token: String): Completable { | ||||
|         val oauth = api.createOAuth(token) | ||||
|         interceptor.setAuth(oauth) | ||||
|         return api.getCurrentUser().map { (username, scoreType) -> | ||||
|             scorePreference.set(scoreType) | ||||
|             saveCredentials(username.toString(), oauth.access_token) | ||||
|          }.doOnError{ | ||||
|             logout() | ||||
|         }.toCompletable() | ||||
|     } | ||||
|  | ||||
|     override fun logout() { | ||||
|         super.logout() | ||||
|         preferences.trackToken(this).set(null) | ||||
|         interceptor.setAuth(null) | ||||
|     } | ||||
|  | ||||
|     fun saveOAuth(oAuth: OAuth?) { | ||||
|         preferences.trackToken(this).set(gson.toJson(oAuth)) | ||||
|     } | ||||
|  | ||||
|     fun loadOAuth(): OAuth? { | ||||
|         return try { | ||||
|             gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) | ||||
|         } catch (e: Exception) { | ||||
|             null | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,286 +1,286 @@ | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| import android.net.Uri | ||||
| import com.github.salomonbrys.kotson.* | ||||
| import com.google.gson.JsonObject | ||||
| import com.google.gson.JsonParser | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.network.asObservableSuccess | ||||
| import okhttp3.MediaType | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| import okhttp3.RequestBody | ||||
| import rx.Observable | ||||
| import java.util.Calendar | ||||
|  | ||||
|  | ||||
| class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { | ||||
|  | ||||
|     private val parser = JsonParser() | ||||
|     private val jsonMime = MediaType.parse("application/json; charset=utf-8") | ||||
|     private val authClient = client.newBuilder().addInterceptor(interceptor).build() | ||||
|  | ||||
|     fun addLibManga(track: Track): Observable<Track> { | ||||
|         val query = """ | ||||
|             |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) { | ||||
|                 |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {  | ||||
|                 |   id  | ||||
|                 |   status  | ||||
|                 |}  | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val variables = jsonObject( | ||||
|                 "mangaId" to track.media_id, | ||||
|                 "progress" to track.last_chapter_read, | ||||
|                 "status" to track.toAnilistStatus() | ||||
|         ) | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query, | ||||
|                 "variables" to variables | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { netResponse -> | ||||
|                     val responseBody = netResponse.body()?.string().orEmpty() | ||||
|                     netResponse.close() | ||||
|                     if (responseBody.isEmpty()) { | ||||
|                         throw Exception("Null Response") | ||||
|                     } | ||||
|                     val response = parser.parse(responseBody).obj | ||||
|                     track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong | ||||
|                     track | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     fun updateLibManga(track: Track): Observable<Track> { | ||||
|         val query = """ | ||||
|             |mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) { | ||||
|                 |SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) { | ||||
|                     |id | ||||
|                     |status | ||||
|                     |progress | ||||
|                 |} | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val variables = jsonObject( | ||||
|                 "listId" to track.library_id, | ||||
|                 "progress" to track.last_chapter_read, | ||||
|                 "status" to track.toAnilistStatus(), | ||||
|                 "score" to track.score.toInt() | ||||
|         ) | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query, | ||||
|                 "variables" to variables | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { | ||||
|                     track | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     fun search(search: String): Observable<List<TrackSearch>> { | ||||
|         val query = """ | ||||
|             |query Search(${'$'}query: String) { | ||||
|                 |Page (perPage: 50) { | ||||
|                     |media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) { | ||||
|                         |id | ||||
|                         |title { | ||||
|                             |romaji | ||||
|                         |} | ||||
|                         |coverImage { | ||||
|                             |large | ||||
|                         |} | ||||
|                         |type | ||||
|                         |status | ||||
|                         |chapters | ||||
|                         |description | ||||
|                         |startDate { | ||||
|                             |year | ||||
|                             |month | ||||
|                             |day | ||||
|                         |} | ||||
|                     |} | ||||
|                 |} | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val variables = jsonObject( | ||||
|                 "query" to search | ||||
|         ) | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query, | ||||
|                 "variables" to variables | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { netResponse -> | ||||
|                     val responseBody = netResponse.body()?.string().orEmpty() | ||||
|                     if (responseBody.isEmpty()) { | ||||
|                         throw Exception("Null Response") | ||||
|                     } | ||||
|                     val response = parser.parse(responseBody).obj | ||||
|                     val data = response["data"]!!.obj | ||||
|                     val page = data["Page"].obj | ||||
|                     val media = page["media"].array | ||||
|                     val entries = media.map { jsonToALManga(it.obj) } | ||||
|                     entries.map { it.toTrack() } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     fun findLibManga(track: Track, userid: Int): Observable<Track?> { | ||||
|         val query = """ | ||||
|             |query (${'$'}id: Int!, ${'$'}manga_id: Int!) { | ||||
|                 |Page { | ||||
|                     |mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) { | ||||
|                         |id | ||||
|                         |status | ||||
|                         |scoreRaw: score(format: POINT_100) | ||||
|                         |progress | ||||
|                         |media { | ||||
|                             |id | ||||
|                             |title { | ||||
|                                 |romaji | ||||
|                             |} | ||||
|                             |coverImage { | ||||
|                                 |large | ||||
|                             |} | ||||
|                             |type | ||||
|                             |status | ||||
|                             |chapters | ||||
|                             |description | ||||
|                             |startDate { | ||||
|                                 |year | ||||
|                                 |month | ||||
|                                 |day | ||||
|                             |} | ||||
|                         |} | ||||
|                     |} | ||||
|                 |} | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val variables = jsonObject( | ||||
|                 "id" to userid, | ||||
|                 "manga_id" to track.media_id | ||||
|         ) | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query, | ||||
|                 "variables" to variables | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { netResponse -> | ||||
|                     val responseBody = netResponse.body()?.string().orEmpty() | ||||
|                     if (responseBody.isEmpty()) { | ||||
|                         throw Exception("Null Response") | ||||
|                     } | ||||
|                     val response = parser.parse(responseBody).obj | ||||
|                     val data = response["data"]!!.obj | ||||
|                     val page = data["Page"].obj | ||||
|                     val media = page["mediaList"].array | ||||
|                     val entries = media.map { jsonToALUserManga(it.obj) } | ||||
|                     entries.firstOrNull()?.toTrack() | ||||
|  | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     fun getLibManga(track: Track, userid: Int): Observable<Track> { | ||||
|         return findLibManga(track, userid) | ||||
|                 .map { it ?: throw Exception("Could not find manga") } | ||||
|     } | ||||
|  | ||||
|     fun createOAuth(token: String): OAuth { | ||||
|         return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000) | ||||
|     } | ||||
|  | ||||
|     fun getCurrentUser(): Observable<Pair<Int, String>> { | ||||
|         val query = """ | ||||
|             |query User { | ||||
|                 |Viewer { | ||||
|                     |id | ||||
|                     |mediaListOptions { | ||||
|                         |scoreFormat | ||||
|                     |} | ||||
|                 |} | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { netResponse -> | ||||
|                     val responseBody = netResponse.body()?.string().orEmpty() | ||||
|                     if (responseBody.isEmpty()) { | ||||
|                         throw Exception("Null Response") | ||||
|                     } | ||||
|                     val response = parser.parse(responseBody).obj | ||||
|                     val data = response["data"]!!.obj | ||||
|                     val viewer = data["Viewer"].obj | ||||
|                     Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString) | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     private fun jsonToALManga(struct: JsonObject): ALManga { | ||||
|         val date = try { | ||||
|             val date = Calendar.getInstance() | ||||
|             date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1, | ||||
|                     struct["startDate"]["day"].nullInt ?: 0) | ||||
|             date.timeInMillis | ||||
|         } catch (_: Exception) { | ||||
|             0L | ||||
|         } | ||||
|  | ||||
|         return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString, | ||||
|                 struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString, | ||||
|                 date, struct["chapters"].nullInt ?: 0) | ||||
|     } | ||||
|  | ||||
|     private fun jsonToALUserManga(struct: JsonObject): ALUserManga { | ||||
|         return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj)) | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         private const val clientId = "385" | ||||
|         private const val clientUrl = "tachiyomi://anilist-auth" | ||||
|         private const val apiUrl = "https://graphql.anilist.co/" | ||||
|         private const val baseUrl = "https://anilist.co/api/v2/" | ||||
|         private const val baseMangaUrl = "https://anilist.co/manga/" | ||||
|  | ||||
|         fun mangaUrl(mediaId: Int): String { | ||||
|             return baseMangaUrl + mediaId | ||||
|         } | ||||
|  | ||||
|         fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon() | ||||
|                 .appendQueryParameter("client_id", clientId) | ||||
|                 .appendQueryParameter("response_type", "token") | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| import android.net.Uri | ||||
| import com.github.salomonbrys.kotson.* | ||||
| import com.google.gson.JsonObject | ||||
| import com.google.gson.JsonParser | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.network.asObservableSuccess | ||||
| import okhttp3.MediaType | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| import okhttp3.RequestBody | ||||
| import rx.Observable | ||||
| import java.util.Calendar | ||||
|  | ||||
|  | ||||
| class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { | ||||
|  | ||||
|     private val parser = JsonParser() | ||||
|     private val jsonMime = MediaType.parse("application/json; charset=utf-8") | ||||
|     private val authClient = client.newBuilder().addInterceptor(interceptor).build() | ||||
|  | ||||
|     fun addLibManga(track: Track): Observable<Track> { | ||||
|         val query = """ | ||||
|             |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) { | ||||
|                 |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {  | ||||
|                 |   id  | ||||
|                 |   status  | ||||
|                 |}  | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val variables = jsonObject( | ||||
|                 "mangaId" to track.media_id, | ||||
|                 "progress" to track.last_chapter_read, | ||||
|                 "status" to track.toAnilistStatus() | ||||
|         ) | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query, | ||||
|                 "variables" to variables | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { netResponse -> | ||||
|                     val responseBody = netResponse.body()?.string().orEmpty() | ||||
|                     netResponse.close() | ||||
|                     if (responseBody.isEmpty()) { | ||||
|                         throw Exception("Null Response") | ||||
|                     } | ||||
|                     val response = parser.parse(responseBody).obj | ||||
|                     track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong | ||||
|                     track | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     fun updateLibManga(track: Track): Observable<Track> { | ||||
|         val query = """ | ||||
|             |mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) { | ||||
|                 |SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) { | ||||
|                     |id | ||||
|                     |status | ||||
|                     |progress | ||||
|                 |} | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val variables = jsonObject( | ||||
|                 "listId" to track.library_id, | ||||
|                 "progress" to track.last_chapter_read, | ||||
|                 "status" to track.toAnilistStatus(), | ||||
|                 "score" to track.score.toInt() | ||||
|         ) | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query, | ||||
|                 "variables" to variables | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { | ||||
|                     track | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     fun search(search: String): Observable<List<TrackSearch>> { | ||||
|         val query = """ | ||||
|             |query Search(${'$'}query: String) { | ||||
|                 |Page (perPage: 50) { | ||||
|                     |media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) { | ||||
|                         |id | ||||
|                         |title { | ||||
|                             |romaji | ||||
|                         |} | ||||
|                         |coverImage { | ||||
|                             |large | ||||
|                         |} | ||||
|                         |type | ||||
|                         |status | ||||
|                         |chapters | ||||
|                         |description | ||||
|                         |startDate { | ||||
|                             |year | ||||
|                             |month | ||||
|                             |day | ||||
|                         |} | ||||
|                     |} | ||||
|                 |} | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val variables = jsonObject( | ||||
|                 "query" to search | ||||
|         ) | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query, | ||||
|                 "variables" to variables | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { netResponse -> | ||||
|                     val responseBody = netResponse.body()?.string().orEmpty() | ||||
|                     if (responseBody.isEmpty()) { | ||||
|                         throw Exception("Null Response") | ||||
|                     } | ||||
|                     val response = parser.parse(responseBody).obj | ||||
|                     val data = response["data"]!!.obj | ||||
|                     val page = data["Page"].obj | ||||
|                     val media = page["media"].array | ||||
|                     val entries = media.map { jsonToALManga(it.obj) } | ||||
|                     entries.map { it.toTrack() } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     fun findLibManga(track: Track, userid: Int): Observable<Track?> { | ||||
|         val query = """ | ||||
|             |query (${'$'}id: Int!, ${'$'}manga_id: Int!) { | ||||
|                 |Page { | ||||
|                     |mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) { | ||||
|                         |id | ||||
|                         |status | ||||
|                         |scoreRaw: score(format: POINT_100) | ||||
|                         |progress | ||||
|                         |media { | ||||
|                             |id | ||||
|                             |title { | ||||
|                                 |romaji | ||||
|                             |} | ||||
|                             |coverImage { | ||||
|                                 |large | ||||
|                             |} | ||||
|                             |type | ||||
|                             |status | ||||
|                             |chapters | ||||
|                             |description | ||||
|                             |startDate { | ||||
|                                 |year | ||||
|                                 |month | ||||
|                                 |day | ||||
|                             |} | ||||
|                         |} | ||||
|                     |} | ||||
|                 |} | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val variables = jsonObject( | ||||
|                 "id" to userid, | ||||
|                 "manga_id" to track.media_id | ||||
|         ) | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query, | ||||
|                 "variables" to variables | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { netResponse -> | ||||
|                     val responseBody = netResponse.body()?.string().orEmpty() | ||||
|                     if (responseBody.isEmpty()) { | ||||
|                         throw Exception("Null Response") | ||||
|                     } | ||||
|                     val response = parser.parse(responseBody).obj | ||||
|                     val data = response["data"]!!.obj | ||||
|                     val page = data["Page"].obj | ||||
|                     val media = page["mediaList"].array | ||||
|                     val entries = media.map { jsonToALUserManga(it.obj) } | ||||
|                     entries.firstOrNull()?.toTrack() | ||||
|  | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     fun getLibManga(track: Track, userid: Int): Observable<Track> { | ||||
|         return findLibManga(track, userid) | ||||
|                 .map { it ?: throw Exception("Could not find manga") } | ||||
|     } | ||||
|  | ||||
|     fun createOAuth(token: String): OAuth { | ||||
|         return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000) | ||||
|     } | ||||
|  | ||||
|     fun getCurrentUser(): Observable<Pair<Int, String>> { | ||||
|         val query = """ | ||||
|             |query User { | ||||
|                 |Viewer { | ||||
|                     |id | ||||
|                     |mediaListOptions { | ||||
|                         |scoreFormat | ||||
|                     |} | ||||
|                 |} | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { netResponse -> | ||||
|                     val responseBody = netResponse.body()?.string().orEmpty() | ||||
|                     if (responseBody.isEmpty()) { | ||||
|                         throw Exception("Null Response") | ||||
|                     } | ||||
|                     val response = parser.parse(responseBody).obj | ||||
|                     val data = response["data"]!!.obj | ||||
|                     val viewer = data["Viewer"].obj | ||||
|                     Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString) | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     private fun jsonToALManga(struct: JsonObject): ALManga { | ||||
|         val date = try { | ||||
|             val date = Calendar.getInstance() | ||||
|             date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1, | ||||
|                     struct["startDate"]["day"].nullInt ?: 0) | ||||
|             date.timeInMillis | ||||
|         } catch (_: Exception) { | ||||
|             0L | ||||
|         } | ||||
|  | ||||
|         return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString, | ||||
|                 struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString, | ||||
|                 date, struct["chapters"].nullInt ?: 0) | ||||
|     } | ||||
|  | ||||
|     private fun jsonToALUserManga(struct: JsonObject): ALUserManga { | ||||
|         return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj)) | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         private const val clientId = "385" | ||||
|         private const val clientUrl = "tachiyomi://anilist-auth" | ||||
|         private const val apiUrl = "https://graphql.anilist.co/" | ||||
|         private const val baseUrl = "https://anilist.co/api/v2/" | ||||
|         private const val baseMangaUrl = "https://anilist.co/manga/" | ||||
|  | ||||
|         fun mangaUrl(mediaId: Int): String { | ||||
|             return baseMangaUrl + mediaId | ||||
|         } | ||||
|  | ||||
|         fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon() | ||||
|                 .appendQueryParameter("client_id", clientId) | ||||
|                 .appendQueryParameter("response_type", "token") | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,58 +1,58 @@ | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.Response | ||||
|  | ||||
|  | ||||
| class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor { | ||||
|  | ||||
|     /** | ||||
|      * OAuth object used for authenticated requests. | ||||
|      * | ||||
|      * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute | ||||
|      * before its original expiration date. | ||||
|      */ | ||||
|     private var oauth: OAuth? = null | ||||
|         set(value) { | ||||
|             field = value?.copy(expires = value.expires * 1000 - 60 * 1000) | ||||
|         } | ||||
|  | ||||
|     override fun intercept(chain: Interceptor.Chain): Response { | ||||
|         val originalRequest = chain.request() | ||||
|  | ||||
|         if (token.isNullOrEmpty()) { | ||||
|             throw Exception("Not authenticated with Anilist") | ||||
|         } | ||||
|         if (oauth == null){ | ||||
|             oauth = anilist.loadOAuth() | ||||
|         } | ||||
|         // Refresh access token if null or expired. | ||||
|         if (oauth!!.isExpired()) { | ||||
|             anilist.logout() | ||||
|             throw Exception("Token expired") | ||||
|         } | ||||
|  | ||||
|         // Throw on null auth. | ||||
|         if (oauth == null) { | ||||
|             throw Exception("No authentication token") | ||||
|         } | ||||
|  | ||||
|         // Add the authorization header to the original request. | ||||
|         val authRequest = originalRequest.newBuilder() | ||||
|                 .addHeader("Authorization", "Bearer ${oauth!!.access_token}") | ||||
|                 .build() | ||||
|  | ||||
|         return chain.proceed(authRequest) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when the user authenticates with Anilist for the first time. Sets the refresh token | ||||
|      * and the oauth object. | ||||
|      */ | ||||
|     fun setAuth(oauth: OAuth?) { | ||||
|         token = oauth?.access_token | ||||
|         this.oauth = oauth | ||||
|         anilist.saveOAuth(oauth) | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.Response | ||||
|  | ||||
|  | ||||
| class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor { | ||||
|  | ||||
|     /** | ||||
|      * OAuth object used for authenticated requests. | ||||
|      * | ||||
|      * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute | ||||
|      * before its original expiration date. | ||||
|      */ | ||||
|     private var oauth: OAuth? = null | ||||
|         set(value) { | ||||
|             field = value?.copy(expires = value.expires * 1000 - 60 * 1000) | ||||
|         } | ||||
|  | ||||
|     override fun intercept(chain: Interceptor.Chain): Response { | ||||
|         val originalRequest = chain.request() | ||||
|  | ||||
|         if (token.isNullOrEmpty()) { | ||||
|             throw Exception("Not authenticated with Anilist") | ||||
|         } | ||||
|         if (oauth == null){ | ||||
|             oauth = anilist.loadOAuth() | ||||
|         } | ||||
|         // Refresh access token if null or expired. | ||||
|         if (oauth!!.isExpired()) { | ||||
|             anilist.logout() | ||||
|             throw Exception("Token expired") | ||||
|         } | ||||
|  | ||||
|         // Throw on null auth. | ||||
|         if (oauth == null) { | ||||
|             throw Exception("No authentication token") | ||||
|         } | ||||
|  | ||||
|         // Add the authorization header to the original request. | ||||
|         val authRequest = originalRequest.newBuilder() | ||||
|                 .addHeader("Authorization", "Bearer ${oauth!!.access_token}") | ||||
|                 .build() | ||||
|  | ||||
|         return chain.proceed(authRequest) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when the user authenticates with Anilist for the first time. Sets the refresh token | ||||
|      * and the oauth object. | ||||
|      */ | ||||
|     fun setAuth(oauth: OAuth?) { | ||||
|         token = oauth?.access_token | ||||
|         this.oauth = oauth | ||||
|         anilist.saveOAuth(oauth) | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,10 +1,10 @@ | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| data class OAuth( | ||||
|         val access_token: String, | ||||
|         val token_type: String, | ||||
|         val expires: Long, | ||||
|         val expires_in: Long) { | ||||
|  | ||||
|     fun isExpired() = System.currentTimeMillis() > expires | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| data class OAuth( | ||||
|         val access_token: String, | ||||
|         val token_type: String, | ||||
|         val expires: Long, | ||||
|         val expires_in: Long) { | ||||
|  | ||||
|     fun isExpired() = System.currentTimeMillis() > expires | ||||
| } | ||||
| @@ -1,144 +1,144 @@ | ||||
| package eu.kanade.tachiyomi.data.track.bangumi | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class Bangumi(private val context: Context, id: Int) : TrackService(id) { | ||||
|  | ||||
|   override fun getScoreList(): List<String> { | ||||
|     return IntRange(0, 10).map(Int::toString) | ||||
|   } | ||||
|  | ||||
|   override fun displayScore(track: Track): String { | ||||
|     return track.score.toInt().toString() | ||||
|   } | ||||
|  | ||||
|   override fun add(track: Track): Observable<Track> { | ||||
|     return api.addLibManga(track) | ||||
|   } | ||||
|  | ||||
|   override fun update(track: Track): Observable<Track> { | ||||
|     if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { | ||||
|       track.status = COMPLETED | ||||
|     } | ||||
|     return api.updateLibManga(track) | ||||
|   } | ||||
|  | ||||
|   override fun bind(track: Track): Observable<Track> { | ||||
|     return api.statusLibManga(track) | ||||
|       .flatMap { | ||||
|         api.findLibManga(track).flatMap { remoteTrack -> | ||||
|           if (remoteTrack != null && it != null) { | ||||
|             track.copyPersonalFrom(remoteTrack) | ||||
|             track.library_id = remoteTrack.library_id | ||||
|             track.status = remoteTrack.status | ||||
|             track.last_chapter_read = remoteTrack.last_chapter_read | ||||
|             update(track) | ||||
|           } else { | ||||
|             // Set default fields if it's not found in the list | ||||
|             track.score = DEFAULT_SCORE.toFloat() | ||||
|             track.status = DEFAULT_STATUS | ||||
|             add(track) | ||||
|             update(track) | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|   } | ||||
|  | ||||
|   override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|     return api.search(query) | ||||
|   } | ||||
|  | ||||
|   override fun refresh(track: Track): Observable<Track> { | ||||
|     return api.statusLibManga(track) | ||||
|       .flatMap { | ||||
|         track.copyPersonalFrom(it!!) | ||||
|         api.findLibManga(track) | ||||
|           .map { remoteTrack -> | ||||
|             if (remoteTrack != null) { | ||||
|               track.total_chapters = remoteTrack.total_chapters | ||||
|               track.status = remoteTrack.status | ||||
|             } | ||||
|             track | ||||
|           } | ||||
|       } | ||||
|   } | ||||
|  | ||||
|   companion object { | ||||
|     const val READING = 3 | ||||
|     const val COMPLETED = 2 | ||||
|     const val ON_HOLD = 4 | ||||
|     const val DROPPED = 5 | ||||
|     const val PLANNING = 1 | ||||
|  | ||||
|     const val DEFAULT_STATUS = READING | ||||
|     const val DEFAULT_SCORE = 0 | ||||
|   } | ||||
|  | ||||
|   override val name = "Bangumi" | ||||
|  | ||||
|   private val gson: Gson by injectLazy() | ||||
|  | ||||
|   private val interceptor by lazy { BangumiInterceptor(this, gson) } | ||||
|  | ||||
|   private val api by lazy { BangumiApi(client, interceptor) } | ||||
|  | ||||
|   override fun getLogo() = R.drawable.bangumi | ||||
|  | ||||
|   override fun getLogoColor() = Color.rgb(0xF0, 0x91, 0x99) | ||||
|  | ||||
|   override fun getStatusList(): List<Int> { | ||||
|     return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING) | ||||
|   } | ||||
|  | ||||
|   override fun getStatus(status: Int): String = with(context) { | ||||
|     when (status) { | ||||
|       READING -> getString(R.string.reading) | ||||
|       COMPLETED -> getString(R.string.completed) | ||||
|       ON_HOLD -> getString(R.string.on_hold) | ||||
|       DROPPED -> getString(R.string.dropped) | ||||
|       PLANNING -> getString(R.string.plan_to_read) | ||||
|       else -> "" | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   override fun login(username: String, password: String) = login(password) | ||||
|  | ||||
|   fun login(code: String): Completable { | ||||
|     return api.accessToken(code).map { oauth: OAuth? -> | ||||
|       interceptor.newAuth(oauth) | ||||
|       if (oauth != null) { | ||||
|         saveCredentials(oauth.user_id.toString(), oauth.access_token) | ||||
|       } | ||||
|     }.doOnError { | ||||
|       logout() | ||||
|     }.toCompletable() | ||||
|   } | ||||
|  | ||||
|   fun saveToken(oauth: OAuth?) { | ||||
|     val json = gson.toJson(oauth) | ||||
|     preferences.trackToken(this).set(json) | ||||
|   } | ||||
|  | ||||
|   fun restoreToken(): OAuth? { | ||||
|     return try { | ||||
|       gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) | ||||
|     } catch (e: Exception) { | ||||
|       null | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   override fun logout() { | ||||
|     super.logout() | ||||
|     preferences.trackToken(this).set(null) | ||||
|     interceptor.newAuth(null) | ||||
|   } | ||||
| } | ||||
| package eu.kanade.tachiyomi.data.track.bangumi | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class Bangumi(private val context: Context, id: Int) : TrackService(id) { | ||||
|  | ||||
|   override fun getScoreList(): List<String> { | ||||
|     return IntRange(0, 10).map(Int::toString) | ||||
|   } | ||||
|  | ||||
|   override fun displayScore(track: Track): String { | ||||
|     return track.score.toInt().toString() | ||||
|   } | ||||
|  | ||||
|   override fun add(track: Track): Observable<Track> { | ||||
|     return api.addLibManga(track) | ||||
|   } | ||||
|  | ||||
|   override fun update(track: Track): Observable<Track> { | ||||
|     if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { | ||||
|       track.status = COMPLETED | ||||
|     } | ||||
|     return api.updateLibManga(track) | ||||
|   } | ||||
|  | ||||
|   override fun bind(track: Track): Observable<Track> { | ||||
|     return api.statusLibManga(track) | ||||
|       .flatMap { | ||||
|         api.findLibManga(track).flatMap { remoteTrack -> | ||||
|           if (remoteTrack != null && it != null) { | ||||
|             track.copyPersonalFrom(remoteTrack) | ||||
|             track.library_id = remoteTrack.library_id | ||||
|             track.status = remoteTrack.status | ||||
|             track.last_chapter_read = remoteTrack.last_chapter_read | ||||
|             update(track) | ||||
|           } else { | ||||
|             // Set default fields if it's not found in the list | ||||
|             track.score = DEFAULT_SCORE.toFloat() | ||||
|             track.status = DEFAULT_STATUS | ||||
|             add(track) | ||||
|             update(track) | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|   } | ||||
|  | ||||
|   override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|     return api.search(query) | ||||
|   } | ||||
|  | ||||
|   override fun refresh(track: Track): Observable<Track> { | ||||
|     return api.statusLibManga(track) | ||||
|       .flatMap { | ||||
|         track.copyPersonalFrom(it!!) | ||||
|         api.findLibManga(track) | ||||
|           .map { remoteTrack -> | ||||
|             if (remoteTrack != null) { | ||||
|               track.total_chapters = remoteTrack.total_chapters | ||||
|               track.status = remoteTrack.status | ||||
|             } | ||||
|             track | ||||
|           } | ||||
|       } | ||||
|   } | ||||
|  | ||||
|   companion object { | ||||
|     const val READING = 3 | ||||
|     const val COMPLETED = 2 | ||||
|     const val ON_HOLD = 4 | ||||
|     const val DROPPED = 5 | ||||
|     const val PLANNING = 1 | ||||
|  | ||||
|     const val DEFAULT_STATUS = READING | ||||
|     const val DEFAULT_SCORE = 0 | ||||
|   } | ||||
|  | ||||
|   override val name = "Bangumi" | ||||
|  | ||||
|   private val gson: Gson by injectLazy() | ||||
|  | ||||
|   private val interceptor by lazy { BangumiInterceptor(this, gson) } | ||||
|  | ||||
|   private val api by lazy { BangumiApi(client, interceptor) } | ||||
|  | ||||
|   override fun getLogo() = R.drawable.bangumi | ||||
|  | ||||
|   override fun getLogoColor() = Color.rgb(0xF0, 0x91, 0x99) | ||||
|  | ||||
|   override fun getStatusList(): List<Int> { | ||||
|     return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING) | ||||
|   } | ||||
|  | ||||
|   override fun getStatus(status: Int): String = with(context) { | ||||
|     when (status) { | ||||
|       READING -> getString(R.string.reading) | ||||
|       COMPLETED -> getString(R.string.completed) | ||||
|       ON_HOLD -> getString(R.string.on_hold) | ||||
|       DROPPED -> getString(R.string.dropped) | ||||
|       PLANNING -> getString(R.string.plan_to_read) | ||||
|       else -> "" | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   override fun login(username: String, password: String) = login(password) | ||||
|  | ||||
|   fun login(code: String): Completable { | ||||
|     return api.accessToken(code).map { oauth: OAuth? -> | ||||
|       interceptor.newAuth(oauth) | ||||
|       if (oauth != null) { | ||||
|         saveCredentials(oauth.user_id.toString(), oauth.access_token) | ||||
|       } | ||||
|     }.doOnError { | ||||
|       logout() | ||||
|     }.toCompletable() | ||||
|   } | ||||
|  | ||||
|   fun saveToken(oauth: OAuth?) { | ||||
|     val json = gson.toJson(oauth) | ||||
|     preferences.trackToken(this).set(json) | ||||
|   } | ||||
|  | ||||
|   fun restoreToken(): OAuth? { | ||||
|     return try { | ||||
|       gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) | ||||
|     } catch (e: Exception) { | ||||
|       null | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   override fun logout() { | ||||
|     super.logout() | ||||
|     preferences.trackToken(this).set(null) | ||||
|     interceptor.newAuth(null) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,16 +1,16 @@ | ||||
| package eu.kanade.tachiyomi.data.track.bangumi | ||||
|  | ||||
| data class OAuth( | ||||
|   val access_token: String, | ||||
|   val token_type: String, | ||||
|   val created_at: Long, | ||||
|   val expires_in: Long, | ||||
|   val refresh_token: String?, | ||||
|   val user_id: Long? | ||||
| ) { | ||||
|  | ||||
|   // Access token refersh before expired | ||||
|   fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) | ||||
|  | ||||
| } | ||||
|  | ||||
| package eu.kanade.tachiyomi.data.track.bangumi | ||||
|  | ||||
| data class OAuth( | ||||
|   val access_token: String, | ||||
|   val token_type: String, | ||||
|   val created_at: Long, | ||||
|   val expires_in: Long, | ||||
|   val refresh_token: String?, | ||||
|   val user_id: Long? | ||||
| ) { | ||||
|  | ||||
|   // Access token refersh before expired | ||||
|   fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) | ||||
|  | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,144 +1,144 @@ | ||||
| package eu.kanade.tachiyomi.data.track.kitsu | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.text.DecimalFormat | ||||
|  | ||||
| class Kitsu(private val context: Context, id: Int) : TrackService(id) { | ||||
|  | ||||
|     companion object { | ||||
|         const val READING = 1 | ||||
|         const val COMPLETED = 2 | ||||
|         const val ON_HOLD = 3 | ||||
|         const val DROPPED = 4 | ||||
|         const val PLAN_TO_READ = 5 | ||||
|  | ||||
|         const val DEFAULT_STATUS = READING | ||||
|         const val DEFAULT_SCORE = 0f | ||||
|     } | ||||
|  | ||||
|     override val name = "Kitsu" | ||||
|  | ||||
|     private val gson: Gson by injectLazy() | ||||
|  | ||||
|     private val interceptor by lazy { KitsuInterceptor(this, gson) } | ||||
|  | ||||
|     private val api by lazy { KitsuApi(client, interceptor) } | ||||
|  | ||||
|     override fun getLogo(): Int { | ||||
|         return R.drawable.kitsu | ||||
|     } | ||||
|  | ||||
|     override fun getLogoColor(): Int { | ||||
|         return Color.rgb(51, 37, 50) | ||||
|     } | ||||
|  | ||||
|     override fun getStatusList(): List<Int> { | ||||
|         return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) | ||||
|     } | ||||
|  | ||||
|     override fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             READING -> getString(R.string.reading) | ||||
|             COMPLETED -> getString(R.string.completed) | ||||
|             ON_HOLD -> getString(R.string.on_hold) | ||||
|             DROPPED -> getString(R.string.dropped) | ||||
|             PLAN_TO_READ -> getString(R.string.plan_to_read) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getScoreList(): List<String> { | ||||
|         val df = DecimalFormat("0.#") | ||||
|         return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) } | ||||
|     } | ||||
|  | ||||
|     override fun indexToScore(index: Int): Float { | ||||
|         return if (index > 0) (index + 1) / 2f else 0f | ||||
|     } | ||||
|  | ||||
|     override fun displayScore(track: Track): String { | ||||
|         val df = DecimalFormat("0.#") | ||||
|         return df.format(track.score) | ||||
|     } | ||||
|  | ||||
|     override fun add(track: Track): Observable<Track> { | ||||
|         return api.addLibManga(track, getUserId()) | ||||
|     } | ||||
|  | ||||
|     override fun update(track: Track): Observable<Track> { | ||||
|         if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { | ||||
|             track.status = COMPLETED | ||||
|         } | ||||
|  | ||||
|         return api.updateLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun bind(track: Track): Observable<Track> { | ||||
|         return api.findLibManga(track, getUserId()) | ||||
|                 .flatMap { remoteTrack -> | ||||
|                     if (remoteTrack != null) { | ||||
|                         track.copyPersonalFrom(remoteTrack) | ||||
|                         track.media_id = remoteTrack.media_id | ||||
|                         update(track) | ||||
|                     } else { | ||||
|                         track.score = DEFAULT_SCORE | ||||
|                         track.status = DEFAULT_STATUS | ||||
|                         add(track) | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|         return api.search(query) | ||||
|     } | ||||
|  | ||||
|     override fun refresh(track: Track): Observable<Track> { | ||||
|         return api.getLibManga(track) | ||||
|                 .map { remoteTrack -> | ||||
|                     track.copyPersonalFrom(remoteTrack) | ||||
|                     track.total_chapters = remoteTrack.total_chapters | ||||
|                     track | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun login(username: String, password: String): Completable { | ||||
|         return api.login(username, password) | ||||
|                 .doOnNext { interceptor.newAuth(it) } | ||||
|                 .flatMap { api.getCurrentUser() } | ||||
|                 .doOnNext { userId -> saveCredentials(username, userId) } | ||||
|                 .doOnError { logout() } | ||||
|                 .toCompletable() | ||||
|     } | ||||
|  | ||||
|     override fun logout() { | ||||
|         super.logout() | ||||
|         interceptor.newAuth(null) | ||||
|     } | ||||
|  | ||||
|     private fun getUserId(): String { | ||||
|         return getPassword() | ||||
|     } | ||||
|  | ||||
|     fun saveToken(oauth: OAuth?) { | ||||
|         val json = gson.toJson(oauth) | ||||
|         preferences.trackToken(this).set(json) | ||||
|     } | ||||
|  | ||||
|     fun restoreToken(): OAuth? { | ||||
|         return try { | ||||
|             gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) | ||||
|         } catch (e: Exception) { | ||||
|             null | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.data.track.kitsu | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.text.DecimalFormat | ||||
|  | ||||
| class Kitsu(private val context: Context, id: Int) : TrackService(id) { | ||||
|  | ||||
|     companion object { | ||||
|         const val READING = 1 | ||||
|         const val COMPLETED = 2 | ||||
|         const val ON_HOLD = 3 | ||||
|         const val DROPPED = 4 | ||||
|         const val PLAN_TO_READ = 5 | ||||
|  | ||||
|         const val DEFAULT_STATUS = READING | ||||
|         const val DEFAULT_SCORE = 0f | ||||
|     } | ||||
|  | ||||
|     override val name = "Kitsu" | ||||
|  | ||||
|     private val gson: Gson by injectLazy() | ||||
|  | ||||
|     private val interceptor by lazy { KitsuInterceptor(this, gson) } | ||||
|  | ||||
|     private val api by lazy { KitsuApi(client, interceptor) } | ||||
|  | ||||
|     override fun getLogo(): Int { | ||||
|         return R.drawable.kitsu | ||||
|     } | ||||
|  | ||||
|     override fun getLogoColor(): Int { | ||||
|         return Color.rgb(51, 37, 50) | ||||
|     } | ||||
|  | ||||
|     override fun getStatusList(): List<Int> { | ||||
|         return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) | ||||
|     } | ||||
|  | ||||
|     override fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             READING -> getString(R.string.reading) | ||||
|             COMPLETED -> getString(R.string.completed) | ||||
|             ON_HOLD -> getString(R.string.on_hold) | ||||
|             DROPPED -> getString(R.string.dropped) | ||||
|             PLAN_TO_READ -> getString(R.string.plan_to_read) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getScoreList(): List<String> { | ||||
|         val df = DecimalFormat("0.#") | ||||
|         return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) } | ||||
|     } | ||||
|  | ||||
|     override fun indexToScore(index: Int): Float { | ||||
|         return if (index > 0) (index + 1) / 2f else 0f | ||||
|     } | ||||
|  | ||||
|     override fun displayScore(track: Track): String { | ||||
|         val df = DecimalFormat("0.#") | ||||
|         return df.format(track.score) | ||||
|     } | ||||
|  | ||||
|     override fun add(track: Track): Observable<Track> { | ||||
|         return api.addLibManga(track, getUserId()) | ||||
|     } | ||||
|  | ||||
|     override fun update(track: Track): Observable<Track> { | ||||
|         if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { | ||||
|             track.status = COMPLETED | ||||
|         } | ||||
|  | ||||
|         return api.updateLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun bind(track: Track): Observable<Track> { | ||||
|         return api.findLibManga(track, getUserId()) | ||||
|                 .flatMap { remoteTrack -> | ||||
|                     if (remoteTrack != null) { | ||||
|                         track.copyPersonalFrom(remoteTrack) | ||||
|                         track.media_id = remoteTrack.media_id | ||||
|                         update(track) | ||||
|                     } else { | ||||
|                         track.score = DEFAULT_SCORE | ||||
|                         track.status = DEFAULT_STATUS | ||||
|                         add(track) | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|         return api.search(query) | ||||
|     } | ||||
|  | ||||
|     override fun refresh(track: Track): Observable<Track> { | ||||
|         return api.getLibManga(track) | ||||
|                 .map { remoteTrack -> | ||||
|                     track.copyPersonalFrom(remoteTrack) | ||||
|                     track.total_chapters = remoteTrack.total_chapters | ||||
|                     track | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun login(username: String, password: String): Completable { | ||||
|         return api.login(username, password) | ||||
|                 .doOnNext { interceptor.newAuth(it) } | ||||
|                 .flatMap { api.getCurrentUser() } | ||||
|                 .doOnNext { userId -> saveCredentials(username, userId) } | ||||
|                 .doOnError { logout() } | ||||
|                 .toCompletable() | ||||
|     } | ||||
|  | ||||
|     override fun logout() { | ||||
|         super.logout() | ||||
|         interceptor.newAuth(null) | ||||
|     } | ||||
|  | ||||
|     private fun getUserId(): String { | ||||
|         return getPassword() | ||||
|     } | ||||
|  | ||||
|     fun saveToken(oauth: OAuth?) { | ||||
|         val json = gson.toJson(oauth) | ||||
|         preferences.trackToken(this).set(json) | ||||
|     } | ||||
|  | ||||
|     fun restoreToken(): OAuth? { | ||||
|         return try { | ||||
|             gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) | ||||
|         } catch (e: Exception) { | ||||
|             null | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| package eu.kanade.tachiyomi.data.track.kitsu | ||||
|  | ||||
| data class OAuth( | ||||
|         val access_token: String, | ||||
|         val token_type: String, | ||||
|         val created_at: Long, | ||||
|         val expires_in: Long, | ||||
|         val refresh_token: String?) { | ||||
|  | ||||
|     fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) | ||||
| package eu.kanade.tachiyomi.data.track.kitsu | ||||
|  | ||||
| data class OAuth( | ||||
|         val access_token: String, | ||||
|         val token_type: String, | ||||
|         val created_at: Long, | ||||
|         val expires_in: Long, | ||||
|         val refresh_token: String?) { | ||||
|  | ||||
|     fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) | ||||
| } | ||||
| @@ -1,164 +1,164 @@ | ||||
| package eu.kanade.tachiyomi.data.track.myanimelist | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import okhttp3.HttpUrl | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import java.lang.Exception | ||||
|  | ||||
| class Myanimelist(private val context: Context, id: Int) : TrackService(id) { | ||||
|  | ||||
|     companion object { | ||||
|         const val READING = 1 | ||||
|         const val COMPLETED = 2 | ||||
|         const val ON_HOLD = 3 | ||||
|         const val DROPPED = 4 | ||||
|         const val PLAN_TO_READ = 6 | ||||
|  | ||||
|         const val DEFAULT_STATUS = READING | ||||
|         const val DEFAULT_SCORE = 0 | ||||
|  | ||||
|         const val BASE_URL = "https://myanimelist.net" | ||||
|         const val USER_SESSION_COOKIE = "MALSESSIONID" | ||||
|         const val LOGGED_IN_COOKIE = "is_logged_in" | ||||
|     } | ||||
|  | ||||
|     private val interceptor by lazy { MyAnimeListInterceptor(this) } | ||||
|     private val api by lazy { MyanimelistApi(client, interceptor) } | ||||
|  | ||||
|     override val name: String | ||||
|         get() = "MyAnimeList" | ||||
|  | ||||
|     override fun getLogo() = R.drawable.mal | ||||
|  | ||||
|     override fun getLogoColor() = Color.rgb(46, 81, 162) | ||||
|  | ||||
|     override fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             READING -> getString(R.string.reading) | ||||
|             COMPLETED -> getString(R.string.completed) | ||||
|             ON_HOLD -> getString(R.string.on_hold) | ||||
|             DROPPED -> getString(R.string.dropped) | ||||
|             PLAN_TO_READ -> getString(R.string.plan_to_read) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getStatusList(): List<Int> { | ||||
|         return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) | ||||
|     } | ||||
|  | ||||
|     override fun getScoreList(): List<String> { | ||||
|         return IntRange(0, 10).map(Int::toString) | ||||
|     } | ||||
|  | ||||
|     override fun displayScore(track: Track): String { | ||||
|         return track.score.toInt().toString() | ||||
|     } | ||||
|  | ||||
|     override fun add(track: Track): Observable<Track> { | ||||
|         return api.addLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun update(track: Track): Observable<Track> { | ||||
|         if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { | ||||
|             track.status = COMPLETED | ||||
|         } | ||||
|  | ||||
|         return api.updateLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun bind(track: Track): Observable<Track> { | ||||
|         return api.findLibManga(track) | ||||
|                 .flatMap { remoteTrack -> | ||||
|                     if (remoteTrack != null) { | ||||
|                         track.copyPersonalFrom(remoteTrack) | ||||
|                         update(track) | ||||
|                     } else { | ||||
|                         // Set default fields if it's not found in the list | ||||
|                         track.score = DEFAULT_SCORE.toFloat() | ||||
|                         track.status = DEFAULT_STATUS | ||||
|                         add(track) | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|         return api.search(query) | ||||
|     } | ||||
|  | ||||
|     override fun refresh(track: Track): Observable<Track> { | ||||
|         return api.getLibManga(track) | ||||
|                 .map { remoteTrack -> | ||||
|                     track.copyPersonalFrom(remoteTrack) | ||||
|                     track.total_chapters = remoteTrack.total_chapters | ||||
|                     track | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun login(username: String, password: String): Completable { | ||||
|         logout() | ||||
|  | ||||
|         return Observable.fromCallable { api.login(username, password) } | ||||
|                 .doOnNext { csrf -> saveCSRF(csrf) } | ||||
|                 .doOnNext { saveCredentials(username, password) } | ||||
|                 .doOnError { logout() } | ||||
|                 .toCompletable() | ||||
|     } | ||||
|  | ||||
|     fun refreshLogin() { | ||||
|         val username = getUsername() | ||||
|         val password = getPassword() | ||||
|         logout() | ||||
|  | ||||
|         try { | ||||
|             val csrf = api.login(username, password) | ||||
|             saveCSRF(csrf) | ||||
|             saveCredentials(username, password) | ||||
|         } catch (e: Exception) { | ||||
|             logout() | ||||
|             throw e | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Attempt to login again if cookies have been cleared but credentials are still filled | ||||
|     fun ensureLoggedIn() { | ||||
|         if (isAuthorized) return | ||||
|         if (!isLogged) throw Exception("MAL Login Credentials not found") | ||||
|  | ||||
|         refreshLogin() | ||||
|     } | ||||
|  | ||||
|     override fun logout() { | ||||
|         super.logout() | ||||
|         preferences.trackToken(this).delete() | ||||
|         networkService.cookieManager.remove(HttpUrl.parse(BASE_URL)!!) | ||||
|     } | ||||
|  | ||||
|     val isAuthorized: Boolean | ||||
|         get() = super.isLogged && | ||||
|                 getCSRF().isNotEmpty() && | ||||
|                 checkCookies() | ||||
|  | ||||
|     fun getCSRF(): String = preferences.trackToken(this).getOrDefault() | ||||
|  | ||||
|     private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf) | ||||
|  | ||||
|     private fun checkCookies(): Boolean { | ||||
|         var ckCount = 0 | ||||
|         val url = HttpUrl.parse(BASE_URL)!! | ||||
|         for (ck in networkService.cookieManager.get(url)) { | ||||
|             if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE) | ||||
|                 ckCount++ | ||||
|         } | ||||
|  | ||||
|         return ckCount == 2 | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.data.track.myanimelist | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import okhttp3.HttpUrl | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import java.lang.Exception | ||||
|  | ||||
| class Myanimelist(private val context: Context, id: Int) : TrackService(id) { | ||||
|  | ||||
|     companion object { | ||||
|         const val READING = 1 | ||||
|         const val COMPLETED = 2 | ||||
|         const val ON_HOLD = 3 | ||||
|         const val DROPPED = 4 | ||||
|         const val PLAN_TO_READ = 6 | ||||
|  | ||||
|         const val DEFAULT_STATUS = READING | ||||
|         const val DEFAULT_SCORE = 0 | ||||
|  | ||||
|         const val BASE_URL = "https://myanimelist.net" | ||||
|         const val USER_SESSION_COOKIE = "MALSESSIONID" | ||||
|         const val LOGGED_IN_COOKIE = "is_logged_in" | ||||
|     } | ||||
|  | ||||
|     private val interceptor by lazy { MyAnimeListInterceptor(this) } | ||||
|     private val api by lazy { MyanimelistApi(client, interceptor) } | ||||
|  | ||||
|     override val name: String | ||||
|         get() = "MyAnimeList" | ||||
|  | ||||
|     override fun getLogo() = R.drawable.mal | ||||
|  | ||||
|     override fun getLogoColor() = Color.rgb(46, 81, 162) | ||||
|  | ||||
|     override fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             READING -> getString(R.string.reading) | ||||
|             COMPLETED -> getString(R.string.completed) | ||||
|             ON_HOLD -> getString(R.string.on_hold) | ||||
|             DROPPED -> getString(R.string.dropped) | ||||
|             PLAN_TO_READ -> getString(R.string.plan_to_read) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getStatusList(): List<Int> { | ||||
|         return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) | ||||
|     } | ||||
|  | ||||
|     override fun getScoreList(): List<String> { | ||||
|         return IntRange(0, 10).map(Int::toString) | ||||
|     } | ||||
|  | ||||
|     override fun displayScore(track: Track): String { | ||||
|         return track.score.toInt().toString() | ||||
|     } | ||||
|  | ||||
|     override fun add(track: Track): Observable<Track> { | ||||
|         return api.addLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun update(track: Track): Observable<Track> { | ||||
|         if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { | ||||
|             track.status = COMPLETED | ||||
|         } | ||||
|  | ||||
|         return api.updateLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun bind(track: Track): Observable<Track> { | ||||
|         return api.findLibManga(track) | ||||
|                 .flatMap { remoteTrack -> | ||||
|                     if (remoteTrack != null) { | ||||
|                         track.copyPersonalFrom(remoteTrack) | ||||
|                         update(track) | ||||
|                     } else { | ||||
|                         // Set default fields if it's not found in the list | ||||
|                         track.score = DEFAULT_SCORE.toFloat() | ||||
|                         track.status = DEFAULT_STATUS | ||||
|                         add(track) | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|         return api.search(query) | ||||
|     } | ||||
|  | ||||
|     override fun refresh(track: Track): Observable<Track> { | ||||
|         return api.getLibManga(track) | ||||
|                 .map { remoteTrack -> | ||||
|                     track.copyPersonalFrom(remoteTrack) | ||||
|                     track.total_chapters = remoteTrack.total_chapters | ||||
|                     track | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun login(username: String, password: String): Completable { | ||||
|         logout() | ||||
|  | ||||
|         return Observable.fromCallable { api.login(username, password) } | ||||
|                 .doOnNext { csrf -> saveCSRF(csrf) } | ||||
|                 .doOnNext { saveCredentials(username, password) } | ||||
|                 .doOnError { logout() } | ||||
|                 .toCompletable() | ||||
|     } | ||||
|  | ||||
|     fun refreshLogin() { | ||||
|         val username = getUsername() | ||||
|         val password = getPassword() | ||||
|         logout() | ||||
|  | ||||
|         try { | ||||
|             val csrf = api.login(username, password) | ||||
|             saveCSRF(csrf) | ||||
|             saveCredentials(username, password) | ||||
|         } catch (e: Exception) { | ||||
|             logout() | ||||
|             throw e | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Attempt to login again if cookies have been cleared but credentials are still filled | ||||
|     fun ensureLoggedIn() { | ||||
|         if (isAuthorized) return | ||||
|         if (!isLogged) throw Exception("MAL Login Credentials not found") | ||||
|  | ||||
|         refreshLogin() | ||||
|     } | ||||
|  | ||||
|     override fun logout() { | ||||
|         super.logout() | ||||
|         preferences.trackToken(this).delete() | ||||
|         networkService.cookieManager.remove(HttpUrl.parse(BASE_URL)!!) | ||||
|     } | ||||
|  | ||||
|     val isAuthorized: Boolean | ||||
|         get() = super.isLogged && | ||||
|                 getCSRF().isNotEmpty() && | ||||
|                 checkCookies() | ||||
|  | ||||
|     fun getCSRF(): String = preferences.trackToken(this).getOrDefault() | ||||
|  | ||||
|     private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf) | ||||
|  | ||||
|     private fun checkCookies(): Boolean { | ||||
|         var ckCount = 0 | ||||
|         val url = HttpUrl.parse(BASE_URL)!! | ||||
|         for (ck in networkService.cookieManager.get(url)) { | ||||
|             if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE) | ||||
|                 ckCount++ | ||||
|         } | ||||
|  | ||||
|         return ckCount == 2 | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,13 +1,13 @@ | ||||
| package eu.kanade.tachiyomi.data.track.shikimori | ||||
|  | ||||
| data class OAuth( | ||||
|         val access_token: String, | ||||
|         val token_type: String, | ||||
|         val created_at: Long, | ||||
|         val expires_in: Long, | ||||
|         val refresh_token: String?) { | ||||
|  | ||||
|     // Access token lives 1 day | ||||
|     fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) | ||||
| } | ||||
|  | ||||
| package eu.kanade.tachiyomi.data.track.shikimori | ||||
|  | ||||
| data class OAuth( | ||||
|         val access_token: String, | ||||
|         val token_type: String, | ||||
|         val created_at: Long, | ||||
|         val expires_in: Long, | ||||
|         val refresh_token: String?) { | ||||
|  | ||||
|     // Access token lives 1 day | ||||
|     fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,139 +1,139 @@ | ||||
| package eu.kanade.tachiyomi.data.track.shikimori | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import android.util.Log | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class Shikimori(private val context: Context, id: Int) : TrackService(id) { | ||||
|  | ||||
|     override fun getScoreList(): List<String> { | ||||
|         return IntRange(0, 10).map(Int::toString) | ||||
|     } | ||||
|  | ||||
|     override fun displayScore(track: Track): String { | ||||
|         return track.score.toInt().toString() | ||||
|     } | ||||
|  | ||||
|     override fun add(track: Track): Observable<Track> { | ||||
|         return api.addLibManga(track, getUsername()) | ||||
|     } | ||||
|  | ||||
|     override fun update(track: Track): Observable<Track> { | ||||
|         if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { | ||||
|             track.status = COMPLETED | ||||
|         } | ||||
|         return api.updateLibManga(track, getUsername()) | ||||
|     } | ||||
|  | ||||
|     override fun bind(track: Track): Observable<Track> { | ||||
|         return api.findLibManga(track, getUsername()) | ||||
|                 .flatMap { remoteTrack -> | ||||
|                     if (remoteTrack != null) { | ||||
|                         track.copyPersonalFrom(remoteTrack) | ||||
|                         track.library_id = remoteTrack.library_id | ||||
|                         update(track) | ||||
|                     } else { | ||||
|                         // Set default fields if it's not found in the list | ||||
|                         track.score = DEFAULT_SCORE.toFloat() | ||||
|                         track.status = DEFAULT_STATUS | ||||
|                         add(track) | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|         return api.search(query) | ||||
|     } | ||||
|  | ||||
|     override fun refresh(track: Track): Observable<Track> { | ||||
|         return api.findLibManga(track, getUsername()) | ||||
|                 .map { remoteTrack -> | ||||
|                     if (remoteTrack != null) { | ||||
|                         track.copyPersonalFrom(remoteTrack) | ||||
|                         track.total_chapters = remoteTrack.total_chapters | ||||
|                     } | ||||
|                     track | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val READING = 1 | ||||
|         const val COMPLETED = 2 | ||||
|         const val ON_HOLD = 3 | ||||
|         const val DROPPED = 4 | ||||
|         const val PLANNING = 5 | ||||
|         const val REPEATING = 6 | ||||
|  | ||||
|         const val DEFAULT_STATUS = READING | ||||
|         const val DEFAULT_SCORE = 0 | ||||
|     } | ||||
|  | ||||
|     override val name = "Shikimori" | ||||
|  | ||||
|     private val gson: Gson by injectLazy() | ||||
|  | ||||
|     private val interceptor by lazy { ShikimoriInterceptor(this, gson) } | ||||
|  | ||||
|     private val api by lazy { ShikimoriApi(client, interceptor) } | ||||
|  | ||||
|     override fun getLogo() = R.drawable.shikimori | ||||
|  | ||||
|     override fun getLogoColor() = Color.rgb(40, 40, 40) | ||||
|  | ||||
|     override fun getStatusList(): List<Int> { | ||||
|         return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING) | ||||
|     } | ||||
|  | ||||
|     override fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             READING -> getString(R.string.reading) | ||||
|             COMPLETED -> getString(R.string.completed) | ||||
|             ON_HOLD -> getString(R.string.on_hold) | ||||
|             DROPPED -> getString(R.string.dropped) | ||||
|             PLANNING -> getString(R.string.plan_to_read) | ||||
|             REPEATING -> getString(R.string.repeating) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun login(username: String, password: String) = login(password) | ||||
|  | ||||
|     fun login(code: String): Completable { | ||||
|         return api.accessToken(code).map { oauth: OAuth? -> | ||||
|             interceptor.newAuth(oauth) | ||||
|             if (oauth != null) { | ||||
|                 val user = api.getCurrentUser() | ||||
|                 saveCredentials(user.toString(), oauth.access_token) | ||||
|             } | ||||
|         }.doOnError { | ||||
|             logout() | ||||
|         }.toCompletable() | ||||
|     } | ||||
|  | ||||
|     fun saveToken(oauth: OAuth?) { | ||||
|         val json = gson.toJson(oauth) | ||||
|         preferences.trackToken(this).set(json) | ||||
|     } | ||||
|  | ||||
|     fun restoreToken(): OAuth? { | ||||
|         return try { | ||||
|             gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) | ||||
|         } catch (e: Exception) { | ||||
|             null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun logout() { | ||||
|         super.logout() | ||||
|         preferences.trackToken(this).set(null) | ||||
|         interceptor.newAuth(null) | ||||
|     } | ||||
| } | ||||
| package eu.kanade.tachiyomi.data.track.shikimori | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import android.util.Log | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class Shikimori(private val context: Context, id: Int) : TrackService(id) { | ||||
|  | ||||
|     override fun getScoreList(): List<String> { | ||||
|         return IntRange(0, 10).map(Int::toString) | ||||
|     } | ||||
|  | ||||
|     override fun displayScore(track: Track): String { | ||||
|         return track.score.toInt().toString() | ||||
|     } | ||||
|  | ||||
|     override fun add(track: Track): Observable<Track> { | ||||
|         return api.addLibManga(track, getUsername()) | ||||
|     } | ||||
|  | ||||
|     override fun update(track: Track): Observable<Track> { | ||||
|         if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { | ||||
|             track.status = COMPLETED | ||||
|         } | ||||
|         return api.updateLibManga(track, getUsername()) | ||||
|     } | ||||
|  | ||||
|     override fun bind(track: Track): Observable<Track> { | ||||
|         return api.findLibManga(track, getUsername()) | ||||
|                 .flatMap { remoteTrack -> | ||||
|                     if (remoteTrack != null) { | ||||
|                         track.copyPersonalFrom(remoteTrack) | ||||
|                         track.library_id = remoteTrack.library_id | ||||
|                         update(track) | ||||
|                     } else { | ||||
|                         // Set default fields if it's not found in the list | ||||
|                         track.score = DEFAULT_SCORE.toFloat() | ||||
|                         track.status = DEFAULT_STATUS | ||||
|                         add(track) | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|         return api.search(query) | ||||
|     } | ||||
|  | ||||
|     override fun refresh(track: Track): Observable<Track> { | ||||
|         return api.findLibManga(track, getUsername()) | ||||
|                 .map { remoteTrack -> | ||||
|                     if (remoteTrack != null) { | ||||
|                         track.copyPersonalFrom(remoteTrack) | ||||
|                         track.total_chapters = remoteTrack.total_chapters | ||||
|                     } | ||||
|                     track | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val READING = 1 | ||||
|         const val COMPLETED = 2 | ||||
|         const val ON_HOLD = 3 | ||||
|         const val DROPPED = 4 | ||||
|         const val PLANNING = 5 | ||||
|         const val REPEATING = 6 | ||||
|  | ||||
|         const val DEFAULT_STATUS = READING | ||||
|         const val DEFAULT_SCORE = 0 | ||||
|     } | ||||
|  | ||||
|     override val name = "Shikimori" | ||||
|  | ||||
|     private val gson: Gson by injectLazy() | ||||
|  | ||||
|     private val interceptor by lazy { ShikimoriInterceptor(this, gson) } | ||||
|  | ||||
|     private val api by lazy { ShikimoriApi(client, interceptor) } | ||||
|  | ||||
|     override fun getLogo() = R.drawable.shikimori | ||||
|  | ||||
|     override fun getLogoColor() = Color.rgb(40, 40, 40) | ||||
|  | ||||
|     override fun getStatusList(): List<Int> { | ||||
|         return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING) | ||||
|     } | ||||
|  | ||||
|     override fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             READING -> getString(R.string.reading) | ||||
|             COMPLETED -> getString(R.string.completed) | ||||
|             ON_HOLD -> getString(R.string.on_hold) | ||||
|             DROPPED -> getString(R.string.dropped) | ||||
|             PLANNING -> getString(R.string.plan_to_read) | ||||
|             REPEATING -> getString(R.string.repeating) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun login(username: String, password: String) = login(password) | ||||
|  | ||||
|     fun login(code: String): Completable { | ||||
|         return api.accessToken(code).map { oauth: OAuth? -> | ||||
|             interceptor.newAuth(oauth) | ||||
|             if (oauth != null) { | ||||
|                 val user = api.getCurrentUser() | ||||
|                 saveCredentials(user.toString(), oauth.access_token) | ||||
|             } | ||||
|         }.doOnError { | ||||
|             logout() | ||||
|         }.toCompletable() | ||||
|     } | ||||
|  | ||||
|     fun saveToken(oauth: OAuth?) { | ||||
|         val json = gson.toJson(oauth) | ||||
|         preferences.trackToken(this).set(json) | ||||
|     } | ||||
|  | ||||
|     fun restoreToken(): OAuth? { | ||||
|         return try { | ||||
|             gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) | ||||
|         } catch (e: Exception) { | ||||
|             null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun logout() { | ||||
|         super.logout() | ||||
|         preferences.trackToken(this).set(null) | ||||
|         interceptor.newAuth(null) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,154 +1,154 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.content.Context | ||||
| import android.os.Build | ||||
| import android.os.Handler | ||||
| import android.os.Looper | ||||
| import android.webkit.WebResourceResponse | ||||
| import android.webkit.WebSettings | ||||
| import android.webkit.WebView | ||||
| import eu.kanade.tachiyomi.util.WebViewClientCompat | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import java.io.IOException | ||||
| import java.util.concurrent.CountDownLatch | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| class CloudflareInterceptor(private val context: Context) : Interceptor { | ||||
|  | ||||
|     private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare") | ||||
|  | ||||
|     private val handler = Handler(Looper.getMainLooper()) | ||||
|  | ||||
|     /** | ||||
|      * When this is called, it initializes the WebView if it wasn't already. We use this to avoid | ||||
|      * blocking the main thread too much. If used too often we could consider moving it to the | ||||
|      * Application class. | ||||
|      */ | ||||
|     private val initWebView by lazy { | ||||
|         if (Build.VERSION.SDK_INT >= 17) { | ||||
|             WebSettings.getDefaultUserAgent(context) | ||||
|         } else { | ||||
|             null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Synchronized | ||||
|     override fun intercept(chain: Interceptor.Chain): Response { | ||||
|         initWebView | ||||
|  | ||||
|         val response = chain.proceed(chain.request()) | ||||
|  | ||||
|         // Check if Cloudflare anti-bot is on | ||||
|         if (response.code() == 503 && response.header("Server") in serverCheck) { | ||||
|             try { | ||||
|                 response.close() | ||||
|                 val solutionRequest = resolveWithWebView(chain.request()) | ||||
|                 return chain.proceed(solutionRequest) | ||||
|             } catch (e: Exception) { | ||||
|                 // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that | ||||
|                 // we don't crash the entire app | ||||
|                 throw IOException(e) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return response | ||||
|     } | ||||
|  | ||||
|     private fun isChallengeSolutionUrl(url: String): Boolean { | ||||
|         return "chk_jschl" in url | ||||
|     } | ||||
|  | ||||
|     @SuppressLint("SetJavaScriptEnabled") | ||||
|     private fun resolveWithWebView(request: Request): Request { | ||||
|         // We need to lock this thread until the WebView finds the challenge solution url, because | ||||
|         // OkHttp doesn't support asynchronous interceptors. | ||||
|         val latch = CountDownLatch(1) | ||||
|  | ||||
|         var webView: WebView? = null | ||||
|         var solutionUrl: String? = null | ||||
|         var challengeFound = false | ||||
|  | ||||
|         val origRequestUrl = request.url().toString() | ||||
|         val headers = request.headers().toMultimap().mapValues { it.value.getOrNull(0) ?: "" } | ||||
|  | ||||
|         handler.post { | ||||
|             val view = WebView(context) | ||||
|             webView = view | ||||
|             view.settings.javaScriptEnabled = true | ||||
|             view.settings.userAgentString = request.header("User-Agent") | ||||
|             view.webViewClient = object : WebViewClientCompat() { | ||||
|  | ||||
|                 override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean { | ||||
|                     if (isChallengeSolutionUrl(url)) { | ||||
|                         solutionUrl = url | ||||
|                         latch.countDown() | ||||
|                     } | ||||
|                     return solutionUrl != null | ||||
|                 } | ||||
|  | ||||
|                 override fun shouldInterceptRequestCompat( | ||||
|                         view: WebView, | ||||
|                         url: String | ||||
|                 ): WebResourceResponse? { | ||||
|                     if (solutionUrl != null) { | ||||
|                         // Intercept any request when we have the solution. | ||||
|                         return WebResourceResponse("text/plain", "UTF-8", null) | ||||
|                     } | ||||
|                     return null | ||||
|                 } | ||||
|  | ||||
|                 override fun onPageFinished(view: WebView, url: String) { | ||||
|                     // Http error codes are only received since M | ||||
|                     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && | ||||
|                         url == origRequestUrl && !challengeFound | ||||
|                     ) { | ||||
|                         // The first request didn't return the challenge, abort. | ||||
|                         latch.countDown() | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 override fun onReceivedErrorCompat( | ||||
|                         view: WebView, | ||||
|                         errorCode: Int, | ||||
|                         description: String?, | ||||
|                         failingUrl: String, | ||||
|                         isMainFrame: Boolean | ||||
|                 ) { | ||||
|                     if (isMainFrame) { | ||||
|                         if (errorCode == 503) { | ||||
|                             // Found the cloudflare challenge page. | ||||
|                             challengeFound = true | ||||
|                         } else { | ||||
|                             // Unlock thread, the challenge wasn't found. | ||||
|                             latch.countDown() | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             webView?.loadUrl(origRequestUrl, headers) | ||||
|         } | ||||
|  | ||||
|         // Wait a reasonable amount of time to retrieve the solution. The minimum should be | ||||
|         // around 4 seconds but it can take more due to slow networks or server issues. | ||||
|         latch.await(12, TimeUnit.SECONDS) | ||||
|  | ||||
|         handler.post { | ||||
|             webView?.stopLoading() | ||||
|             webView?.destroy() | ||||
|         } | ||||
|  | ||||
|         val solution = solutionUrl ?: throw Exception("Challenge not found") | ||||
|  | ||||
|         return Request.Builder().get() | ||||
|             .url(solution) | ||||
|             .headers(request.headers()) | ||||
|             .addHeader("Referer", origRequestUrl) | ||||
|             .addHeader("Accept", "text/html,application/xhtml+xml,application/xml") | ||||
|             .addHeader("Accept-Language", "en") | ||||
|             .build() | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.content.Context | ||||
| import android.os.Build | ||||
| import android.os.Handler | ||||
| import android.os.Looper | ||||
| import android.webkit.WebResourceResponse | ||||
| import android.webkit.WebSettings | ||||
| import android.webkit.WebView | ||||
| import eu.kanade.tachiyomi.util.WebViewClientCompat | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import java.io.IOException | ||||
| import java.util.concurrent.CountDownLatch | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| class CloudflareInterceptor(private val context: Context) : Interceptor { | ||||
|  | ||||
|     private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare") | ||||
|  | ||||
|     private val handler = Handler(Looper.getMainLooper()) | ||||
|  | ||||
|     /** | ||||
|      * When this is called, it initializes the WebView if it wasn't already. We use this to avoid | ||||
|      * blocking the main thread too much. If used too often we could consider moving it to the | ||||
|      * Application class. | ||||
|      */ | ||||
|     private val initWebView by lazy { | ||||
|         if (Build.VERSION.SDK_INT >= 17) { | ||||
|             WebSettings.getDefaultUserAgent(context) | ||||
|         } else { | ||||
|             null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Synchronized | ||||
|     override fun intercept(chain: Interceptor.Chain): Response { | ||||
|         initWebView | ||||
|  | ||||
|         val response = chain.proceed(chain.request()) | ||||
|  | ||||
|         // Check if Cloudflare anti-bot is on | ||||
|         if (response.code() == 503 && response.header("Server") in serverCheck) { | ||||
|             try { | ||||
|                 response.close() | ||||
|                 val solutionRequest = resolveWithWebView(chain.request()) | ||||
|                 return chain.proceed(solutionRequest) | ||||
|             } catch (e: Exception) { | ||||
|                 // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that | ||||
|                 // we don't crash the entire app | ||||
|                 throw IOException(e) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return response | ||||
|     } | ||||
|  | ||||
|     private fun isChallengeSolutionUrl(url: String): Boolean { | ||||
|         return "chk_jschl" in url | ||||
|     } | ||||
|  | ||||
|     @SuppressLint("SetJavaScriptEnabled") | ||||
|     private fun resolveWithWebView(request: Request): Request { | ||||
|         // We need to lock this thread until the WebView finds the challenge solution url, because | ||||
|         // OkHttp doesn't support asynchronous interceptors. | ||||
|         val latch = CountDownLatch(1) | ||||
|  | ||||
|         var webView: WebView? = null | ||||
|         var solutionUrl: String? = null | ||||
|         var challengeFound = false | ||||
|  | ||||
|         val origRequestUrl = request.url().toString() | ||||
|         val headers = request.headers().toMultimap().mapValues { it.value.getOrNull(0) ?: "" } | ||||
|  | ||||
|         handler.post { | ||||
|             val view = WebView(context) | ||||
|             webView = view | ||||
|             view.settings.javaScriptEnabled = true | ||||
|             view.settings.userAgentString = request.header("User-Agent") | ||||
|             view.webViewClient = object : WebViewClientCompat() { | ||||
|  | ||||
|                 override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean { | ||||
|                     if (isChallengeSolutionUrl(url)) { | ||||
|                         solutionUrl = url | ||||
|                         latch.countDown() | ||||
|                     } | ||||
|                     return solutionUrl != null | ||||
|                 } | ||||
|  | ||||
|                 override fun shouldInterceptRequestCompat( | ||||
|                         view: WebView, | ||||
|                         url: String | ||||
|                 ): WebResourceResponse? { | ||||
|                     if (solutionUrl != null) { | ||||
|                         // Intercept any request when we have the solution. | ||||
|                         return WebResourceResponse("text/plain", "UTF-8", null) | ||||
|                     } | ||||
|                     return null | ||||
|                 } | ||||
|  | ||||
|                 override fun onPageFinished(view: WebView, url: String) { | ||||
|                     // Http error codes are only received since M | ||||
|                     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && | ||||
|                         url == origRequestUrl && !challengeFound | ||||
|                     ) { | ||||
|                         // The first request didn't return the challenge, abort. | ||||
|                         latch.countDown() | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 override fun onReceivedErrorCompat( | ||||
|                         view: WebView, | ||||
|                         errorCode: Int, | ||||
|                         description: String?, | ||||
|                         failingUrl: String, | ||||
|                         isMainFrame: Boolean | ||||
|                 ) { | ||||
|                     if (isMainFrame) { | ||||
|                         if (errorCode == 503) { | ||||
|                             // Found the cloudflare challenge page. | ||||
|                             challengeFound = true | ||||
|                         } else { | ||||
|                             // Unlock thread, the challenge wasn't found. | ||||
|                             latch.countDown() | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             webView?.loadUrl(origRequestUrl, headers) | ||||
|         } | ||||
|  | ||||
|         // Wait a reasonable amount of time to retrieve the solution. The minimum should be | ||||
|         // around 4 seconds but it can take more due to slow networks or server issues. | ||||
|         latch.await(12, TimeUnit.SECONDS) | ||||
|  | ||||
|         handler.post { | ||||
|             webView?.stopLoading() | ||||
|             webView?.destroy() | ||||
|         } | ||||
|  | ||||
|         val solution = solutionUrl ?: throw Exception("Challenge not found") | ||||
|  | ||||
|         return Request.Builder().get() | ||||
|             .url(solution) | ||||
|             .headers(request.headers()) | ||||
|             .addHeader("Referer", origRequestUrl) | ||||
|             .addHeader("Accept", "text/html,application/xhtml+xml,application/xml") | ||||
|             .addHeader("Accept-Language", "en") | ||||
|             .build() | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,117 +1,117 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import android.content.Context | ||||
| import android.os.Build | ||||
| import okhttp3.* | ||||
| import java.io.File | ||||
| import java.io.IOException | ||||
| import java.net.InetAddress | ||||
| import java.net.Socket | ||||
| import java.net.UnknownHostException | ||||
| import java.security.KeyManagementException | ||||
| import java.security.KeyStore | ||||
| import java.security.NoSuchAlgorithmException | ||||
| import javax.net.ssl.* | ||||
|  | ||||
| class NetworkHelper(context: Context) { | ||||
|  | ||||
|     private val cacheDir = File(context.cacheDir, "network_cache") | ||||
|  | ||||
|     private val cacheSize = 5L * 1024 * 1024 // 5 MiB | ||||
|  | ||||
|     val cookieManager = AndroidCookieJar(context) | ||||
|  | ||||
|     val client = OkHttpClient.Builder() | ||||
|             .cookieJar(cookieManager) | ||||
|             .cache(Cache(cacheDir, cacheSize)) | ||||
|             .enableTLS12() | ||||
|             .build() | ||||
|  | ||||
|     val cloudflareClient = client.newBuilder() | ||||
|             .addInterceptor(CloudflareInterceptor(context)) | ||||
|             .build() | ||||
|  | ||||
|     private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder { | ||||
|         if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) { | ||||
|             return this | ||||
|         } | ||||
|  | ||||
|         val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) | ||||
|         trustManagerFactory.init(null as KeyStore?) | ||||
|         val trustManagers = trustManagerFactory.trustManagers | ||||
|         if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) { | ||||
|             class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class) | ||||
|             constructor() : SSLSocketFactory() { | ||||
|  | ||||
|                 private val internalSSLSocketFactory: SSLSocketFactory | ||||
|  | ||||
|                 init { | ||||
|                     val context = SSLContext.getInstance("TLS") | ||||
|                     context.init(null, null, null) | ||||
|                     internalSSLSocketFactory = context.socketFactory | ||||
|                 } | ||||
|  | ||||
|                 override fun getDefaultCipherSuites(): Array<String> { | ||||
|                     return internalSSLSocketFactory.defaultCipherSuites | ||||
|                 } | ||||
|  | ||||
|                 override fun getSupportedCipherSuites(): Array<String> { | ||||
|                     return internalSSLSocketFactory.supportedCipherSuites | ||||
|                 } | ||||
|  | ||||
|                 @Throws(IOException::class) | ||||
|                 override fun createSocket(): Socket? { | ||||
|                     return enableTLSOnSocket(internalSSLSocketFactory.createSocket()) | ||||
|                 } | ||||
|  | ||||
|                 @Throws(IOException::class) | ||||
|                 override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? { | ||||
|                     return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose)) | ||||
|                 } | ||||
|  | ||||
|                 @Throws(IOException::class, UnknownHostException::class) | ||||
|                 override fun createSocket(host: String, port: Int): Socket? { | ||||
|                     return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) | ||||
|                 } | ||||
|  | ||||
|                 @Throws(IOException::class, UnknownHostException::class) | ||||
|                 override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? { | ||||
|                     return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort)) | ||||
|                 } | ||||
|  | ||||
|                 @Throws(IOException::class) | ||||
|                 override fun createSocket(host: InetAddress, port: Int): Socket? { | ||||
|                     return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) | ||||
|                 } | ||||
|  | ||||
|                 @Throws(IOException::class) | ||||
|                 override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? { | ||||
|                     return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort)) | ||||
|                 } | ||||
|  | ||||
|                 private fun enableTLSOnSocket(socket: Socket?): Socket? { | ||||
|                     if (socket != null && socket is SSLSocket) { | ||||
|                         socket.enabledProtocols = socket.supportedProtocols | ||||
|                     } | ||||
|                     return socket | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager) | ||||
|         } | ||||
|  | ||||
|         val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) | ||||
|             .tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0) | ||||
|             .cipherSuites( | ||||
|                     *ConnectionSpec.MODERN_TLS.cipherSuites().orEmpty().toTypedArray(), | ||||
|                     CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, | ||||
|                     CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA | ||||
|             ) | ||||
|             .build() | ||||
|  | ||||
|         val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT) | ||||
|         connectionSpecs(specs) | ||||
|  | ||||
|         return this | ||||
|     } | ||||
| } | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import android.content.Context | ||||
| import android.os.Build | ||||
| import okhttp3.* | ||||
| import java.io.File | ||||
| import java.io.IOException | ||||
| import java.net.InetAddress | ||||
| import java.net.Socket | ||||
| import java.net.UnknownHostException | ||||
| import java.security.KeyManagementException | ||||
| import java.security.KeyStore | ||||
| import java.security.NoSuchAlgorithmException | ||||
| import javax.net.ssl.* | ||||
|  | ||||
| class NetworkHelper(context: Context) { | ||||
|  | ||||
|     private val cacheDir = File(context.cacheDir, "network_cache") | ||||
|  | ||||
|     private val cacheSize = 5L * 1024 * 1024 // 5 MiB | ||||
|  | ||||
|     val cookieManager = AndroidCookieJar(context) | ||||
|  | ||||
|     val client = OkHttpClient.Builder() | ||||
|             .cookieJar(cookieManager) | ||||
|             .cache(Cache(cacheDir, cacheSize)) | ||||
|             .enableTLS12() | ||||
|             .build() | ||||
|  | ||||
|     val cloudflareClient = client.newBuilder() | ||||
|             .addInterceptor(CloudflareInterceptor(context)) | ||||
|             .build() | ||||
|  | ||||
|     private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder { | ||||
|         if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) { | ||||
|             return this | ||||
|         } | ||||
|  | ||||
|         val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) | ||||
|         trustManagerFactory.init(null as KeyStore?) | ||||
|         val trustManagers = trustManagerFactory.trustManagers | ||||
|         if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) { | ||||
|             class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class) | ||||
|             constructor() : SSLSocketFactory() { | ||||
|  | ||||
|                 private val internalSSLSocketFactory: SSLSocketFactory | ||||
|  | ||||
|                 init { | ||||
|                     val context = SSLContext.getInstance("TLS") | ||||
|                     context.init(null, null, null) | ||||
|                     internalSSLSocketFactory = context.socketFactory | ||||
|                 } | ||||
|  | ||||
|                 override fun getDefaultCipherSuites(): Array<String> { | ||||
|                     return internalSSLSocketFactory.defaultCipherSuites | ||||
|                 } | ||||
|  | ||||
|                 override fun getSupportedCipherSuites(): Array<String> { | ||||
|                     return internalSSLSocketFactory.supportedCipherSuites | ||||
|                 } | ||||
|  | ||||
|                 @Throws(IOException::class) | ||||
|                 override fun createSocket(): Socket? { | ||||
|                     return enableTLSOnSocket(internalSSLSocketFactory.createSocket()) | ||||
|                 } | ||||
|  | ||||
|                 @Throws(IOException::class) | ||||
|                 override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? { | ||||
|                     return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose)) | ||||
|                 } | ||||
|  | ||||
|                 @Throws(IOException::class, UnknownHostException::class) | ||||
|                 override fun createSocket(host: String, port: Int): Socket? { | ||||
|                     return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) | ||||
|                 } | ||||
|  | ||||
|                 @Throws(IOException::class, UnknownHostException::class) | ||||
|                 override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? { | ||||
|                     return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort)) | ||||
|                 } | ||||
|  | ||||
|                 @Throws(IOException::class) | ||||
|                 override fun createSocket(host: InetAddress, port: Int): Socket? { | ||||
|                     return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) | ||||
|                 } | ||||
|  | ||||
|                 @Throws(IOException::class) | ||||
|                 override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? { | ||||
|                     return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort)) | ||||
|                 } | ||||
|  | ||||
|                 private fun enableTLSOnSocket(socket: Socket?): Socket? { | ||||
|                     if (socket != null && socket is SSLSocket) { | ||||
|                         socket.enabledProtocols = socket.supportedProtocols | ||||
|                     } | ||||
|                     return socket | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager) | ||||
|         } | ||||
|  | ||||
|         val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) | ||||
|             .tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0) | ||||
|             .cipherSuites( | ||||
|                     *ConnectionSpec.MODERN_TLS.cipherSuites().orEmpty().toTypedArray(), | ||||
|                     CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, | ||||
|                     CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA | ||||
|             ) | ||||
|             .build() | ||||
|  | ||||
|         val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT) | ||||
|         connectionSpecs(specs) | ||||
|  | ||||
|         return this | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,70 +1,70 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import okhttp3.Call | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import rx.Observable | ||||
| import rx.Producer | ||||
| import rx.Subscription | ||||
| import java.util.concurrent.atomic.AtomicBoolean | ||||
|  | ||||
| fun Call.asObservable(): Observable<Response> { | ||||
|     return Observable.unsafeCreate { subscriber -> | ||||
|         // Since Call is a one-shot type, clone it for each new subscriber. | ||||
|         val call = clone() | ||||
|  | ||||
|         // Wrap the call in a helper which handles both unsubscription and backpressure. | ||||
|         val requestArbiter = object : AtomicBoolean(), Producer, Subscription { | ||||
|             override fun request(n: Long) { | ||||
|                 if (n == 0L || !compareAndSet(false, true)) return | ||||
|  | ||||
|                 try { | ||||
|                     val response = call.execute() | ||||
|                     if (!subscriber.isUnsubscribed) { | ||||
|                         subscriber.onNext(response) | ||||
|                         subscriber.onCompleted() | ||||
|                     } | ||||
|                 } catch (error: Exception) { | ||||
|                     if (!subscriber.isUnsubscribed) { | ||||
|                         subscriber.onError(error) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             override fun unsubscribe() { | ||||
|                 call.cancel() | ||||
|             } | ||||
|  | ||||
|             override fun isUnsubscribed(): Boolean { | ||||
|                 return call.isCanceled | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         subscriber.add(requestArbiter) | ||||
|         subscriber.setProducer(requestArbiter) | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun Call.asObservableSuccess(): Observable<Response> { | ||||
|     return asObservable().doOnNext { response -> | ||||
|         if (!response.isSuccessful) { | ||||
|             response.close() | ||||
|             throw Exception("HTTP error ${response.code()}") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call { | ||||
|     val progressClient = newBuilder() | ||||
|             .cache(null) | ||||
|             .addNetworkInterceptor { chain -> | ||||
|                 val originalResponse = chain.proceed(chain.request()) | ||||
|                 originalResponse.newBuilder() | ||||
|                         .body(ProgressResponseBody(originalResponse.body()!!, listener)) | ||||
|                         .build() | ||||
|             } | ||||
|             .build() | ||||
|  | ||||
|     return progressClient.newCall(request) | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import okhttp3.Call | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import rx.Observable | ||||
| import rx.Producer | ||||
| import rx.Subscription | ||||
| import java.util.concurrent.atomic.AtomicBoolean | ||||
|  | ||||
| fun Call.asObservable(): Observable<Response> { | ||||
|     return Observable.unsafeCreate { subscriber -> | ||||
|         // Since Call is a one-shot type, clone it for each new subscriber. | ||||
|         val call = clone() | ||||
|  | ||||
|         // Wrap the call in a helper which handles both unsubscription and backpressure. | ||||
|         val requestArbiter = object : AtomicBoolean(), Producer, Subscription { | ||||
|             override fun request(n: Long) { | ||||
|                 if (n == 0L || !compareAndSet(false, true)) return | ||||
|  | ||||
|                 try { | ||||
|                     val response = call.execute() | ||||
|                     if (!subscriber.isUnsubscribed) { | ||||
|                         subscriber.onNext(response) | ||||
|                         subscriber.onCompleted() | ||||
|                     } | ||||
|                 } catch (error: Exception) { | ||||
|                     if (!subscriber.isUnsubscribed) { | ||||
|                         subscriber.onError(error) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             override fun unsubscribe() { | ||||
|                 call.cancel() | ||||
|             } | ||||
|  | ||||
|             override fun isUnsubscribed(): Boolean { | ||||
|                 return call.isCanceled | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         subscriber.add(requestArbiter) | ||||
|         subscriber.setProducer(requestArbiter) | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun Call.asObservableSuccess(): Observable<Response> { | ||||
|     return asObservable().doOnNext { response -> | ||||
|         if (!response.isSuccessful) { | ||||
|             response.close() | ||||
|             throw Exception("HTTP error ${response.code()}") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call { | ||||
|     val progressClient = newBuilder() | ||||
|             .cache(null) | ||||
|             .addNetworkInterceptor { chain -> | ||||
|                 val originalResponse = chain.proceed(chain.request()) | ||||
|                 originalResponse.newBuilder() | ||||
|                         .body(ProgressResponseBody(originalResponse.body()!!, listener)) | ||||
|                         .build() | ||||
|             } | ||||
|             .build() | ||||
|  | ||||
|     return progressClient.newCall(request) | ||||
| } | ||||
| @@ -1,5 +1,5 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| interface ProgressListener { | ||||
|     fun update(bytesRead: Long, contentLength: Long, done: Boolean) | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| interface ProgressListener { | ||||
|     fun update(bytesRead: Long, contentLength: Long, done: Boolean) | ||||
| } | ||||
| @@ -1,40 +1,40 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import okhttp3.MediaType | ||||
| import okhttp3.ResponseBody | ||||
| import okio.* | ||||
| import java.io.IOException | ||||
|  | ||||
| class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() { | ||||
|  | ||||
|     private val bufferedSource: BufferedSource by lazy { | ||||
|         Okio.buffer(source(responseBody.source())) | ||||
|     } | ||||
|  | ||||
|     override fun contentType(): MediaType { | ||||
|         return responseBody.contentType()!! | ||||
|     } | ||||
|  | ||||
|     override fun contentLength(): Long { | ||||
|         return responseBody.contentLength() | ||||
|     } | ||||
|  | ||||
|     override fun source(): BufferedSource { | ||||
|         return bufferedSource | ||||
|     } | ||||
|  | ||||
|     private fun source(source: Source): Source { | ||||
|         return object : ForwardingSource(source) { | ||||
|             internal var totalBytesRead = 0L | ||||
|  | ||||
|             @Throws(IOException::class) | ||||
|             override fun read(sink: Buffer, byteCount: Long): Long { | ||||
|                 val bytesRead = super.read(sink, byteCount) | ||||
|                 // read() returns the number of bytes read, or -1 if this source is exhausted. | ||||
|                 totalBytesRead += if (bytesRead != -1L) bytesRead else 0 | ||||
|                 progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L) | ||||
|                 return bytesRead | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import okhttp3.MediaType | ||||
| import okhttp3.ResponseBody | ||||
| import okio.* | ||||
| import java.io.IOException | ||||
|  | ||||
| class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() { | ||||
|  | ||||
|     private val bufferedSource: BufferedSource by lazy { | ||||
|         Okio.buffer(source(responseBody.source())) | ||||
|     } | ||||
|  | ||||
|     override fun contentType(): MediaType { | ||||
|         return responseBody.contentType()!! | ||||
|     } | ||||
|  | ||||
|     override fun contentLength(): Long { | ||||
|         return responseBody.contentLength() | ||||
|     } | ||||
|  | ||||
|     override fun source(): BufferedSource { | ||||
|         return bufferedSource | ||||
|     } | ||||
|  | ||||
|     private fun source(source: Source): Source { | ||||
|         return object : ForwardingSource(source) { | ||||
|             internal var totalBytesRead = 0L | ||||
|  | ||||
|             @Throws(IOException::class) | ||||
|             override fun read(sink: Buffer, byteCount: Long): Long { | ||||
|                 val bytesRead = super.read(sink, byteCount) | ||||
|                 // read() returns the number of bytes read, or -1 if this source is exhausted. | ||||
|                 totalBytesRead += if (bytesRead != -1L) bytesRead else 0 | ||||
|                 progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L) | ||||
|                 return bytesRead | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,32 +1,32 @@ | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import okhttp3.* | ||||
| import java.util.concurrent.TimeUnit.MINUTES | ||||
|  | ||||
| private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build() | ||||
| private val DEFAULT_HEADERS = Headers.Builder().build() | ||||
| private val DEFAULT_BODY: RequestBody = FormBody.Builder().build() | ||||
|  | ||||
| fun GET(url: String, | ||||
|         headers: Headers = DEFAULT_HEADERS, | ||||
|         cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { | ||||
|  | ||||
|     return Request.Builder() | ||||
|             .url(url) | ||||
|             .headers(headers) | ||||
|             .cacheControl(cache) | ||||
|             .build() | ||||
| } | ||||
|  | ||||
| fun POST(url: String, | ||||
|          headers: Headers = DEFAULT_HEADERS, | ||||
|          body: RequestBody = DEFAULT_BODY, | ||||
|          cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { | ||||
|  | ||||
|     return Request.Builder() | ||||
|             .url(url) | ||||
|             .post(body) | ||||
|             .headers(headers) | ||||
|             .cacheControl(cache) | ||||
|             .build() | ||||
| } | ||||
| package eu.kanade.tachiyomi.network | ||||
|  | ||||
| import okhttp3.* | ||||
| import java.util.concurrent.TimeUnit.MINUTES | ||||
|  | ||||
| private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build() | ||||
| private val DEFAULT_HEADERS = Headers.Builder().build() | ||||
| private val DEFAULT_BODY: RequestBody = FormBody.Builder().build() | ||||
|  | ||||
| fun GET(url: String, | ||||
|         headers: Headers = DEFAULT_HEADERS, | ||||
|         cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { | ||||
|  | ||||
|     return Request.Builder() | ||||
|             .url(url) | ||||
|             .headers(headers) | ||||
|             .cacheControl(cache) | ||||
|             .build() | ||||
| } | ||||
|  | ||||
| fun POST(url: String, | ||||
|          headers: Headers = DEFAULT_HEADERS, | ||||
|          body: RequestBody = DEFAULT_BODY, | ||||
|          cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { | ||||
|  | ||||
|     return Request.Builder() | ||||
|             .url(url) | ||||
|             .post(body) | ||||
|             .headers(headers) | ||||
|             .cacheControl(cache) | ||||
|             .build() | ||||
| } | ||||
|   | ||||
| @@ -1,46 +1,46 @@ | ||||
| package eu.kanade.tachiyomi.source | ||||
|  | ||||
| import eu.kanade.tachiyomi.source.model.FilterList | ||||
| import eu.kanade.tachiyomi.source.model.MangasPage | ||||
| import rx.Observable | ||||
|  | ||||
| interface CatalogueSource : Source { | ||||
|  | ||||
|     /** | ||||
|      * An ISO 639-1 compliant language code (two letters in lower case). | ||||
|      */ | ||||
|     val lang: String | ||||
|  | ||||
|     /** | ||||
|      * Whether the source has support for latest updates. | ||||
|      */ | ||||
|     val supportsLatest: Boolean | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of manga. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     fun fetchPopularManga(page: Int): Observable<MangasPage> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of manga. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      * @param query the search query. | ||||
|      * @param filters the list of filters to apply. | ||||
|      */ | ||||
|     fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of latest manga updates. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     fun fetchLatestUpdates(page: Int): Observable<MangasPage> | ||||
|  | ||||
|     /** | ||||
|      * Returns the list of filters for the source. | ||||
|      */ | ||||
|     fun getFilterList(): FilterList | ||||
| package eu.kanade.tachiyomi.source | ||||
|  | ||||
| import eu.kanade.tachiyomi.source.model.FilterList | ||||
| import eu.kanade.tachiyomi.source.model.MangasPage | ||||
| import rx.Observable | ||||
|  | ||||
| interface CatalogueSource : Source { | ||||
|  | ||||
|     /** | ||||
|      * An ISO 639-1 compliant language code (two letters in lower case). | ||||
|      */ | ||||
|     val lang: String | ||||
|  | ||||
|     /** | ||||
|      * Whether the source has support for latest updates. | ||||
|      */ | ||||
|     val supportsLatest: Boolean | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of manga. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     fun fetchPopularManga(page: Int): Observable<MangasPage> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of manga. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      * @param query the search query. | ||||
|      * @param filters the list of filters to apply. | ||||
|      */ | ||||
|     fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of latest manga updates. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     fun fetchLatestUpdates(page: Int): Observable<MangasPage> | ||||
|  | ||||
|     /** | ||||
|      * Returns the list of filters for the source. | ||||
|      */ | ||||
|     fun getFilterList(): FilterList | ||||
| } | ||||
| @@ -1,44 +1,44 @@ | ||||
| package eu.kanade.tachiyomi.source | ||||
|  | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import eu.kanade.tachiyomi.source.model.SChapter | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import rx.Observable | ||||
|  | ||||
| /** | ||||
|  * A basic interface for creating a source. It could be an online source, a local source, etc... | ||||
|  */ | ||||
| interface Source { | ||||
|  | ||||
|     /** | ||||
|      * Id for the source. Must be unique. | ||||
|      */ | ||||
|     val id: Long | ||||
|  | ||||
|     /** | ||||
|      * Name of the source. | ||||
|      */ | ||||
|     val name: String | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the updated details for a manga. | ||||
|      * | ||||
|      * @param manga the manga to update. | ||||
|      */ | ||||
|     fun fetchMangaDetails(manga: SManga): Observable<SManga> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with all the available chapters for a manga. | ||||
|      * | ||||
|      * @param manga the manga to update. | ||||
|      */ | ||||
|     fun fetchChapterList(manga: SManga): Observable<List<SChapter>> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the list of pages a chapter has. | ||||
|      * | ||||
|      * @param chapter the chapter. | ||||
|      */ | ||||
|     fun fetchPageList(chapter: SChapter): Observable<List<Page>> | ||||
|  | ||||
| package eu.kanade.tachiyomi.source | ||||
|  | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import eu.kanade.tachiyomi.source.model.SChapter | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import rx.Observable | ||||
|  | ||||
| /** | ||||
|  * A basic interface for creating a source. It could be an online source, a local source, etc... | ||||
|  */ | ||||
| interface Source { | ||||
|  | ||||
|     /** | ||||
|      * Id for the source. Must be unique. | ||||
|      */ | ||||
|     val id: Long | ||||
|  | ||||
|     /** | ||||
|      * Name of the source. | ||||
|      */ | ||||
|     val name: String | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the updated details for a manga. | ||||
|      * | ||||
|      * @param manga the manga to update. | ||||
|      */ | ||||
|     fun fetchMangaDetails(manga: SManga): Observable<SManga> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with all the available chapters for a manga. | ||||
|      * | ||||
|      * @param manga the manga to update. | ||||
|      */ | ||||
|     fun fetchChapterList(manga: SManga): Observable<List<SChapter>> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the list of pages a chapter has. | ||||
|      * | ||||
|      * @param chapter the chapter. | ||||
|      */ | ||||
|     fun fetchPageList(chapter: SChapter): Observable<List<Page>> | ||||
|  | ||||
| } | ||||
| @@ -1,74 +1,74 @@ | ||||
| package eu.kanade.tachiyomi.source | ||||
|  | ||||
| import android.content.Context | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import eu.kanade.tachiyomi.source.model.SChapter | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import eu.kanade.tachiyomi.source.online.HttpSource | ||||
| import rx.Observable | ||||
|  | ||||
| open class SourceManager(private val context: Context) { | ||||
|  | ||||
|     private val sourcesMap = mutableMapOf<Long, Source>() | ||||
|  | ||||
|     private val stubSourcesMap = mutableMapOf<Long, StubSource>() | ||||
|  | ||||
|     init { | ||||
|         createInternalSources().forEach { registerSource(it) } | ||||
|     } | ||||
|  | ||||
|     open fun get(sourceKey: Long): Source? { | ||||
|         return sourcesMap[sourceKey] | ||||
|     } | ||||
|  | ||||
|     fun getOrStub(sourceKey: Long): Source { | ||||
|         return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) { | ||||
|             StubSource(sourceKey) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun getOnlineSources() = sourcesMap.values.filterIsInstance<HttpSource>() | ||||
|  | ||||
|     fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>() | ||||
|  | ||||
|     internal fun registerSource(source: Source, overwrite: Boolean = false) { | ||||
|         if (overwrite || !sourcesMap.containsKey(source.id)) { | ||||
|             sourcesMap[source.id] = source | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     internal fun unregisterSource(source: Source) { | ||||
|         sourcesMap.remove(source.id) | ||||
|     } | ||||
|  | ||||
|     private fun createInternalSources(): List<Source> = listOf( | ||||
|             LocalSource(context) | ||||
|     ) | ||||
|  | ||||
|     private inner class StubSource(override val id: Long) : Source { | ||||
|  | ||||
|         override val name: String | ||||
|             get() = id.toString() | ||||
|  | ||||
|         override fun fetchMangaDetails(manga: SManga): Observable<SManga> { | ||||
|             return Observable.error(getSourceNotInstalledException()) | ||||
|         } | ||||
|  | ||||
|         override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { | ||||
|             return Observable.error(getSourceNotInstalledException()) | ||||
|         } | ||||
|  | ||||
|         override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { | ||||
|             return Observable.error(getSourceNotInstalledException()) | ||||
|         } | ||||
|  | ||||
|         override fun toString(): String { | ||||
|             return name | ||||
|         } | ||||
|  | ||||
|         private fun getSourceNotInstalledException(): Exception { | ||||
|             return Exception(context.getString(R.string.source_not_installed, id.toString())) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| package eu.kanade.tachiyomi.source | ||||
|  | ||||
| import android.content.Context | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import eu.kanade.tachiyomi.source.model.SChapter | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import eu.kanade.tachiyomi.source.online.HttpSource | ||||
| import rx.Observable | ||||
|  | ||||
| open class SourceManager(private val context: Context) { | ||||
|  | ||||
|     private val sourcesMap = mutableMapOf<Long, Source>() | ||||
|  | ||||
|     private val stubSourcesMap = mutableMapOf<Long, StubSource>() | ||||
|  | ||||
|     init { | ||||
|         createInternalSources().forEach { registerSource(it) } | ||||
|     } | ||||
|  | ||||
|     open fun get(sourceKey: Long): Source? { | ||||
|         return sourcesMap[sourceKey] | ||||
|     } | ||||
|  | ||||
|     fun getOrStub(sourceKey: Long): Source { | ||||
|         return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) { | ||||
|             StubSource(sourceKey) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun getOnlineSources() = sourcesMap.values.filterIsInstance<HttpSource>() | ||||
|  | ||||
|     fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>() | ||||
|  | ||||
|     internal fun registerSource(source: Source, overwrite: Boolean = false) { | ||||
|         if (overwrite || !sourcesMap.containsKey(source.id)) { | ||||
|             sourcesMap[source.id] = source | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     internal fun unregisterSource(source: Source) { | ||||
|         sourcesMap.remove(source.id) | ||||
|     } | ||||
|  | ||||
|     private fun createInternalSources(): List<Source> = listOf( | ||||
|             LocalSource(context) | ||||
|     ) | ||||
|  | ||||
|     private inner class StubSource(override val id: Long) : Source { | ||||
|  | ||||
|         override val name: String | ||||
|             get() = id.toString() | ||||
|  | ||||
|         override fun fetchMangaDetails(manga: SManga): Observable<SManga> { | ||||
|             return Observable.error(getSourceNotInstalledException()) | ||||
|         } | ||||
|  | ||||
|         override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { | ||||
|             return Observable.error(getSourceNotInstalledException()) | ||||
|         } | ||||
|  | ||||
|         override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { | ||||
|             return Observable.error(getSourceNotInstalledException()) | ||||
|         } | ||||
|  | ||||
|         override fun toString(): String { | ||||
|             return name | ||||
|         } | ||||
|  | ||||
|         private fun getSourceNotInstalledException(): Exception { | ||||
|             return Exception(context.getString(R.string.source_not_installed, id.toString())) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,40 +1,40 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| sealed class Filter<T>(val name: String, var state: T) { | ||||
|     open class Header(name: String) : Filter<Any>(name, 0) | ||||
|     open class Separator(name: String = "") : Filter<Any>(name, 0) | ||||
|     abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state) | ||||
|     abstract class Text(name: String, state: String = "") : Filter<String>(name, state) | ||||
|     abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state) | ||||
|     abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) { | ||||
|         fun isIgnored() = state == STATE_IGNORE | ||||
|         fun isIncluded() = state == STATE_INCLUDE | ||||
|         fun isExcluded() = state == STATE_EXCLUDE | ||||
|  | ||||
|         companion object { | ||||
|             const val STATE_IGNORE = 0 | ||||
|             const val STATE_INCLUDE = 1 | ||||
|             const val STATE_EXCLUDE = 2 | ||||
|         } | ||||
|     } | ||||
|     abstract class Group<V>(name: String, state: List<V>): Filter<List<V>>(name, state) | ||||
|  | ||||
|     abstract class Sort(name: String, val values: Array<String>, state: Selection? = null) | ||||
|         : Filter<Sort.Selection?>(name, state) { | ||||
|         data class Selection(val index: Int, val ascending: Boolean) | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (other !is Filter<*>) return false | ||||
|  | ||||
|         return name == other.name && state == other.state | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         var result = name.hashCode() | ||||
|         result = 31 * result + (state?.hashCode() ?: 0) | ||||
|         return result | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| sealed class Filter<T>(val name: String, var state: T) { | ||||
|     open class Header(name: String) : Filter<Any>(name, 0) | ||||
|     open class Separator(name: String = "") : Filter<Any>(name, 0) | ||||
|     abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state) | ||||
|     abstract class Text(name: String, state: String = "") : Filter<String>(name, state) | ||||
|     abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state) | ||||
|     abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) { | ||||
|         fun isIgnored() = state == STATE_IGNORE | ||||
|         fun isIncluded() = state == STATE_INCLUDE | ||||
|         fun isExcluded() = state == STATE_EXCLUDE | ||||
|  | ||||
|         companion object { | ||||
|             const val STATE_IGNORE = 0 | ||||
|             const val STATE_INCLUDE = 1 | ||||
|             const val STATE_EXCLUDE = 2 | ||||
|         } | ||||
|     } | ||||
|     abstract class Group<V>(name: String, state: List<V>): Filter<List<V>>(name, state) | ||||
|  | ||||
|     abstract class Sort(name: String, val values: Array<String>, state: Selection? = null) | ||||
|         : Filter<Sort.Selection?>(name, state) { | ||||
|         data class Selection(val index: Int, val ascending: Boolean) | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (other !is Filter<*>) return false | ||||
|  | ||||
|         return name == other.name && state == other.state | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         var result = name.hashCode() | ||||
|         result = 31 * result + (state?.hashCode() ?: 0) | ||||
|         return result | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,7 +1,7 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list { | ||||
|  | ||||
|     constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList()) | ||||
|  | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list { | ||||
|  | ||||
|     constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList()) | ||||
|  | ||||
| } | ||||
| @@ -1,3 +1,3 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| data class MangasPage(val mangas: List<SManga>, val hasNextPage: Boolean) | ||||
| @@ -1,48 +1,48 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| import android.net.Uri | ||||
| import eu.kanade.tachiyomi.network.ProgressListener | ||||
| import rx.subjects.Subject | ||||
|  | ||||
| open class Page( | ||||
|         val index: Int, | ||||
|         val url: String = "", | ||||
|         var imageUrl: String? = null, | ||||
|         @Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions | ||||
| ) : ProgressListener { | ||||
|  | ||||
|     val number: Int | ||||
|         get() = index + 1 | ||||
|  | ||||
|     @Transient @Volatile var status: Int = 0 | ||||
|         set(value) { | ||||
|             field = value | ||||
|             statusSubject?.onNext(value) | ||||
|         } | ||||
|  | ||||
|     @Transient @Volatile var progress: Int = 0 | ||||
|  | ||||
|     @Transient private var statusSubject: Subject<Int, Int>? = null | ||||
|  | ||||
|     override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { | ||||
|         progress = if (contentLength > 0) { | ||||
|             (100 * bytesRead / contentLength).toInt() | ||||
|         } else { | ||||
|             -1 | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun setStatusSubject(subject: Subject<Int, Int>?) { | ||||
|         this.statusSubject = subject | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|  | ||||
|         const val QUEUE = 0 | ||||
|         const val LOAD_PAGE = 1 | ||||
|         const val DOWNLOAD_IMAGE = 2 | ||||
|         const val READY = 3 | ||||
|         const val ERROR = 4 | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| import android.net.Uri | ||||
| import eu.kanade.tachiyomi.network.ProgressListener | ||||
| import rx.subjects.Subject | ||||
|  | ||||
| open class Page( | ||||
|         val index: Int, | ||||
|         val url: String = "", | ||||
|         var imageUrl: String? = null, | ||||
|         @Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions | ||||
| ) : ProgressListener { | ||||
|  | ||||
|     val number: Int | ||||
|         get() = index + 1 | ||||
|  | ||||
|     @Transient @Volatile var status: Int = 0 | ||||
|         set(value) { | ||||
|             field = value | ||||
|             statusSubject?.onNext(value) | ||||
|         } | ||||
|  | ||||
|     @Transient @Volatile var progress: Int = 0 | ||||
|  | ||||
|     @Transient private var statusSubject: Subject<Int, Int>? = null | ||||
|  | ||||
|     override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { | ||||
|         progress = if (contentLength > 0) { | ||||
|             (100 * bytesRead / contentLength).toInt() | ||||
|         } else { | ||||
|             -1 | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun setStatusSubject(subject: Subject<Int, Int>?) { | ||||
|         this.statusSubject = subject | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|  | ||||
|         const val QUEUE = 0 | ||||
|         const val LOAD_PAGE = 1 | ||||
|         const val DOWNLOAD_IMAGE = 2 | ||||
|         const val READY = 3 | ||||
|         const val ERROR = 4 | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,31 +1,31 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| import java.io.Serializable | ||||
|  | ||||
| interface SChapter : Serializable { | ||||
|  | ||||
|     var url: String | ||||
|  | ||||
|     var name: String | ||||
|  | ||||
|     var date_upload: Long | ||||
|  | ||||
|     var chapter_number: Float | ||||
|  | ||||
|     var scanlator: String? | ||||
|  | ||||
|     fun copyFrom(other: SChapter) { | ||||
|         name = other.name | ||||
|         url = other.url | ||||
|         date_upload = other.date_upload | ||||
|         chapter_number = other.chapter_number | ||||
|         scanlator = other.scanlator | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         fun create(): SChapter { | ||||
|             return SChapterImpl() | ||||
|         } | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| import java.io.Serializable | ||||
|  | ||||
| interface SChapter : Serializable { | ||||
|  | ||||
|     var url: String | ||||
|  | ||||
|     var name: String | ||||
|  | ||||
|     var date_upload: Long | ||||
|  | ||||
|     var chapter_number: Float | ||||
|  | ||||
|     var scanlator: String? | ||||
|  | ||||
|     fun copyFrom(other: SChapter) { | ||||
|         name = other.name | ||||
|         url = other.url | ||||
|         date_upload = other.date_upload | ||||
|         chapter_number = other.chapter_number | ||||
|         scanlator = other.scanlator | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         fun create(): SChapter { | ||||
|             return SChapterImpl() | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,15 +1,15 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| class SChapterImpl : SChapter { | ||||
|  | ||||
|     override lateinit var url: String | ||||
|  | ||||
|     override lateinit var name: String | ||||
|  | ||||
|     override var date_upload: Long = 0 | ||||
|  | ||||
|     override var chapter_number: Float = -1f | ||||
|  | ||||
|     override  var scanlator: String? = null | ||||
|  | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| class SChapterImpl : SChapter { | ||||
|  | ||||
|     override lateinit var url: String | ||||
|  | ||||
|     override lateinit var name: String | ||||
|  | ||||
|     override var date_upload: Long = 0 | ||||
|  | ||||
|     override var chapter_number: Float = -1f | ||||
|  | ||||
|     override  var scanlator: String? = null | ||||
|  | ||||
| } | ||||
| @@ -1,58 +1,58 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| import java.io.Serializable | ||||
|  | ||||
| interface SManga : Serializable { | ||||
|  | ||||
|     var url: String | ||||
|  | ||||
|     var title: String | ||||
|  | ||||
|     var artist: String? | ||||
|  | ||||
|     var author: String? | ||||
|  | ||||
|     var description: String? | ||||
|  | ||||
|     var genre: String? | ||||
|  | ||||
|     var status: Int | ||||
|  | ||||
|     var thumbnail_url: String? | ||||
|  | ||||
|     var initialized: Boolean | ||||
|  | ||||
|     fun copyFrom(other: SManga) { | ||||
|         if (other.author != null) | ||||
|             author = other.author | ||||
|  | ||||
|         if (other.artist != null) | ||||
|             artist = other.artist | ||||
|  | ||||
|         if (other.description != null) | ||||
|             description = other.description | ||||
|  | ||||
|         if (other.genre != null) | ||||
|             genre = other.genre | ||||
|  | ||||
|         if (other.thumbnail_url != null) | ||||
|             thumbnail_url = other.thumbnail_url | ||||
|  | ||||
|         status = other.status | ||||
|  | ||||
|         if (!initialized) | ||||
|             initialized = other.initialized | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val UNKNOWN = 0 | ||||
|         const val ONGOING = 1 | ||||
|         const val COMPLETED = 2 | ||||
|         const val LICENSED = 3 | ||||
|  | ||||
|         fun create(): SManga { | ||||
|             return SMangaImpl() | ||||
|         } | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| import java.io.Serializable | ||||
|  | ||||
| interface SManga : Serializable { | ||||
|  | ||||
|     var url: String | ||||
|  | ||||
|     var title: String | ||||
|  | ||||
|     var artist: String? | ||||
|  | ||||
|     var author: String? | ||||
|  | ||||
|     var description: String? | ||||
|  | ||||
|     var genre: String? | ||||
|  | ||||
|     var status: Int | ||||
|  | ||||
|     var thumbnail_url: String? | ||||
|  | ||||
|     var initialized: Boolean | ||||
|  | ||||
|     fun copyFrom(other: SManga) { | ||||
|         if (other.author != null) | ||||
|             author = other.author | ||||
|  | ||||
|         if (other.artist != null) | ||||
|             artist = other.artist | ||||
|  | ||||
|         if (other.description != null) | ||||
|             description = other.description | ||||
|  | ||||
|         if (other.genre != null) | ||||
|             genre = other.genre | ||||
|  | ||||
|         if (other.thumbnail_url != null) | ||||
|             thumbnail_url = other.thumbnail_url | ||||
|  | ||||
|         status = other.status | ||||
|  | ||||
|         if (!initialized) | ||||
|             initialized = other.initialized | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val UNKNOWN = 0 | ||||
|         const val ONGOING = 1 | ||||
|         const val COMPLETED = 2 | ||||
|         const val LICENSED = 3 | ||||
|  | ||||
|         fun create(): SManga { | ||||
|             return SMangaImpl() | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,23 +1,23 @@ | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| class SMangaImpl : SManga { | ||||
|  | ||||
|     override lateinit var url: String | ||||
|  | ||||
|     override lateinit var title: String | ||||
|  | ||||
|     override var artist: String? = null | ||||
|  | ||||
|     override var author: String? = null | ||||
|  | ||||
|     override var description: String? = null | ||||
|  | ||||
|     override var genre: String? = null | ||||
|  | ||||
|     override var status: Int = 0 | ||||
|  | ||||
|     override var thumbnail_url: String? = null | ||||
|  | ||||
|     override var initialized: Boolean = false | ||||
|  | ||||
| package eu.kanade.tachiyomi.source.model | ||||
|  | ||||
| class SMangaImpl : SManga { | ||||
|  | ||||
|     override lateinit var url: String | ||||
|  | ||||
|     override lateinit var title: String | ||||
|  | ||||
|     override var artist: String? = null | ||||
|  | ||||
|     override var author: String? = null | ||||
|  | ||||
|     override var description: String? = null | ||||
|  | ||||
|     override var genre: String? = null | ||||
|  | ||||
|     override var status: Int = 0 | ||||
|  | ||||
|     override var thumbnail_url: String? = null | ||||
|  | ||||
|     override var initialized: Boolean = false | ||||
|  | ||||
| } | ||||
| @@ -1,367 +1,367 @@ | ||||
| package eu.kanade.tachiyomi.source.online | ||||
|  | ||||
| import eu.kanade.tachiyomi.network.GET | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| import eu.kanade.tachiyomi.network.asObservableSuccess | ||||
| import eu.kanade.tachiyomi.network.newCallWithProgress | ||||
| import eu.kanade.tachiyomi.source.CatalogueSource | ||||
| import eu.kanade.tachiyomi.source.model.* | ||||
| import okhttp3.Headers | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.lang.Exception | ||||
| import java.net.URI | ||||
| import java.net.URISyntaxException | ||||
| import java.security.MessageDigest | ||||
|  | ||||
| /** | ||||
|  * A simple implementation for sources from a website. | ||||
|  */ | ||||
| abstract class HttpSource : CatalogueSource { | ||||
|  | ||||
|     /** | ||||
|      * Network service. | ||||
|      */ | ||||
|     protected val network: NetworkHelper by injectLazy() | ||||
|  | ||||
| //    /** | ||||
| //     * Preferences that a source may need. | ||||
| //     */ | ||||
| //    val preferences: SharedPreferences by lazy { | ||||
| //        Injekt.get<Application>().getSharedPreferences("source_$id", Context.MODE_PRIVATE) | ||||
| //    } | ||||
|  | ||||
|     /** | ||||
|      * Base url of the website without the trailing slash, like: http://mysite.com | ||||
|      */ | ||||
|     abstract val baseUrl: String | ||||
|  | ||||
|     /** | ||||
|      * Version id used to generate the source id. If the site completely changes and urls are | ||||
|      * incompatible, you may increase this value and it'll be considered as a new source. | ||||
|      */ | ||||
|     open val versionId = 1 | ||||
|  | ||||
|     /** | ||||
|      * Id of the source. By default it uses a generated id using the first 16 characters (64 bits) | ||||
|      * of the MD5 of the string: sourcename/language/versionId | ||||
|      * Note the generated id sets the sign bit to 0. | ||||
|      */ | ||||
|     override val id by lazy { | ||||
|         val key = "${name.toLowerCase()}/$lang/$versionId" | ||||
|         val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) | ||||
|         (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Headers used for requests. | ||||
|      */ | ||||
|     val headers: Headers by lazy { headersBuilder().build() } | ||||
|  | ||||
|     /** | ||||
|      * Default network client for doing requests. | ||||
|      */ | ||||
|     open val client: OkHttpClient | ||||
|         get() = network.client | ||||
|  | ||||
|     /** | ||||
|      * Headers builder for requests. Implementations can override this method for custom headers. | ||||
|      */ | ||||
|     open protected fun headersBuilder() = Headers.Builder().apply { | ||||
|         add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)") | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Visible name of the source. | ||||
|      */ | ||||
|     override fun toString() = "$name (${lang.toUpperCase()})" | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of manga. Normally it's not needed to | ||||
|      * override this method. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     override fun fetchPopularManga(page: Int): Observable<MangasPage> { | ||||
|         return client.newCall(popularMangaRequest(page)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { response -> | ||||
|                     popularMangaParse(response) | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for the popular manga given the page. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     abstract protected fun popularMangaRequest(page: Int): Request | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     abstract protected fun popularMangaParse(response: Response): MangasPage | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of manga. Normally it's not needed to | ||||
|      * override this method. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      * @param query the search query. | ||||
|      * @param filters the list of filters to apply. | ||||
|      */ | ||||
|     override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { | ||||
|         return client.newCall(searchMangaRequest(page, query, filters)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { response -> | ||||
|                     searchMangaParse(response) | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for the search manga given the page. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      * @param query the search query. | ||||
|      * @param filters the list of filters to apply. | ||||
|      */ | ||||
|     abstract protected fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     abstract protected fun searchMangaParse(response: Response): MangasPage | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of latest manga updates. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     override fun fetchLatestUpdates(page: Int): Observable<MangasPage> { | ||||
|         return client.newCall(latestUpdatesRequest(page)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { response -> | ||||
|                     latestUpdatesParse(response) | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for latest manga given the page. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     abstract protected fun latestUpdatesRequest(page: Int): Request | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     abstract protected fun latestUpdatesParse(response: Response): MangasPage | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the updated details for a manga. Normally it's not needed to | ||||
|      * override this method. | ||||
|      * | ||||
|      * @param manga the manga to be updated. | ||||
|      */ | ||||
|     override fun fetchMangaDetails(manga: SManga): Observable<SManga> { | ||||
|         return client.newCall(mangaDetailsRequest(manga)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { response -> | ||||
|                     mangaDetailsParse(response).apply { initialized = true } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for the details of a manga. Override only if it's needed to change the | ||||
|      * url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param manga the manga to be updated. | ||||
|      */ | ||||
|     open fun mangaDetailsRequest(manga: SManga): Request { | ||||
|         return GET(baseUrl + manga.url, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns the details of a manga. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     abstract protected fun mangaDetailsParse(response: Response): SManga | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the updated chapter list for a manga. Normally it's not needed to | ||||
|      * override this method.  If a manga is licensed an empty chapter list observable is returned | ||||
|      * | ||||
|      * @param manga the manga to look for chapters. | ||||
|      */ | ||||
|     override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { | ||||
|         if (manga.status != SManga.LICENSED) { | ||||
|             return client.newCall(chapterListRequest(manga)) | ||||
|                     .asObservableSuccess() | ||||
|                     .map { response -> | ||||
|                         chapterListParse(response) | ||||
|                     } | ||||
|         } else { | ||||
|             return Observable.error(Exception("Licensed - No chapters to show")) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for updating the chapter list. Override only if it's needed to override | ||||
|      * the url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param manga the manga to look for chapters. | ||||
|      */ | ||||
|     open protected fun chapterListRequest(manga: SManga): Request { | ||||
|         return GET(baseUrl + manga.url, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a list of chapters. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     abstract protected fun chapterListParse(response: Response): List<SChapter> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the page list for a chapter. | ||||
|      * | ||||
|      * @param chapter the chapter whose page list has to be fetched. | ||||
|      */ | ||||
|     override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { | ||||
|         return client.newCall(pageListRequest(chapter)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { response -> | ||||
|                     pageListParse(response) | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for getting the page list. Override only if it's needed to override the | ||||
|      * url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param chapter the chapter whose page list has to be fetched. | ||||
|      */ | ||||
|     open protected fun pageListRequest(chapter: SChapter): Request { | ||||
|         return GET(baseUrl + chapter.url, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a list of pages. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     abstract protected fun pageListParse(response: Response): List<Page> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the page containing the source url of the image. If there's any | ||||
|      * error, it will return null instead of throwing an exception. | ||||
|      * | ||||
|      * @param page the page whose source image has to be fetched. | ||||
|      */ | ||||
|     open fun fetchImageUrl(page: Page): Observable<String> { | ||||
|         return client.newCall(imageUrlRequest(page)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { imageUrlParse(it) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for getting the url to the source image. Override only if it's needed to | ||||
|      * override the url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param page the chapter whose page list has to be fetched | ||||
|      */ | ||||
|     open protected fun imageUrlRequest(page: Page): Request { | ||||
|         return GET(page.url, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns the absolute url to the source image. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     abstract protected fun imageUrlParse(response: Response): String | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the response of the source image. | ||||
|      * | ||||
|      * @param page the page whose source image has to be downloaded. | ||||
|      */ | ||||
|     fun fetchImage(page: Page): Observable<Response> { | ||||
|         return client.newCallWithProgress(imageRequest(page), page) | ||||
|                 .asObservableSuccess() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for getting the source image. Override only if it's needed to override | ||||
|      * the url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param page the chapter whose page list has to be fetched | ||||
|      */ | ||||
|     open protected fun imageRequest(page: Page): Request { | ||||
|         return GET(page.imageUrl!!, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Assigns the url of the chapter without the scheme and domain. It saves some redundancy from | ||||
|      * database and the urls could still work after a domain change. | ||||
|      * | ||||
|      * @param url the full url to the chapter. | ||||
|      */ | ||||
|     fun SChapter.setUrlWithoutDomain(url: String) { | ||||
|         this.url = getUrlWithoutDomain(url) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Assigns the url of the manga without the scheme and domain. It saves some redundancy from | ||||
|      * database and the urls could still work after a domain change. | ||||
|      * | ||||
|      * @param url the full url to the manga. | ||||
|      */ | ||||
|     fun SManga.setUrlWithoutDomain(url: String) { | ||||
|         this.url = getUrlWithoutDomain(url) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the url of the given string without the scheme and domain. | ||||
|      * | ||||
|      * @param orig the full url. | ||||
|      */ | ||||
|     private fun getUrlWithoutDomain(orig: String): String { | ||||
|         try { | ||||
|             val uri = URI(orig) | ||||
|             var out = uri.path | ||||
|             if (uri.query != null) | ||||
|                 out += "?" + uri.query | ||||
|             if (uri.fragment != null) | ||||
|                 out += "#" + uri.fragment | ||||
|             return out | ||||
|         } catch (e: URISyntaxException) { | ||||
|             return orig | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called before inserting a new chapter into database. Use it if you need to override chapter | ||||
|      * fields, like the title or the chapter number. Do not change anything to [manga]. | ||||
|      * | ||||
|      * @param chapter the chapter to be added. | ||||
|      * @param manga the manga of the chapter. | ||||
|      */ | ||||
|     open fun prepareNewChapter(chapter: SChapter, manga: SManga) { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the list of filters for the source. | ||||
|      */ | ||||
|     override fun getFilterList() = FilterList() | ||||
| } | ||||
| package eu.kanade.tachiyomi.source.online | ||||
|  | ||||
| import eu.kanade.tachiyomi.network.GET | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| import eu.kanade.tachiyomi.network.asObservableSuccess | ||||
| import eu.kanade.tachiyomi.network.newCallWithProgress | ||||
| import eu.kanade.tachiyomi.source.CatalogueSource | ||||
| import eu.kanade.tachiyomi.source.model.* | ||||
| import okhttp3.Headers | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| import okhttp3.Response | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.lang.Exception | ||||
| import java.net.URI | ||||
| import java.net.URISyntaxException | ||||
| import java.security.MessageDigest | ||||
|  | ||||
| /** | ||||
|  * A simple implementation for sources from a website. | ||||
|  */ | ||||
| abstract class HttpSource : CatalogueSource { | ||||
|  | ||||
|     /** | ||||
|      * Network service. | ||||
|      */ | ||||
|     protected val network: NetworkHelper by injectLazy() | ||||
|  | ||||
| //    /** | ||||
| //     * Preferences that a source may need. | ||||
| //     */ | ||||
| //    val preferences: SharedPreferences by lazy { | ||||
| //        Injekt.get<Application>().getSharedPreferences("source_$id", Context.MODE_PRIVATE) | ||||
| //    } | ||||
|  | ||||
|     /** | ||||
|      * Base url of the website without the trailing slash, like: http://mysite.com | ||||
|      */ | ||||
|     abstract val baseUrl: String | ||||
|  | ||||
|     /** | ||||
|      * Version id used to generate the source id. If the site completely changes and urls are | ||||
|      * incompatible, you may increase this value and it'll be considered as a new source. | ||||
|      */ | ||||
|     open val versionId = 1 | ||||
|  | ||||
|     /** | ||||
|      * Id of the source. By default it uses a generated id using the first 16 characters (64 bits) | ||||
|      * of the MD5 of the string: sourcename/language/versionId | ||||
|      * Note the generated id sets the sign bit to 0. | ||||
|      */ | ||||
|     override val id by lazy { | ||||
|         val key = "${name.toLowerCase()}/$lang/$versionId" | ||||
|         val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) | ||||
|         (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Headers used for requests. | ||||
|      */ | ||||
|     val headers: Headers by lazy { headersBuilder().build() } | ||||
|  | ||||
|     /** | ||||
|      * Default network client for doing requests. | ||||
|      */ | ||||
|     open val client: OkHttpClient | ||||
|         get() = network.client | ||||
|  | ||||
|     /** | ||||
|      * Headers builder for requests. Implementations can override this method for custom headers. | ||||
|      */ | ||||
|     open protected fun headersBuilder() = Headers.Builder().apply { | ||||
|         add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)") | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Visible name of the source. | ||||
|      */ | ||||
|     override fun toString() = "$name (${lang.toUpperCase()})" | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of manga. Normally it's not needed to | ||||
|      * override this method. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     override fun fetchPopularManga(page: Int): Observable<MangasPage> { | ||||
|         return client.newCall(popularMangaRequest(page)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { response -> | ||||
|                     popularMangaParse(response) | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for the popular manga given the page. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     abstract protected fun popularMangaRequest(page: Int): Request | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     abstract protected fun popularMangaParse(response: Response): MangasPage | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of manga. Normally it's not needed to | ||||
|      * override this method. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      * @param query the search query. | ||||
|      * @param filters the list of filters to apply. | ||||
|      */ | ||||
|     override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { | ||||
|         return client.newCall(searchMangaRequest(page, query, filters)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { response -> | ||||
|                     searchMangaParse(response) | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for the search manga given the page. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      * @param query the search query. | ||||
|      * @param filters the list of filters to apply. | ||||
|      */ | ||||
|     abstract protected fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     abstract protected fun searchMangaParse(response: Response): MangasPage | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable containing a page with a list of latest manga updates. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     override fun fetchLatestUpdates(page: Int): Observable<MangasPage> { | ||||
|         return client.newCall(latestUpdatesRequest(page)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { response -> | ||||
|                     latestUpdatesParse(response) | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for latest manga given the page. | ||||
|      * | ||||
|      * @param page the page number to retrieve. | ||||
|      */ | ||||
|     abstract protected fun latestUpdatesRequest(page: Int): Request | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     abstract protected fun latestUpdatesParse(response: Response): MangasPage | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the updated details for a manga. Normally it's not needed to | ||||
|      * override this method. | ||||
|      * | ||||
|      * @param manga the manga to be updated. | ||||
|      */ | ||||
|     override fun fetchMangaDetails(manga: SManga): Observable<SManga> { | ||||
|         return client.newCall(mangaDetailsRequest(manga)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { response -> | ||||
|                     mangaDetailsParse(response).apply { initialized = true } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for the details of a manga. Override only if it's needed to change the | ||||
|      * url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param manga the manga to be updated. | ||||
|      */ | ||||
|     open fun mangaDetailsRequest(manga: SManga): Request { | ||||
|         return GET(baseUrl + manga.url, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns the details of a manga. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     abstract protected fun mangaDetailsParse(response: Response): SManga | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the updated chapter list for a manga. Normally it's not needed to | ||||
|      * override this method.  If a manga is licensed an empty chapter list observable is returned | ||||
|      * | ||||
|      * @param manga the manga to look for chapters. | ||||
|      */ | ||||
|     override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { | ||||
|         if (manga.status != SManga.LICENSED) { | ||||
|             return client.newCall(chapterListRequest(manga)) | ||||
|                     .asObservableSuccess() | ||||
|                     .map { response -> | ||||
|                         chapterListParse(response) | ||||
|                     } | ||||
|         } else { | ||||
|             return Observable.error(Exception("Licensed - No chapters to show")) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for updating the chapter list. Override only if it's needed to override | ||||
|      * the url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param manga the manga to look for chapters. | ||||
|      */ | ||||
|     open protected fun chapterListRequest(manga: SManga): Request { | ||||
|         return GET(baseUrl + manga.url, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a list of chapters. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     abstract protected fun chapterListParse(response: Response): List<SChapter> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the page list for a chapter. | ||||
|      * | ||||
|      * @param chapter the chapter whose page list has to be fetched. | ||||
|      */ | ||||
|     override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { | ||||
|         return client.newCall(pageListRequest(chapter)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { response -> | ||||
|                     pageListParse(response) | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for getting the page list. Override only if it's needed to override the | ||||
|      * url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param chapter the chapter whose page list has to be fetched. | ||||
|      */ | ||||
|     open protected fun pageListRequest(chapter: SChapter): Request { | ||||
|         return GET(baseUrl + chapter.url, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a list of pages. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     abstract protected fun pageListParse(response: Response): List<Page> | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the page containing the source url of the image. If there's any | ||||
|      * error, it will return null instead of throwing an exception. | ||||
|      * | ||||
|      * @param page the page whose source image has to be fetched. | ||||
|      */ | ||||
|     open fun fetchImageUrl(page: Page): Observable<String> { | ||||
|         return client.newCall(imageUrlRequest(page)) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { imageUrlParse(it) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for getting the url to the source image. Override only if it's needed to | ||||
|      * override the url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param page the chapter whose page list has to be fetched | ||||
|      */ | ||||
|     open protected fun imageUrlRequest(page: Page): Request { | ||||
|         return GET(page.url, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns the absolute url to the source image. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     abstract protected fun imageUrlParse(response: Response): String | ||||
|  | ||||
|     /** | ||||
|      * Returns an observable with the response of the source image. | ||||
|      * | ||||
|      * @param page the page whose source image has to be downloaded. | ||||
|      */ | ||||
|     fun fetchImage(page: Page): Observable<Response> { | ||||
|         return client.newCallWithProgress(imageRequest(page), page) | ||||
|                 .asObservableSuccess() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the request for getting the source image. Override only if it's needed to override | ||||
|      * the url, send different headers or request method like POST. | ||||
|      * | ||||
|      * @param page the chapter whose page list has to be fetched | ||||
|      */ | ||||
|     open protected fun imageRequest(page: Page): Request { | ||||
|         return GET(page.imageUrl!!, headers) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Assigns the url of the chapter without the scheme and domain. It saves some redundancy from | ||||
|      * database and the urls could still work after a domain change. | ||||
|      * | ||||
|      * @param url the full url to the chapter. | ||||
|      */ | ||||
|     fun SChapter.setUrlWithoutDomain(url: String) { | ||||
|         this.url = getUrlWithoutDomain(url) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Assigns the url of the manga without the scheme and domain. It saves some redundancy from | ||||
|      * database and the urls could still work after a domain change. | ||||
|      * | ||||
|      * @param url the full url to the manga. | ||||
|      */ | ||||
|     fun SManga.setUrlWithoutDomain(url: String) { | ||||
|         this.url = getUrlWithoutDomain(url) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the url of the given string without the scheme and domain. | ||||
|      * | ||||
|      * @param orig the full url. | ||||
|      */ | ||||
|     private fun getUrlWithoutDomain(orig: String): String { | ||||
|         try { | ||||
|             val uri = URI(orig) | ||||
|             var out = uri.path | ||||
|             if (uri.query != null) | ||||
|                 out += "?" + uri.query | ||||
|             if (uri.fragment != null) | ||||
|                 out += "#" + uri.fragment | ||||
|             return out | ||||
|         } catch (e: URISyntaxException) { | ||||
|             return orig | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called before inserting a new chapter into database. Use it if you need to override chapter | ||||
|      * fields, like the title or the chapter number. Do not change anything to [manga]. | ||||
|      * | ||||
|      * @param chapter the chapter to be added. | ||||
|      * @param manga the manga of the chapter. | ||||
|      */ | ||||
|     open fun prepareNewChapter(chapter: SChapter, manga: SManga) { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the list of filters for the source. | ||||
|      */ | ||||
|     override fun getFilterList() = FilterList() | ||||
| } | ||||
|   | ||||
| @@ -1,25 +1,25 @@ | ||||
| package eu.kanade.tachiyomi.source.online | ||||
|  | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import rx.Observable | ||||
|  | ||||
| fun HttpSource.getImageUrl(page: Page): Observable<Page> { | ||||
|     page.status = Page.LOAD_PAGE | ||||
|     return fetchImageUrl(page) | ||||
|         .doOnError { page.status = Page.ERROR } | ||||
|         .onErrorReturn { null } | ||||
|         .doOnNext { page.imageUrl = it } | ||||
|         .map { page } | ||||
| } | ||||
|  | ||||
| fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> { | ||||
|     return Observable.from(pages) | ||||
|             .filter { !it.imageUrl.isNullOrEmpty() } | ||||
|             .mergeWith(fetchRemainingImageUrlsFromPageList(pages)) | ||||
| } | ||||
|  | ||||
| fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> { | ||||
|     return Observable.from(pages) | ||||
|             .filter { it.imageUrl.isNullOrEmpty() } | ||||
|             .concatMap { getImageUrl(it) } | ||||
| } | ||||
| package eu.kanade.tachiyomi.source.online | ||||
|  | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import rx.Observable | ||||
|  | ||||
| fun HttpSource.getImageUrl(page: Page): Observable<Page> { | ||||
|     page.status = Page.LOAD_PAGE | ||||
|     return fetchImageUrl(page) | ||||
|         .doOnError { page.status = Page.ERROR } | ||||
|         .onErrorReturn { null } | ||||
|         .doOnNext { page.imageUrl = it } | ||||
|         .map { page } | ||||
| } | ||||
|  | ||||
| fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> { | ||||
|     return Observable.from(pages) | ||||
|             .filter { !it.imageUrl.isNullOrEmpty() } | ||||
|             .mergeWith(fetchRemainingImageUrlsFromPageList(pages)) | ||||
| } | ||||
|  | ||||
| fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> { | ||||
|     return Observable.from(pages) | ||||
|             .filter { it.imageUrl.isNullOrEmpty() } | ||||
|             .concatMap { getImageUrl(it) } | ||||
| } | ||||
|   | ||||
| @@ -1,15 +1,15 @@ | ||||
| package eu.kanade.tachiyomi.source.online | ||||
|  | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import okhttp3.Response | ||||
| import rx.Observable | ||||
|  | ||||
| interface LoginSource : Source { | ||||
|  | ||||
|     fun isLogged(): Boolean | ||||
|  | ||||
|     fun login(username: String, password: String): Observable<Boolean> | ||||
|  | ||||
|     fun isAuthenticationSuccessful(response: Response): Boolean | ||||
|  | ||||
| package eu.kanade.tachiyomi.source.online | ||||
|  | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import okhttp3.Response | ||||
| import rx.Observable | ||||
|  | ||||
| interface LoginSource : Source { | ||||
|  | ||||
|     fun isLogged(): Boolean | ||||
|  | ||||
|     fun login(username: String, password: String): Observable<Boolean> | ||||
|  | ||||
|     fun isAuthenticationSuccessful(response: Response): Boolean | ||||
|  | ||||
| } | ||||
| @@ -1,200 +1,200 @@ | ||||
| package eu.kanade.tachiyomi.source.online | ||||
|  | ||||
| import eu.kanade.tachiyomi.source.model.MangasPage | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import eu.kanade.tachiyomi.source.model.SChapter | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import eu.kanade.tachiyomi.util.asJsoup | ||||
| import okhttp3.Response | ||||
| import org.jsoup.nodes.Document | ||||
| import org.jsoup.nodes.Element | ||||
|  | ||||
| /** | ||||
|  * A simple implementation for sources from a website using Jsoup, an HTML parser. | ||||
|  */ | ||||
| abstract class ParsedHttpSource : HttpSource() { | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun popularMangaParse(response: Response): MangasPage { | ||||
|         val document = response.asJsoup() | ||||
|  | ||||
|         val mangas = document.select(popularMangaSelector()).map { element -> | ||||
|             popularMangaFromElement(element) | ||||
|         } | ||||
|  | ||||
|         val hasNextPage = popularMangaNextPageSelector()?.let { selector -> | ||||
|             document.select(selector).first() | ||||
|         } != null | ||||
|  | ||||
|         return MangasPage(mangas, hasNextPage) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. | ||||
|      */ | ||||
|     abstract protected fun popularMangaSelector(): String | ||||
|  | ||||
|     /** | ||||
|      * Returns a manga from the given [element]. Most sites only show the title and the url, it's | ||||
|      * totally fine to fill only those two values. | ||||
|      * | ||||
|      * @param element an element obtained from [popularMangaSelector]. | ||||
|      */ | ||||
|     abstract protected fun popularMangaFromElement(element: Element): SManga | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if | ||||
|      * there's no next page. | ||||
|      */ | ||||
|     abstract protected fun popularMangaNextPageSelector(): String? | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun searchMangaParse(response: Response): MangasPage { | ||||
|         val document = response.asJsoup() | ||||
|  | ||||
|         val mangas = document.select(searchMangaSelector()).map { element -> | ||||
|             searchMangaFromElement(element) | ||||
|         } | ||||
|  | ||||
|         val hasNextPage = searchMangaNextPageSelector()?.let { selector -> | ||||
|             document.select(selector).first() | ||||
|         } != null | ||||
|  | ||||
|         return MangasPage(mangas, hasNextPage) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. | ||||
|      */ | ||||
|     abstract protected fun searchMangaSelector(): String | ||||
|  | ||||
|     /** | ||||
|      * Returns a manga from the given [element]. Most sites only show the title and the url, it's | ||||
|      * totally fine to fill only those two values. | ||||
|      * | ||||
|      * @param element an element obtained from [searchMangaSelector]. | ||||
|      */ | ||||
|     abstract protected fun searchMangaFromElement(element: Element): SManga | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if | ||||
|      * there's no next page. | ||||
|      */ | ||||
|     abstract protected fun searchMangaNextPageSelector(): String? | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun latestUpdatesParse(response: Response): MangasPage { | ||||
|         val document = response.asJsoup() | ||||
|  | ||||
|         val mangas = document.select(latestUpdatesSelector()).map { element -> | ||||
|             latestUpdatesFromElement(element) | ||||
|         } | ||||
|  | ||||
|         val hasNextPage = latestUpdatesNextPageSelector()?.let { selector -> | ||||
|             document.select(selector).first() | ||||
|         } != null | ||||
|  | ||||
|         return MangasPage(mangas, hasNextPage) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. | ||||
|      */ | ||||
|     abstract protected fun latestUpdatesSelector(): String | ||||
|  | ||||
|     /** | ||||
|      * Returns a manga from the given [element]. Most sites only show the title and the url, it's | ||||
|      * totally fine to fill only those two values. | ||||
|      * | ||||
|      * @param element an element obtained from [latestUpdatesSelector]. | ||||
|      */ | ||||
|     abstract protected fun latestUpdatesFromElement(element: Element): SManga | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if | ||||
|      * there's no next page. | ||||
|      */ | ||||
|     abstract protected fun latestUpdatesNextPageSelector(): String? | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns the details of a manga. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun mangaDetailsParse(response: Response): SManga { | ||||
|         return mangaDetailsParse(response.asJsoup()) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the details of the manga from the given [document]. | ||||
|      * | ||||
|      * @param document the parsed document. | ||||
|      */ | ||||
|     abstract protected fun mangaDetailsParse(document: Document): SManga | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a list of chapters. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun chapterListParse(response: Response): List<SChapter> { | ||||
|         val document = response.asJsoup() | ||||
|         return document.select(chapterListSelector()).map { chapterFromElement(it) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter. | ||||
|      */ | ||||
|     abstract protected fun chapterListSelector(): String | ||||
|  | ||||
|     /** | ||||
|      * Returns a chapter from the given element. | ||||
|      * | ||||
|      * @param element an element obtained from [chapterListSelector]. | ||||
|      */ | ||||
|     abstract protected fun chapterFromElement(element: Element): SChapter | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns the page list. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun pageListParse(response: Response): List<Page> { | ||||
|         return pageListParse(response.asJsoup()) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns a page list from the given document. | ||||
|      * | ||||
|      * @param document the parsed document. | ||||
|      */ | ||||
|     abstract protected fun pageListParse(document: Document): List<Page> | ||||
|  | ||||
|     /** | ||||
|      * Parse the response from the site and returns the absolute url to the source image. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun imageUrlParse(response: Response): String { | ||||
|         return imageUrlParse(response.asJsoup()) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the absolute url to the source image from the document. | ||||
|      * | ||||
|      * @param document the parsed document. | ||||
|      */ | ||||
|     abstract protected fun imageUrlParse(document: Document): String | ||||
| } | ||||
| package eu.kanade.tachiyomi.source.online | ||||
|  | ||||
| import eu.kanade.tachiyomi.source.model.MangasPage | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import eu.kanade.tachiyomi.source.model.SChapter | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import eu.kanade.tachiyomi.util.asJsoup | ||||
| import okhttp3.Response | ||||
| import org.jsoup.nodes.Document | ||||
| import org.jsoup.nodes.Element | ||||
|  | ||||
| /** | ||||
|  * A simple implementation for sources from a website using Jsoup, an HTML parser. | ||||
|  */ | ||||
| abstract class ParsedHttpSource : HttpSource() { | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun popularMangaParse(response: Response): MangasPage { | ||||
|         val document = response.asJsoup() | ||||
|  | ||||
|         val mangas = document.select(popularMangaSelector()).map { element -> | ||||
|             popularMangaFromElement(element) | ||||
|         } | ||||
|  | ||||
|         val hasNextPage = popularMangaNextPageSelector()?.let { selector -> | ||||
|             document.select(selector).first() | ||||
|         } != null | ||||
|  | ||||
|         return MangasPage(mangas, hasNextPage) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. | ||||
|      */ | ||||
|     abstract protected fun popularMangaSelector(): String | ||||
|  | ||||
|     /** | ||||
|      * Returns a manga from the given [element]. Most sites only show the title and the url, it's | ||||
|      * totally fine to fill only those two values. | ||||
|      * | ||||
|      * @param element an element obtained from [popularMangaSelector]. | ||||
|      */ | ||||
|     abstract protected fun popularMangaFromElement(element: Element): SManga | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if | ||||
|      * there's no next page. | ||||
|      */ | ||||
|     abstract protected fun popularMangaNextPageSelector(): String? | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun searchMangaParse(response: Response): MangasPage { | ||||
|         val document = response.asJsoup() | ||||
|  | ||||
|         val mangas = document.select(searchMangaSelector()).map { element -> | ||||
|             searchMangaFromElement(element) | ||||
|         } | ||||
|  | ||||
|         val hasNextPage = searchMangaNextPageSelector()?.let { selector -> | ||||
|             document.select(selector).first() | ||||
|         } != null | ||||
|  | ||||
|         return MangasPage(mangas, hasNextPage) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. | ||||
|      */ | ||||
|     abstract protected fun searchMangaSelector(): String | ||||
|  | ||||
|     /** | ||||
|      * Returns a manga from the given [element]. Most sites only show the title and the url, it's | ||||
|      * totally fine to fill only those two values. | ||||
|      * | ||||
|      * @param element an element obtained from [searchMangaSelector]. | ||||
|      */ | ||||
|     abstract protected fun searchMangaFromElement(element: Element): SManga | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if | ||||
|      * there's no next page. | ||||
|      */ | ||||
|     abstract protected fun searchMangaNextPageSelector(): String? | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a [MangasPage] object. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun latestUpdatesParse(response: Response): MangasPage { | ||||
|         val document = response.asJsoup() | ||||
|  | ||||
|         val mangas = document.select(latestUpdatesSelector()).map { element -> | ||||
|             latestUpdatesFromElement(element) | ||||
|         } | ||||
|  | ||||
|         val hasNextPage = latestUpdatesNextPageSelector()?.let { selector -> | ||||
|             document.select(selector).first() | ||||
|         } != null | ||||
|  | ||||
|         return MangasPage(mangas, hasNextPage) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. | ||||
|      */ | ||||
|     abstract protected fun latestUpdatesSelector(): String | ||||
|  | ||||
|     /** | ||||
|      * Returns a manga from the given [element]. Most sites only show the title and the url, it's | ||||
|      * totally fine to fill only those two values. | ||||
|      * | ||||
|      * @param element an element obtained from [latestUpdatesSelector]. | ||||
|      */ | ||||
|     abstract protected fun latestUpdatesFromElement(element: Element): SManga | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if | ||||
|      * there's no next page. | ||||
|      */ | ||||
|     abstract protected fun latestUpdatesNextPageSelector(): String? | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns the details of a manga. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun mangaDetailsParse(response: Response): SManga { | ||||
|         return mangaDetailsParse(response.asJsoup()) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the details of the manga from the given [document]. | ||||
|      * | ||||
|      * @param document the parsed document. | ||||
|      */ | ||||
|     abstract protected fun mangaDetailsParse(document: Document): SManga | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns a list of chapters. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun chapterListParse(response: Response): List<SChapter> { | ||||
|         val document = response.asJsoup() | ||||
|         return document.select(chapterListSelector()).map { chapterFromElement(it) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter. | ||||
|      */ | ||||
|     abstract protected fun chapterListSelector(): String | ||||
|  | ||||
|     /** | ||||
|      * Returns a chapter from the given element. | ||||
|      * | ||||
|      * @param element an element obtained from [chapterListSelector]. | ||||
|      */ | ||||
|     abstract protected fun chapterFromElement(element: Element): SChapter | ||||
|  | ||||
|     /** | ||||
|      * Parses the response from the site and returns the page list. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun pageListParse(response: Response): List<Page> { | ||||
|         return pageListParse(response.asJsoup()) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns a page list from the given document. | ||||
|      * | ||||
|      * @param document the parsed document. | ||||
|      */ | ||||
|     abstract protected fun pageListParse(document: Document): List<Page> | ||||
|  | ||||
|     /** | ||||
|      * Parse the response from the site and returns the absolute url to the source image. | ||||
|      * | ||||
|      * @param response the response from the site. | ||||
|      */ | ||||
|     override fun imageUrlParse(response: Response): String { | ||||
|         return imageUrlParse(response.asJsoup()) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the absolute url to the source image from the document. | ||||
|      * | ||||
|      * @param document the parsed document. | ||||
|      */ | ||||
|     abstract protected fun imageUrlParse(document: Document): String | ||||
| } | ||||
|   | ||||
| @@ -1,21 +1,21 @@ | ||||
| package eu.kanade.tachiyomi.ui.base.controller | ||||
|  | ||||
| import android.os.Bundle | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorDelegate | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorLifecycleListener | ||||
| import nucleus.factory.PresenterFactory | ||||
| import nucleus.presenter.Presenter | ||||
|  | ||||
| @Suppress("LeakingThis") | ||||
| abstract class NucleusController<P : Presenter<*>>(val bundle: Bundle? = null) : RxController(bundle), | ||||
|         PresenterFactory<P> { | ||||
|  | ||||
|     private val delegate = NucleusConductorDelegate(this) | ||||
|  | ||||
|     val presenter: P | ||||
|         get() = delegate.presenter | ||||
|  | ||||
|     init { | ||||
|         addLifecycleListener(NucleusConductorLifecycleListener(delegate)) | ||||
|     } | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.base.controller | ||||
|  | ||||
| import android.os.Bundle | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorDelegate | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorLifecycleListener | ||||
| import nucleus.factory.PresenterFactory | ||||
| import nucleus.presenter.Presenter | ||||
|  | ||||
| @Suppress("LeakingThis") | ||||
| abstract class NucleusController<P : Presenter<*>>(val bundle: Bundle? = null) : RxController(bundle), | ||||
|         PresenterFactory<P> { | ||||
|  | ||||
|     private val delegate = NucleusConductorDelegate(this) | ||||
|  | ||||
|     val presenter: P | ||||
|         get() = delegate.presenter | ||||
|  | ||||
|     init { | ||||
|         addLifecycleListener(NucleusConductorLifecycleListener(delegate)) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,61 +1,61 @@ | ||||
| package eu.kanade.tachiyomi.ui.base.presenter; | ||||
|  | ||||
| import android.os.Bundle; | ||||
| import android.support.annotation.Nullable; | ||||
|  | ||||
| import nucleus.factory.PresenterFactory; | ||||
| import nucleus.presenter.Presenter; | ||||
|  | ||||
| public class NucleusConductorDelegate<P extends Presenter> { | ||||
|  | ||||
|     @Nullable private P presenter; | ||||
|     @Nullable private Bundle bundle; | ||||
|  | ||||
|     private PresenterFactory<P> factory; | ||||
|  | ||||
|     public NucleusConductorDelegate(PresenterFactory<P> creator) { | ||||
|         this.factory = creator; | ||||
|     } | ||||
|  | ||||
|     public P getPresenter() { | ||||
|         if (presenter == null) { | ||||
|             presenter = factory.createPresenter(); | ||||
|             presenter.create(bundle); | ||||
|             bundle = null; | ||||
|         } | ||||
|         return presenter; | ||||
|     } | ||||
|  | ||||
|     Bundle onSaveInstanceState() { | ||||
|         Bundle bundle = new Bundle(); | ||||
| //        getPresenter(); // Workaround a crash related to saving instance state with child routers | ||||
|         if (presenter != null) { | ||||
|             presenter.save(bundle); | ||||
|         } | ||||
|         return bundle; | ||||
|     } | ||||
|  | ||||
|     void onRestoreInstanceState(Bundle presenterState) { | ||||
|         bundle = presenterState; | ||||
|     } | ||||
|  | ||||
|     void onTakeView(Object view) { | ||||
|         getPresenter(); | ||||
|         if (presenter != null) { | ||||
|             //noinspection unchecked | ||||
|             presenter.takeView(view); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     void onDropView() { | ||||
|         if (presenter != null) { | ||||
|             presenter.dropView(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     void onDestroy() { | ||||
|         if (presenter != null) { | ||||
|             presenter.destroy(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.base.presenter; | ||||
|  | ||||
| import android.os.Bundle; | ||||
| import android.support.annotation.Nullable; | ||||
|  | ||||
| import nucleus.factory.PresenterFactory; | ||||
| import nucleus.presenter.Presenter; | ||||
|  | ||||
| public class NucleusConductorDelegate<P extends Presenter> { | ||||
|  | ||||
|     @Nullable private P presenter; | ||||
|     @Nullable private Bundle bundle; | ||||
|  | ||||
|     private PresenterFactory<P> factory; | ||||
|  | ||||
|     public NucleusConductorDelegate(PresenterFactory<P> creator) { | ||||
|         this.factory = creator; | ||||
|     } | ||||
|  | ||||
|     public P getPresenter() { | ||||
|         if (presenter == null) { | ||||
|             presenter = factory.createPresenter(); | ||||
|             presenter.create(bundle); | ||||
|             bundle = null; | ||||
|         } | ||||
|         return presenter; | ||||
|     } | ||||
|  | ||||
|     Bundle onSaveInstanceState() { | ||||
|         Bundle bundle = new Bundle(); | ||||
| //        getPresenter(); // Workaround a crash related to saving instance state with child routers | ||||
|         if (presenter != null) { | ||||
|             presenter.save(bundle); | ||||
|         } | ||||
|         return bundle; | ||||
|     } | ||||
|  | ||||
|     void onRestoreInstanceState(Bundle presenterState) { | ||||
|         bundle = presenterState; | ||||
|     } | ||||
|  | ||||
|     void onTakeView(Object view) { | ||||
|         getPresenter(); | ||||
|         if (presenter != null) { | ||||
|             //noinspection unchecked | ||||
|             presenter.takeView(view); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     void onDropView() { | ||||
|         if (presenter != null) { | ||||
|             presenter.dropView(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     void onDestroy() { | ||||
|         if (presenter != null) { | ||||
|             presenter.destroy(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,44 +1,44 @@ | ||||
| package eu.kanade.tachiyomi.ui.base.presenter; | ||||
|  | ||||
| import android.os.Bundle; | ||||
| import android.support.annotation.NonNull; | ||||
| import android.view.View; | ||||
|  | ||||
| import com.bluelinelabs.conductor.Controller; | ||||
|  | ||||
| public class NucleusConductorLifecycleListener extends Controller.LifecycleListener { | ||||
|  | ||||
|     private static final String PRESENTER_STATE_KEY = "presenter_state"; | ||||
|  | ||||
|     private NucleusConductorDelegate delegate; | ||||
|  | ||||
|     public NucleusConductorLifecycleListener(NucleusConductorDelegate delegate) { | ||||
|         this.delegate = delegate; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void postCreateView(@NonNull Controller controller, @NonNull View view) { | ||||
|         delegate.onTakeView(controller); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void preDestroyView(@NonNull Controller controller, @NonNull View view) { | ||||
|         delegate.onDropView(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void preDestroy(@NonNull Controller controller) { | ||||
|         delegate.onDestroy(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onSaveInstanceState(@NonNull Controller controller, @NonNull Bundle outState) { | ||||
|         outState.putBundle(PRESENTER_STATE_KEY, delegate.onSaveInstanceState()); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onRestoreInstanceState(@NonNull Controller controller, @NonNull Bundle savedInstanceState) { | ||||
|         delegate.onRestoreInstanceState(savedInstanceState.getBundle(PRESENTER_STATE_KEY)); | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.base.presenter; | ||||
|  | ||||
| import android.os.Bundle; | ||||
| import android.support.annotation.NonNull; | ||||
| import android.view.View; | ||||
|  | ||||
| import com.bluelinelabs.conductor.Controller; | ||||
|  | ||||
| public class NucleusConductorLifecycleListener extends Controller.LifecycleListener { | ||||
|  | ||||
|     private static final String PRESENTER_STATE_KEY = "presenter_state"; | ||||
|  | ||||
|     private NucleusConductorDelegate delegate; | ||||
|  | ||||
|     public NucleusConductorLifecycleListener(NucleusConductorDelegate delegate) { | ||||
|         this.delegate = delegate; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void postCreateView(@NonNull Controller controller, @NonNull View view) { | ||||
|         delegate.onTakeView(controller); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void preDestroyView(@NonNull Controller controller, @NonNull View view) { | ||||
|         delegate.onDropView(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void preDestroy(@NonNull Controller controller) { | ||||
|         delegate.onDestroy(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onSaveInstanceState(@NonNull Controller controller, @NonNull Bundle outState) { | ||||
|         outState.putBundle(PRESENTER_STATE_KEY, delegate.onSaveInstanceState()); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onRestoreInstanceState(@NonNull Controller controller, @NonNull Bundle savedInstanceState) { | ||||
|         delegate.onRestoreInstanceState(savedInstanceState.getBundle(PRESENTER_STATE_KEY)); | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,88 +1,88 @@ | ||||
| package eu.kanade.tachiyomi.ui.catalogue.filter | ||||
|  | ||||
| import eu.davidea.flexibleadapter.items.ISectionable | ||||
| import eu.kanade.tachiyomi.source.model.Filter | ||||
|  | ||||
| class TriStateSectionItem(filter: Filter.TriState) : TriStateItem(filter), ISectionable<TriStateItem.Holder, GroupItem> { | ||||
|  | ||||
|     private var head: GroupItem? = null | ||||
|  | ||||
|     override fun getHeader(): GroupItem? = head | ||||
|  | ||||
|     override fun setHeader(header: GroupItem?) { | ||||
|         head = header | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (javaClass != other?.javaClass) return false | ||||
|         return filter == (other as TriStateSectionItem).filter | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return filter.hashCode() | ||||
|     } | ||||
| } | ||||
|  | ||||
| class TextSectionItem(filter: Filter.Text) : TextItem(filter), ISectionable<TextItem.Holder, GroupItem> { | ||||
|  | ||||
|     private var head: GroupItem? = null | ||||
|  | ||||
|     override fun getHeader(): GroupItem? = head | ||||
|  | ||||
|     override fun setHeader(header: GroupItem?) { | ||||
|         head = header | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (javaClass != other?.javaClass) return false | ||||
|         return filter == (other as TextSectionItem).filter | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return filter.hashCode() | ||||
|     } | ||||
| } | ||||
|  | ||||
| class CheckboxSectionItem(filter: Filter.CheckBox) : CheckboxItem(filter), ISectionable<CheckboxItem.Holder, GroupItem> { | ||||
|  | ||||
|     private var head: GroupItem? = null | ||||
|  | ||||
|     override fun getHeader(): GroupItem? = head | ||||
|  | ||||
|     override fun setHeader(header: GroupItem?) { | ||||
|         head = header | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (javaClass != other?.javaClass) return false | ||||
|         return filter == (other as CheckboxSectionItem).filter | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return filter.hashCode() | ||||
|     } | ||||
| } | ||||
|  | ||||
| class SelectSectionItem(filter: Filter.Select<*>) : SelectItem(filter), ISectionable<SelectItem.Holder, GroupItem> { | ||||
|  | ||||
|     private var head: GroupItem? = null | ||||
|  | ||||
|     override fun getHeader(): GroupItem? = head | ||||
|  | ||||
|     override fun setHeader(header: GroupItem?) { | ||||
|         head = header | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (javaClass != other?.javaClass) return false | ||||
|         return filter == (other as SelectSectionItem).filter | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return filter.hashCode() | ||||
|     } | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.catalogue.filter | ||||
|  | ||||
| import eu.davidea.flexibleadapter.items.ISectionable | ||||
| import eu.kanade.tachiyomi.source.model.Filter | ||||
|  | ||||
| class TriStateSectionItem(filter: Filter.TriState) : TriStateItem(filter), ISectionable<TriStateItem.Holder, GroupItem> { | ||||
|  | ||||
|     private var head: GroupItem? = null | ||||
|  | ||||
|     override fun getHeader(): GroupItem? = head | ||||
|  | ||||
|     override fun setHeader(header: GroupItem?) { | ||||
|         head = header | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (javaClass != other?.javaClass) return false | ||||
|         return filter == (other as TriStateSectionItem).filter | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return filter.hashCode() | ||||
|     } | ||||
| } | ||||
|  | ||||
| class TextSectionItem(filter: Filter.Text) : TextItem(filter), ISectionable<TextItem.Holder, GroupItem> { | ||||
|  | ||||
|     private var head: GroupItem? = null | ||||
|  | ||||
|     override fun getHeader(): GroupItem? = head | ||||
|  | ||||
|     override fun setHeader(header: GroupItem?) { | ||||
|         head = header | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (javaClass != other?.javaClass) return false | ||||
|         return filter == (other as TextSectionItem).filter | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return filter.hashCode() | ||||
|     } | ||||
| } | ||||
|  | ||||
| class CheckboxSectionItem(filter: Filter.CheckBox) : CheckboxItem(filter), ISectionable<CheckboxItem.Holder, GroupItem> { | ||||
|  | ||||
|     private var head: GroupItem? = null | ||||
|  | ||||
|     override fun getHeader(): GroupItem? = head | ||||
|  | ||||
|     override fun setHeader(header: GroupItem?) { | ||||
|         head = header | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (javaClass != other?.javaClass) return false | ||||
|         return filter == (other as CheckboxSectionItem).filter | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return filter.hashCode() | ||||
|     } | ||||
| } | ||||
|  | ||||
| class SelectSectionItem(filter: Filter.Select<*>) : SelectItem(filter), ISectionable<SelectItem.Holder, GroupItem> { | ||||
|  | ||||
|     private var head: GroupItem? = null | ||||
|  | ||||
|     override fun getHeader(): GroupItem? = head | ||||
|  | ||||
|     override fun setHeader(header: GroupItem?) { | ||||
|         head = header | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (javaClass != other?.javaClass) return false | ||||
|         return filter == (other as SelectSectionItem).filter | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return filter.hashCode() | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,52 +1,52 @@ | ||||
| package eu.kanade.tachiyomi.ui.catalogue.filter | ||||
|  | ||||
| import android.view.View | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem | ||||
| import eu.davidea.flexibleadapter.items.ISectionable | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.source.model.Filter | ||||
| import eu.kanade.tachiyomi.util.setVectorCompat | ||||
|  | ||||
| class SortGroup(val filter: Filter.Sort) : AbstractExpandableHeaderItem<SortGroup.Holder, ISectionable<*, *>>() { | ||||
|  | ||||
|     init { | ||||
|         isExpanded = false | ||||
|     } | ||||
|  | ||||
|     override fun getLayoutRes(): Int { | ||||
|         return R.layout.navigation_view_group | ||||
|     } | ||||
|  | ||||
|     override fun getItemViewType(): Int { | ||||
|         return 100 | ||||
|     } | ||||
|  | ||||
|     override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): Holder { | ||||
|         return Holder(view, adapter) | ||||
|     } | ||||
|  | ||||
|     override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder, position: Int, payloads: List<Any?>?) { | ||||
|         holder.title.text = filter.name | ||||
|  | ||||
|         holder.icon.setVectorCompat(if (isExpanded) | ||||
|             R.drawable.ic_expand_more_white_24dp | ||||
|         else | ||||
|             R.drawable.ic_chevron_right_white_24dp) | ||||
|  | ||||
|         holder.itemView.setOnClickListener(holder) | ||||
|  | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (javaClass != other?.javaClass) return false | ||||
|         return filter == (other as SortGroup).filter | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return filter.hashCode() | ||||
|     } | ||||
|  | ||||
|     class Holder(view: View, adapter: FlexibleAdapter<*>) : GroupItem.Holder(view, adapter) | ||||
| package eu.kanade.tachiyomi.ui.catalogue.filter | ||||
|  | ||||
| import android.view.View | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem | ||||
| import eu.davidea.flexibleadapter.items.ISectionable | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.source.model.Filter | ||||
| import eu.kanade.tachiyomi.util.setVectorCompat | ||||
|  | ||||
| class SortGroup(val filter: Filter.Sort) : AbstractExpandableHeaderItem<SortGroup.Holder, ISectionable<*, *>>() { | ||||
|  | ||||
|     init { | ||||
|         isExpanded = false | ||||
|     } | ||||
|  | ||||
|     override fun getLayoutRes(): Int { | ||||
|         return R.layout.navigation_view_group | ||||
|     } | ||||
|  | ||||
|     override fun getItemViewType(): Int { | ||||
|         return 100 | ||||
|     } | ||||
|  | ||||
|     override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): Holder { | ||||
|         return Holder(view, adapter) | ||||
|     } | ||||
|  | ||||
|     override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder, position: Int, payloads: List<Any?>?) { | ||||
|         holder.title.text = filter.name | ||||
|  | ||||
|         holder.icon.setVectorCompat(if (isExpanded) | ||||
|             R.drawable.ic_expand_more_white_24dp | ||||
|         else | ||||
|             R.drawable.ic_chevron_right_white_24dp) | ||||
|  | ||||
|         holder.itemView.setOnClickListener(holder) | ||||
|  | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (javaClass != other?.javaClass) return false | ||||
|         return filter == (other as SortGroup).filter | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return filter.hashCode() | ||||
|     } | ||||
|  | ||||
|     class Holder(view: View, adapter: FlexibleAdapter<*>) : GroupItem.Holder(view, adapter) | ||||
| } | ||||
| @@ -1,28 +1,28 @@ | ||||
| package eu.kanade.tachiyomi.ui.catalogue.global_search | ||||
|  | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
|  | ||||
| /** | ||||
|  * Adapter that holds the manga items from search results. | ||||
|  * | ||||
|  * @param controller instance of [CatalogueSearchController]. | ||||
|  */ | ||||
| class CatalogueSearchCardAdapter(controller: CatalogueSearchController) : | ||||
|         FlexibleAdapter<CatalogueSearchCardItem>(null, controller, true) { | ||||
|  | ||||
|     /** | ||||
|      * Listen for browse item clicks. | ||||
|      */ | ||||
|     val mangaClickListener: OnMangaClickListener = controller | ||||
|  | ||||
|     /** | ||||
|      * Listener which should be called when user clicks browse. | ||||
|      * Note: Should only be handled by [CatalogueSearchController] | ||||
|      */ | ||||
|     interface OnMangaClickListener { | ||||
|         fun onMangaClick(manga: Manga) | ||||
|         fun onMangaLongClick(manga: Manga) | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.catalogue.global_search | ||||
|  | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
|  | ||||
| /** | ||||
|  * Adapter that holds the manga items from search results. | ||||
|  * | ||||
|  * @param controller instance of [CatalogueSearchController]. | ||||
|  */ | ||||
| class CatalogueSearchCardAdapter(controller: CatalogueSearchController) : | ||||
|         FlexibleAdapter<CatalogueSearchCardItem>(null, controller, true) { | ||||
|  | ||||
|     /** | ||||
|      * Listen for browse item clicks. | ||||
|      */ | ||||
|     val mangaClickListener: OnMangaClickListener = controller | ||||
|  | ||||
|     /** | ||||
|      * Listener which should be called when user clicks browse. | ||||
|      * Note: Should only be handled by [CatalogueSearchController] | ||||
|      */ | ||||
|     interface OnMangaClickListener { | ||||
|         fun onMangaClick(manga: Manga) | ||||
|         fun onMangaLongClick(manga: Manga) | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,52 +1,52 @@ | ||||
| package eu.kanade.tachiyomi.ui.catalogue.global_search | ||||
|  | ||||
| import android.view.View | ||||
| import com.bumptech.glide.load.engine.DiskCacheStrategy | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.glide.GlideApp | ||||
| import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder | ||||
| import eu.kanade.tachiyomi.widget.StateImageViewTarget | ||||
| import kotlinx.android.synthetic.main.catalogue_global_search_controller_card_item.* | ||||
|  | ||||
| class CatalogueSearchCardHolder(view: View, adapter: CatalogueSearchCardAdapter) | ||||
|     : BaseFlexibleViewHolder(view, adapter) { | ||||
|  | ||||
|     init { | ||||
|         // Call onMangaClickListener when item is pressed. | ||||
|         itemView.setOnClickListener { | ||||
|             val item = adapter.getItem(adapterPosition) | ||||
|             if (item != null) { | ||||
|                 adapter.mangaClickListener.onMangaClick(item.manga) | ||||
|             } | ||||
|         } | ||||
|         itemView.setOnLongClickListener { | ||||
|             val item = adapter.getItem(adapterPosition) | ||||
|             if (item != null) { | ||||
|                 adapter.mangaClickListener.onMangaLongClick(item.manga) | ||||
|             } | ||||
|             true | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun bind(manga: Manga) { | ||||
|         tvTitle.text = manga.title | ||||
|         // Set alpha of thumbnail. | ||||
|         itemImage.alpha = if (manga.favorite) 0.3f else 1.0f | ||||
|  | ||||
|         setImage(manga) | ||||
|     } | ||||
|  | ||||
|     fun setImage(manga: Manga) { | ||||
|         GlideApp.with(itemView.context).clear(itemImage) | ||||
|         if (!manga.thumbnail_url.isNullOrEmpty()) { | ||||
|             GlideApp.with(itemView.context) | ||||
|                     .load(manga) | ||||
|                     .diskCacheStrategy(DiskCacheStrategy.DATA) | ||||
|                     .centerCrop() | ||||
|                     .skipMemoryCache(true) | ||||
|                     .placeholder(android.R.color.transparent) | ||||
|                     .into(StateImageViewTarget(itemImage, progress)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.catalogue.global_search | ||||
|  | ||||
| import android.view.View | ||||
| import com.bumptech.glide.load.engine.DiskCacheStrategy | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.glide.GlideApp | ||||
| import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder | ||||
| import eu.kanade.tachiyomi.widget.StateImageViewTarget | ||||
| import kotlinx.android.synthetic.main.catalogue_global_search_controller_card_item.* | ||||
|  | ||||
| class CatalogueSearchCardHolder(view: View, adapter: CatalogueSearchCardAdapter) | ||||
|     : BaseFlexibleViewHolder(view, adapter) { | ||||
|  | ||||
|     init { | ||||
|         // Call onMangaClickListener when item is pressed. | ||||
|         itemView.setOnClickListener { | ||||
|             val item = adapter.getItem(adapterPosition) | ||||
|             if (item != null) { | ||||
|                 adapter.mangaClickListener.onMangaClick(item.manga) | ||||
|             } | ||||
|         } | ||||
|         itemView.setOnLongClickListener { | ||||
|             val item = adapter.getItem(adapterPosition) | ||||
|             if (item != null) { | ||||
|                 adapter.mangaClickListener.onMangaLongClick(item.manga) | ||||
|             } | ||||
|             true | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun bind(manga: Manga) { | ||||
|         tvTitle.text = manga.title | ||||
|         // Set alpha of thumbnail. | ||||
|         itemImage.alpha = if (manga.favorite) 0.3f else 1.0f | ||||
|  | ||||
|         setImage(manga) | ||||
|     } | ||||
|  | ||||
|     fun setImage(manga: Manga) { | ||||
|         GlideApp.with(itemView.context).clear(itemImage) | ||||
|         if (!manga.thumbnail_url.isNullOrEmpty()) { | ||||
|             GlideApp.with(itemView.context) | ||||
|                     .load(manga) | ||||
|                     .diskCacheStrategy(DiskCacheStrategy.DATA) | ||||
|                     .centerCrop() | ||||
|                     .skipMemoryCache(true) | ||||
|                     .placeholder(android.R.color.transparent) | ||||
|                     .into(StateImageViewTarget(itemImage, progress)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,35 +1,35 @@ | ||||
| package eu.kanade.tachiyomi.ui.catalogue.global_search | ||||
|  | ||||
| import android.view.View | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.items.AbstractFlexibleItem | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
|  | ||||
| class CatalogueSearchCardItem(val manga: Manga) : AbstractFlexibleItem<CatalogueSearchCardHolder>() { | ||||
|  | ||||
|     override fun getLayoutRes(): Int { | ||||
|         return R.layout.catalogue_global_search_controller_card_item | ||||
|     } | ||||
|  | ||||
|     override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): CatalogueSearchCardHolder { | ||||
|         return CatalogueSearchCardHolder(view, adapter as CatalogueSearchCardAdapter) | ||||
|     } | ||||
|  | ||||
|     override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: CatalogueSearchCardHolder, | ||||
|                                 position: Int, payloads: List<Any?>?) { | ||||
|         holder.bind(manga) | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (other is CatalogueSearchCardItem) { | ||||
|             return manga.id == other.manga.id | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return manga.id?.toInt() ?: 0 | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.catalogue.global_search | ||||
|  | ||||
| import android.view.View | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.items.AbstractFlexibleItem | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
|  | ||||
| class CatalogueSearchCardItem(val manga: Manga) : AbstractFlexibleItem<CatalogueSearchCardHolder>() { | ||||
|  | ||||
|     override fun getLayoutRes(): Int { | ||||
|         return R.layout.catalogue_global_search_controller_card_item | ||||
|     } | ||||
|  | ||||
|     override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): CatalogueSearchCardHolder { | ||||
|         return CatalogueSearchCardHolder(view, adapter as CatalogueSearchCardAdapter) | ||||
|     } | ||||
|  | ||||
|     override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: CatalogueSearchCardHolder, | ||||
|                                 position: Int, payloads: List<Any?>?) { | ||||
|         holder.bind(manga) | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (other is CatalogueSearchCardItem) { | ||||
|             return manga.id == other.manga.id | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return manga.id?.toInt() ?: 0 | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,247 +1,247 @@ | ||||
| package eu.kanade.tachiyomi.ui.download | ||||
|  | ||||
| import android.support.v7.widget.LinearLayoutManager | ||||
| import android.view.* | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.download.DownloadService | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import eu.kanade.tachiyomi.ui.base.controller.NucleusController | ||||
| import kotlinx.android.synthetic.main.download_controller.* | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import java.util.* | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| /** | ||||
|  * Controller that shows the currently active downloads. | ||||
|  * Uses R.layout.fragment_download_queue. | ||||
|  */ | ||||
| class DownloadController : NucleusController<DownloadPresenter>() { | ||||
|  | ||||
|     /** | ||||
|      * Adapter containing the active downloads. | ||||
|      */ | ||||
|     private var adapter: DownloadAdapter? = null | ||||
|  | ||||
|     /** | ||||
|      * Map of subscriptions for active downloads. | ||||
|      */ | ||||
|     private val progressSubscriptions by lazy { HashMap<Download, Subscription>() } | ||||
|  | ||||
|     /** | ||||
|      * Whether the download queue is running or not. | ||||
|      */ | ||||
|     private var isRunning: Boolean = false | ||||
|  | ||||
|     init { | ||||
|         setHasOptionsMenu(true) | ||||
|     } | ||||
|  | ||||
|     override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { | ||||
|         return inflater.inflate(R.layout.download_controller, container, false) | ||||
|     } | ||||
|  | ||||
|     override fun createPresenter(): DownloadPresenter { | ||||
|         return DownloadPresenter() | ||||
|     } | ||||
|  | ||||
|     override fun getTitle(): String? { | ||||
|         return resources?.getString(R.string.label_download_queue) | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|  | ||||
|         // Check if download queue is empty and update information accordingly. | ||||
|         setInformationView() | ||||
|  | ||||
|         // Initialize adapter. | ||||
|         adapter = DownloadAdapter() | ||||
|         recycler.adapter = adapter | ||||
|  | ||||
|         // Set the layout manager for the recycler and fixed size. | ||||
|         recycler.layoutManager = LinearLayoutManager(view.context) | ||||
|         recycler.setHasFixedSize(true) | ||||
|  | ||||
|         // Suscribe to changes | ||||
|         DownloadService.runningRelay | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeUntilDestroy { onQueueStatusChange(it) } | ||||
|  | ||||
|         presenter.getDownloadStatusObservable() | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeUntilDestroy { onStatusChange(it) } | ||||
|  | ||||
|         presenter.getDownloadProgressObservable() | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeUntilDestroy { onUpdateDownloadedPages(it) } | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         for (subscription in progressSubscriptions.values) { | ||||
|             subscription.unsubscribe() | ||||
|         } | ||||
|         progressSubscriptions.clear() | ||||
|         adapter = null | ||||
|         super.onDestroyView(view) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { | ||||
|         inflater.inflate(R.menu.download_queue, menu) | ||||
|     } | ||||
|  | ||||
|     override fun onPrepareOptionsMenu(menu: Menu) { | ||||
|         // Set start button visibility. | ||||
|         menu.findItem(R.id.start_queue).isVisible = !isRunning && !presenter.downloadQueue.isEmpty() | ||||
|  | ||||
|         // Set pause button visibility. | ||||
|         menu.findItem(R.id.pause_queue).isVisible = isRunning | ||||
|  | ||||
|         // Set clear button visibility. | ||||
|         menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty() | ||||
|     } | ||||
|  | ||||
|     override fun onOptionsItemSelected(item: MenuItem): Boolean { | ||||
|         val context = applicationContext ?: return false | ||||
|         when (item.itemId) { | ||||
|             R.id.start_queue -> DownloadService.start(context) | ||||
|             R.id.pause_queue -> { | ||||
|                 DownloadService.stop(context) | ||||
|                 presenter.pauseDownloads() | ||||
|             } | ||||
|             R.id.clear_queue -> { | ||||
|                 DownloadService.stop(context) | ||||
|                 presenter.clearQueue() | ||||
|             } | ||||
|             else -> return super.onOptionsItemSelected(item) | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when the status of a download changes. | ||||
|      * | ||||
|      * @param download the download whose status has changed. | ||||
|      */ | ||||
|     private fun onStatusChange(download: Download) { | ||||
|         when (download.status) { | ||||
|             Download.DOWNLOADING -> { | ||||
|                 observeProgress(download) | ||||
|                 // Initial update of the downloaded pages | ||||
|                 onUpdateDownloadedPages(download) | ||||
|             } | ||||
|             Download.DOWNLOADED -> { | ||||
|                 unsubscribeProgress(download) | ||||
|                 onUpdateProgress(download) | ||||
|                 onUpdateDownloadedPages(download) | ||||
|             } | ||||
|             Download.ERROR -> unsubscribeProgress(download) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Observe the progress of a download and notify the view. | ||||
|      * | ||||
|      * @param download the download to observe its progress. | ||||
|      */ | ||||
|     private fun observeProgress(download: Download) { | ||||
|         val subscription = Observable.interval(50, TimeUnit.MILLISECONDS) | ||||
|                 // Get the sum of percentages for all the pages. | ||||
|                 .flatMap { | ||||
|                     Observable.from(download.pages) | ||||
|                             .map(Page::progress) | ||||
|                             .reduce { x, y -> x + y } | ||||
|                 } | ||||
|                 // Keep only the latest emission to avoid backpressure. | ||||
|                 .onBackpressureLatest() | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe { progress -> | ||||
|                     // Update the view only if the progress has changed. | ||||
|                     if (download.totalProgress != progress) { | ||||
|                         download.totalProgress = progress | ||||
|                         onUpdateProgress(download) | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|         // Avoid leaking subscriptions | ||||
|         progressSubscriptions.remove(download)?.unsubscribe() | ||||
|  | ||||
|         progressSubscriptions.put(download, subscription) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Unsubscribes the given download from the progress subscriptions. | ||||
|      * | ||||
|      * @param download the download to unsubscribe. | ||||
|      */ | ||||
|     private fun unsubscribeProgress(download: Download) { | ||||
|         progressSubscriptions.remove(download)?.unsubscribe() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when the queue's status has changed. Updates the visibility of the buttons. | ||||
|      * | ||||
|      * @param running whether the queue is now running or not. | ||||
|      */ | ||||
|     private fun onQueueStatusChange(running: Boolean) { | ||||
|         isRunning = running | ||||
|         activity?.invalidateOptionsMenu() | ||||
|  | ||||
|         // Check if download queue is empty and update information accordingly. | ||||
|         setInformationView() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called from the presenter to assign the downloads for the adapter. | ||||
|      * | ||||
|      * @param downloads the downloads from the queue. | ||||
|      */ | ||||
|     fun onNextDownloads(downloads: List<Download>) { | ||||
|         activity?.invalidateOptionsMenu() | ||||
|         setInformationView() | ||||
|         adapter?.setItems(downloads) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when the progress of a download changes. | ||||
|      * | ||||
|      * @param download the download whose progress has changed. | ||||
|      */ | ||||
|     fun onUpdateProgress(download: Download) { | ||||
|         getHolder(download)?.notifyProgress() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when a page of a download is downloaded. | ||||
|      * | ||||
|      * @param download the download whose page has been downloaded. | ||||
|      */ | ||||
|     fun onUpdateDownloadedPages(download: Download) { | ||||
|         getHolder(download)?.notifyDownloadedPages() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the holder for the given download. | ||||
|      * | ||||
|      * @param download the download to find. | ||||
|      * @return the holder of the download or null if it's not bound. | ||||
|      */ | ||||
|     private fun getHolder(download: Download): DownloadHolder? { | ||||
|         return recycler?.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Set information view when queue is empty | ||||
|      */ | ||||
|     private fun setInformationView() { | ||||
|         if (presenter.downloadQueue.isEmpty()) { | ||||
|             empty_view?.show(R.drawable.ic_file_download_black_128dp, | ||||
|                     R.string.information_no_downloads) | ||||
|         } else { | ||||
|             empty_view?.hide() | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.download | ||||
|  | ||||
| import android.support.v7.widget.LinearLayoutManager | ||||
| import android.view.* | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.download.DownloadService | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import eu.kanade.tachiyomi.ui.base.controller.NucleusController | ||||
| import kotlinx.android.synthetic.main.download_controller.* | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import java.util.* | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| /** | ||||
|  * Controller that shows the currently active downloads. | ||||
|  * Uses R.layout.fragment_download_queue. | ||||
|  */ | ||||
| class DownloadController : NucleusController<DownloadPresenter>() { | ||||
|  | ||||
|     /** | ||||
|      * Adapter containing the active downloads. | ||||
|      */ | ||||
|     private var adapter: DownloadAdapter? = null | ||||
|  | ||||
|     /** | ||||
|      * Map of subscriptions for active downloads. | ||||
|      */ | ||||
|     private val progressSubscriptions by lazy { HashMap<Download, Subscription>() } | ||||
|  | ||||
|     /** | ||||
|      * Whether the download queue is running or not. | ||||
|      */ | ||||
|     private var isRunning: Boolean = false | ||||
|  | ||||
|     init { | ||||
|         setHasOptionsMenu(true) | ||||
|     } | ||||
|  | ||||
|     override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { | ||||
|         return inflater.inflate(R.layout.download_controller, container, false) | ||||
|     } | ||||
|  | ||||
|     override fun createPresenter(): DownloadPresenter { | ||||
|         return DownloadPresenter() | ||||
|     } | ||||
|  | ||||
|     override fun getTitle(): String? { | ||||
|         return resources?.getString(R.string.label_download_queue) | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|  | ||||
|         // Check if download queue is empty and update information accordingly. | ||||
|         setInformationView() | ||||
|  | ||||
|         // Initialize adapter. | ||||
|         adapter = DownloadAdapter() | ||||
|         recycler.adapter = adapter | ||||
|  | ||||
|         // Set the layout manager for the recycler and fixed size. | ||||
|         recycler.layoutManager = LinearLayoutManager(view.context) | ||||
|         recycler.setHasFixedSize(true) | ||||
|  | ||||
|         // Suscribe to changes | ||||
|         DownloadService.runningRelay | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeUntilDestroy { onQueueStatusChange(it) } | ||||
|  | ||||
|         presenter.getDownloadStatusObservable() | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeUntilDestroy { onStatusChange(it) } | ||||
|  | ||||
|         presenter.getDownloadProgressObservable() | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeUntilDestroy { onUpdateDownloadedPages(it) } | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         for (subscription in progressSubscriptions.values) { | ||||
|             subscription.unsubscribe() | ||||
|         } | ||||
|         progressSubscriptions.clear() | ||||
|         adapter = null | ||||
|         super.onDestroyView(view) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { | ||||
|         inflater.inflate(R.menu.download_queue, menu) | ||||
|     } | ||||
|  | ||||
|     override fun onPrepareOptionsMenu(menu: Menu) { | ||||
|         // Set start button visibility. | ||||
|         menu.findItem(R.id.start_queue).isVisible = !isRunning && !presenter.downloadQueue.isEmpty() | ||||
|  | ||||
|         // Set pause button visibility. | ||||
|         menu.findItem(R.id.pause_queue).isVisible = isRunning | ||||
|  | ||||
|         // Set clear button visibility. | ||||
|         menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty() | ||||
|     } | ||||
|  | ||||
|     override fun onOptionsItemSelected(item: MenuItem): Boolean { | ||||
|         val context = applicationContext ?: return false | ||||
|         when (item.itemId) { | ||||
|             R.id.start_queue -> DownloadService.start(context) | ||||
|             R.id.pause_queue -> { | ||||
|                 DownloadService.stop(context) | ||||
|                 presenter.pauseDownloads() | ||||
|             } | ||||
|             R.id.clear_queue -> { | ||||
|                 DownloadService.stop(context) | ||||
|                 presenter.clearQueue() | ||||
|             } | ||||
|             else -> return super.onOptionsItemSelected(item) | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when the status of a download changes. | ||||
|      * | ||||
|      * @param download the download whose status has changed. | ||||
|      */ | ||||
|     private fun onStatusChange(download: Download) { | ||||
|         when (download.status) { | ||||
|             Download.DOWNLOADING -> { | ||||
|                 observeProgress(download) | ||||
|                 // Initial update of the downloaded pages | ||||
|                 onUpdateDownloadedPages(download) | ||||
|             } | ||||
|             Download.DOWNLOADED -> { | ||||
|                 unsubscribeProgress(download) | ||||
|                 onUpdateProgress(download) | ||||
|                 onUpdateDownloadedPages(download) | ||||
|             } | ||||
|             Download.ERROR -> unsubscribeProgress(download) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Observe the progress of a download and notify the view. | ||||
|      * | ||||
|      * @param download the download to observe its progress. | ||||
|      */ | ||||
|     private fun observeProgress(download: Download) { | ||||
|         val subscription = Observable.interval(50, TimeUnit.MILLISECONDS) | ||||
|                 // Get the sum of percentages for all the pages. | ||||
|                 .flatMap { | ||||
|                     Observable.from(download.pages) | ||||
|                             .map(Page::progress) | ||||
|                             .reduce { x, y -> x + y } | ||||
|                 } | ||||
|                 // Keep only the latest emission to avoid backpressure. | ||||
|                 .onBackpressureLatest() | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe { progress -> | ||||
|                     // Update the view only if the progress has changed. | ||||
|                     if (download.totalProgress != progress) { | ||||
|                         download.totalProgress = progress | ||||
|                         onUpdateProgress(download) | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|         // Avoid leaking subscriptions | ||||
|         progressSubscriptions.remove(download)?.unsubscribe() | ||||
|  | ||||
|         progressSubscriptions.put(download, subscription) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Unsubscribes the given download from the progress subscriptions. | ||||
|      * | ||||
|      * @param download the download to unsubscribe. | ||||
|      */ | ||||
|     private fun unsubscribeProgress(download: Download) { | ||||
|         progressSubscriptions.remove(download)?.unsubscribe() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when the queue's status has changed. Updates the visibility of the buttons. | ||||
|      * | ||||
|      * @param running whether the queue is now running or not. | ||||
|      */ | ||||
|     private fun onQueueStatusChange(running: Boolean) { | ||||
|         isRunning = running | ||||
|         activity?.invalidateOptionsMenu() | ||||
|  | ||||
|         // Check if download queue is empty and update information accordingly. | ||||
|         setInformationView() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called from the presenter to assign the downloads for the adapter. | ||||
|      * | ||||
|      * @param downloads the downloads from the queue. | ||||
|      */ | ||||
|     fun onNextDownloads(downloads: List<Download>) { | ||||
|         activity?.invalidateOptionsMenu() | ||||
|         setInformationView() | ||||
|         adapter?.setItems(downloads) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when the progress of a download changes. | ||||
|      * | ||||
|      * @param download the download whose progress has changed. | ||||
|      */ | ||||
|     fun onUpdateProgress(download: Download) { | ||||
|         getHolder(download)?.notifyProgress() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when a page of a download is downloaded. | ||||
|      * | ||||
|      * @param download the download whose page has been downloaded. | ||||
|      */ | ||||
|     fun onUpdateDownloadedPages(download: Download) { | ||||
|         getHolder(download)?.notifyDownloadedPages() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the holder for the given download. | ||||
|      * | ||||
|      * @param download the download to find. | ||||
|      * @return the holder of the download or null if it's not bound. | ||||
|      */ | ||||
|     private fun getHolder(download: Download): DownloadHolder? { | ||||
|         return recycler?.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Set information view when queue is empty | ||||
|      */ | ||||
|     private fun setInformationView() { | ||||
|         if (presenter.downloadQueue.isEmpty()) { | ||||
|             empty_view?.show(R.drawable.ic_file_download_black_128dp, | ||||
|                     R.string.information_no_downloads) | ||||
|         } else { | ||||
|             empty_view?.hide() | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,48 +1,48 @@ | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Category | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
|  | ||||
| class ChangeMangaCategoriesDialog<T>(bundle: Bundle? = null) : | ||||
|         DialogController(bundle) where T : Controller, T : ChangeMangaCategoriesDialog.Listener { | ||||
|  | ||||
|     private var mangas = emptyList<Manga>() | ||||
|  | ||||
|     private var categories = emptyList<Category>() | ||||
|  | ||||
|     private var preselected = emptyArray<Int>() | ||||
|  | ||||
|     constructor(target: T, mangas: List<Manga>, categories: List<Category>, | ||||
|                 preselected: Array<Int>) : this() { | ||||
|  | ||||
|         this.mangas = mangas | ||||
|         this.categories = categories | ||||
|         this.preselected = preselected | ||||
|         targetController = target | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         return MaterialDialog.Builder(activity!!) | ||||
|                 .title(R.string.action_move_category) | ||||
|                 .items(categories.map { it.name }) | ||||
|                 .itemsCallbackMultiChoice(preselected) { dialog, _, _ -> | ||||
|                     val newCategories = dialog.selectedIndices?.map { categories[it] }.orEmpty() | ||||
|                     (targetController as? Listener)?.updateCategoriesForMangas(mangas, newCategories) | ||||
|                     true | ||||
|                 } | ||||
|                 .positiveText(android.R.string.ok) | ||||
|                 .negativeText(android.R.string.cancel) | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Category | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
|  | ||||
| class ChangeMangaCategoriesDialog<T>(bundle: Bundle? = null) : | ||||
|         DialogController(bundle) where T : Controller, T : ChangeMangaCategoriesDialog.Listener { | ||||
|  | ||||
|     private var mangas = emptyList<Manga>() | ||||
|  | ||||
|     private var categories = emptyList<Category>() | ||||
|  | ||||
|     private var preselected = emptyArray<Int>() | ||||
|  | ||||
|     constructor(target: T, mangas: List<Manga>, categories: List<Category>, | ||||
|                 preselected: Array<Int>) : this() { | ||||
|  | ||||
|         this.mangas = mangas | ||||
|         this.categories = categories | ||||
|         this.preselected = preselected | ||||
|         targetController = target | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         return MaterialDialog.Builder(activity!!) | ||||
|                 .title(R.string.action_move_category) | ||||
|                 .items(categories.map { it.name }) | ||||
|                 .itemsCallbackMultiChoice(preselected) { dialog, _, _ -> | ||||
|                     val newCategories = dialog.selectedIndices?.map { categories[it] }.orEmpty() | ||||
|                     (targetController as? Listener)?.updateCategoriesForMangas(mangas, newCategories) | ||||
|                     true | ||||
|                 } | ||||
|                 .positiveText(android.R.string.ok) | ||||
|                 .negativeText(android.R.string.cancel) | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,43 +1,43 @@ | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
| import eu.kanade.tachiyomi.widget.DialogCheckboxView | ||||
|  | ||||
| class DeleteLibraryMangasDialog<T>(bundle: Bundle? = null) : | ||||
|         DialogController(bundle) where T : Controller, T: DeleteLibraryMangasDialog.Listener { | ||||
|  | ||||
|     private var mangas = emptyList<Manga>() | ||||
|  | ||||
|     constructor(target: T, mangas: List<Manga>) : this() { | ||||
|         this.mangas = mangas | ||||
|         targetController = target | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val view = DialogCheckboxView(activity!!).apply { | ||||
|             setDescription(R.string.confirm_delete_manga) | ||||
|             setOptionDescription(R.string.also_delete_chapters) | ||||
|         } | ||||
|  | ||||
|         return MaterialDialog.Builder(activity!!) | ||||
|                 .title(R.string.action_remove) | ||||
|                 .customView(view, true) | ||||
|                 .positiveText(android.R.string.yes) | ||||
|                 .negativeText(android.R.string.no) | ||||
|                 .onPositive { _, _ -> | ||||
|                     val deleteChapters = view.isChecked() | ||||
|                     (targetController as? Listener)?.deleteMangasFromLibrary(mangas, deleteChapters) | ||||
|                 } | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) | ||||
|     } | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
| import eu.kanade.tachiyomi.widget.DialogCheckboxView | ||||
|  | ||||
| class DeleteLibraryMangasDialog<T>(bundle: Bundle? = null) : | ||||
|         DialogController(bundle) where T : Controller, T: DeleteLibraryMangasDialog.Listener { | ||||
|  | ||||
|     private var mangas = emptyList<Manga>() | ||||
|  | ||||
|     constructor(target: T, mangas: List<Manga>) : this() { | ||||
|         this.mangas = mangas | ||||
|         targetController = target | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val view = DialogCheckboxView(activity!!).apply { | ||||
|             setDescription(R.string.confirm_delete_manga) | ||||
|             setOptionDescription(R.string.also_delete_chapters) | ||||
|         } | ||||
|  | ||||
|         return MaterialDialog.Builder(activity!!) | ||||
|                 .title(R.string.action_remove) | ||||
|                 .customView(view, true) | ||||
|                 .positiveText(android.R.string.yes) | ||||
|                 .negativeText(android.R.string.no) | ||||
|                 .onPositive { _, _ -> | ||||
|                     val deleteChapters = view.isChecked() | ||||
|                     (targetController as? Listener)?.deleteMangasFromLibrary(mangas, deleteChapters) | ||||
|                 } | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) | ||||
|     } | ||||
| } | ||||
| @@ -1,103 +1,103 @@ | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Category | ||||
| import eu.kanade.tachiyomi.util.inflate | ||||
| import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter | ||||
|  | ||||
| /** | ||||
|  * This adapter stores the categories from the library, used with a ViewPager. | ||||
|  * | ||||
|  * @constructor creates an instance of the adapter. | ||||
|  */ | ||||
| class LibraryAdapter(private val controller: LibraryController) : RecyclerViewPagerAdapter() { | ||||
|  | ||||
|     /** | ||||
|      * The categories to bind in the adapter. | ||||
|      */ | ||||
|     var categories: List<Category> = emptyList() | ||||
|         // This setter helps to not refresh the adapter if the reference to the list doesn't change. | ||||
|         set(value) { | ||||
|             if (field !== value) { | ||||
|                 field = value | ||||
|                 notifyDataSetChanged() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|     private var boundViews = arrayListOf<View>() | ||||
|  | ||||
|     /** | ||||
|      * Creates a new view for this adapter. | ||||
|      * | ||||
|      * @return a new view. | ||||
|      */ | ||||
|     override fun createView(container: ViewGroup): View { | ||||
|         val view = container.inflate(R.layout.library_category) as LibraryCategoryView | ||||
|         view.onCreate(controller) | ||||
|         return view | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Binds a view with a position. | ||||
|      * | ||||
|      * @param view the view to bind. | ||||
|      * @param position the position in the adapter. | ||||
|      */ | ||||
|     override fun bindView(view: View, position: Int) { | ||||
|         (view as LibraryCategoryView).onBind(categories[position]) | ||||
|         boundViews.add(view) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Recycles a view. | ||||
|      * | ||||
|      * @param view the view to recycle. | ||||
|      * @param position the position in the adapter. | ||||
|      */ | ||||
|     override fun recycleView(view: View, position: Int) { | ||||
|         (view as LibraryCategoryView).onRecycle() | ||||
|         boundViews.remove(view) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the number of categories. | ||||
|      * | ||||
|      * @return the number of categories or 0 if the list is null. | ||||
|      */ | ||||
|     override fun getCount(): Int { | ||||
|         return categories.size | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the title to display for a category. | ||||
|      * | ||||
|      * @param position the position of the element. | ||||
|      * @return the title to display. | ||||
|      */ | ||||
|     override fun getPageTitle(position: Int): CharSequence { | ||||
|         return categories[position].name | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the position of the view. | ||||
|      */ | ||||
|     override fun getItemPosition(obj: Any): Int { | ||||
|         val view = obj as? LibraryCategoryView ?: return POSITION_NONE | ||||
|         val index = categories.indexOfFirst { it.id == view.category.id } | ||||
|         return if (index == -1) POSITION_NONE else index | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when the view of this adapter is being destroyed. | ||||
|      */ | ||||
|     fun onDestroy() { | ||||
|         for (view in boundViews) { | ||||
|             if (view is LibraryCategoryView) { | ||||
|                 view.unsubscribe() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Category | ||||
| import eu.kanade.tachiyomi.util.inflate | ||||
| import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter | ||||
|  | ||||
| /** | ||||
|  * This adapter stores the categories from the library, used with a ViewPager. | ||||
|  * | ||||
|  * @constructor creates an instance of the adapter. | ||||
|  */ | ||||
| class LibraryAdapter(private val controller: LibraryController) : RecyclerViewPagerAdapter() { | ||||
|  | ||||
|     /** | ||||
|      * The categories to bind in the adapter. | ||||
|      */ | ||||
|     var categories: List<Category> = emptyList() | ||||
|         // This setter helps to not refresh the adapter if the reference to the list doesn't change. | ||||
|         set(value) { | ||||
|             if (field !== value) { | ||||
|                 field = value | ||||
|                 notifyDataSetChanged() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|     private var boundViews = arrayListOf<View>() | ||||
|  | ||||
|     /** | ||||
|      * Creates a new view for this adapter. | ||||
|      * | ||||
|      * @return a new view. | ||||
|      */ | ||||
|     override fun createView(container: ViewGroup): View { | ||||
|         val view = container.inflate(R.layout.library_category) as LibraryCategoryView | ||||
|         view.onCreate(controller) | ||||
|         return view | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Binds a view with a position. | ||||
|      * | ||||
|      * @param view the view to bind. | ||||
|      * @param position the position in the adapter. | ||||
|      */ | ||||
|     override fun bindView(view: View, position: Int) { | ||||
|         (view as LibraryCategoryView).onBind(categories[position]) | ||||
|         boundViews.add(view) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Recycles a view. | ||||
|      * | ||||
|      * @param view the view to recycle. | ||||
|      * @param position the position in the adapter. | ||||
|      */ | ||||
|     override fun recycleView(view: View, position: Int) { | ||||
|         (view as LibraryCategoryView).onRecycle() | ||||
|         boundViews.remove(view) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the number of categories. | ||||
|      * | ||||
|      * @return the number of categories or 0 if the list is null. | ||||
|      */ | ||||
|     override fun getCount(): Int { | ||||
|         return categories.size | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the title to display for a category. | ||||
|      * | ||||
|      * @param position the position of the element. | ||||
|      * @return the title to display. | ||||
|      */ | ||||
|     override fun getPageTitle(position: Int): CharSequence { | ||||
|         return categories[position].name | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the position of the view. | ||||
|      */ | ||||
|     override fun getItemPosition(obj: Any): Int { | ||||
|         val view = obj as? LibraryCategoryView ?: return POSITION_NONE | ||||
|         val index = categories.indexOfFirst { it.id == view.category.id } | ||||
|         return if (index == -1) POSITION_NONE else index | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when the view of this adapter is being destroyed. | ||||
|      */ | ||||
|     fun onDestroy() { | ||||
|         for (view in boundViews) { | ||||
|             if (view is LibraryCategoryView) { | ||||
|                 view.unsubscribe() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,44 +1,44 @@ | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
|  | ||||
| /** | ||||
|  * Adapter storing a list of manga in a certain category. | ||||
|  * | ||||
|  * @param view the fragment containing this adapter. | ||||
|  */ | ||||
| class LibraryCategoryAdapter(view: LibraryCategoryView) : | ||||
|         FlexibleAdapter<LibraryItem>(null, view, true) { | ||||
|  | ||||
|     /** | ||||
|      * The list of manga in this category. | ||||
|      */ | ||||
|     private var mangas: List<LibraryItem> = emptyList() | ||||
|  | ||||
|     /** | ||||
|      * Sets a list of manga in the adapter. | ||||
|      * | ||||
|      * @param list the list to set. | ||||
|      */ | ||||
|     fun setItems(list: List<LibraryItem>) { | ||||
|         // A copy of manga always unfiltered. | ||||
|         mangas = list.toList() | ||||
|  | ||||
|         performFilter() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the position in the adapter for the given manga. | ||||
|      * | ||||
|      * @param manga the manga to find. | ||||
|      */ | ||||
|     fun indexOf(manga: Manga): Int { | ||||
|         return currentItems.indexOfFirst { it.manga.id == manga.id } | ||||
|     } | ||||
|  | ||||
|     fun performFilter() { | ||||
|         updateDataSet(mangas.filter { it.filter(searchText) }) | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
|  | ||||
| /** | ||||
|  * Adapter storing a list of manga in a certain category. | ||||
|  * | ||||
|  * @param view the fragment containing this adapter. | ||||
|  */ | ||||
| class LibraryCategoryAdapter(view: LibraryCategoryView) : | ||||
|         FlexibleAdapter<LibraryItem>(null, view, true) { | ||||
|  | ||||
|     /** | ||||
|      * The list of manga in this category. | ||||
|      */ | ||||
|     private var mangas: List<LibraryItem> = emptyList() | ||||
|  | ||||
|     /** | ||||
|      * Sets a list of manga in the adapter. | ||||
|      * | ||||
|      * @param list the list to set. | ||||
|      */ | ||||
|     fun setItems(list: List<LibraryItem>) { | ||||
|         // A copy of manga always unfiltered. | ||||
|         mangas = list.toList() | ||||
|  | ||||
|         performFilter() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the position in the adapter for the given manga. | ||||
|      * | ||||
|      * @param manga the manga to find. | ||||
|      */ | ||||
|     fun indexOf(manga: Manga): Int { | ||||
|         return currentItems.indexOfFirst { it.manga.id == manga.id } | ||||
|     } | ||||
|  | ||||
|     fun performFilter() { | ||||
|         updateDataSet(mangas.filter { it.filter(searchText) }) | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,247 +1,247 @@ | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.content.Context | ||||
| import android.support.v7.widget.LinearLayoutManager | ||||
| import android.support.v7.widget.RecyclerView | ||||
| import android.util.AttributeSet | ||||
| import android.widget.FrameLayout | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.SelectableAdapter | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Category | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateService | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.util.inflate | ||||
| import eu.kanade.tachiyomi.util.plusAssign | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import eu.kanade.tachiyomi.widget.AutofitRecyclerView | ||||
| import kotlinx.android.synthetic.main.library_category.view.* | ||||
| import rx.subscriptions.CompositeSubscription | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| /** | ||||
|  * Fragment containing the library manga for a certain category. | ||||
|  */ | ||||
| class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : | ||||
|         FrameLayout(context, attrs), | ||||
|         FlexibleAdapter.OnItemClickListener, | ||||
|         FlexibleAdapter.OnItemLongClickListener { | ||||
|  | ||||
|     /** | ||||
|      * Preferences. | ||||
|      */ | ||||
|     private val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * The fragment containing this view. | ||||
|      */ | ||||
|     private lateinit var controller: LibraryController | ||||
|  | ||||
|     /** | ||||
|      * Category for this view. | ||||
|      */ | ||||
|     lateinit var category: Category | ||||
|         private set | ||||
|  | ||||
|     /** | ||||
|      * Recycler view of the list of manga. | ||||
|      */ | ||||
|     private lateinit var recycler: RecyclerView | ||||
|  | ||||
|     /** | ||||
|      * Adapter to hold the manga in this category. | ||||
|      */ | ||||
|     private lateinit var adapter: LibraryCategoryAdapter | ||||
|  | ||||
|     /** | ||||
|      * Subscriptions while the view is bound. | ||||
|      */ | ||||
|     private var subscriptions = CompositeSubscription() | ||||
|  | ||||
|     fun onCreate(controller: LibraryController) { | ||||
|         this.controller = controller | ||||
|  | ||||
|         recycler = if (preferences.libraryAsList().getOrDefault()) { | ||||
|             (swipe_refresh.inflate(R.layout.library_list_recycler) as RecyclerView).apply { | ||||
|                 layoutManager = LinearLayoutManager(context) | ||||
|             } | ||||
|         } else { | ||||
|             (swipe_refresh.inflate(R.layout.library_grid_recycler) as AutofitRecyclerView).apply { | ||||
|                 spanCount = controller.mangaPerRow | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         adapter = LibraryCategoryAdapter(this) | ||||
|  | ||||
|         recycler.setHasFixedSize(true) | ||||
|         recycler.adapter = adapter | ||||
|         swipe_refresh.addView(recycler) | ||||
|  | ||||
|         recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { | ||||
|             override fun onScrollStateChanged(recycler: RecyclerView, newState: Int) { | ||||
|                 // Disable swipe refresh when view is not at the top | ||||
|                 val firstPos = (recycler.layoutManager as LinearLayoutManager) | ||||
|                         .findFirstCompletelyVisibleItemPosition() | ||||
|                 swipe_refresh.isEnabled = firstPos <= 0 | ||||
|             } | ||||
|         }) | ||||
|  | ||||
|         // Double the distance required to trigger sync | ||||
|         swipe_refresh.setDistanceToTriggerSync((2 * 64 * resources.displayMetrics.density).toInt()) | ||||
|         swipe_refresh.setOnRefreshListener { | ||||
|             if (!LibraryUpdateService.isRunning(context)) { | ||||
|                 LibraryUpdateService.start(context, category) | ||||
|                 context.toast(R.string.updating_category) | ||||
|             } | ||||
|             // It can be a very long operation, so we disable swipe refresh and show a toast. | ||||
|             swipe_refresh.isRefreshing = false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun onBind(category: Category) { | ||||
|         this.category = category | ||||
|  | ||||
|         adapter.mode = if (controller.selectedMangas.isNotEmpty()) { | ||||
|             SelectableAdapter.Mode.MULTI | ||||
|         } else { | ||||
|             SelectableAdapter.Mode.SINGLE | ||||
|         } | ||||
|  | ||||
|         subscriptions += controller.searchRelay | ||||
|                 .doOnNext { adapter.searchText = it } | ||||
|                 .skip(1) | ||||
|                 .subscribe { adapter.performFilter() } | ||||
|  | ||||
|         subscriptions += controller.libraryMangaRelay | ||||
|                 .subscribe { onNextLibraryManga(it) } | ||||
|  | ||||
|         subscriptions += controller.selectionRelay | ||||
|                 .subscribe { onSelectionChanged(it) } | ||||
|     } | ||||
|  | ||||
|     fun onRecycle() { | ||||
|         adapter.setItems(emptyList()) | ||||
|         adapter.clearSelection() | ||||
|         unsubscribe() | ||||
|     } | ||||
|  | ||||
|     fun unsubscribe() { | ||||
|         subscriptions.clear() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Subscribe to [LibraryMangaEvent]. When an event is received, it updates the content of the | ||||
|      * adapter. | ||||
|      * | ||||
|      * @param event the event received. | ||||
|      */ | ||||
|     fun onNextLibraryManga(event: LibraryMangaEvent) { | ||||
|         // Get the manga list for this category. | ||||
|         val mangaForCategory = event.getMangaForCategory(category).orEmpty() | ||||
|  | ||||
|         // Update the category with its manga. | ||||
|         adapter.setItems(mangaForCategory) | ||||
|  | ||||
|         if (adapter.mode == SelectableAdapter.Mode.MULTI) { | ||||
|             controller.selectedMangas.forEach { manga -> | ||||
|                 val position = adapter.indexOf(manga) | ||||
|                 if (position != -1 && !adapter.isSelected(position)) { | ||||
|                     adapter.toggleSelection(position) | ||||
|                     (recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Subscribe to [LibrarySelectionEvent]. When an event is received, it updates the selection | ||||
|      * depending on the type of event received. | ||||
|      * | ||||
|      * @param event the selection event received. | ||||
|      */ | ||||
|     private fun onSelectionChanged(event: LibrarySelectionEvent) { | ||||
|         when (event) { | ||||
|             is LibrarySelectionEvent.Selected -> { | ||||
|                 if (adapter.mode != SelectableAdapter.Mode.MULTI) { | ||||
|                     adapter.mode = SelectableAdapter.Mode.MULTI | ||||
|                 } | ||||
|                 findAndToggleSelection(event.manga) | ||||
|             } | ||||
|             is LibrarySelectionEvent.Unselected -> { | ||||
|                 findAndToggleSelection(event.manga) | ||||
|                 if (controller.selectedMangas.isEmpty()) { | ||||
|                     adapter.mode = SelectableAdapter.Mode.SINGLE | ||||
|                 } | ||||
|             } | ||||
|             is LibrarySelectionEvent.Cleared -> { | ||||
|                 adapter.mode = SelectableAdapter.Mode.SINGLE | ||||
|                 adapter.clearSelection() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Toggles the selection for the given manga and updates the view if needed. | ||||
|      * | ||||
|      * @param manga the manga to toggle. | ||||
|      */ | ||||
|     private fun findAndToggleSelection(manga: Manga) { | ||||
|         val position = adapter.indexOf(manga) | ||||
|         if (position != -1) { | ||||
|             adapter.toggleSelection(position) | ||||
|             (recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when a manga is clicked. | ||||
|      * | ||||
|      * @param position the position of the element clicked. | ||||
|      * @return true if the item should be selected, false otherwise. | ||||
|      */ | ||||
|     override fun onItemClick(position: Int): Boolean { | ||||
|         // If the action mode is created and the position is valid, toggle the selection. | ||||
|         val item = adapter.getItem(position) ?: return false | ||||
|         if (adapter.mode == SelectableAdapter.Mode.MULTI) { | ||||
|             toggleSelection(position) | ||||
|             return true | ||||
|         } else { | ||||
|             openManga(item.manga) | ||||
|             return false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when a manga is long clicked. | ||||
|      * | ||||
|      * @param position the position of the element clicked. | ||||
|      */ | ||||
|     override fun onItemLongClick(position: Int) { | ||||
|         controller.createActionModeIfNeeded() | ||||
|         toggleSelection(position) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Opens a manga. | ||||
|      * | ||||
|      * @param manga the manga to open. | ||||
|      */ | ||||
|     private fun openManga(manga: Manga) { | ||||
|         controller.openManga(manga) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Tells the presenter to toggle the selection for the given position. | ||||
|      * | ||||
|      * @param position the position to toggle. | ||||
|      */ | ||||
|     private fun toggleSelection(position: Int) { | ||||
|         val item = adapter.getItem(position) ?: return | ||||
|  | ||||
|         controller.setSelection(item.manga, !adapter.isSelected(position)) | ||||
|         controller.invalidateActionMode() | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.content.Context | ||||
| import android.support.v7.widget.LinearLayoutManager | ||||
| import android.support.v7.widget.RecyclerView | ||||
| import android.util.AttributeSet | ||||
| import android.widget.FrameLayout | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.SelectableAdapter | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Category | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateService | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.util.inflate | ||||
| import eu.kanade.tachiyomi.util.plusAssign | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import eu.kanade.tachiyomi.widget.AutofitRecyclerView | ||||
| import kotlinx.android.synthetic.main.library_category.view.* | ||||
| import rx.subscriptions.CompositeSubscription | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| /** | ||||
|  * Fragment containing the library manga for a certain category. | ||||
|  */ | ||||
| class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : | ||||
|         FrameLayout(context, attrs), | ||||
|         FlexibleAdapter.OnItemClickListener, | ||||
|         FlexibleAdapter.OnItemLongClickListener { | ||||
|  | ||||
|     /** | ||||
|      * Preferences. | ||||
|      */ | ||||
|     private val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * The fragment containing this view. | ||||
|      */ | ||||
|     private lateinit var controller: LibraryController | ||||
|  | ||||
|     /** | ||||
|      * Category for this view. | ||||
|      */ | ||||
|     lateinit var category: Category | ||||
|         private set | ||||
|  | ||||
|     /** | ||||
|      * Recycler view of the list of manga. | ||||
|      */ | ||||
|     private lateinit var recycler: RecyclerView | ||||
|  | ||||
|     /** | ||||
|      * Adapter to hold the manga in this category. | ||||
|      */ | ||||
|     private lateinit var adapter: LibraryCategoryAdapter | ||||
|  | ||||
|     /** | ||||
|      * Subscriptions while the view is bound. | ||||
|      */ | ||||
|     private var subscriptions = CompositeSubscription() | ||||
|  | ||||
|     fun onCreate(controller: LibraryController) { | ||||
|         this.controller = controller | ||||
|  | ||||
|         recycler = if (preferences.libraryAsList().getOrDefault()) { | ||||
|             (swipe_refresh.inflate(R.layout.library_list_recycler) as RecyclerView).apply { | ||||
|                 layoutManager = LinearLayoutManager(context) | ||||
|             } | ||||
|         } else { | ||||
|             (swipe_refresh.inflate(R.layout.library_grid_recycler) as AutofitRecyclerView).apply { | ||||
|                 spanCount = controller.mangaPerRow | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         adapter = LibraryCategoryAdapter(this) | ||||
|  | ||||
|         recycler.setHasFixedSize(true) | ||||
|         recycler.adapter = adapter | ||||
|         swipe_refresh.addView(recycler) | ||||
|  | ||||
|         recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { | ||||
|             override fun onScrollStateChanged(recycler: RecyclerView, newState: Int) { | ||||
|                 // Disable swipe refresh when view is not at the top | ||||
|                 val firstPos = (recycler.layoutManager as LinearLayoutManager) | ||||
|                         .findFirstCompletelyVisibleItemPosition() | ||||
|                 swipe_refresh.isEnabled = firstPos <= 0 | ||||
|             } | ||||
|         }) | ||||
|  | ||||
|         // Double the distance required to trigger sync | ||||
|         swipe_refresh.setDistanceToTriggerSync((2 * 64 * resources.displayMetrics.density).toInt()) | ||||
|         swipe_refresh.setOnRefreshListener { | ||||
|             if (!LibraryUpdateService.isRunning(context)) { | ||||
|                 LibraryUpdateService.start(context, category) | ||||
|                 context.toast(R.string.updating_category) | ||||
|             } | ||||
|             // It can be a very long operation, so we disable swipe refresh and show a toast. | ||||
|             swipe_refresh.isRefreshing = false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun onBind(category: Category) { | ||||
|         this.category = category | ||||
|  | ||||
|         adapter.mode = if (controller.selectedMangas.isNotEmpty()) { | ||||
|             SelectableAdapter.Mode.MULTI | ||||
|         } else { | ||||
|             SelectableAdapter.Mode.SINGLE | ||||
|         } | ||||
|  | ||||
|         subscriptions += controller.searchRelay | ||||
|                 .doOnNext { adapter.searchText = it } | ||||
|                 .skip(1) | ||||
|                 .subscribe { adapter.performFilter() } | ||||
|  | ||||
|         subscriptions += controller.libraryMangaRelay | ||||
|                 .subscribe { onNextLibraryManga(it) } | ||||
|  | ||||
|         subscriptions += controller.selectionRelay | ||||
|                 .subscribe { onSelectionChanged(it) } | ||||
|     } | ||||
|  | ||||
|     fun onRecycle() { | ||||
|         adapter.setItems(emptyList()) | ||||
|         adapter.clearSelection() | ||||
|         unsubscribe() | ||||
|     } | ||||
|  | ||||
|     fun unsubscribe() { | ||||
|         subscriptions.clear() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Subscribe to [LibraryMangaEvent]. When an event is received, it updates the content of the | ||||
|      * adapter. | ||||
|      * | ||||
|      * @param event the event received. | ||||
|      */ | ||||
|     fun onNextLibraryManga(event: LibraryMangaEvent) { | ||||
|         // Get the manga list for this category. | ||||
|         val mangaForCategory = event.getMangaForCategory(category).orEmpty() | ||||
|  | ||||
|         // Update the category with its manga. | ||||
|         adapter.setItems(mangaForCategory) | ||||
|  | ||||
|         if (adapter.mode == SelectableAdapter.Mode.MULTI) { | ||||
|             controller.selectedMangas.forEach { manga -> | ||||
|                 val position = adapter.indexOf(manga) | ||||
|                 if (position != -1 && !adapter.isSelected(position)) { | ||||
|                     adapter.toggleSelection(position) | ||||
|                     (recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Subscribe to [LibrarySelectionEvent]. When an event is received, it updates the selection | ||||
|      * depending on the type of event received. | ||||
|      * | ||||
|      * @param event the selection event received. | ||||
|      */ | ||||
|     private fun onSelectionChanged(event: LibrarySelectionEvent) { | ||||
|         when (event) { | ||||
|             is LibrarySelectionEvent.Selected -> { | ||||
|                 if (adapter.mode != SelectableAdapter.Mode.MULTI) { | ||||
|                     adapter.mode = SelectableAdapter.Mode.MULTI | ||||
|                 } | ||||
|                 findAndToggleSelection(event.manga) | ||||
|             } | ||||
|             is LibrarySelectionEvent.Unselected -> { | ||||
|                 findAndToggleSelection(event.manga) | ||||
|                 if (controller.selectedMangas.isEmpty()) { | ||||
|                     adapter.mode = SelectableAdapter.Mode.SINGLE | ||||
|                 } | ||||
|             } | ||||
|             is LibrarySelectionEvent.Cleared -> { | ||||
|                 adapter.mode = SelectableAdapter.Mode.SINGLE | ||||
|                 adapter.clearSelection() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Toggles the selection for the given manga and updates the view if needed. | ||||
|      * | ||||
|      * @param manga the manga to toggle. | ||||
|      */ | ||||
|     private fun findAndToggleSelection(manga: Manga) { | ||||
|         val position = adapter.indexOf(manga) | ||||
|         if (position != -1) { | ||||
|             adapter.toggleSelection(position) | ||||
|             (recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when a manga is clicked. | ||||
|      * | ||||
|      * @param position the position of the element clicked. | ||||
|      * @return true if the item should be selected, false otherwise. | ||||
|      */ | ||||
|     override fun onItemClick(position: Int): Boolean { | ||||
|         // If the action mode is created and the position is valid, toggle the selection. | ||||
|         val item = adapter.getItem(position) ?: return false | ||||
|         if (adapter.mode == SelectableAdapter.Mode.MULTI) { | ||||
|             toggleSelection(position) | ||||
|             return true | ||||
|         } else { | ||||
|             openManga(item.manga) | ||||
|             return false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when a manga is long clicked. | ||||
|      * | ||||
|      * @param position the position of the element clicked. | ||||
|      */ | ||||
|     override fun onItemLongClick(position: Int) { | ||||
|         controller.createActionModeIfNeeded() | ||||
|         toggleSelection(position) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Opens a manga. | ||||
|      * | ||||
|      * @param manga the manga to open. | ||||
|      */ | ||||
|     private fun openManga(manga: Manga) { | ||||
|         controller.openManga(manga) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Tells the presenter to toggle the selection for the given position. | ||||
|      * | ||||
|      * @param position the position to toggle. | ||||
|      */ | ||||
|     private fun toggleSelection(position: Int) { | ||||
|         val item = adapter.getItem(position) ?: return | ||||
|  | ||||
|         controller.setSelection(item.manga, !adapter.isSelected(position)) | ||||
|         controller.invalidateActionMode() | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,57 +1,57 @@ | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.view.View | ||||
| import com.bumptech.glide.load.engine.DiskCacheStrategy | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.kanade.tachiyomi.data.glide.GlideApp | ||||
| import eu.kanade.tachiyomi.source.LocalSource | ||||
| import kotlinx.android.synthetic.main.catalogue_grid_item.* | ||||
|  | ||||
| /** | ||||
|  * Class used to hold the displayed data of a manga in the library, like the cover or the title. | ||||
|  * All the elements from the layout file "item_catalogue_grid" are available in this class. | ||||
|  * | ||||
|  * @param view the inflated view for this holder. | ||||
|  * @param adapter the adapter handling this holder. | ||||
|  * @param listener a listener to react to single tap and long tap events. | ||||
|  * @constructor creates a new library holder. | ||||
|  */ | ||||
| class LibraryGridHolder( | ||||
|         private val view: View, | ||||
|         private val adapter: FlexibleAdapter<*> | ||||
|  | ||||
| ) : LibraryHolder(view, adapter) { | ||||
|  | ||||
|     /** | ||||
|      * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this | ||||
|      * holder with the given manga. | ||||
|      * | ||||
|      * @param item the manga item to bind. | ||||
|      */ | ||||
|     override fun onSetValues(item: LibraryItem) { | ||||
|         // Update the title of the manga. | ||||
|         title.text = item.manga.title | ||||
|  | ||||
|         // Update the unread count and its visibility. | ||||
|         with(unread_text) { | ||||
|             visibility = if (item.manga.unread > 0) View.VISIBLE else View.GONE | ||||
|             text = item.manga.unread.toString() | ||||
|         } | ||||
|         // Update the download count and its visibility. | ||||
|         with(download_text) { | ||||
|             visibility = if (item.downloadCount > 0) View.VISIBLE else View.GONE | ||||
|             text = item.downloadCount.toString() | ||||
|         } | ||||
|         //set local visibility if its local manga | ||||
|         local_text.visibility = if(item.manga.source == LocalSource.ID) View.VISIBLE else View.GONE | ||||
|  | ||||
|         // Update the cover. | ||||
|         GlideApp.with(view.context).clear(thumbnail) | ||||
|         GlideApp.with(view.context) | ||||
|                 .load(item.manga) | ||||
|                 .diskCacheStrategy(DiskCacheStrategy.RESOURCE) | ||||
|                 .centerCrop() | ||||
|                 .into(thumbnail) | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.view.View | ||||
| import com.bumptech.glide.load.engine.DiskCacheStrategy | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.kanade.tachiyomi.data.glide.GlideApp | ||||
| import eu.kanade.tachiyomi.source.LocalSource | ||||
| import kotlinx.android.synthetic.main.catalogue_grid_item.* | ||||
|  | ||||
| /** | ||||
|  * Class used to hold the displayed data of a manga in the library, like the cover or the title. | ||||
|  * All the elements from the layout file "item_catalogue_grid" are available in this class. | ||||
|  * | ||||
|  * @param view the inflated view for this holder. | ||||
|  * @param adapter the adapter handling this holder. | ||||
|  * @param listener a listener to react to single tap and long tap events. | ||||
|  * @constructor creates a new library holder. | ||||
|  */ | ||||
| class LibraryGridHolder( | ||||
|         private val view: View, | ||||
|         private val adapter: FlexibleAdapter<*> | ||||
|  | ||||
| ) : LibraryHolder(view, adapter) { | ||||
|  | ||||
|     /** | ||||
|      * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this | ||||
|      * holder with the given manga. | ||||
|      * | ||||
|      * @param item the manga item to bind. | ||||
|      */ | ||||
|     override fun onSetValues(item: LibraryItem) { | ||||
|         // Update the title of the manga. | ||||
|         title.text = item.manga.title | ||||
|  | ||||
|         // Update the unread count and its visibility. | ||||
|         with(unread_text) { | ||||
|             visibility = if (item.manga.unread > 0) View.VISIBLE else View.GONE | ||||
|             text = item.manga.unread.toString() | ||||
|         } | ||||
|         // Update the download count and its visibility. | ||||
|         with(download_text) { | ||||
|             visibility = if (item.downloadCount > 0) View.VISIBLE else View.GONE | ||||
|             text = item.downloadCount.toString() | ||||
|         } | ||||
|         //set local visibility if its local manga | ||||
|         local_text.visibility = if(item.manga.source == LocalSource.ID) View.VISIBLE else View.GONE | ||||
|  | ||||
|         // Update the cover. | ||||
|         GlideApp.with(view.context).clear(thumbnail) | ||||
|         GlideApp.with(view.context) | ||||
|                 .load(item.manga) | ||||
|                 .diskCacheStrategy(DiskCacheStrategy.RESOURCE) | ||||
|                 .centerCrop() | ||||
|                 .into(thumbnail) | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,27 +1,27 @@ | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.view.View | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder | ||||
|  | ||||
| /** | ||||
|  * Generic class used to hold the displayed data of a manga in the library. | ||||
|  * @param view the inflated view for this holder. | ||||
|  * @param adapter the adapter handling this holder. | ||||
|  * @param listener a listener to react to the single tap and long tap events. | ||||
|  */ | ||||
|  | ||||
| abstract class LibraryHolder( | ||||
|         view: View, | ||||
|         adapter: FlexibleAdapter<*> | ||||
| ) : BaseFlexibleViewHolder(view, adapter) { | ||||
|  | ||||
|     /** | ||||
|      * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this | ||||
|      * holder with the given manga. | ||||
|      * | ||||
|      * @param item the manga item to bind. | ||||
|      */ | ||||
|     abstract fun onSetValues(item: LibraryItem) | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.view.View | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder | ||||
|  | ||||
| /** | ||||
|  * Generic class used to hold the displayed data of a manga in the library. | ||||
|  * @param view the inflated view for this holder. | ||||
|  * @param adapter the adapter handling this holder. | ||||
|  * @param listener a listener to react to the single tap and long tap events. | ||||
|  */ | ||||
|  | ||||
| abstract class LibraryHolder( | ||||
|         view: View, | ||||
|         adapter: FlexibleAdapter<*> | ||||
| ) : BaseFlexibleViewHolder(view, adapter) { | ||||
|  | ||||
|     /** | ||||
|      * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this | ||||
|      * holder with the given manga. | ||||
|      * | ||||
|      * @param item the manga item to bind. | ||||
|      */ | ||||
|     abstract fun onSetValues(item: LibraryItem) | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,73 +1,73 @@ | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.view.Gravity | ||||
| import android.view.View | ||||
| import android.view.ViewGroup.LayoutParams.MATCH_PARENT | ||||
| import android.widget.FrameLayout | ||||
| import com.f2prateek.rx.preferences.Preference | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.items.AbstractFlexibleItem | ||||
| import eu.davidea.flexibleadapter.items.IFilterable | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.LibraryManga | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.widget.AutofitRecyclerView | ||||
| import kotlinx.android.synthetic.main.catalogue_grid_item.view.* | ||||
|  | ||||
| class LibraryItem(val manga: LibraryManga, private val libraryAsList: Preference<Boolean>) : | ||||
|         AbstractFlexibleItem<LibraryHolder>(), IFilterable { | ||||
|  | ||||
|     var downloadCount = -1 | ||||
|  | ||||
|     override fun getLayoutRes(): Int { | ||||
|         return if (libraryAsList.getOrDefault()) | ||||
|             R.layout.catalogue_list_item | ||||
|         else | ||||
|             R.layout.catalogue_grid_item | ||||
|     } | ||||
|  | ||||
|     override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): LibraryHolder { | ||||
|         val parent = adapter.recyclerView | ||||
|         return if (parent is AutofitRecyclerView) { | ||||
|             view.apply { | ||||
|                 val coverHeight = parent.itemWidth / 3 * 4 | ||||
|                 card.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight) | ||||
|                 gradient.layoutParams = FrameLayout.LayoutParams( | ||||
|                         MATCH_PARENT, coverHeight / 2, Gravity.BOTTOM) | ||||
|             } | ||||
|             LibraryGridHolder(view, adapter) | ||||
|         } else { | ||||
|             LibraryListHolder(view, adapter) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun bindViewHolder(adapter: FlexibleAdapter<*>, | ||||
|                                 holder: LibraryHolder, | ||||
|                                 position: Int, | ||||
|                                 payloads: List<Any?>?) { | ||||
|  | ||||
|         holder.onSetValues(this) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Filters a manga depending on a query. | ||||
|      * | ||||
|      * @param constraint the query to apply. | ||||
|      * @return true if the manga should be included, false otherwise. | ||||
|      */ | ||||
|     override fun filter(constraint: String): Boolean { | ||||
|         return manga.title.contains(constraint, true) || | ||||
|                 (manga.author?.contains(constraint, true) ?: false) | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (other is LibraryItem) { | ||||
|             return manga.id == other.manga.id | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return manga.id!!.hashCode() | ||||
|     } | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.view.Gravity | ||||
| import android.view.View | ||||
| import android.view.ViewGroup.LayoutParams.MATCH_PARENT | ||||
| import android.widget.FrameLayout | ||||
| import com.f2prateek.rx.preferences.Preference | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.items.AbstractFlexibleItem | ||||
| import eu.davidea.flexibleadapter.items.IFilterable | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.LibraryManga | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.widget.AutofitRecyclerView | ||||
| import kotlinx.android.synthetic.main.catalogue_grid_item.view.* | ||||
|  | ||||
| class LibraryItem(val manga: LibraryManga, private val libraryAsList: Preference<Boolean>) : | ||||
|         AbstractFlexibleItem<LibraryHolder>(), IFilterable { | ||||
|  | ||||
|     var downloadCount = -1 | ||||
|  | ||||
|     override fun getLayoutRes(): Int { | ||||
|         return if (libraryAsList.getOrDefault()) | ||||
|             R.layout.catalogue_list_item | ||||
|         else | ||||
|             R.layout.catalogue_grid_item | ||||
|     } | ||||
|  | ||||
|     override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): LibraryHolder { | ||||
|         val parent = adapter.recyclerView | ||||
|         return if (parent is AutofitRecyclerView) { | ||||
|             view.apply { | ||||
|                 val coverHeight = parent.itemWidth / 3 * 4 | ||||
|                 card.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight) | ||||
|                 gradient.layoutParams = FrameLayout.LayoutParams( | ||||
|                         MATCH_PARENT, coverHeight / 2, Gravity.BOTTOM) | ||||
|             } | ||||
|             LibraryGridHolder(view, adapter) | ||||
|         } else { | ||||
|             LibraryListHolder(view, adapter) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun bindViewHolder(adapter: FlexibleAdapter<*>, | ||||
|                                 holder: LibraryHolder, | ||||
|                                 position: Int, | ||||
|                                 payloads: List<Any?>?) { | ||||
|  | ||||
|         holder.onSetValues(this) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Filters a manga depending on a query. | ||||
|      * | ||||
|      * @param constraint the query to apply. | ||||
|      * @return true if the manga should be included, false otherwise. | ||||
|      */ | ||||
|     override fun filter(constraint: String): Boolean { | ||||
|         return manga.title.contains(constraint, true) || | ||||
|                 (manga.author?.contains(constraint, true) ?: false) | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (other is LibraryItem) { | ||||
|             return manga.id == other.manga.id | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return manga.id!!.hashCode() | ||||
|     } | ||||
| } | ||||
| @@ -1,65 +1,65 @@ | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.view.View | ||||
| import com.bumptech.glide.load.engine.DiskCacheStrategy | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.kanade.tachiyomi.data.glide.GlideApp | ||||
| import eu.kanade.tachiyomi.source.LocalSource | ||||
| import kotlinx.android.synthetic.main.catalogue_list_item.* | ||||
|  | ||||
| /** | ||||
|  * Class used to hold the displayed data of a manga in the library, like the cover or the title. | ||||
|  * All the elements from the layout file "item_library_list" are available in this class. | ||||
|  * | ||||
|  * @param view the inflated view for this holder. | ||||
|  * @param adapter the adapter handling this holder. | ||||
|  * @param listener a listener to react to single tap and long tap events. | ||||
|  * @constructor creates a new library holder. | ||||
|  */ | ||||
|  | ||||
| class LibraryListHolder( | ||||
|         private val view: View, | ||||
|         private val adapter: FlexibleAdapter<*> | ||||
| ) : LibraryHolder(view, adapter) { | ||||
|  | ||||
|     /** | ||||
|      * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this | ||||
|      * holder with the given manga. | ||||
|      * | ||||
|      * @param item the manga item to bind. | ||||
|      */ | ||||
|     override fun onSetValues(item: LibraryItem) { | ||||
|         // Update the title of the manga. | ||||
|         title.text = item.manga.title | ||||
|  | ||||
|         // Update the unread count and its visibility. | ||||
|         with(unread_text) { | ||||
|             visibility = if (item.manga.unread > 0) View.VISIBLE else View.GONE | ||||
|             text = item.manga.unread.toString() | ||||
|         } | ||||
|         // Update the download count and its visibility. | ||||
|         with(download_text) { | ||||
|             visibility = if (item.downloadCount > 0) View.VISIBLE else View.GONE | ||||
|             text = "${item.downloadCount}" | ||||
|         } | ||||
|         //show local text badge if local manga | ||||
|         local_text.visibility = if (item.manga.source == LocalSource.ID) View.VISIBLE else View.GONE | ||||
|  | ||||
|         // Create thumbnail onclick to simulate long click | ||||
|         thumbnail.setOnClickListener { | ||||
|             // Simulate long click on this view to enter selection mode | ||||
|             onLongClick(itemView) | ||||
|         } | ||||
|  | ||||
|         // Update the cover. | ||||
|         GlideApp.with(itemView.context).clear(thumbnail) | ||||
|         GlideApp.with(itemView.context) | ||||
|                 .load(item.manga) | ||||
|                 .diskCacheStrategy(DiskCacheStrategy.RESOURCE) | ||||
|                 .centerCrop() | ||||
|                 .circleCrop() | ||||
|                 .dontAnimate() | ||||
|                 .into(thumbnail) | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.view.View | ||||
| import com.bumptech.glide.load.engine.DiskCacheStrategy | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.kanade.tachiyomi.data.glide.GlideApp | ||||
| import eu.kanade.tachiyomi.source.LocalSource | ||||
| import kotlinx.android.synthetic.main.catalogue_list_item.* | ||||
|  | ||||
| /** | ||||
|  * Class used to hold the displayed data of a manga in the library, like the cover or the title. | ||||
|  * All the elements from the layout file "item_library_list" are available in this class. | ||||
|  * | ||||
|  * @param view the inflated view for this holder. | ||||
|  * @param adapter the adapter handling this holder. | ||||
|  * @param listener a listener to react to single tap and long tap events. | ||||
|  * @constructor creates a new library holder. | ||||
|  */ | ||||
|  | ||||
| class LibraryListHolder( | ||||
|         private val view: View, | ||||
|         private val adapter: FlexibleAdapter<*> | ||||
| ) : LibraryHolder(view, adapter) { | ||||
|  | ||||
|     /** | ||||
|      * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this | ||||
|      * holder with the given manga. | ||||
|      * | ||||
|      * @param item the manga item to bind. | ||||
|      */ | ||||
|     override fun onSetValues(item: LibraryItem) { | ||||
|         // Update the title of the manga. | ||||
|         title.text = item.manga.title | ||||
|  | ||||
|         // Update the unread count and its visibility. | ||||
|         with(unread_text) { | ||||
|             visibility = if (item.manga.unread > 0) View.VISIBLE else View.GONE | ||||
|             text = item.manga.unread.toString() | ||||
|         } | ||||
|         // Update the download count and its visibility. | ||||
|         with(download_text) { | ||||
|             visibility = if (item.downloadCount > 0) View.VISIBLE else View.GONE | ||||
|             text = "${item.downloadCount}" | ||||
|         } | ||||
|         //show local text badge if local manga | ||||
|         local_text.visibility = if (item.manga.source == LocalSource.ID) View.VISIBLE else View.GONE | ||||
|  | ||||
|         // Create thumbnail onclick to simulate long click | ||||
|         thumbnail.setOnClickListener { | ||||
|             // Simulate long click on this view to enter selection mode | ||||
|             onLongClick(itemView) | ||||
|         } | ||||
|  | ||||
|         // Update the cover. | ||||
|         GlideApp.with(itemView.context).clear(thumbnail) | ||||
|         GlideApp.with(itemView.context) | ||||
|                 .load(item.manga) | ||||
|                 .diskCacheStrategy(DiskCacheStrategy.RESOURCE) | ||||
|                 .centerCrop() | ||||
|                 .circleCrop() | ||||
|                 .dontAnimate() | ||||
|                 .into(thumbnail) | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,217 +1,217 @@ | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.content.Context | ||||
| import android.util.AttributeSet | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.widget.ExtendedNavigationView | ||||
| import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_ASC | ||||
| import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_DESC | ||||
| import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_NONE | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| /** | ||||
|  * The navigation view shown in a drawer with the different options to show the library. | ||||
|  */ | ||||
| class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) | ||||
|     : ExtendedNavigationView(context, attrs) { | ||||
|  | ||||
|     /** | ||||
|      * Preferences helper. | ||||
|      */ | ||||
|     private val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * List of groups shown in the view. | ||||
|      */ | ||||
|     private val groups = listOf(FilterGroup(), SortGroup(), DisplayGroup(), BadgeGroup()) | ||||
|  | ||||
|     /** | ||||
|      * Adapter instance. | ||||
|      */ | ||||
|     private val adapter = Adapter(groups.map { it.createItems() }.flatten()) | ||||
|  | ||||
|     /** | ||||
|      * Click listener to notify the parent fragment when an item from a group is clicked. | ||||
|      */ | ||||
|     var onGroupClicked: (Group) -> Unit = {} | ||||
|  | ||||
|     init { | ||||
|         recycler.adapter = adapter | ||||
|         addView(recycler) | ||||
|  | ||||
|         groups.forEach { it.initModels() } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns true if there's at least one filter from [FilterGroup] active. | ||||
|      */ | ||||
|     fun hasActiveFilters(): Boolean { | ||||
|         return (groups[0] as FilterGroup).items.any { it.checked } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Adapter of the recycler view. | ||||
|      */ | ||||
|     inner class Adapter(items: List<Item>) : ExtendedNavigationView.Adapter(items) { | ||||
|  | ||||
|         override fun onItemClicked(item: Item) { | ||||
|             if (item is GroupedItem) { | ||||
|                 item.group.onItemClicked(item) | ||||
|                 onGroupClicked(item.group) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Filters group (unread, downloaded, ...). | ||||
|      */ | ||||
|     inner class FilterGroup : Group { | ||||
|  | ||||
|         private val downloaded = Item.CheckboxGroup(R.string.action_filter_downloaded, this) | ||||
|  | ||||
|         private val unread = Item.CheckboxGroup(R.string.action_filter_unread, this) | ||||
|  | ||||
|         private val completed = Item.CheckboxGroup(R.string.completed, this) | ||||
|  | ||||
|         override val items = listOf(downloaded, unread, completed) | ||||
|  | ||||
|         override val header = Item.Header(R.string.action_filter) | ||||
|  | ||||
|         override val footer = Item.Separator() | ||||
|  | ||||
|         override fun initModels() { | ||||
|             downloaded.checked = preferences.filterDownloaded().getOrDefault() | ||||
|             unread.checked = preferences.filterUnread().getOrDefault() | ||||
|             completed.checked = preferences.filterCompleted().getOrDefault() | ||||
|         } | ||||
|  | ||||
|         override fun onItemClicked(item: Item) { | ||||
|             item as Item.CheckboxGroup | ||||
|             item.checked = !item.checked | ||||
|             when (item) { | ||||
|                 downloaded -> preferences.filterDownloaded().set(item.checked) | ||||
|                 unread -> preferences.filterUnread().set(item.checked) | ||||
|                 completed -> preferences.filterCompleted().set(item.checked) | ||||
|             } | ||||
|  | ||||
|             adapter.notifyItemChanged(item) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sorting group (alphabetically, by last read, ...) and ascending or descending. | ||||
|      */ | ||||
|     inner class SortGroup : Group { | ||||
|  | ||||
|         private val alphabetically = Item.MultiSort(R.string.action_sort_alpha, this) | ||||
|  | ||||
|         private val total = Item.MultiSort(R.string.action_sort_total, this) | ||||
|  | ||||
|         private val lastRead = Item.MultiSort(R.string.action_sort_last_read, this) | ||||
|  | ||||
|         private val lastUpdated = Item.MultiSort(R.string.action_sort_last_updated, this) | ||||
|  | ||||
|         private val unread = Item.MultiSort(R.string.action_filter_unread, this) | ||||
|  | ||||
|         private val source = Item.MultiSort(R.string.manga_info_source_label, this) | ||||
|  | ||||
|         override val items = listOf(alphabetically, lastRead, lastUpdated, unread, total, source) | ||||
|  | ||||
|         override val header = Item.Header(R.string.action_sort) | ||||
|  | ||||
|         override val footer = Item.Separator() | ||||
|  | ||||
|         override fun initModels() { | ||||
|             val sorting = preferences.librarySortingMode().getOrDefault() | ||||
|             val order = if (preferences.librarySortingAscending().getOrDefault()) | ||||
|                 SORT_ASC else SORT_DESC | ||||
|  | ||||
|             alphabetically.state = if (sorting == LibrarySort.ALPHA) order else SORT_NONE | ||||
|             lastRead.state = if (sorting == LibrarySort.LAST_READ) order else SORT_NONE | ||||
|             lastUpdated.state = if (sorting == LibrarySort.LAST_UPDATED) order else SORT_NONE | ||||
|             unread.state = if (sorting == LibrarySort.UNREAD) order else SORT_NONE | ||||
|             total.state = if (sorting == LibrarySort.TOTAL) order else SORT_NONE | ||||
|             source.state = if (sorting == LibrarySort.SOURCE) order else SORT_NONE | ||||
|         } | ||||
|  | ||||
|         override fun onItemClicked(item: Item) { | ||||
|             item as Item.MultiStateGroup | ||||
|             val prevState = item.state | ||||
|  | ||||
|             item.group.items.forEach { (it as Item.MultiStateGroup).state = SORT_NONE } | ||||
|             item.state = when (prevState) { | ||||
|                 SORT_NONE -> SORT_ASC | ||||
|                 SORT_ASC -> SORT_DESC | ||||
|                 SORT_DESC -> SORT_ASC | ||||
|                 else -> throw Exception("Unknown state") | ||||
|             } | ||||
|  | ||||
|             preferences.librarySortingMode().set(when (item) { | ||||
|                 alphabetically -> LibrarySort.ALPHA | ||||
|                 lastRead -> LibrarySort.LAST_READ | ||||
|                 lastUpdated -> LibrarySort.LAST_UPDATED | ||||
|                 unread -> LibrarySort.UNREAD | ||||
|                 total -> LibrarySort.TOTAL | ||||
|                 source -> LibrarySort.SOURCE | ||||
|                 else -> throw Exception("Unknown sorting") | ||||
|             }) | ||||
|             preferences.librarySortingAscending().set(if (item.state == SORT_ASC) true else false) | ||||
|  | ||||
|             item.group.items.forEach { adapter.notifyItemChanged(it) } | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     inner class BadgeGroup : Group { | ||||
|         private val downloadBadge = Item.CheckboxGroup(R.string.action_display_download_badge, this) | ||||
|         override val header = null | ||||
|         override val footer = null | ||||
|         override val items = listOf(downloadBadge) | ||||
|         override fun initModels() { | ||||
|             downloadBadge.checked = preferences.downloadBadge().getOrDefault() | ||||
|         } | ||||
|  | ||||
|         override fun onItemClicked(item: Item) { | ||||
|             item as Item.CheckboxGroup | ||||
|             item.checked = !item.checked | ||||
|             preferences.downloadBadge().set((item.checked)) | ||||
|             adapter.notifyItemChanged(item) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Display group, to show the library as a list or a grid. | ||||
|      */ | ||||
|     inner class DisplayGroup : Group { | ||||
|  | ||||
|         private val grid = Item.Radio(R.string.action_display_grid, this) | ||||
|  | ||||
|         private val list = Item.Radio(R.string.action_display_list, this) | ||||
|  | ||||
|         override val items = listOf(grid, list) | ||||
|  | ||||
|         override val header = Item.Header(R.string.action_display) | ||||
|  | ||||
|         override val footer = null | ||||
|  | ||||
|         override fun initModels() { | ||||
|             val asList = preferences.libraryAsList().getOrDefault() | ||||
|             grid.checked = !asList | ||||
|             list.checked = asList | ||||
|         } | ||||
|  | ||||
|         override fun onItemClicked(item: Item) { | ||||
|             item as Item.Radio | ||||
|             if (item.checked) return | ||||
|  | ||||
|             item.group.items.forEach { (it as Item.Radio).checked = false } | ||||
|             item.checked = true | ||||
|  | ||||
|             preferences.libraryAsList().set(if (item == list) true else false) | ||||
|  | ||||
|             item.group.items.forEach { adapter.notifyItemChanged(it) } | ||||
|         } | ||||
|     } | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.content.Context | ||||
| import android.util.AttributeSet | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.widget.ExtendedNavigationView | ||||
| import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_ASC | ||||
| import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_DESC | ||||
| import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_NONE | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| /** | ||||
|  * The navigation view shown in a drawer with the different options to show the library. | ||||
|  */ | ||||
| class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) | ||||
|     : ExtendedNavigationView(context, attrs) { | ||||
|  | ||||
|     /** | ||||
|      * Preferences helper. | ||||
|      */ | ||||
|     private val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * List of groups shown in the view. | ||||
|      */ | ||||
|     private val groups = listOf(FilterGroup(), SortGroup(), DisplayGroup(), BadgeGroup()) | ||||
|  | ||||
|     /** | ||||
|      * Adapter instance. | ||||
|      */ | ||||
|     private val adapter = Adapter(groups.map { it.createItems() }.flatten()) | ||||
|  | ||||
|     /** | ||||
|      * Click listener to notify the parent fragment when an item from a group is clicked. | ||||
|      */ | ||||
|     var onGroupClicked: (Group) -> Unit = {} | ||||
|  | ||||
|     init { | ||||
|         recycler.adapter = adapter | ||||
|         addView(recycler) | ||||
|  | ||||
|         groups.forEach { it.initModels() } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns true if there's at least one filter from [FilterGroup] active. | ||||
|      */ | ||||
|     fun hasActiveFilters(): Boolean { | ||||
|         return (groups[0] as FilterGroup).items.any { it.checked } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Adapter of the recycler view. | ||||
|      */ | ||||
|     inner class Adapter(items: List<Item>) : ExtendedNavigationView.Adapter(items) { | ||||
|  | ||||
|         override fun onItemClicked(item: Item) { | ||||
|             if (item is GroupedItem) { | ||||
|                 item.group.onItemClicked(item) | ||||
|                 onGroupClicked(item.group) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Filters group (unread, downloaded, ...). | ||||
|      */ | ||||
|     inner class FilterGroup : Group { | ||||
|  | ||||
|         private val downloaded = Item.CheckboxGroup(R.string.action_filter_downloaded, this) | ||||
|  | ||||
|         private val unread = Item.CheckboxGroup(R.string.action_filter_unread, this) | ||||
|  | ||||
|         private val completed = Item.CheckboxGroup(R.string.completed, this) | ||||
|  | ||||
|         override val items = listOf(downloaded, unread, completed) | ||||
|  | ||||
|         override val header = Item.Header(R.string.action_filter) | ||||
|  | ||||
|         override val footer = Item.Separator() | ||||
|  | ||||
|         override fun initModels() { | ||||
|             downloaded.checked = preferences.filterDownloaded().getOrDefault() | ||||
|             unread.checked = preferences.filterUnread().getOrDefault() | ||||
|             completed.checked = preferences.filterCompleted().getOrDefault() | ||||
|         } | ||||
|  | ||||
|         override fun onItemClicked(item: Item) { | ||||
|             item as Item.CheckboxGroup | ||||
|             item.checked = !item.checked | ||||
|             when (item) { | ||||
|                 downloaded -> preferences.filterDownloaded().set(item.checked) | ||||
|                 unread -> preferences.filterUnread().set(item.checked) | ||||
|                 completed -> preferences.filterCompleted().set(item.checked) | ||||
|             } | ||||
|  | ||||
|             adapter.notifyItemChanged(item) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sorting group (alphabetically, by last read, ...) and ascending or descending. | ||||
|      */ | ||||
|     inner class SortGroup : Group { | ||||
|  | ||||
|         private val alphabetically = Item.MultiSort(R.string.action_sort_alpha, this) | ||||
|  | ||||
|         private val total = Item.MultiSort(R.string.action_sort_total, this) | ||||
|  | ||||
|         private val lastRead = Item.MultiSort(R.string.action_sort_last_read, this) | ||||
|  | ||||
|         private val lastUpdated = Item.MultiSort(R.string.action_sort_last_updated, this) | ||||
|  | ||||
|         private val unread = Item.MultiSort(R.string.action_filter_unread, this) | ||||
|  | ||||
|         private val source = Item.MultiSort(R.string.manga_info_source_label, this) | ||||
|  | ||||
|         override val items = listOf(alphabetically, lastRead, lastUpdated, unread, total, source) | ||||
|  | ||||
|         override val header = Item.Header(R.string.action_sort) | ||||
|  | ||||
|         override val footer = Item.Separator() | ||||
|  | ||||
|         override fun initModels() { | ||||
|             val sorting = preferences.librarySortingMode().getOrDefault() | ||||
|             val order = if (preferences.librarySortingAscending().getOrDefault()) | ||||
|                 SORT_ASC else SORT_DESC | ||||
|  | ||||
|             alphabetically.state = if (sorting == LibrarySort.ALPHA) order else SORT_NONE | ||||
|             lastRead.state = if (sorting == LibrarySort.LAST_READ) order else SORT_NONE | ||||
|             lastUpdated.state = if (sorting == LibrarySort.LAST_UPDATED) order else SORT_NONE | ||||
|             unread.state = if (sorting == LibrarySort.UNREAD) order else SORT_NONE | ||||
|             total.state = if (sorting == LibrarySort.TOTAL) order else SORT_NONE | ||||
|             source.state = if (sorting == LibrarySort.SOURCE) order else SORT_NONE | ||||
|         } | ||||
|  | ||||
|         override fun onItemClicked(item: Item) { | ||||
|             item as Item.MultiStateGroup | ||||
|             val prevState = item.state | ||||
|  | ||||
|             item.group.items.forEach { (it as Item.MultiStateGroup).state = SORT_NONE } | ||||
|             item.state = when (prevState) { | ||||
|                 SORT_NONE -> SORT_ASC | ||||
|                 SORT_ASC -> SORT_DESC | ||||
|                 SORT_DESC -> SORT_ASC | ||||
|                 else -> throw Exception("Unknown state") | ||||
|             } | ||||
|  | ||||
|             preferences.librarySortingMode().set(when (item) { | ||||
|                 alphabetically -> LibrarySort.ALPHA | ||||
|                 lastRead -> LibrarySort.LAST_READ | ||||
|                 lastUpdated -> LibrarySort.LAST_UPDATED | ||||
|                 unread -> LibrarySort.UNREAD | ||||
|                 total -> LibrarySort.TOTAL | ||||
|                 source -> LibrarySort.SOURCE | ||||
|                 else -> throw Exception("Unknown sorting") | ||||
|             }) | ||||
|             preferences.librarySortingAscending().set(if (item.state == SORT_ASC) true else false) | ||||
|  | ||||
|             item.group.items.forEach { adapter.notifyItemChanged(it) } | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     inner class BadgeGroup : Group { | ||||
|         private val downloadBadge = Item.CheckboxGroup(R.string.action_display_download_badge, this) | ||||
|         override val header = null | ||||
|         override val footer = null | ||||
|         override val items = listOf(downloadBadge) | ||||
|         override fun initModels() { | ||||
|             downloadBadge.checked = preferences.downloadBadge().getOrDefault() | ||||
|         } | ||||
|  | ||||
|         override fun onItemClicked(item: Item) { | ||||
|             item as Item.CheckboxGroup | ||||
|             item.checked = !item.checked | ||||
|             preferences.downloadBadge().set((item.checked)) | ||||
|             adapter.notifyItemChanged(item) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Display group, to show the library as a list or a grid. | ||||
|      */ | ||||
|     inner class DisplayGroup : Group { | ||||
|  | ||||
|         private val grid = Item.Radio(R.string.action_display_grid, this) | ||||
|  | ||||
|         private val list = Item.Radio(R.string.action_display_list, this) | ||||
|  | ||||
|         override val items = listOf(grid, list) | ||||
|  | ||||
|         override val header = Item.Header(R.string.action_display) | ||||
|  | ||||
|         override val footer = null | ||||
|  | ||||
|         override fun initModels() { | ||||
|             val asList = preferences.libraryAsList().getOrDefault() | ||||
|             grid.checked = !asList | ||||
|             list.checked = asList | ||||
|         } | ||||
|  | ||||
|         override fun onItemClicked(item: Item) { | ||||
|             item as Item.Radio | ||||
|             if (item.checked) return | ||||
|  | ||||
|             item.group.items.forEach { (it as Item.Radio).checked = false } | ||||
|             item.checked = true | ||||
|  | ||||
|             preferences.libraryAsList().set(if (item == list) true else false) | ||||
|  | ||||
|             item.group.items.forEach { adapter.notifyItemChanged(it) } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,371 +1,371 @@ | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.os.Bundle | ||||
| import com.jakewharton.rxrelay.BehaviorRelay | ||||
| import eu.kanade.tachiyomi.data.cache.CoverCache | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.Category | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaCategory | ||||
| import eu.kanade.tachiyomi.data.download.DownloadManager | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.source.LocalSource | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import eu.kanade.tachiyomi.source.online.HttpSource | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter | ||||
| import eu.kanade.tachiyomi.util.combineLatest | ||||
| import eu.kanade.tachiyomi.util.isNullOrUnsubscribed | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.schedulers.Schedulers | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.io.IOException | ||||
| import java.io.InputStream | ||||
| import java.util.ArrayList | ||||
| import java.util.Collections | ||||
| import java.util.Comparator | ||||
|  | ||||
| /** | ||||
|  * Class containing library information. | ||||
|  */ | ||||
| private data class Library(val categories: List<Category>, val mangaMap: LibraryMap) | ||||
|  | ||||
| /** | ||||
|  * Typealias for the library manga, using the category as keys, and list of manga as values. | ||||
|  */ | ||||
| private typealias LibraryMap = Map<Int, List<LibraryItem>> | ||||
|  | ||||
| /** | ||||
|  * Presenter of [LibraryController]. | ||||
|  */ | ||||
| class LibraryPresenter( | ||||
|         private val db: DatabaseHelper = Injekt.get(), | ||||
|         private val preferences: PreferencesHelper = Injekt.get(), | ||||
|         private val coverCache: CoverCache = Injekt.get(), | ||||
|         private val sourceManager: SourceManager = Injekt.get(), | ||||
|         private val downloadManager: DownloadManager = Injekt.get() | ||||
| ) : BasePresenter<LibraryController>() { | ||||
|  | ||||
|     private val context = preferences.context | ||||
|  | ||||
|     /** | ||||
|      * Categories of the library. | ||||
|      */ | ||||
|     var categories: List<Category> = emptyList() | ||||
|         private set | ||||
|  | ||||
|     /** | ||||
|      * Relay used to apply the UI filters to the last emission of the library. | ||||
|      */ | ||||
|     private val filterTriggerRelay = BehaviorRelay.create(Unit) | ||||
|  | ||||
|     /** | ||||
|      * Relay used to apply the UI update to the last emission of the library. | ||||
|      */ | ||||
|     private val downloadTriggerRelay = BehaviorRelay.create(Unit) | ||||
|  | ||||
|     /** | ||||
|      * Relay used to apply the selected sorting method to the last emission of the library. | ||||
|      */ | ||||
|     private val sortTriggerRelay = BehaviorRelay.create(Unit) | ||||
|  | ||||
|     /** | ||||
|      * Library subscription. | ||||
|      */ | ||||
|     private var librarySubscription: Subscription? = null | ||||
|  | ||||
|     override fun onCreate(savedState: Bundle?) { | ||||
|         super.onCreate(savedState) | ||||
|         subscribeLibrary() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Subscribes to library if needed. | ||||
|      */ | ||||
|     fun subscribeLibrary() { | ||||
|         if (librarySubscription.isNullOrUnsubscribed()) { | ||||
|             librarySubscription = getLibraryObservable() | ||||
|                     .combineLatest(downloadTriggerRelay.observeOn(Schedulers.io()), | ||||
|                             { lib, _ -> lib.apply { setDownloadCount(mangaMap) } }) | ||||
|                     .combineLatest(filterTriggerRelay.observeOn(Schedulers.io()), | ||||
|                             { lib, _ -> lib.copy(mangaMap = applyFilters(lib.mangaMap)) }) | ||||
|                     .combineLatest(sortTriggerRelay.observeOn(Schedulers.io()), | ||||
|                             { lib, _ -> lib.copy(mangaMap = applySort(lib.mangaMap)) }) | ||||
|                     .observeOn(AndroidSchedulers.mainThread()) | ||||
|                     .subscribeLatestCache({ view, (categories, mangaMap) -> | ||||
|                         view.onNextLibraryUpdate(categories, mangaMap) | ||||
|                     }) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Applies library filters to the given map of manga. | ||||
|      * | ||||
|      * @param map the map to filter. | ||||
|      */ | ||||
|     private fun applyFilters(map: LibraryMap): LibraryMap { | ||||
|         val filterDownloaded = preferences.filterDownloaded().getOrDefault() | ||||
|  | ||||
|         val filterUnread = preferences.filterUnread().getOrDefault() | ||||
|  | ||||
|         val filterCompleted = preferences.filterCompleted().getOrDefault() | ||||
|  | ||||
|         val filterFn: (LibraryItem) -> Boolean = f@ { item -> | ||||
|             // Filter when there isn't unread chapters. | ||||
|             if (filterUnread && item.manga.unread == 0) { | ||||
|                 return@f false | ||||
|             } | ||||
|  | ||||
|             if (filterCompleted && item.manga.status != SManga.COMPLETED) { | ||||
|                 return@f false | ||||
|             } | ||||
|  | ||||
|             // Filter when there are no downloads. | ||||
|             if (filterDownloaded) { | ||||
|                 // Local manga are always downloaded | ||||
|                 if (item.manga.source == LocalSource.ID) { | ||||
|                     return@f true | ||||
|                 } | ||||
|                 // Don't bother with directory checking if download count has been set. | ||||
|                 if (item.downloadCount != -1) { | ||||
|                     return@f item.downloadCount > 0 | ||||
|                 } | ||||
|  | ||||
|                 return@f downloadManager.getDownloadCount(item.manga) > 0 | ||||
|             } | ||||
|             true | ||||
|         } | ||||
|  | ||||
|         return map.mapValues { entry -> entry.value.filter(filterFn) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets downloaded chapter count to each manga. | ||||
|      * | ||||
|      * @param map the map of manga. | ||||
|      */ | ||||
|     private fun setDownloadCount(map: LibraryMap) { | ||||
|         if (!preferences.downloadBadge().getOrDefault()) { | ||||
|             // Unset download count if the preference is not enabled. | ||||
|             for ((_, itemList) in map) { | ||||
|                 for (item in itemList) { | ||||
|                     item.downloadCount = -1 | ||||
|                 } | ||||
|             } | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         for ((_, itemList) in map) { | ||||
|             for (item in itemList) { | ||||
|                 item.downloadCount = downloadManager.getDownloadCount(item.manga) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Applies library sorting to the given map of manga. | ||||
|      * | ||||
|      * @param map the map to sort. | ||||
|      */ | ||||
|     private fun applySort(map: LibraryMap): LibraryMap { | ||||
|         val sortingMode = preferences.librarySortingMode().getOrDefault() | ||||
|  | ||||
|         val lastReadManga by lazy { | ||||
|             var counter = 0 | ||||
|             db.getLastReadManga().executeAsBlocking().associate { it.id!! to counter++ } | ||||
|         } | ||||
|         val totalChapterManga by lazy { | ||||
|             var counter = 0 | ||||
|             db.getTotalChapterManga().executeAsBlocking().associate { it.id!! to counter++ } | ||||
|         } | ||||
|  | ||||
|         val sortFn: (LibraryItem, LibraryItem) -> Int = { i1, i2 -> | ||||
|             when (sortingMode) { | ||||
|                 LibrarySort.ALPHA -> i1.manga.title.compareTo(i2.manga.title, true) | ||||
|                 LibrarySort.LAST_READ -> { | ||||
|                     // Get index of manga, set equal to list if size unknown. | ||||
|                     val manga1LastRead = lastReadManga[i1.manga.id!!] ?: lastReadManga.size | ||||
|                     val manga2LastRead = lastReadManga[i2.manga.id!!] ?: lastReadManga.size | ||||
|                     manga1LastRead.compareTo(manga2LastRead) | ||||
|                 } | ||||
|                 LibrarySort.LAST_UPDATED -> i2.manga.last_update.compareTo(i1.manga.last_update) | ||||
|                 LibrarySort.UNREAD -> i1.manga.unread.compareTo(i2.manga.unread) | ||||
|                 LibrarySort.TOTAL -> { | ||||
|                     val manga1TotalChapter = totalChapterManga[i1.manga.id!!] ?: 0 | ||||
|                     val mange2TotalChapter = totalChapterManga[i2.manga.id!!] ?: 0 | ||||
|                     manga1TotalChapter.compareTo(mange2TotalChapter) | ||||
|                 } | ||||
|                 LibrarySort.SOURCE -> { | ||||
|                     val source1Name = sourceManager.getOrStub(i1.manga.source).name | ||||
|                     val source2Name = sourceManager.getOrStub(i2.manga.source).name | ||||
|                     source1Name.compareTo(source2Name) | ||||
|                 } | ||||
|                 else -> throw Exception("Unknown sorting mode") | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         val comparator = if (preferences.librarySortingAscending().getOrDefault()) | ||||
|             Comparator(sortFn) | ||||
|         else | ||||
|             Collections.reverseOrder(sortFn) | ||||
|  | ||||
|         return map.mapValues { entry -> entry.value.sortedWith(comparator) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get the categories and all its manga from the database. | ||||
|      * | ||||
|      * @return an observable of the categories and its manga. | ||||
|      */ | ||||
|     private fun getLibraryObservable(): Observable<Library> { | ||||
|         return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(), | ||||
|                 { dbCategories, libraryManga -> | ||||
|                     val categories = if (libraryManga.containsKey(0)) | ||||
|                         arrayListOf(Category.createDefault()) + dbCategories | ||||
|                     else | ||||
|                         dbCategories | ||||
|  | ||||
|                     this.categories = categories | ||||
|                     Library(categories, libraryManga) | ||||
|                 }) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get the categories from the database. | ||||
|      * | ||||
|      * @return an observable of the categories. | ||||
|      */ | ||||
|     private fun getCategoriesObservable(): Observable<List<Category>> { | ||||
|         return db.getCategories().asRxObservable() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get the manga grouped by categories. | ||||
|      * | ||||
|      * @return an observable containing a map with the category id as key and a list of manga as the | ||||
|      * value. | ||||
|      */ | ||||
|     private fun getLibraryMangasObservable(): Observable<LibraryMap> { | ||||
|         val libraryAsList = preferences.libraryAsList() | ||||
|         return db.getLibraryMangas().asRxObservable() | ||||
|                 .map { list -> | ||||
|                     list.map { LibraryItem(it, libraryAsList) }.groupBy { it.manga.category } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Requests the library to be filtered. | ||||
|      */ | ||||
|     fun requestFilterUpdate() { | ||||
|         filterTriggerRelay.call(Unit) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Requests the library to have download badges added. | ||||
|      */ | ||||
|     fun requestDownloadBadgesUpdate() { | ||||
|         downloadTriggerRelay.call(Unit) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Requests the library to be sorted. | ||||
|      */ | ||||
|     fun requestSortUpdate() { | ||||
|         sortTriggerRelay.call(Unit) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when a manga is opened. | ||||
|      */ | ||||
|     fun onOpenManga() { | ||||
|         // Avoid further db updates for the library when it's not needed | ||||
|         librarySubscription?.let { remove(it) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the common categories for the given list of manga. | ||||
|      * | ||||
|      * @param mangas the list of manga. | ||||
|      */ | ||||
|     fun getCommonCategories(mangas: List<Manga>): Collection<Category> { | ||||
|         if (mangas.isEmpty()) return emptyList() | ||||
|         return mangas.toSet() | ||||
|                 .map { db.getCategoriesForManga(it).executeAsBlocking() } | ||||
|                 .reduce { set1: Iterable<Category>, set2 -> set1.intersect(set2) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Remove the selected manga from the library. | ||||
|      * | ||||
|      * @param mangas the list of manga to delete. | ||||
|      * @param deleteChapters whether to also delete downloaded chapters. | ||||
|      */ | ||||
|     fun removeMangaFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) { | ||||
|         // Create a set of the list | ||||
|         val mangaToDelete = mangas.distinctBy { it.id } | ||||
|         mangaToDelete.forEach { it.favorite = false } | ||||
|  | ||||
|         Observable.fromCallable { db.insertMangas(mangaToDelete).executeAsBlocking() } | ||||
|                 .onErrorResumeNext { Observable.empty() } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .subscribe() | ||||
|  | ||||
|         Observable.fromCallable { | ||||
|             mangaToDelete.forEach { manga -> | ||||
|                 coverCache.deleteFromCache(manga.thumbnail_url) | ||||
|                 if (deleteChapters) { | ||||
|                     val source = sourceManager.get(manga.source) as? HttpSource | ||||
|                     if (source != null) { | ||||
|                         downloadManager.deleteManga(manga, source) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .subscribe() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Move the given list of manga to categories. | ||||
|      * | ||||
|      * @param categories the selected categories. | ||||
|      * @param mangas the list of manga to move. | ||||
|      */ | ||||
|     fun moveMangasToCategories(categories: List<Category>, mangas: List<Manga>) { | ||||
|         val mc = ArrayList<MangaCategory>() | ||||
|  | ||||
|         for (manga in mangas) { | ||||
|             for (cat in categories) { | ||||
|                 mc.add(MangaCategory.create(manga, cat)) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         db.setMangaCategories(mc, mangas) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update cover with local file. | ||||
|      * | ||||
|      * @param inputStream the new cover. | ||||
|      * @param manga the manga edited. | ||||
|      * @return true if the cover is updated, false otherwise | ||||
|      */ | ||||
|     @Throws(IOException::class) | ||||
|     fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean { | ||||
|         if (manga.source == LocalSource.ID) { | ||||
|             LocalSource.updateCover(context, manga, inputStream) | ||||
|             return true | ||||
|         } | ||||
|  | ||||
|         if (manga.thumbnail_url != null && manga.favorite) { | ||||
|             coverCache.copyToCache(manga.thumbnail_url!!, inputStream) | ||||
|             return true | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| import android.os.Bundle | ||||
| import com.jakewharton.rxrelay.BehaviorRelay | ||||
| import eu.kanade.tachiyomi.data.cache.CoverCache | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.Category | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaCategory | ||||
| import eu.kanade.tachiyomi.data.download.DownloadManager | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.source.LocalSource | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import eu.kanade.tachiyomi.source.online.HttpSource | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter | ||||
| import eu.kanade.tachiyomi.util.combineLatest | ||||
| import eu.kanade.tachiyomi.util.isNullOrUnsubscribed | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.schedulers.Schedulers | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.io.IOException | ||||
| import java.io.InputStream | ||||
| import java.util.ArrayList | ||||
| import java.util.Collections | ||||
| import java.util.Comparator | ||||
|  | ||||
| /** | ||||
|  * Class containing library information. | ||||
|  */ | ||||
| private data class Library(val categories: List<Category>, val mangaMap: LibraryMap) | ||||
|  | ||||
| /** | ||||
|  * Typealias for the library manga, using the category as keys, and list of manga as values. | ||||
|  */ | ||||
| private typealias LibraryMap = Map<Int, List<LibraryItem>> | ||||
|  | ||||
| /** | ||||
|  * Presenter of [LibraryController]. | ||||
|  */ | ||||
| class LibraryPresenter( | ||||
|         private val db: DatabaseHelper = Injekt.get(), | ||||
|         private val preferences: PreferencesHelper = Injekt.get(), | ||||
|         private val coverCache: CoverCache = Injekt.get(), | ||||
|         private val sourceManager: SourceManager = Injekt.get(), | ||||
|         private val downloadManager: DownloadManager = Injekt.get() | ||||
| ) : BasePresenter<LibraryController>() { | ||||
|  | ||||
|     private val context = preferences.context | ||||
|  | ||||
|     /** | ||||
|      * Categories of the library. | ||||
|      */ | ||||
|     var categories: List<Category> = emptyList() | ||||
|         private set | ||||
|  | ||||
|     /** | ||||
|      * Relay used to apply the UI filters to the last emission of the library. | ||||
|      */ | ||||
|     private val filterTriggerRelay = BehaviorRelay.create(Unit) | ||||
|  | ||||
|     /** | ||||
|      * Relay used to apply the UI update to the last emission of the library. | ||||
|      */ | ||||
|     private val downloadTriggerRelay = BehaviorRelay.create(Unit) | ||||
|  | ||||
|     /** | ||||
|      * Relay used to apply the selected sorting method to the last emission of the library. | ||||
|      */ | ||||
|     private val sortTriggerRelay = BehaviorRelay.create(Unit) | ||||
|  | ||||
|     /** | ||||
|      * Library subscription. | ||||
|      */ | ||||
|     private var librarySubscription: Subscription? = null | ||||
|  | ||||
|     override fun onCreate(savedState: Bundle?) { | ||||
|         super.onCreate(savedState) | ||||
|         subscribeLibrary() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Subscribes to library if needed. | ||||
|      */ | ||||
|     fun subscribeLibrary() { | ||||
|         if (librarySubscription.isNullOrUnsubscribed()) { | ||||
|             librarySubscription = getLibraryObservable() | ||||
|                     .combineLatest(downloadTriggerRelay.observeOn(Schedulers.io()), | ||||
|                             { lib, _ -> lib.apply { setDownloadCount(mangaMap) } }) | ||||
|                     .combineLatest(filterTriggerRelay.observeOn(Schedulers.io()), | ||||
|                             { lib, _ -> lib.copy(mangaMap = applyFilters(lib.mangaMap)) }) | ||||
|                     .combineLatest(sortTriggerRelay.observeOn(Schedulers.io()), | ||||
|                             { lib, _ -> lib.copy(mangaMap = applySort(lib.mangaMap)) }) | ||||
|                     .observeOn(AndroidSchedulers.mainThread()) | ||||
|                     .subscribeLatestCache({ view, (categories, mangaMap) -> | ||||
|                         view.onNextLibraryUpdate(categories, mangaMap) | ||||
|                     }) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Applies library filters to the given map of manga. | ||||
|      * | ||||
|      * @param map the map to filter. | ||||
|      */ | ||||
|     private fun applyFilters(map: LibraryMap): LibraryMap { | ||||
|         val filterDownloaded = preferences.filterDownloaded().getOrDefault() | ||||
|  | ||||
|         val filterUnread = preferences.filterUnread().getOrDefault() | ||||
|  | ||||
|         val filterCompleted = preferences.filterCompleted().getOrDefault() | ||||
|  | ||||
|         val filterFn: (LibraryItem) -> Boolean = f@ { item -> | ||||
|             // Filter when there isn't unread chapters. | ||||
|             if (filterUnread && item.manga.unread == 0) { | ||||
|                 return@f false | ||||
|             } | ||||
|  | ||||
|             if (filterCompleted && item.manga.status != SManga.COMPLETED) { | ||||
|                 return@f false | ||||
|             } | ||||
|  | ||||
|             // Filter when there are no downloads. | ||||
|             if (filterDownloaded) { | ||||
|                 // Local manga are always downloaded | ||||
|                 if (item.manga.source == LocalSource.ID) { | ||||
|                     return@f true | ||||
|                 } | ||||
|                 // Don't bother with directory checking if download count has been set. | ||||
|                 if (item.downloadCount != -1) { | ||||
|                     return@f item.downloadCount > 0 | ||||
|                 } | ||||
|  | ||||
|                 return@f downloadManager.getDownloadCount(item.manga) > 0 | ||||
|             } | ||||
|             true | ||||
|         } | ||||
|  | ||||
|         return map.mapValues { entry -> entry.value.filter(filterFn) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets downloaded chapter count to each manga. | ||||
|      * | ||||
|      * @param map the map of manga. | ||||
|      */ | ||||
|     private fun setDownloadCount(map: LibraryMap) { | ||||
|         if (!preferences.downloadBadge().getOrDefault()) { | ||||
|             // Unset download count if the preference is not enabled. | ||||
|             for ((_, itemList) in map) { | ||||
|                 for (item in itemList) { | ||||
|                     item.downloadCount = -1 | ||||
|                 } | ||||
|             } | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         for ((_, itemList) in map) { | ||||
|             for (item in itemList) { | ||||
|                 item.downloadCount = downloadManager.getDownloadCount(item.manga) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Applies library sorting to the given map of manga. | ||||
|      * | ||||
|      * @param map the map to sort. | ||||
|      */ | ||||
|     private fun applySort(map: LibraryMap): LibraryMap { | ||||
|         val sortingMode = preferences.librarySortingMode().getOrDefault() | ||||
|  | ||||
|         val lastReadManga by lazy { | ||||
|             var counter = 0 | ||||
|             db.getLastReadManga().executeAsBlocking().associate { it.id!! to counter++ } | ||||
|         } | ||||
|         val totalChapterManga by lazy { | ||||
|             var counter = 0 | ||||
|             db.getTotalChapterManga().executeAsBlocking().associate { it.id!! to counter++ } | ||||
|         } | ||||
|  | ||||
|         val sortFn: (LibraryItem, LibraryItem) -> Int = { i1, i2 -> | ||||
|             when (sortingMode) { | ||||
|                 LibrarySort.ALPHA -> i1.manga.title.compareTo(i2.manga.title, true) | ||||
|                 LibrarySort.LAST_READ -> { | ||||
|                     // Get index of manga, set equal to list if size unknown. | ||||
|                     val manga1LastRead = lastReadManga[i1.manga.id!!] ?: lastReadManga.size | ||||
|                     val manga2LastRead = lastReadManga[i2.manga.id!!] ?: lastReadManga.size | ||||
|                     manga1LastRead.compareTo(manga2LastRead) | ||||
|                 } | ||||
|                 LibrarySort.LAST_UPDATED -> i2.manga.last_update.compareTo(i1.manga.last_update) | ||||
|                 LibrarySort.UNREAD -> i1.manga.unread.compareTo(i2.manga.unread) | ||||
|                 LibrarySort.TOTAL -> { | ||||
|                     val manga1TotalChapter = totalChapterManga[i1.manga.id!!] ?: 0 | ||||
|                     val mange2TotalChapter = totalChapterManga[i2.manga.id!!] ?: 0 | ||||
|                     manga1TotalChapter.compareTo(mange2TotalChapter) | ||||
|                 } | ||||
|                 LibrarySort.SOURCE -> { | ||||
|                     val source1Name = sourceManager.getOrStub(i1.manga.source).name | ||||
|                     val source2Name = sourceManager.getOrStub(i2.manga.source).name | ||||
|                     source1Name.compareTo(source2Name) | ||||
|                 } | ||||
|                 else -> throw Exception("Unknown sorting mode") | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         val comparator = if (preferences.librarySortingAscending().getOrDefault()) | ||||
|             Comparator(sortFn) | ||||
|         else | ||||
|             Collections.reverseOrder(sortFn) | ||||
|  | ||||
|         return map.mapValues { entry -> entry.value.sortedWith(comparator) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get the categories and all its manga from the database. | ||||
|      * | ||||
|      * @return an observable of the categories and its manga. | ||||
|      */ | ||||
|     private fun getLibraryObservable(): Observable<Library> { | ||||
|         return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(), | ||||
|                 { dbCategories, libraryManga -> | ||||
|                     val categories = if (libraryManga.containsKey(0)) | ||||
|                         arrayListOf(Category.createDefault()) + dbCategories | ||||
|                     else | ||||
|                         dbCategories | ||||
|  | ||||
|                     this.categories = categories | ||||
|                     Library(categories, libraryManga) | ||||
|                 }) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get the categories from the database. | ||||
|      * | ||||
|      * @return an observable of the categories. | ||||
|      */ | ||||
|     private fun getCategoriesObservable(): Observable<List<Category>> { | ||||
|         return db.getCategories().asRxObservable() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get the manga grouped by categories. | ||||
|      * | ||||
|      * @return an observable containing a map with the category id as key and a list of manga as the | ||||
|      * value. | ||||
|      */ | ||||
|     private fun getLibraryMangasObservable(): Observable<LibraryMap> { | ||||
|         val libraryAsList = preferences.libraryAsList() | ||||
|         return db.getLibraryMangas().asRxObservable() | ||||
|                 .map { list -> | ||||
|                     list.map { LibraryItem(it, libraryAsList) }.groupBy { it.manga.category } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Requests the library to be filtered. | ||||
|      */ | ||||
|     fun requestFilterUpdate() { | ||||
|         filterTriggerRelay.call(Unit) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Requests the library to have download badges added. | ||||
|      */ | ||||
|     fun requestDownloadBadgesUpdate() { | ||||
|         downloadTriggerRelay.call(Unit) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Requests the library to be sorted. | ||||
|      */ | ||||
|     fun requestSortUpdate() { | ||||
|         sortTriggerRelay.call(Unit) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when a manga is opened. | ||||
|      */ | ||||
|     fun onOpenManga() { | ||||
|         // Avoid further db updates for the library when it's not needed | ||||
|         librarySubscription?.let { remove(it) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the common categories for the given list of manga. | ||||
|      * | ||||
|      * @param mangas the list of manga. | ||||
|      */ | ||||
|     fun getCommonCategories(mangas: List<Manga>): Collection<Category> { | ||||
|         if (mangas.isEmpty()) return emptyList() | ||||
|         return mangas.toSet() | ||||
|                 .map { db.getCategoriesForManga(it).executeAsBlocking() } | ||||
|                 .reduce { set1: Iterable<Category>, set2 -> set1.intersect(set2) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Remove the selected manga from the library. | ||||
|      * | ||||
|      * @param mangas the list of manga to delete. | ||||
|      * @param deleteChapters whether to also delete downloaded chapters. | ||||
|      */ | ||||
|     fun removeMangaFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) { | ||||
|         // Create a set of the list | ||||
|         val mangaToDelete = mangas.distinctBy { it.id } | ||||
|         mangaToDelete.forEach { it.favorite = false } | ||||
|  | ||||
|         Observable.fromCallable { db.insertMangas(mangaToDelete).executeAsBlocking() } | ||||
|                 .onErrorResumeNext { Observable.empty() } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .subscribe() | ||||
|  | ||||
|         Observable.fromCallable { | ||||
|             mangaToDelete.forEach { manga -> | ||||
|                 coverCache.deleteFromCache(manga.thumbnail_url) | ||||
|                 if (deleteChapters) { | ||||
|                     val source = sourceManager.get(manga.source) as? HttpSource | ||||
|                     if (source != null) { | ||||
|                         downloadManager.deleteManga(manga, source) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .subscribe() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Move the given list of manga to categories. | ||||
|      * | ||||
|      * @param categories the selected categories. | ||||
|      * @param mangas the list of manga to move. | ||||
|      */ | ||||
|     fun moveMangasToCategories(categories: List<Category>, mangas: List<Manga>) { | ||||
|         val mc = ArrayList<MangaCategory>() | ||||
|  | ||||
|         for (manga in mangas) { | ||||
|             for (cat in categories) { | ||||
|                 mc.add(MangaCategory.create(manga, cat)) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         db.setMangaCategories(mc, mangas) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update cover with local file. | ||||
|      * | ||||
|      * @param inputStream the new cover. | ||||
|      * @param manga the manga edited. | ||||
|      * @return true if the cover is updated, false otherwise | ||||
|      */ | ||||
|     @Throws(IOException::class) | ||||
|     fun editCoverWithStream(inputStream: InputStream, manga: Manga): Boolean { | ||||
|         if (manga.source == LocalSource.ID) { | ||||
|             LocalSource.updateCover(context, manga, inputStream) | ||||
|             return true | ||||
|         } | ||||
|  | ||||
|         if (manga.thumbnail_url != null && manga.favorite) { | ||||
|             coverCache.copyToCache(manga.thumbnail_url!!, inputStream) | ||||
|             return true | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| object LibrarySort { | ||||
|  | ||||
|     const val ALPHA = 0 | ||||
|     const val LAST_READ = 1 | ||||
|     const val LAST_UPDATED = 2 | ||||
|     const val UNREAD = 3 | ||||
|     const val TOTAL = 4 | ||||
|     const val SOURCE = 5 | ||||
| package eu.kanade.tachiyomi.ui.library | ||||
|  | ||||
| object LibrarySort { | ||||
|  | ||||
|     const val ALPHA = 0 | ||||
|     const val LAST_READ = 1 | ||||
|     const val LAST_UPDATED = 2 | ||||
|     const val UNREAD = 3 | ||||
|     const val TOTAL = 4 | ||||
|     const val SOURCE = 5 | ||||
| } | ||||
| @@ -1,32 +1,32 @@ | ||||
| package eu.kanade.tachiyomi.ui.main | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.content.Context | ||||
| import android.os.Bundle | ||||
| import android.util.AttributeSet | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import eu.kanade.tachiyomi.BuildConfig | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
| import it.gmariotti.changelibs.library.view.ChangeLogRecyclerView | ||||
|  | ||||
| class ChangelogDialogController : DialogController() { | ||||
|  | ||||
|     override fun onCreateDialog(savedState: Bundle?): Dialog { | ||||
|         val activity = activity!! | ||||
|         val view = WhatsNewRecyclerView(activity) | ||||
|         return MaterialDialog.Builder(activity) | ||||
|                 .title(if (BuildConfig.DEBUG) "Notices" else "Changelog") | ||||
|                 .customView(view, false) | ||||
|                 .positiveText(android.R.string.yes) | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     class WhatsNewRecyclerView(context: Context) : ChangeLogRecyclerView(context) { | ||||
|         override fun initAttrs(attrs: AttributeSet?, defStyle: Int) { | ||||
|             mRowLayoutId = R.layout.changelog_row_layout | ||||
|             mRowHeaderLayoutId = R.layout.changelog_header_layout | ||||
|             mChangeLogFileResourceId = if (BuildConfig.DEBUG) R.raw.changelog_debug else R.raw.changelog_release | ||||
|         } | ||||
|     } | ||||
| package eu.kanade.tachiyomi.ui.main | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.content.Context | ||||
| import android.os.Bundle | ||||
| import android.util.AttributeSet | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import eu.kanade.tachiyomi.BuildConfig | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
| import it.gmariotti.changelibs.library.view.ChangeLogRecyclerView | ||||
|  | ||||
| class ChangelogDialogController : DialogController() { | ||||
|  | ||||
|     override fun onCreateDialog(savedState: Bundle?): Dialog { | ||||
|         val activity = activity!! | ||||
|         val view = WhatsNewRecyclerView(activity) | ||||
|         return MaterialDialog.Builder(activity) | ||||
|                 .title(if (BuildConfig.DEBUG) "Notices" else "Changelog") | ||||
|                 .customView(view, false) | ||||
|                 .positiveText(android.R.string.yes) | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     class WhatsNewRecyclerView(context: Context) : ChangeLogRecyclerView(context) { | ||||
|         override fun initAttrs(attrs: AttributeSet?, defStyle: Int) { | ||||
|             mRowLayoutId = R.layout.changelog_row_layout | ||||
|             mRowHeaderLayoutId = R.layout.changelog_header_layout | ||||
|             mChangeLogFileResourceId = if (BuildConfig.DEBUG) R.raw.changelog_debug else R.raw.changelog_release | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,282 +1,282 @@ | ||||
| package eu.kanade.tachiyomi.ui.main | ||||
|  | ||||
| import android.animation.ObjectAnimator | ||||
| import android.app.SearchManager | ||||
| import android.content.Intent | ||||
| import android.graphics.Color | ||||
| import android.os.Bundle | ||||
| import android.support.v4.view.GravityCompat | ||||
| import android.support.v4.widget.DrawerLayout | ||||
| import android.support.v7.graphics.drawable.DrawerArrowDrawable | ||||
| import android.view.ViewGroup | ||||
| import com.bluelinelabs.conductor.* | ||||
| import eu.kanade.tachiyomi.Migrations | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.ui.base.activity.BaseActivity | ||||
| import eu.kanade.tachiyomi.ui.base.controller.* | ||||
| import eu.kanade.tachiyomi.ui.catalogue.CatalogueController | ||||
| import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController | ||||
| import eu.kanade.tachiyomi.ui.download.DownloadController | ||||
| import eu.kanade.tachiyomi.ui.extension.ExtensionController | ||||
| import eu.kanade.tachiyomi.ui.library.LibraryController | ||||
| import eu.kanade.tachiyomi.ui.manga.MangaController | ||||
| import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersController | ||||
| import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadController | ||||
| import eu.kanade.tachiyomi.ui.setting.SettingsMainController | ||||
| import eu.kanade.tachiyomi.util.openInBrowser | ||||
| import kotlinx.android.synthetic.main.main_activity.* | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
|  | ||||
| class MainActivity : BaseActivity() { | ||||
|  | ||||
|     private lateinit var router: Router | ||||
|  | ||||
|     val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
|     private var drawerArrow: DrawerArrowDrawable? = null | ||||
|  | ||||
|     private var secondaryDrawer: ViewGroup? = null | ||||
|  | ||||
|     private val startScreenId by lazy { | ||||
|         when (preferences.startScreen()) { | ||||
|             2 -> R.id.nav_drawer_recently_read | ||||
|             3 -> R.id.nav_drawer_recent_updates | ||||
|             else -> R.id.nav_drawer_library | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     lateinit var tabAnimator: TabsAnimator | ||||
|  | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         setTheme(when (preferences.theme()) { | ||||
|             2 -> R.style.Theme_Tachiyomi_Dark | ||||
|             3 -> R.style.Theme_Tachiyomi_Amoled | ||||
|             4 -> R.style.Theme_Tachiyomi_DarkBlue | ||||
|             else -> R.style.Theme_Tachiyomi | ||||
|         }) | ||||
|         super.onCreate(savedInstanceState) | ||||
|  | ||||
|         // Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079 | ||||
|         if (!isTaskRoot) { | ||||
|             finish() | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         setContentView(R.layout.main_activity) | ||||
|  | ||||
|         setSupportActionBar(toolbar) | ||||
|  | ||||
|         drawerArrow = DrawerArrowDrawable(this) | ||||
|         drawerArrow?.color = Color.WHITE | ||||
|         toolbar.navigationIcon = drawerArrow | ||||
|  | ||||
|         tabAnimator = TabsAnimator(tabs) | ||||
|  | ||||
|         // Set behavior of Navigation drawer | ||||
|         nav_view.setNavigationItemSelectedListener { item -> | ||||
|             val id = item.itemId | ||||
|  | ||||
|             val currentRoot = router.backstack.firstOrNull() | ||||
|             if (currentRoot?.tag()?.toIntOrNull() != id) { | ||||
|                 when (id) { | ||||
|                     R.id.nav_drawer_library -> setRoot(LibraryController(), id) | ||||
|                     R.id.nav_drawer_recent_updates -> setRoot(RecentChaptersController(), id) | ||||
|                     R.id.nav_drawer_recently_read -> setRoot(RecentlyReadController(), id) | ||||
|                     R.id.nav_drawer_catalogues -> setRoot(CatalogueController(), id) | ||||
|                     R.id.nav_drawer_extensions -> setRoot(ExtensionController(), id) | ||||
|                     R.id.nav_drawer_downloads -> { | ||||
|                         router.pushController(DownloadController().withFadeTransaction()) | ||||
|                     } | ||||
|                     R.id.nav_drawer_settings -> { | ||||
|                         router.pushController(SettingsMainController().withFadeTransaction()) | ||||
|                     } | ||||
|                     R.id.nav_drawer_help -> { | ||||
|                         openInBrowser(URL_HELP) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             drawer.closeDrawer(GravityCompat.START) | ||||
|             true | ||||
|         } | ||||
|  | ||||
|         val container: ViewGroup = findViewById(R.id.controller_container) | ||||
|  | ||||
|         router = Conductor.attachRouter(this, container, savedInstanceState) | ||||
|         if (!router.hasRootController()) { | ||||
|             // Set start screen | ||||
|             if (!handleIntentAction(intent)) { | ||||
|                 setSelectedDrawerItem(startScreenId) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         toolbar.setNavigationOnClickListener { | ||||
|             if (router.backstackSize == 1) { | ||||
|                 drawer.openDrawer(GravityCompat.START) | ||||
|             } else { | ||||
|                 onBackPressed() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         router.addChangeListener(object : ControllerChangeHandler.ControllerChangeListener { | ||||
|             override fun onChangeStarted(to: Controller?, from: Controller?, isPush: Boolean, | ||||
|                                          container: ViewGroup, handler: ControllerChangeHandler) { | ||||
|  | ||||
|                 syncActivityViewWithController(to, from) | ||||
|             } | ||||
|  | ||||
|             override fun onChangeCompleted(to: Controller?, from: Controller?, isPush: Boolean, | ||||
|                                            container: ViewGroup, handler: ControllerChangeHandler) { | ||||
|  | ||||
|             } | ||||
|  | ||||
|         }) | ||||
|  | ||||
|         syncActivityViewWithController(router.backstack.lastOrNull()?.controller()) | ||||
|  | ||||
|         if (savedInstanceState == null) { | ||||
|             // Show changelog if needed | ||||
|             if (Migrations.upgrade(preferences)) { | ||||
|                 ChangelogDialogController().showDialog(router) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onNewIntent(intent: Intent) { | ||||
|         if (!handleIntentAction(intent)) { | ||||
|             super.onNewIntent(intent) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun handleIntentAction(intent: Intent): Boolean { | ||||
|         when (intent.action) { | ||||
|             SHORTCUT_LIBRARY -> setSelectedDrawerItem(R.id.nav_drawer_library) | ||||
|             SHORTCUT_RECENTLY_UPDATED -> setSelectedDrawerItem(R.id.nav_drawer_recent_updates) | ||||
|             SHORTCUT_RECENTLY_READ -> setSelectedDrawerItem(R.id.nav_drawer_recently_read) | ||||
|             SHORTCUT_CATALOGUES -> setSelectedDrawerItem(R.id.nav_drawer_catalogues) | ||||
|             SHORTCUT_MANGA -> { | ||||
|                 val extras = intent.extras ?: return false | ||||
|                 router.setRoot(RouterTransaction.with(MangaController(extras))) | ||||
|             } | ||||
|             SHORTCUT_DOWNLOADS -> { | ||||
|                 if (router.backstack.none { it.controller() is DownloadController }) { | ||||
|                     setSelectedDrawerItem(R.id.nav_drawer_downloads) | ||||
|                 } | ||||
|             } | ||||
|             Intent.ACTION_SEARCH, "com.google.android.gms.actions.SEARCH_ACTION" -> { | ||||
|                 //If the intent match the "standard" Android search intent | ||||
|                 // or the Google-specific search intent (triggered by saying or typing "search *query* on *Tachiyomi*" in Google Search/Google Assistant) | ||||
|  | ||||
|                 //Get the search query provided in extras, and if not null, perform a global search with it. | ||||
|                 val query = intent.getStringExtra(SearchManager.QUERY) | ||||
|                 if (query != null && !query.isEmpty()) { | ||||
|                     if (router.backstackSize > 1) { | ||||
|                         router.popToRoot() | ||||
|                     } | ||||
|                     router.pushController(CatalogueSearchController(query).withFadeTransaction()) | ||||
|                 } | ||||
|             } | ||||
|             INTENT_SEARCH -> { | ||||
|                 val query = intent.getStringExtra(INTENT_SEARCH_QUERY) | ||||
|                 val filter = intent.getStringExtra(INTENT_SEARCH_FILTER) | ||||
|                 if (query != null && !query.isEmpty()) { | ||||
|                     if (router.backstackSize > 1) { | ||||
|                         router.popToRoot() | ||||
|                     } | ||||
|                     router.pushController(CatalogueSearchController(query, filter).withFadeTransaction()) | ||||
|                 } | ||||
|             } | ||||
|             else -> return false | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     override fun onDestroy() { | ||||
|         super.onDestroy() | ||||
|         nav_view?.setNavigationItemSelectedListener(null) | ||||
|         toolbar?.setNavigationOnClickListener(null) | ||||
|     } | ||||
|  | ||||
|     override fun onBackPressed() { | ||||
|         val backstackSize = router.backstackSize | ||||
|         if (drawer.isDrawerOpen(GravityCompat.START) || drawer.isDrawerOpen(GravityCompat.END)) { | ||||
|             drawer.closeDrawers() | ||||
|         } else if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) { | ||||
|             setSelectedDrawerItem(startScreenId) | ||||
|         } else if (backstackSize == 1 || !router.handleBack()) { | ||||
|             super.onBackPressed() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun setSelectedDrawerItem(itemId: Int) { | ||||
|         if (!isFinishing) { | ||||
|             nav_view.setCheckedItem(itemId) | ||||
|             nav_view.menu.performIdentifierAction(itemId, 0) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun setRoot(controller: Controller, id: Int) { | ||||
|         router.setRoot(controller.withFadeTransaction().tag(id.toString())) | ||||
|     } | ||||
|  | ||||
|     private fun syncActivityViewWithController(to: Controller?, from: Controller? = null) { | ||||
|         if (from is DialogController || to is DialogController) { | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         val showHamburger = router.backstackSize == 1 | ||||
|         if (showHamburger) { | ||||
|             drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED) | ||||
|         } else { | ||||
|             drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) | ||||
|         } | ||||
|  | ||||
|         ObjectAnimator.ofFloat(drawerArrow, "progress", if (showHamburger) 0f else 1f).start() | ||||
|  | ||||
|         if (from is TabbedController) { | ||||
|             from.cleanupTabs(tabs) | ||||
|         } | ||||
|         if (to is TabbedController) { | ||||
|             tabAnimator.expand() | ||||
|             to.configureTabs(tabs) | ||||
|         } else { | ||||
|             tabAnimator.collapse() | ||||
|             tabs.setupWithViewPager(null) | ||||
|         } | ||||
|  | ||||
|         if (from is SecondaryDrawerController) { | ||||
|             if (secondaryDrawer != null) { | ||||
|                 from.cleanupSecondaryDrawer(drawer) | ||||
|                 drawer.removeView(secondaryDrawer) | ||||
|                 secondaryDrawer = null | ||||
|             } | ||||
|         } | ||||
|         if (to is SecondaryDrawerController) { | ||||
|             secondaryDrawer = to.createSecondaryDrawer(drawer)?.also { drawer.addView(it) } | ||||
|         } | ||||
|  | ||||
|         if (to is NoToolbarElevationController) { | ||||
|             appbar.disableElevation() | ||||
|         } else { | ||||
|             appbar.enableElevation() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         // Shortcut actions | ||||
|         const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY" | ||||
|         const val SHORTCUT_RECENTLY_UPDATED = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED" | ||||
|         const val SHORTCUT_RECENTLY_READ = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ" | ||||
|         const val SHORTCUT_CATALOGUES = "eu.kanade.tachiyomi.SHOW_CATALOGUES" | ||||
|         const val SHORTCUT_DOWNLOADS = "eu.kanade.tachiyomi.SHOW_DOWNLOADS" | ||||
|         const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA" | ||||
|  | ||||
|         const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH" | ||||
|         const val INTENT_SEARCH_QUERY = "query" | ||||
|         const val INTENT_SEARCH_FILTER = "filter" | ||||
|  | ||||
|         private const val URL_HELP = "https://tachiyomi.org/help/" | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.main | ||||
|  | ||||
| import android.animation.ObjectAnimator | ||||
| import android.app.SearchManager | ||||
| import android.content.Intent | ||||
| import android.graphics.Color | ||||
| import android.os.Bundle | ||||
| import android.support.v4.view.GravityCompat | ||||
| import android.support.v4.widget.DrawerLayout | ||||
| import android.support.v7.graphics.drawable.DrawerArrowDrawable | ||||
| import android.view.ViewGroup | ||||
| import com.bluelinelabs.conductor.* | ||||
| import eu.kanade.tachiyomi.Migrations | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.ui.base.activity.BaseActivity | ||||
| import eu.kanade.tachiyomi.ui.base.controller.* | ||||
| import eu.kanade.tachiyomi.ui.catalogue.CatalogueController | ||||
| import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController | ||||
| import eu.kanade.tachiyomi.ui.download.DownloadController | ||||
| import eu.kanade.tachiyomi.ui.extension.ExtensionController | ||||
| import eu.kanade.tachiyomi.ui.library.LibraryController | ||||
| import eu.kanade.tachiyomi.ui.manga.MangaController | ||||
| import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersController | ||||
| import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadController | ||||
| import eu.kanade.tachiyomi.ui.setting.SettingsMainController | ||||
| import eu.kanade.tachiyomi.util.openInBrowser | ||||
| import kotlinx.android.synthetic.main.main_activity.* | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
|  | ||||
| class MainActivity : BaseActivity() { | ||||
|  | ||||
|     private lateinit var router: Router | ||||
|  | ||||
|     val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
|     private var drawerArrow: DrawerArrowDrawable? = null | ||||
|  | ||||
|     private var secondaryDrawer: ViewGroup? = null | ||||
|  | ||||
|     private val startScreenId by lazy { | ||||
|         when (preferences.startScreen()) { | ||||
|             2 -> R.id.nav_drawer_recently_read | ||||
|             3 -> R.id.nav_drawer_recent_updates | ||||
|             else -> R.id.nav_drawer_library | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     lateinit var tabAnimator: TabsAnimator | ||||
|  | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         setTheme(when (preferences.theme()) { | ||||
|             2 -> R.style.Theme_Tachiyomi_Dark | ||||
|             3 -> R.style.Theme_Tachiyomi_Amoled | ||||
|             4 -> R.style.Theme_Tachiyomi_DarkBlue | ||||
|             else -> R.style.Theme_Tachiyomi | ||||
|         }) | ||||
|         super.onCreate(savedInstanceState) | ||||
|  | ||||
|         // Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079 | ||||
|         if (!isTaskRoot) { | ||||
|             finish() | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         setContentView(R.layout.main_activity) | ||||
|  | ||||
|         setSupportActionBar(toolbar) | ||||
|  | ||||
|         drawerArrow = DrawerArrowDrawable(this) | ||||
|         drawerArrow?.color = Color.WHITE | ||||
|         toolbar.navigationIcon = drawerArrow | ||||
|  | ||||
|         tabAnimator = TabsAnimator(tabs) | ||||
|  | ||||
|         // Set behavior of Navigation drawer | ||||
|         nav_view.setNavigationItemSelectedListener { item -> | ||||
|             val id = item.itemId | ||||
|  | ||||
|             val currentRoot = router.backstack.firstOrNull() | ||||
|             if (currentRoot?.tag()?.toIntOrNull() != id) { | ||||
|                 when (id) { | ||||
|                     R.id.nav_drawer_library -> setRoot(LibraryController(), id) | ||||
|                     R.id.nav_drawer_recent_updates -> setRoot(RecentChaptersController(), id) | ||||
|                     R.id.nav_drawer_recently_read -> setRoot(RecentlyReadController(), id) | ||||
|                     R.id.nav_drawer_catalogues -> setRoot(CatalogueController(), id) | ||||
|                     R.id.nav_drawer_extensions -> setRoot(ExtensionController(), id) | ||||
|                     R.id.nav_drawer_downloads -> { | ||||
|                         router.pushController(DownloadController().withFadeTransaction()) | ||||
|                     } | ||||
|                     R.id.nav_drawer_settings -> { | ||||
|                         router.pushController(SettingsMainController().withFadeTransaction()) | ||||
|                     } | ||||
|                     R.id.nav_drawer_help -> { | ||||
|                         openInBrowser(URL_HELP) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             drawer.closeDrawer(GravityCompat.START) | ||||
|             true | ||||
|         } | ||||
|  | ||||
|         val container: ViewGroup = findViewById(R.id.controller_container) | ||||
|  | ||||
|         router = Conductor.attachRouter(this, container, savedInstanceState) | ||||
|         if (!router.hasRootController()) { | ||||
|             // Set start screen | ||||
|             if (!handleIntentAction(intent)) { | ||||
|                 setSelectedDrawerItem(startScreenId) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         toolbar.setNavigationOnClickListener { | ||||
|             if (router.backstackSize == 1) { | ||||
|                 drawer.openDrawer(GravityCompat.START) | ||||
|             } else { | ||||
|                 onBackPressed() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         router.addChangeListener(object : ControllerChangeHandler.ControllerChangeListener { | ||||
|             override fun onChangeStarted(to: Controller?, from: Controller?, isPush: Boolean, | ||||
|                                          container: ViewGroup, handler: ControllerChangeHandler) { | ||||
|  | ||||
|                 syncActivityViewWithController(to, from) | ||||
|             } | ||||
|  | ||||
|             override fun onChangeCompleted(to: Controller?, from: Controller?, isPush: Boolean, | ||||
|                                            container: ViewGroup, handler: ControllerChangeHandler) { | ||||
|  | ||||
|             } | ||||
|  | ||||
|         }) | ||||
|  | ||||
|         syncActivityViewWithController(router.backstack.lastOrNull()?.controller()) | ||||
|  | ||||
|         if (savedInstanceState == null) { | ||||
|             // Show changelog if needed | ||||
|             if (Migrations.upgrade(preferences)) { | ||||
|                 ChangelogDialogController().showDialog(router) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onNewIntent(intent: Intent) { | ||||
|         if (!handleIntentAction(intent)) { | ||||
|             super.onNewIntent(intent) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun handleIntentAction(intent: Intent): Boolean { | ||||
|         when (intent.action) { | ||||
|             SHORTCUT_LIBRARY -> setSelectedDrawerItem(R.id.nav_drawer_library) | ||||
|             SHORTCUT_RECENTLY_UPDATED -> setSelectedDrawerItem(R.id.nav_drawer_recent_updates) | ||||
|             SHORTCUT_RECENTLY_READ -> setSelectedDrawerItem(R.id.nav_drawer_recently_read) | ||||
|             SHORTCUT_CATALOGUES -> setSelectedDrawerItem(R.id.nav_drawer_catalogues) | ||||
|             SHORTCUT_MANGA -> { | ||||
|                 val extras = intent.extras ?: return false | ||||
|                 router.setRoot(RouterTransaction.with(MangaController(extras))) | ||||
|             } | ||||
|             SHORTCUT_DOWNLOADS -> { | ||||
|                 if (router.backstack.none { it.controller() is DownloadController }) { | ||||
|                     setSelectedDrawerItem(R.id.nav_drawer_downloads) | ||||
|                 } | ||||
|             } | ||||
|             Intent.ACTION_SEARCH, "com.google.android.gms.actions.SEARCH_ACTION" -> { | ||||
|                 //If the intent match the "standard" Android search intent | ||||
|                 // or the Google-specific search intent (triggered by saying or typing "search *query* on *Tachiyomi*" in Google Search/Google Assistant) | ||||
|  | ||||
|                 //Get the search query provided in extras, and if not null, perform a global search with it. | ||||
|                 val query = intent.getStringExtra(SearchManager.QUERY) | ||||
|                 if (query != null && !query.isEmpty()) { | ||||
|                     if (router.backstackSize > 1) { | ||||
|                         router.popToRoot() | ||||
|                     } | ||||
|                     router.pushController(CatalogueSearchController(query).withFadeTransaction()) | ||||
|                 } | ||||
|             } | ||||
|             INTENT_SEARCH -> { | ||||
|                 val query = intent.getStringExtra(INTENT_SEARCH_QUERY) | ||||
|                 val filter = intent.getStringExtra(INTENT_SEARCH_FILTER) | ||||
|                 if (query != null && !query.isEmpty()) { | ||||
|                     if (router.backstackSize > 1) { | ||||
|                         router.popToRoot() | ||||
|                     } | ||||
|                     router.pushController(CatalogueSearchController(query, filter).withFadeTransaction()) | ||||
|                 } | ||||
|             } | ||||
|             else -> return false | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     override fun onDestroy() { | ||||
|         super.onDestroy() | ||||
|         nav_view?.setNavigationItemSelectedListener(null) | ||||
|         toolbar?.setNavigationOnClickListener(null) | ||||
|     } | ||||
|  | ||||
|     override fun onBackPressed() { | ||||
|         val backstackSize = router.backstackSize | ||||
|         if (drawer.isDrawerOpen(GravityCompat.START) || drawer.isDrawerOpen(GravityCompat.END)) { | ||||
|             drawer.closeDrawers() | ||||
|         } else if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) { | ||||
|             setSelectedDrawerItem(startScreenId) | ||||
|         } else if (backstackSize == 1 || !router.handleBack()) { | ||||
|             super.onBackPressed() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun setSelectedDrawerItem(itemId: Int) { | ||||
|         if (!isFinishing) { | ||||
|             nav_view.setCheckedItem(itemId) | ||||
|             nav_view.menu.performIdentifierAction(itemId, 0) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun setRoot(controller: Controller, id: Int) { | ||||
|         router.setRoot(controller.withFadeTransaction().tag(id.toString())) | ||||
|     } | ||||
|  | ||||
|     private fun syncActivityViewWithController(to: Controller?, from: Controller? = null) { | ||||
|         if (from is DialogController || to is DialogController) { | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         val showHamburger = router.backstackSize == 1 | ||||
|         if (showHamburger) { | ||||
|             drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED) | ||||
|         } else { | ||||
|             drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) | ||||
|         } | ||||
|  | ||||
|         ObjectAnimator.ofFloat(drawerArrow, "progress", if (showHamburger) 0f else 1f).start() | ||||
|  | ||||
|         if (from is TabbedController) { | ||||
|             from.cleanupTabs(tabs) | ||||
|         } | ||||
|         if (to is TabbedController) { | ||||
|             tabAnimator.expand() | ||||
|             to.configureTabs(tabs) | ||||
|         } else { | ||||
|             tabAnimator.collapse() | ||||
|             tabs.setupWithViewPager(null) | ||||
|         } | ||||
|  | ||||
|         if (from is SecondaryDrawerController) { | ||||
|             if (secondaryDrawer != null) { | ||||
|                 from.cleanupSecondaryDrawer(drawer) | ||||
|                 drawer.removeView(secondaryDrawer) | ||||
|                 secondaryDrawer = null | ||||
|             } | ||||
|         } | ||||
|         if (to is SecondaryDrawerController) { | ||||
|             secondaryDrawer = to.createSecondaryDrawer(drawer)?.also { drawer.addView(it) } | ||||
|         } | ||||
|  | ||||
|         if (to is NoToolbarElevationController) { | ||||
|             appbar.disableElevation() | ||||
|         } else { | ||||
|             appbar.enableElevation() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         // Shortcut actions | ||||
|         const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY" | ||||
|         const val SHORTCUT_RECENTLY_UPDATED = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED" | ||||
|         const val SHORTCUT_RECENTLY_READ = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ" | ||||
|         const val SHORTCUT_CATALOGUES = "eu.kanade.tachiyomi.SHOW_CATALOGUES" | ||||
|         const val SHORTCUT_DOWNLOADS = "eu.kanade.tachiyomi.SHOW_DOWNLOADS" | ||||
|         const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA" | ||||
|  | ||||
|         const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH" | ||||
|         const val INTENT_SEARCH_QUERY = "query" | ||||
|         const val INTENT_SEARCH_FILTER = "filter" | ||||
|  | ||||
|         private const val URL_HELP = "https://tachiyomi.org/help/" | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,193 +1,193 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga | ||||
|  | ||||
| import android.Manifest.permission.WRITE_EXTERNAL_STORAGE | ||||
| import android.os.Bundle | ||||
| import android.support.design.widget.TabLayout | ||||
| import android.support.graphics.drawable.VectorDrawableCompat | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import android.widget.LinearLayout | ||||
| import android.widget.TextView | ||||
| import com.bluelinelabs.conductor.ControllerChangeHandler | ||||
| import com.bluelinelabs.conductor.ControllerChangeType | ||||
| import com.bluelinelabs.conductor.Router | ||||
| import com.bluelinelabs.conductor.RouterTransaction | ||||
| import com.bluelinelabs.conductor.support.RouterPagerAdapter | ||||
| import com.jakewharton.rxrelay.BehaviorRelay | ||||
| import com.jakewharton.rxrelay.PublishRelay | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import eu.kanade.tachiyomi.ui.base.controller.RxController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.TabbedController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe | ||||
| import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersController | ||||
| import eu.kanade.tachiyomi.ui.manga.info.MangaInfoController | ||||
| import eu.kanade.tachiyomi.ui.manga.track.TrackController | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import kotlinx.android.synthetic.main.main_activity.* | ||||
| import kotlinx.android.synthetic.main.manga_controller.* | ||||
| import rx.Subscription | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.Date | ||||
|  | ||||
| class MangaController : RxController, TabbedController { | ||||
|  | ||||
|     constructor(manga: Manga?, fromCatalogue: Boolean = false) : super(Bundle().apply { | ||||
|         putLong(MANGA_EXTRA, manga?.id ?: 0) | ||||
|         putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue) | ||||
|     }) { | ||||
|         this.manga = manga | ||||
|         if (manga != null) { | ||||
|             source = Injekt.get<SourceManager>().getOrStub(manga.source) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     constructor(mangaId: Long) : this( | ||||
|             Injekt.get<DatabaseHelper>().getManga(mangaId).executeAsBlocking()) | ||||
|  | ||||
|     @Suppress("unused") | ||||
|     constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA)) | ||||
|  | ||||
|     var manga: Manga? = null | ||||
|         private set | ||||
|  | ||||
|     var source: Source? = null | ||||
|         private set | ||||
|  | ||||
|     private var adapter: MangaDetailAdapter? = null | ||||
|  | ||||
|     val fromCatalogue = args.getBoolean(FROM_CATALOGUE_EXTRA, false) | ||||
|  | ||||
|     val lastUpdateRelay: BehaviorRelay<Date> = BehaviorRelay.create() | ||||
|  | ||||
|     val chapterCountRelay: BehaviorRelay<Float> = BehaviorRelay.create() | ||||
|  | ||||
|     val mangaFavoriteRelay: PublishRelay<Boolean> = PublishRelay.create() | ||||
|  | ||||
|     private val trackingIconRelay: BehaviorRelay<Boolean> = BehaviorRelay.create() | ||||
|  | ||||
|     private var trackingIconSubscription: Subscription? = null | ||||
|  | ||||
|     override fun getTitle(): String? { | ||||
|         return manga?.title | ||||
|     } | ||||
|  | ||||
|     override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { | ||||
|         return inflater.inflate(R.layout.manga_controller, container, false) | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|  | ||||
|         if (manga == null || source == null) return | ||||
|  | ||||
|         requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301) | ||||
|  | ||||
|         adapter = MangaDetailAdapter() | ||||
|         manga_pager.offscreenPageLimit = 3 | ||||
|         manga_pager.adapter = adapter | ||||
|  | ||||
|         if (!fromCatalogue) | ||||
|             manga_pager.currentItem = CHAPTERS_CONTROLLER | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         super.onDestroyView(view) | ||||
|         adapter = null | ||||
|     } | ||||
|  | ||||
|     override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { | ||||
|         super.onChangeStarted(handler, type) | ||||
|         if (type.isEnter) { | ||||
|             activity?.tabs?.setupWithViewPager(manga_pager) | ||||
|             trackingIconSubscription = trackingIconRelay.subscribe { setTrackingIconInternal(it) } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onChangeEnded(handler: ControllerChangeHandler, type: ControllerChangeType) { | ||||
|         super.onChangeEnded(handler, type) | ||||
|         if (manga == null || source == null) { | ||||
|             activity?.toast(R.string.manga_not_in_db) | ||||
|             router.popController(this) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun configureTabs(tabs: TabLayout) { | ||||
|         with(tabs) { | ||||
|             tabGravity = TabLayout.GRAVITY_FILL | ||||
|             tabMode = TabLayout.MODE_FIXED | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun cleanupTabs(tabs: TabLayout) { | ||||
|         trackingIconSubscription?.unsubscribe() | ||||
|         setTrackingIconInternal(false) | ||||
|     } | ||||
|  | ||||
|     fun setTrackingIcon(visible: Boolean) { | ||||
|         trackingIconRelay.call(visible) | ||||
|     } | ||||
|  | ||||
|     private fun setTrackingIconInternal(visible: Boolean) { | ||||
|         val tab = activity?.tabs?.getTabAt(TRACK_CONTROLLER) ?: return | ||||
|         val drawable = if (visible) | ||||
|             VectorDrawableCompat.create(resources!!, R.drawable.ic_done_white_18dp, null) | ||||
|         else null | ||||
|  | ||||
|         val view = tabField.get(tab) as LinearLayout | ||||
|         val textView = view.getChildAt(1) as TextView | ||||
|         textView.setCompoundDrawablesWithIntrinsicBounds(null, null, drawable, null) | ||||
|         textView.compoundDrawablePadding = if (visible) 4 else 0 | ||||
|     } | ||||
|  | ||||
|     private inner class MangaDetailAdapter : RouterPagerAdapter(this@MangaController) { | ||||
|  | ||||
|         private val tabCount = if (Injekt.get<TrackManager>().hasLoggedServices()) 3 else 2 | ||||
|  | ||||
|         private val tabTitles = listOf( | ||||
|                 R.string.manga_detail_tab, | ||||
|                 R.string.manga_chapters_tab, | ||||
|                 R.string.manga_tracking_tab) | ||||
|                 .map { resources!!.getString(it) } | ||||
|  | ||||
|         override fun getCount(): Int { | ||||
|             return tabCount | ||||
|         } | ||||
|  | ||||
|         override fun configureRouter(router: Router, position: Int) { | ||||
|             if (!router.hasRootController()) { | ||||
|                 val controller = when (position) { | ||||
|                     INFO_CONTROLLER -> MangaInfoController() | ||||
|                     CHAPTERS_CONTROLLER -> ChaptersController() | ||||
|                     TRACK_CONTROLLER -> TrackController() | ||||
|                     else -> error("Wrong position $position") | ||||
|                 } | ||||
|                 router.setRoot(RouterTransaction.with(controller)) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         override fun getPageTitle(position: Int): CharSequence { | ||||
|             return tabTitles[position] | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val FROM_CATALOGUE_EXTRA = "from_catalogue" | ||||
|         const val MANGA_EXTRA = "manga" | ||||
|  | ||||
|         const val INFO_CONTROLLER = 0 | ||||
|         const val CHAPTERS_CONTROLLER = 1 | ||||
|         const val TRACK_CONTROLLER = 2 | ||||
|  | ||||
|         private val tabField = TabLayout.Tab::class.java.getDeclaredField("view") | ||||
|                 .apply { isAccessible = true } | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.manga | ||||
|  | ||||
| import android.Manifest.permission.WRITE_EXTERNAL_STORAGE | ||||
| import android.os.Bundle | ||||
| import android.support.design.widget.TabLayout | ||||
| import android.support.graphics.drawable.VectorDrawableCompat | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import android.widget.LinearLayout | ||||
| import android.widget.TextView | ||||
| import com.bluelinelabs.conductor.ControllerChangeHandler | ||||
| import com.bluelinelabs.conductor.ControllerChangeType | ||||
| import com.bluelinelabs.conductor.Router | ||||
| import com.bluelinelabs.conductor.RouterTransaction | ||||
| import com.bluelinelabs.conductor.support.RouterPagerAdapter | ||||
| import com.jakewharton.rxrelay.BehaviorRelay | ||||
| import com.jakewharton.rxrelay.PublishRelay | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import eu.kanade.tachiyomi.ui.base.controller.RxController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.TabbedController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe | ||||
| import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersController | ||||
| import eu.kanade.tachiyomi.ui.manga.info.MangaInfoController | ||||
| import eu.kanade.tachiyomi.ui.manga.track.TrackController | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import kotlinx.android.synthetic.main.main_activity.* | ||||
| import kotlinx.android.synthetic.main.manga_controller.* | ||||
| import rx.Subscription | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.Date | ||||
|  | ||||
| class MangaController : RxController, TabbedController { | ||||
|  | ||||
|     constructor(manga: Manga?, fromCatalogue: Boolean = false) : super(Bundle().apply { | ||||
|         putLong(MANGA_EXTRA, manga?.id ?: 0) | ||||
|         putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue) | ||||
|     }) { | ||||
|         this.manga = manga | ||||
|         if (manga != null) { | ||||
|             source = Injekt.get<SourceManager>().getOrStub(manga.source) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     constructor(mangaId: Long) : this( | ||||
|             Injekt.get<DatabaseHelper>().getManga(mangaId).executeAsBlocking()) | ||||
|  | ||||
|     @Suppress("unused") | ||||
|     constructor(bundle: Bundle) : this(bundle.getLong(MANGA_EXTRA)) | ||||
|  | ||||
|     var manga: Manga? = null | ||||
|         private set | ||||
|  | ||||
|     var source: Source? = null | ||||
|         private set | ||||
|  | ||||
|     private var adapter: MangaDetailAdapter? = null | ||||
|  | ||||
|     val fromCatalogue = args.getBoolean(FROM_CATALOGUE_EXTRA, false) | ||||
|  | ||||
|     val lastUpdateRelay: BehaviorRelay<Date> = BehaviorRelay.create() | ||||
|  | ||||
|     val chapterCountRelay: BehaviorRelay<Float> = BehaviorRelay.create() | ||||
|  | ||||
|     val mangaFavoriteRelay: PublishRelay<Boolean> = PublishRelay.create() | ||||
|  | ||||
|     private val trackingIconRelay: BehaviorRelay<Boolean> = BehaviorRelay.create() | ||||
|  | ||||
|     private var trackingIconSubscription: Subscription? = null | ||||
|  | ||||
|     override fun getTitle(): String? { | ||||
|         return manga?.title | ||||
|     } | ||||
|  | ||||
|     override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { | ||||
|         return inflater.inflate(R.layout.manga_controller, container, false) | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|  | ||||
|         if (manga == null || source == null) return | ||||
|  | ||||
|         requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301) | ||||
|  | ||||
|         adapter = MangaDetailAdapter() | ||||
|         manga_pager.offscreenPageLimit = 3 | ||||
|         manga_pager.adapter = adapter | ||||
|  | ||||
|         if (!fromCatalogue) | ||||
|             manga_pager.currentItem = CHAPTERS_CONTROLLER | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         super.onDestroyView(view) | ||||
|         adapter = null | ||||
|     } | ||||
|  | ||||
|     override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { | ||||
|         super.onChangeStarted(handler, type) | ||||
|         if (type.isEnter) { | ||||
|             activity?.tabs?.setupWithViewPager(manga_pager) | ||||
|             trackingIconSubscription = trackingIconRelay.subscribe { setTrackingIconInternal(it) } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onChangeEnded(handler: ControllerChangeHandler, type: ControllerChangeType) { | ||||
|         super.onChangeEnded(handler, type) | ||||
|         if (manga == null || source == null) { | ||||
|             activity?.toast(R.string.manga_not_in_db) | ||||
|             router.popController(this) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun configureTabs(tabs: TabLayout) { | ||||
|         with(tabs) { | ||||
|             tabGravity = TabLayout.GRAVITY_FILL | ||||
|             tabMode = TabLayout.MODE_FIXED | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun cleanupTabs(tabs: TabLayout) { | ||||
|         trackingIconSubscription?.unsubscribe() | ||||
|         setTrackingIconInternal(false) | ||||
|     } | ||||
|  | ||||
|     fun setTrackingIcon(visible: Boolean) { | ||||
|         trackingIconRelay.call(visible) | ||||
|     } | ||||
|  | ||||
|     private fun setTrackingIconInternal(visible: Boolean) { | ||||
|         val tab = activity?.tabs?.getTabAt(TRACK_CONTROLLER) ?: return | ||||
|         val drawable = if (visible) | ||||
|             VectorDrawableCompat.create(resources!!, R.drawable.ic_done_white_18dp, null) | ||||
|         else null | ||||
|  | ||||
|         val view = tabField.get(tab) as LinearLayout | ||||
|         val textView = view.getChildAt(1) as TextView | ||||
|         textView.setCompoundDrawablesWithIntrinsicBounds(null, null, drawable, null) | ||||
|         textView.compoundDrawablePadding = if (visible) 4 else 0 | ||||
|     } | ||||
|  | ||||
|     private inner class MangaDetailAdapter : RouterPagerAdapter(this@MangaController) { | ||||
|  | ||||
|         private val tabCount = if (Injekt.get<TrackManager>().hasLoggedServices()) 3 else 2 | ||||
|  | ||||
|         private val tabTitles = listOf( | ||||
|                 R.string.manga_detail_tab, | ||||
|                 R.string.manga_chapters_tab, | ||||
|                 R.string.manga_tracking_tab) | ||||
|                 .map { resources!!.getString(it) } | ||||
|  | ||||
|         override fun getCount(): Int { | ||||
|             return tabCount | ||||
|         } | ||||
|  | ||||
|         override fun configureRouter(router: Router, position: Int) { | ||||
|             if (!router.hasRootController()) { | ||||
|                 val controller = when (position) { | ||||
|                     INFO_CONTROLLER -> MangaInfoController() | ||||
|                     CHAPTERS_CONTROLLER -> ChaptersController() | ||||
|                     TRACK_CONTROLLER -> TrackController() | ||||
|                     else -> error("Wrong position $position") | ||||
|                 } | ||||
|                 router.setRoot(RouterTransaction.with(controller)) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         override fun getPageTitle(position: Int): CharSequence { | ||||
|             return tabTitles[position] | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val FROM_CATALOGUE_EXTRA = "from_catalogue" | ||||
|         const val MANGA_EXTRA = "manga" | ||||
|  | ||||
|         const val INFO_CONTROLLER = 0 | ||||
|         const val CHAPTERS_CONTROLLER = 1 | ||||
|         const val TRACK_CONTROLLER = 2 | ||||
|  | ||||
|         private val tabField = TabLayout.Tab::class.java.getDeclaredField("view") | ||||
|                 .apply { isAccessible = true } | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,122 +1,122 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.view.View | ||||
| import android.widget.PopupMenu | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
| import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder | ||||
| import eu.kanade.tachiyomi.util.getResourceColor | ||||
| import eu.kanade.tachiyomi.util.gone | ||||
| import eu.kanade.tachiyomi.util.setVectorCompat | ||||
| import kotlinx.android.synthetic.main.chapters_item.* | ||||
| import java.util.* | ||||
|  | ||||
| class ChapterHolder( | ||||
|         private val view: View, | ||||
|         private val adapter: ChaptersAdapter | ||||
| ) : BaseFlexibleViewHolder(view, adapter) { | ||||
|  | ||||
|     init { | ||||
|         // We need to post a Runnable to show the popup to make sure that the PopupMenu is | ||||
|         // correctly positioned. The reason being that the view may change position before the | ||||
|         // PopupMenu is shown. | ||||
|         chapter_menu.setOnClickListener { it.post { showPopupMenu(it) } } | ||||
|     } | ||||
|  | ||||
|     fun bind(item: ChapterItem, manga: Manga) { | ||||
|         val chapter = item.chapter | ||||
|  | ||||
|         chapter_title.text = when (manga.displayMode) { | ||||
|             Manga.DISPLAY_NUMBER -> { | ||||
|                 val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble()) | ||||
|                 itemView.context.getString(R.string.display_mode_chapter, number) | ||||
|             } | ||||
|             else -> chapter.name | ||||
|         } | ||||
|  | ||||
|         // Set the correct drawable for dropdown and update the tint to match theme. | ||||
|         chapter_menu.setVectorCompat(R.drawable.ic_more_vert_black_24dp, view.context.getResourceColor(R.attr.icon_color)) | ||||
|  | ||||
|         // Set correct text color | ||||
|         chapter_title.setTextColor(if (chapter.read) adapter.readColor else adapter.unreadColor) | ||||
|         if (chapter.bookmark) chapter_title.setTextColor(adapter.bookmarkedColor) | ||||
|  | ||||
|         if (chapter.date_upload > 0) { | ||||
|             chapter_date.text = adapter.dateFormat.format(Date(chapter.date_upload)) | ||||
|             chapter_date.setTextColor(if (chapter.read) adapter.readColor else adapter.unreadColor) | ||||
|         } else { | ||||
|             chapter_date.text = "" | ||||
|         } | ||||
|  | ||||
|         //add scanlator if exists | ||||
|         chapter_scanlator.text = chapter.scanlator | ||||
|         //allow longer titles if there is no scanlator (most sources) | ||||
|         if (chapter_scanlator.text.isNullOrBlank()) { | ||||
|             chapter_title.maxLines = 2 | ||||
|             chapter_scanlator.gone() | ||||
|         } else { | ||||
|             chapter_title.maxLines = 1 | ||||
|         } | ||||
|  | ||||
|         chapter_pages.text = if (!chapter.read && chapter.last_page_read > 0) { | ||||
|             itemView.context.getString(R.string.chapter_progress, chapter.last_page_read + 1) | ||||
|         } else { | ||||
|             "" | ||||
|         } | ||||
|  | ||||
|         notifyStatus(item.status) | ||||
|     } | ||||
|  | ||||
|     fun notifyStatus(status: Int) = with(download_text) { | ||||
|         when (status) { | ||||
|             Download.QUEUE -> setText(R.string.chapter_queued) | ||||
|             Download.DOWNLOADING -> setText(R.string.chapter_downloading) | ||||
|             Download.DOWNLOADED -> setText(R.string.chapter_downloaded) | ||||
|             Download.ERROR -> setText(R.string.chapter_error) | ||||
|             else -> text = "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun showPopupMenu(view: View) { | ||||
|         val item = adapter.getItem(adapterPosition) ?: return | ||||
|  | ||||
|         // Create a PopupMenu, giving it the clicked view for an anchor | ||||
|         val popup = PopupMenu(view.context, view) | ||||
|  | ||||
|         // Inflate our menu resource into the PopupMenu's Menu | ||||
|         popup.menuInflater.inflate(R.menu.chapter_single, popup.menu) | ||||
|  | ||||
|         val chapter = item.chapter | ||||
|  | ||||
|         // Hide download and show delete if the chapter is downloaded | ||||
|         if (item.isDownloaded) { | ||||
|             popup.menu.findItem(R.id.action_download).isVisible = false | ||||
|             popup.menu.findItem(R.id.action_delete).isVisible = true | ||||
|         } | ||||
|  | ||||
|         // Hide bookmark if bookmark | ||||
|         popup.menu.findItem(R.id.action_bookmark).isVisible = !chapter.bookmark | ||||
|         popup.menu.findItem(R.id.action_remove_bookmark).isVisible = chapter.bookmark | ||||
|  | ||||
|         // Hide mark as unread when the chapter is unread | ||||
|         if (!chapter.read && chapter.last_page_read == 0) { | ||||
|             popup.menu.findItem(R.id.action_mark_as_unread).isVisible = false | ||||
|         } | ||||
|  | ||||
|         // Hide mark as read when the chapter is read | ||||
|         if (chapter.read) { | ||||
|             popup.menu.findItem(R.id.action_mark_as_read).isVisible = false | ||||
|         } | ||||
|  | ||||
|         // Set a listener so we are notified if a menu item is clicked | ||||
|         popup.setOnMenuItemClickListener { menuItem -> | ||||
|             adapter.menuItemListener.onMenuItemClick(adapterPosition, menuItem) | ||||
|             true | ||||
|         } | ||||
|  | ||||
|         // Finally show the PopupMenu | ||||
|         popup.show() | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.view.View | ||||
| import android.widget.PopupMenu | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
| import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder | ||||
| import eu.kanade.tachiyomi.util.getResourceColor | ||||
| import eu.kanade.tachiyomi.util.gone | ||||
| import eu.kanade.tachiyomi.util.setVectorCompat | ||||
| import kotlinx.android.synthetic.main.chapters_item.* | ||||
| import java.util.* | ||||
|  | ||||
| class ChapterHolder( | ||||
|         private val view: View, | ||||
|         private val adapter: ChaptersAdapter | ||||
| ) : BaseFlexibleViewHolder(view, adapter) { | ||||
|  | ||||
|     init { | ||||
|         // We need to post a Runnable to show the popup to make sure that the PopupMenu is | ||||
|         // correctly positioned. The reason being that the view may change position before the | ||||
|         // PopupMenu is shown. | ||||
|         chapter_menu.setOnClickListener { it.post { showPopupMenu(it) } } | ||||
|     } | ||||
|  | ||||
|     fun bind(item: ChapterItem, manga: Manga) { | ||||
|         val chapter = item.chapter | ||||
|  | ||||
|         chapter_title.text = when (manga.displayMode) { | ||||
|             Manga.DISPLAY_NUMBER -> { | ||||
|                 val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble()) | ||||
|                 itemView.context.getString(R.string.display_mode_chapter, number) | ||||
|             } | ||||
|             else -> chapter.name | ||||
|         } | ||||
|  | ||||
|         // Set the correct drawable for dropdown and update the tint to match theme. | ||||
|         chapter_menu.setVectorCompat(R.drawable.ic_more_vert_black_24dp, view.context.getResourceColor(R.attr.icon_color)) | ||||
|  | ||||
|         // Set correct text color | ||||
|         chapter_title.setTextColor(if (chapter.read) adapter.readColor else adapter.unreadColor) | ||||
|         if (chapter.bookmark) chapter_title.setTextColor(adapter.bookmarkedColor) | ||||
|  | ||||
|         if (chapter.date_upload > 0) { | ||||
|             chapter_date.text = adapter.dateFormat.format(Date(chapter.date_upload)) | ||||
|             chapter_date.setTextColor(if (chapter.read) adapter.readColor else adapter.unreadColor) | ||||
|         } else { | ||||
|             chapter_date.text = "" | ||||
|         } | ||||
|  | ||||
|         //add scanlator if exists | ||||
|         chapter_scanlator.text = chapter.scanlator | ||||
|         //allow longer titles if there is no scanlator (most sources) | ||||
|         if (chapter_scanlator.text.isNullOrBlank()) { | ||||
|             chapter_title.maxLines = 2 | ||||
|             chapter_scanlator.gone() | ||||
|         } else { | ||||
|             chapter_title.maxLines = 1 | ||||
|         } | ||||
|  | ||||
|         chapter_pages.text = if (!chapter.read && chapter.last_page_read > 0) { | ||||
|             itemView.context.getString(R.string.chapter_progress, chapter.last_page_read + 1) | ||||
|         } else { | ||||
|             "" | ||||
|         } | ||||
|  | ||||
|         notifyStatus(item.status) | ||||
|     } | ||||
|  | ||||
|     fun notifyStatus(status: Int) = with(download_text) { | ||||
|         when (status) { | ||||
|             Download.QUEUE -> setText(R.string.chapter_queued) | ||||
|             Download.DOWNLOADING -> setText(R.string.chapter_downloading) | ||||
|             Download.DOWNLOADED -> setText(R.string.chapter_downloaded) | ||||
|             Download.ERROR -> setText(R.string.chapter_error) | ||||
|             else -> text = "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun showPopupMenu(view: View) { | ||||
|         val item = adapter.getItem(adapterPosition) ?: return | ||||
|  | ||||
|         // Create a PopupMenu, giving it the clicked view for an anchor | ||||
|         val popup = PopupMenu(view.context, view) | ||||
|  | ||||
|         // Inflate our menu resource into the PopupMenu's Menu | ||||
|         popup.menuInflater.inflate(R.menu.chapter_single, popup.menu) | ||||
|  | ||||
|         val chapter = item.chapter | ||||
|  | ||||
|         // Hide download and show delete if the chapter is downloaded | ||||
|         if (item.isDownloaded) { | ||||
|             popup.menu.findItem(R.id.action_download).isVisible = false | ||||
|             popup.menu.findItem(R.id.action_delete).isVisible = true | ||||
|         } | ||||
|  | ||||
|         // Hide bookmark if bookmark | ||||
|         popup.menu.findItem(R.id.action_bookmark).isVisible = !chapter.bookmark | ||||
|         popup.menu.findItem(R.id.action_remove_bookmark).isVisible = chapter.bookmark | ||||
|  | ||||
|         // Hide mark as unread when the chapter is unread | ||||
|         if (!chapter.read && chapter.last_page_read == 0) { | ||||
|             popup.menu.findItem(R.id.action_mark_as_unread).isVisible = false | ||||
|         } | ||||
|  | ||||
|         // Hide mark as read when the chapter is read | ||||
|         if (chapter.read) { | ||||
|             popup.menu.findItem(R.id.action_mark_as_read).isVisible = false | ||||
|         } | ||||
|  | ||||
|         // Set a listener so we are notified if a menu item is clicked | ||||
|         popup.setOnMenuItemClickListener { menuItem -> | ||||
|             adapter.menuItemListener.onMenuItemClick(adapterPosition, menuItem) | ||||
|             true | ||||
|         } | ||||
|  | ||||
|         // Finally show the PopupMenu | ||||
|         popup.show() | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,53 +1,53 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.view.View | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.items.AbstractFlexibleItem | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
|  | ||||
| class ChapterItem(val chapter: Chapter, val manga: Manga) : AbstractFlexibleItem<ChapterHolder>(), | ||||
|         Chapter by chapter { | ||||
|  | ||||
|     private var _status: Int = 0 | ||||
|  | ||||
|     var status: Int | ||||
|         get() = download?.status ?: _status | ||||
|         set(value) { _status = value } | ||||
|  | ||||
|     @Transient var download: Download? = null | ||||
|  | ||||
|     val isDownloaded: Boolean | ||||
|         get() = status == Download.DOWNLOADED | ||||
|  | ||||
|     override fun getLayoutRes(): Int { | ||||
|         return R.layout.chapters_item | ||||
|     } | ||||
|  | ||||
|     override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): ChapterHolder { | ||||
|         return ChapterHolder(view, adapter as ChaptersAdapter) | ||||
|     } | ||||
|  | ||||
|     override fun bindViewHolder(adapter: FlexibleAdapter<*>, | ||||
|                                 holder: ChapterHolder, | ||||
|                                 position: Int, | ||||
|                                 payloads: List<Any?>?) { | ||||
|  | ||||
|         holder.bind(this, manga) | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (other is ChapterItem) { | ||||
|             return chapter.id!! == other.chapter.id!! | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return chapter.id!!.hashCode() | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.view.View | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.items.AbstractFlexibleItem | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
|  | ||||
| class ChapterItem(val chapter: Chapter, val manga: Manga) : AbstractFlexibleItem<ChapterHolder>(), | ||||
|         Chapter by chapter { | ||||
|  | ||||
|     private var _status: Int = 0 | ||||
|  | ||||
|     var status: Int | ||||
|         get() = download?.status ?: _status | ||||
|         set(value) { _status = value } | ||||
|  | ||||
|     @Transient var download: Download? = null | ||||
|  | ||||
|     val isDownloaded: Boolean | ||||
|         get() = status == Download.DOWNLOADED | ||||
|  | ||||
|     override fun getLayoutRes(): Int { | ||||
|         return R.layout.chapters_item | ||||
|     } | ||||
|  | ||||
|     override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): ChapterHolder { | ||||
|         return ChapterHolder(view, adapter as ChaptersAdapter) | ||||
|     } | ||||
|  | ||||
|     override fun bindViewHolder(adapter: FlexibleAdapter<*>, | ||||
|                                 holder: ChapterHolder, | ||||
|                                 position: Int, | ||||
|                                 payloads: List<Any?>?) { | ||||
|  | ||||
|         holder.bind(this, manga) | ||||
|     } | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (this === other) return true | ||||
|         if (other is ChapterItem) { | ||||
|             return chapter.id!! == other.chapter.id!! | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return chapter.id!!.hashCode() | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,45 +1,45 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.content.Context | ||||
| import android.view.MenuItem | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.util.getResourceColor | ||||
| import java.text.DateFormat | ||||
| import java.text.DecimalFormat | ||||
| import java.text.DecimalFormatSymbols | ||||
|  | ||||
| class ChaptersAdapter( | ||||
|         controller: ChaptersController, | ||||
|         context: Context | ||||
| ) : FlexibleAdapter<ChapterItem>(null, controller, true) { | ||||
|  | ||||
|     var items: List<ChapterItem> = emptyList() | ||||
|  | ||||
|     val menuItemListener: OnMenuItemClickListener = controller | ||||
|  | ||||
|     val readColor = context.getResourceColor(android.R.attr.textColorHint) | ||||
|  | ||||
|     val unreadColor = context.getResourceColor(android.R.attr.textColorPrimary) | ||||
|  | ||||
|     val bookmarkedColor = context.getResourceColor(R.attr.colorAccent) | ||||
|  | ||||
|     val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols() | ||||
|             .apply { decimalSeparator = '.' }) | ||||
|  | ||||
|     val dateFormat: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT) | ||||
|  | ||||
|     override fun updateDataSet(items: List<ChapterItem>?) { | ||||
|         this.items = items ?: emptyList() | ||||
|         super.updateDataSet(items) | ||||
|     } | ||||
|  | ||||
|     fun indexOf(item: ChapterItem): Int { | ||||
|         return items.indexOf(item) | ||||
|     } | ||||
|  | ||||
|     interface OnMenuItemClickListener { | ||||
|         fun onMenuItemClick(position: Int, item: MenuItem) | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.content.Context | ||||
| import android.view.MenuItem | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.util.getResourceColor | ||||
| import java.text.DateFormat | ||||
| import java.text.DecimalFormat | ||||
| import java.text.DecimalFormatSymbols | ||||
|  | ||||
| class ChaptersAdapter( | ||||
|         controller: ChaptersController, | ||||
|         context: Context | ||||
| ) : FlexibleAdapter<ChapterItem>(null, controller, true) { | ||||
|  | ||||
|     var items: List<ChapterItem> = emptyList() | ||||
|  | ||||
|     val menuItemListener: OnMenuItemClickListener = controller | ||||
|  | ||||
|     val readColor = context.getResourceColor(android.R.attr.textColorHint) | ||||
|  | ||||
|     val unreadColor = context.getResourceColor(android.R.attr.textColorPrimary) | ||||
|  | ||||
|     val bookmarkedColor = context.getResourceColor(R.attr.colorAccent) | ||||
|  | ||||
|     val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols() | ||||
|             .apply { decimalSeparator = '.' }) | ||||
|  | ||||
|     val dateFormat: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT) | ||||
|  | ||||
|     override fun updateDataSet(items: List<ChapterItem>?) { | ||||
|         this.items = items ?: emptyList() | ||||
|         super.updateDataSet(items) | ||||
|     } | ||||
|  | ||||
|     fun indexOf(item: ChapterItem): Int { | ||||
|         return items.indexOf(item) | ||||
|     } | ||||
|  | ||||
|     interface OnMenuItemClickListener { | ||||
|         fun onMenuItemClick(position: Int, item: MenuItem) | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,486 +1,486 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.animation.Animator | ||||
| import android.animation.AnimatorListenerAdapter | ||||
| import android.annotation.SuppressLint | ||||
| import android.app.Activity | ||||
| import android.content.Intent | ||||
| import android.support.design.widget.Snackbar | ||||
| import android.support.v7.app.AppCompatActivity | ||||
| import android.support.v7.view.ActionMode | ||||
| import android.support.v7.widget.DividerItemDecoration | ||||
| import android.support.v7.widget.LinearLayoutManager | ||||
| import android.view.* | ||||
| import com.jakewharton.rxbinding.support.v4.widget.refreshes | ||||
| import com.jakewharton.rxbinding.view.clicks | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.SelectableAdapter | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
| import eu.kanade.tachiyomi.ui.base.controller.NucleusController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag | ||||
| import eu.kanade.tachiyomi.ui.manga.MangaController | ||||
| import eu.kanade.tachiyomi.ui.reader.ReaderActivity | ||||
| import eu.kanade.tachiyomi.util.getCoordinates | ||||
| import eu.kanade.tachiyomi.util.snack | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import kotlinx.android.synthetic.main.chapters_controller.* | ||||
| import timber.log.Timber | ||||
|  | ||||
| class ChaptersController : NucleusController<ChaptersPresenter>(), | ||||
|         ActionMode.Callback, | ||||
|         FlexibleAdapter.OnItemClickListener, | ||||
|         FlexibleAdapter.OnItemLongClickListener, | ||||
|         ChaptersAdapter.OnMenuItemClickListener, | ||||
|         SetDisplayModeDialog.Listener, | ||||
|         SetSortingDialog.Listener, | ||||
|         DownloadChaptersDialog.Listener, | ||||
|         DownloadCustomChaptersDialog.Listener, | ||||
|         DeleteChaptersDialog.Listener { | ||||
|  | ||||
|     /** | ||||
|      * Adapter containing a list of chapters. | ||||
|      */ | ||||
|     private var adapter: ChaptersAdapter? = null | ||||
|  | ||||
|     /** | ||||
|      * Action mode for multiple selection. | ||||
|      */ | ||||
|     private var actionMode: ActionMode? = null | ||||
|  | ||||
|     /** | ||||
|      * Selected items. Used to restore selections after a rotation. | ||||
|      */ | ||||
|     private val selectedItems = mutableSetOf<ChapterItem>() | ||||
|  | ||||
|     init { | ||||
|         setHasOptionsMenu(true) | ||||
|         setOptionsMenuHidden(true) | ||||
|     } | ||||
|  | ||||
|     override fun createPresenter(): ChaptersPresenter { | ||||
|         val ctrl = parentController as MangaController | ||||
|         return ChaptersPresenter(ctrl.manga!!, ctrl.source!!, | ||||
|                 ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay) | ||||
|     } | ||||
|  | ||||
|     override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { | ||||
|         return inflater.inflate(R.layout.chapters_controller, container, false) | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|  | ||||
|         // Init RecyclerView and adapter | ||||
|         adapter = ChaptersAdapter(this, view.context) | ||||
|  | ||||
|         recycler.adapter = adapter | ||||
|         recycler.layoutManager = LinearLayoutManager(view.context) | ||||
|         recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) | ||||
|         recycler.setHasFixedSize(true) | ||||
|         adapter?.fastScroller = fast_scroller | ||||
|  | ||||
|         swipe_refresh.refreshes().subscribeUntilDestroy { fetchChaptersFromSource() } | ||||
|  | ||||
|         fab.clicks().subscribeUntilDestroy { | ||||
|             val item = presenter.getNextUnreadChapter() | ||||
|             if (item != null) { | ||||
|                 // Create animation listener | ||||
|                 val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() { | ||||
|                     override fun onAnimationStart(animation: Animator?) { | ||||
|                         openChapter(item.chapter, true) | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 // Get coordinates and start animation | ||||
|                 val coordinates = fab.getCoordinates() | ||||
|                 if (!reveal_view.showRevealEffect(coordinates.x, coordinates.y, revealAnimationListener)) { | ||||
|                     openChapter(item.chapter) | ||||
|                 } | ||||
|             } else { | ||||
|                 view.context.toast(R.string.no_next_chapter) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         adapter = null | ||||
|         actionMode = null | ||||
|         super.onDestroyView(view) | ||||
|     } | ||||
|  | ||||
|     override fun onActivityResumed(activity: Activity) { | ||||
|         if (view == null) return | ||||
|  | ||||
|         // Check if animation view is visible | ||||
|         if (reveal_view.visibility == View.VISIBLE) { | ||||
|             // Show the unReveal effect | ||||
|             val coordinates = fab.getCoordinates() | ||||
|             reveal_view.hideRevealEffect(coordinates.x, coordinates.y, 1920) | ||||
|         } | ||||
|         super.onActivityResumed(activity) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { | ||||
|         inflater.inflate(R.menu.chapters, menu) | ||||
|     } | ||||
|  | ||||
|     override fun onPrepareOptionsMenu(menu: Menu) { | ||||
|         // Initialize menu items. | ||||
|         val menuFilterRead = menu.findItem(R.id.action_filter_read) ?: return | ||||
|         val menuFilterUnread = menu.findItem(R.id.action_filter_unread) | ||||
|         val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded) | ||||
|         val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked) | ||||
|  | ||||
|         // Set correct checkbox values. | ||||
|         menuFilterRead.isChecked = presenter.onlyRead() | ||||
|         menuFilterUnread.isChecked = presenter.onlyUnread() | ||||
|         menuFilterDownloaded.isChecked = presenter.onlyDownloaded() | ||||
|         menuFilterBookmarked.isChecked = presenter.onlyBookmarked() | ||||
|  | ||||
|         if (presenter.onlyRead()) | ||||
|             //Disable unread filter option if read filter is enabled. | ||||
|             menuFilterUnread.isEnabled = false | ||||
|         if (presenter.onlyUnread()) | ||||
|             //Disable read filter option if unread filter is enabled. | ||||
|             menuFilterRead.isEnabled = false | ||||
|     } | ||||
|  | ||||
|     override fun onOptionsItemSelected(item: MenuItem): Boolean { | ||||
|         when (item.itemId) { | ||||
|             R.id.action_display_mode -> showDisplayModeDialog() | ||||
|             R.id.manga_download -> showDownloadDialog() | ||||
|             R.id.action_sorting_mode -> showSortingDialog() | ||||
|             R.id.action_filter_unread -> { | ||||
|                 item.isChecked = !item.isChecked | ||||
|                 presenter.setUnreadFilter(item.isChecked) | ||||
|                 activity?.invalidateOptionsMenu() | ||||
|             } | ||||
|             R.id.action_filter_read -> { | ||||
|                 item.isChecked = !item.isChecked | ||||
|                 presenter.setReadFilter(item.isChecked) | ||||
|                 activity?.invalidateOptionsMenu() | ||||
|             } | ||||
|             R.id.action_filter_downloaded -> { | ||||
|                 item.isChecked = !item.isChecked | ||||
|                 presenter.setDownloadedFilter(item.isChecked) | ||||
|             } | ||||
|             R.id.action_filter_bookmarked -> { | ||||
|                 item.isChecked = !item.isChecked | ||||
|                 presenter.setBookmarkedFilter(item.isChecked) | ||||
|             } | ||||
|             R.id.action_filter_empty -> { | ||||
|                 presenter.removeFilters() | ||||
|                 activity?.invalidateOptionsMenu() | ||||
|             } | ||||
|             R.id.action_sort -> presenter.revertSortOrder() | ||||
|             else -> return super.onOptionsItemSelected(item) | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     fun onNextChapters(chapters: List<ChapterItem>) { | ||||
|         // If the list is empty, fetch chapters from source if the conditions are met | ||||
|         // We use presenter chapters instead because they are always unfiltered | ||||
|         if (presenter.chapters.isEmpty()) | ||||
|             initialFetchChapters() | ||||
|  | ||||
|         val adapter = adapter ?: return | ||||
|         adapter.updateDataSet(chapters) | ||||
|  | ||||
|         if (selectedItems.isNotEmpty()) { | ||||
|             adapter.clearSelection() // we need to start from a clean state, index may have changed | ||||
|             createActionModeIfNeeded() | ||||
|             selectedItems.forEach { item -> | ||||
|                 val position = adapter.indexOf(item) | ||||
|                 if (position != -1 && !adapter.isSelected(position)) { | ||||
|                     adapter.toggleSelection(position) | ||||
|                 } | ||||
|             } | ||||
|             actionMode?.invalidate() | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     private fun initialFetchChapters() { | ||||
|         // Only fetch if this view is from the catalog and it hasn't requested previously | ||||
|         if ((parentController as MangaController).fromCatalogue && !presenter.hasRequested) { | ||||
|             fetchChaptersFromSource() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun fetchChaptersFromSource() { | ||||
|         swipe_refresh?.isRefreshing = true | ||||
|         presenter.fetchChaptersFromSource() | ||||
|     } | ||||
|  | ||||
|     fun onFetchChaptersDone() { | ||||
|         swipe_refresh?.isRefreshing = false | ||||
|     } | ||||
|  | ||||
|     fun onFetchChaptersError(error: Throwable) { | ||||
|         swipe_refresh?.isRefreshing = false | ||||
|         activity?.toast(error.message) | ||||
|     } | ||||
|  | ||||
|     fun onChapterStatusChange(download: Download) { | ||||
|         getHolder(download.chapter)?.notifyStatus(download.status) | ||||
|     } | ||||
|  | ||||
|     private fun getHolder(chapter: Chapter): ChapterHolder? { | ||||
|         return recycler?.findViewHolderForItemId(chapter.id!!) as? ChapterHolder | ||||
|     } | ||||
|  | ||||
|     fun openChapter(chapter: Chapter, hasAnimation: Boolean = false) { | ||||
|         val activity = activity ?: return | ||||
|         val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter) | ||||
|         if (hasAnimation) { | ||||
|             intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) | ||||
|         } | ||||
|         startActivity(intent) | ||||
|     } | ||||
|  | ||||
|     override fun onItemClick(position: Int): Boolean { | ||||
|         val adapter = adapter ?: return false | ||||
|         val item = adapter.getItem(position) ?: return false | ||||
|         if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { | ||||
|             toggleSelection(position) | ||||
|             return true | ||||
|         } else { | ||||
|             openChapter(item.chapter) | ||||
|             return false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onItemLongClick(position: Int) { | ||||
|         createActionModeIfNeeded() | ||||
|         toggleSelection(position) | ||||
|     } | ||||
|  | ||||
|     // SELECTIONS & ACTION MODE | ||||
|  | ||||
|     private fun toggleSelection(position: Int) { | ||||
|         val adapter = adapter ?: return | ||||
|         val item = adapter.getItem(position) ?: return | ||||
|         adapter.toggleSelection(position) | ||||
|         if (adapter.isSelected(position)) { | ||||
|             selectedItems.add(item) | ||||
|         } else { | ||||
|             selectedItems.remove(item) | ||||
|         } | ||||
|         actionMode?.invalidate() | ||||
|     } | ||||
|  | ||||
|     private fun getSelectedChapters(): List<ChapterItem> { | ||||
|         val adapter = adapter ?: return emptyList() | ||||
|         return adapter.selectedPositions.mapNotNull { adapter.getItem(it) } | ||||
|     } | ||||
|  | ||||
|     private fun createActionModeIfNeeded() { | ||||
|         if (actionMode == null) { | ||||
|             actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun destroyActionModeIfNeeded() { | ||||
|         actionMode?.finish() | ||||
|     } | ||||
|  | ||||
|     override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { | ||||
|         mode.menuInflater.inflate(R.menu.chapter_selection, menu) | ||||
|         adapter?.mode = SelectableAdapter.Mode.MULTI | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     @SuppressLint("StringFormatInvalid") | ||||
|     override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { | ||||
|         val count = adapter?.selectedItemCount ?: 0 | ||||
|         if (count == 0) { | ||||
|             // Destroy action mode if there are no items selected. | ||||
|             destroyActionModeIfNeeded() | ||||
|         } else { | ||||
|             mode.title = resources?.getString(R.string.label_selected, count) | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { | ||||
|         when (item.itemId) { | ||||
|             R.id.action_select_all -> selectAll() | ||||
|             R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) | ||||
|             R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) | ||||
|             R.id.action_download -> downloadChapters(getSelectedChapters()) | ||||
|             R.id.action_delete -> showDeleteChaptersConfirmationDialog() | ||||
|             else -> return false | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyActionMode(mode: ActionMode) { | ||||
|         adapter?.mode = SelectableAdapter.Mode.SINGLE | ||||
|         adapter?.clearSelection() | ||||
|         selectedItems.clear() | ||||
|         actionMode = null | ||||
|     } | ||||
|  | ||||
|     override fun onMenuItemClick(position: Int, item: MenuItem) { | ||||
|         val chapter = adapter?.getItem(position) ?: return | ||||
|         val chapters = listOf(chapter) | ||||
|  | ||||
|         when (item.itemId) { | ||||
|             R.id.action_download -> downloadChapters(chapters) | ||||
|             R.id.action_bookmark -> bookmarkChapters(chapters, true) | ||||
|             R.id.action_remove_bookmark -> bookmarkChapters(chapters, false) | ||||
|             R.id.action_delete -> deleteChapters(chapters) | ||||
|             R.id.action_mark_as_read -> markAsRead(chapters) | ||||
|             R.id.action_mark_as_unread -> markAsUnread(chapters) | ||||
|             R.id.action_mark_previous_as_read -> markPreviousAsRead(chapter) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // SELECTION MODE ACTIONS | ||||
|  | ||||
|     private fun selectAll() { | ||||
|         val adapter = adapter ?: return | ||||
|         adapter.selectAll() | ||||
|         selectedItems.addAll(adapter.items) | ||||
|         actionMode?.invalidate() | ||||
|     } | ||||
|  | ||||
|     private fun markAsRead(chapters: List<ChapterItem>) { | ||||
|         presenter.markChaptersRead(chapters, true) | ||||
|         if (presenter.preferences.removeAfterMarkedAsRead()) { | ||||
|             deleteChapters(chapters) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun markAsUnread(chapters: List<ChapterItem>) { | ||||
|         presenter.markChaptersRead(chapters, false) | ||||
|     } | ||||
|  | ||||
|     private fun downloadChapters(chapters: List<ChapterItem>) { | ||||
|         val view = view | ||||
|         destroyActionModeIfNeeded() | ||||
|         presenter.downloadChapters(chapters) | ||||
|         if (view != null && !presenter.manga.favorite) { | ||||
|             recycler?.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) { | ||||
|                 setAction(R.string.action_add) { | ||||
|                     presenter.addToLibrary() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     private fun showDeleteChaptersConfirmationDialog() { | ||||
|         DeleteChaptersDialog(this).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     override fun deleteChapters() { | ||||
|         deleteChapters(getSelectedChapters()) | ||||
|     } | ||||
|  | ||||
|     private fun markPreviousAsRead(chapter: ChapterItem) { | ||||
|         val adapter = adapter ?: return | ||||
|         val chapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items | ||||
|         val chapterPos = chapters.indexOf(chapter) | ||||
|         if (chapterPos != -1) { | ||||
|             markAsRead(chapters.take(chapterPos)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun bookmarkChapters(chapters: List<ChapterItem>, bookmarked: Boolean) { | ||||
|         destroyActionModeIfNeeded() | ||||
|         presenter.bookmarkChapters(chapters, bookmarked) | ||||
|     } | ||||
|  | ||||
|     fun deleteChapters(chapters: List<ChapterItem>) { | ||||
|         destroyActionModeIfNeeded() | ||||
|         if (chapters.isEmpty()) return | ||||
|  | ||||
|         DeletingChaptersDialog().showDialog(router) | ||||
|         presenter.deleteChapters(chapters) | ||||
|     } | ||||
|  | ||||
|     fun onChaptersDeleted() { | ||||
|         dismissDeletingDialog() | ||||
|         adapter?.notifyDataSetChanged() | ||||
|     } | ||||
|  | ||||
|     fun onChaptersDeletedError(error: Throwable) { | ||||
|         dismissDeletingDialog() | ||||
|         Timber.e(error) | ||||
|     } | ||||
|  | ||||
|     private fun dismissDeletingDialog() { | ||||
|         router.popControllerWithTag(DeletingChaptersDialog.TAG) | ||||
|     } | ||||
|  | ||||
|     // OVERFLOW MENU DIALOGS | ||||
|  | ||||
|     private fun showDisplayModeDialog() { | ||||
|         val preselected = if (presenter.manga.displayMode == Manga.DISPLAY_NAME) 0 else 1 | ||||
|         SetDisplayModeDialog(this, preselected).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     override fun setDisplayMode(id: Int) { | ||||
|         presenter.setDisplayMode(id) | ||||
|         adapter?.notifyDataSetChanged() | ||||
|     } | ||||
|  | ||||
|     private fun showSortingDialog() { | ||||
|         val preselected = if (presenter.manga.sorting == Manga.SORTING_SOURCE) 0 else 1 | ||||
|         SetSortingDialog(this, preselected).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     override fun setSorting(id: Int) { | ||||
|         presenter.setSorting(id) | ||||
|     } | ||||
|  | ||||
|     private fun showDownloadDialog() { | ||||
|         DownloadChaptersDialog(this).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     private fun getUnreadChaptersSorted() = presenter.chapters | ||||
|             .filter { !it.read && it.status == Download.NOT_DOWNLOADED } | ||||
|             .distinctBy { it.name } | ||||
|             .sortedByDescending { it.source_order } | ||||
|  | ||||
|     override fun downloadCustomChapters(amount: Int) { | ||||
|         val chaptersToDownload = getUnreadChaptersSorted().take(amount) | ||||
|         if (chaptersToDownload.isNotEmpty()) { | ||||
|             downloadChapters(chaptersToDownload) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun showCustomDownloadDialog() { | ||||
|         DownloadCustomChaptersDialog(this, presenter.chapters.size).showDialog(router) | ||||
|     } | ||||
|  | ||||
|  | ||||
|     override fun downloadChapters(choice: Int) { | ||||
|         // i = 0: Download 1 | ||||
|         // i = 1: Download 5 | ||||
|         // i = 2: Download 10 | ||||
|         // i = 3: Download x | ||||
|         // i = 4: Download unread | ||||
|         // i = 5: Download all | ||||
|         val chaptersToDownload = when (choice) { | ||||
|             0 -> getUnreadChaptersSorted().take(1) | ||||
|             1 -> getUnreadChaptersSorted().take(5) | ||||
|             2 -> getUnreadChaptersSorted().take(10) | ||||
|             3 -> { | ||||
|                 showCustomDownloadDialog() | ||||
|                 return | ||||
|             } | ||||
|             4 -> presenter.chapters.filter { !it.read } | ||||
|             5 -> presenter.chapters | ||||
|             else -> emptyList() | ||||
|         } | ||||
|         if (chaptersToDownload.isNotEmpty()) { | ||||
|             downloadChapters(chaptersToDownload) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.animation.Animator | ||||
| import android.animation.AnimatorListenerAdapter | ||||
| import android.annotation.SuppressLint | ||||
| import android.app.Activity | ||||
| import android.content.Intent | ||||
| import android.support.design.widget.Snackbar | ||||
| import android.support.v7.app.AppCompatActivity | ||||
| import android.support.v7.view.ActionMode | ||||
| import android.support.v7.widget.DividerItemDecoration | ||||
| import android.support.v7.widget.LinearLayoutManager | ||||
| import android.view.* | ||||
| import com.jakewharton.rxbinding.support.v4.widget.refreshes | ||||
| import com.jakewharton.rxbinding.view.clicks | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.SelectableAdapter | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
| import eu.kanade.tachiyomi.ui.base.controller.NucleusController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag | ||||
| import eu.kanade.tachiyomi.ui.manga.MangaController | ||||
| import eu.kanade.tachiyomi.ui.reader.ReaderActivity | ||||
| import eu.kanade.tachiyomi.util.getCoordinates | ||||
| import eu.kanade.tachiyomi.util.snack | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import kotlinx.android.synthetic.main.chapters_controller.* | ||||
| import timber.log.Timber | ||||
|  | ||||
| class ChaptersController : NucleusController<ChaptersPresenter>(), | ||||
|         ActionMode.Callback, | ||||
|         FlexibleAdapter.OnItemClickListener, | ||||
|         FlexibleAdapter.OnItemLongClickListener, | ||||
|         ChaptersAdapter.OnMenuItemClickListener, | ||||
|         SetDisplayModeDialog.Listener, | ||||
|         SetSortingDialog.Listener, | ||||
|         DownloadChaptersDialog.Listener, | ||||
|         DownloadCustomChaptersDialog.Listener, | ||||
|         DeleteChaptersDialog.Listener { | ||||
|  | ||||
|     /** | ||||
|      * Adapter containing a list of chapters. | ||||
|      */ | ||||
|     private var adapter: ChaptersAdapter? = null | ||||
|  | ||||
|     /** | ||||
|      * Action mode for multiple selection. | ||||
|      */ | ||||
|     private var actionMode: ActionMode? = null | ||||
|  | ||||
|     /** | ||||
|      * Selected items. Used to restore selections after a rotation. | ||||
|      */ | ||||
|     private val selectedItems = mutableSetOf<ChapterItem>() | ||||
|  | ||||
|     init { | ||||
|         setHasOptionsMenu(true) | ||||
|         setOptionsMenuHidden(true) | ||||
|     } | ||||
|  | ||||
|     override fun createPresenter(): ChaptersPresenter { | ||||
|         val ctrl = parentController as MangaController | ||||
|         return ChaptersPresenter(ctrl.manga!!, ctrl.source!!, | ||||
|                 ctrl.chapterCountRelay, ctrl.lastUpdateRelay, ctrl.mangaFavoriteRelay) | ||||
|     } | ||||
|  | ||||
|     override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { | ||||
|         return inflater.inflate(R.layout.chapters_controller, container, false) | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|  | ||||
|         // Init RecyclerView and adapter | ||||
|         adapter = ChaptersAdapter(this, view.context) | ||||
|  | ||||
|         recycler.adapter = adapter | ||||
|         recycler.layoutManager = LinearLayoutManager(view.context) | ||||
|         recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) | ||||
|         recycler.setHasFixedSize(true) | ||||
|         adapter?.fastScroller = fast_scroller | ||||
|  | ||||
|         swipe_refresh.refreshes().subscribeUntilDestroy { fetchChaptersFromSource() } | ||||
|  | ||||
|         fab.clicks().subscribeUntilDestroy { | ||||
|             val item = presenter.getNextUnreadChapter() | ||||
|             if (item != null) { | ||||
|                 // Create animation listener | ||||
|                 val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() { | ||||
|                     override fun onAnimationStart(animation: Animator?) { | ||||
|                         openChapter(item.chapter, true) | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 // Get coordinates and start animation | ||||
|                 val coordinates = fab.getCoordinates() | ||||
|                 if (!reveal_view.showRevealEffect(coordinates.x, coordinates.y, revealAnimationListener)) { | ||||
|                     openChapter(item.chapter) | ||||
|                 } | ||||
|             } else { | ||||
|                 view.context.toast(R.string.no_next_chapter) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         adapter = null | ||||
|         actionMode = null | ||||
|         super.onDestroyView(view) | ||||
|     } | ||||
|  | ||||
|     override fun onActivityResumed(activity: Activity) { | ||||
|         if (view == null) return | ||||
|  | ||||
|         // Check if animation view is visible | ||||
|         if (reveal_view.visibility == View.VISIBLE) { | ||||
|             // Show the unReveal effect | ||||
|             val coordinates = fab.getCoordinates() | ||||
|             reveal_view.hideRevealEffect(coordinates.x, coordinates.y, 1920) | ||||
|         } | ||||
|         super.onActivityResumed(activity) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { | ||||
|         inflater.inflate(R.menu.chapters, menu) | ||||
|     } | ||||
|  | ||||
|     override fun onPrepareOptionsMenu(menu: Menu) { | ||||
|         // Initialize menu items. | ||||
|         val menuFilterRead = menu.findItem(R.id.action_filter_read) ?: return | ||||
|         val menuFilterUnread = menu.findItem(R.id.action_filter_unread) | ||||
|         val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded) | ||||
|         val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked) | ||||
|  | ||||
|         // Set correct checkbox values. | ||||
|         menuFilterRead.isChecked = presenter.onlyRead() | ||||
|         menuFilterUnread.isChecked = presenter.onlyUnread() | ||||
|         menuFilterDownloaded.isChecked = presenter.onlyDownloaded() | ||||
|         menuFilterBookmarked.isChecked = presenter.onlyBookmarked() | ||||
|  | ||||
|         if (presenter.onlyRead()) | ||||
|             //Disable unread filter option if read filter is enabled. | ||||
|             menuFilterUnread.isEnabled = false | ||||
|         if (presenter.onlyUnread()) | ||||
|             //Disable read filter option if unread filter is enabled. | ||||
|             menuFilterRead.isEnabled = false | ||||
|     } | ||||
|  | ||||
|     override fun onOptionsItemSelected(item: MenuItem): Boolean { | ||||
|         when (item.itemId) { | ||||
|             R.id.action_display_mode -> showDisplayModeDialog() | ||||
|             R.id.manga_download -> showDownloadDialog() | ||||
|             R.id.action_sorting_mode -> showSortingDialog() | ||||
|             R.id.action_filter_unread -> { | ||||
|                 item.isChecked = !item.isChecked | ||||
|                 presenter.setUnreadFilter(item.isChecked) | ||||
|                 activity?.invalidateOptionsMenu() | ||||
|             } | ||||
|             R.id.action_filter_read -> { | ||||
|                 item.isChecked = !item.isChecked | ||||
|                 presenter.setReadFilter(item.isChecked) | ||||
|                 activity?.invalidateOptionsMenu() | ||||
|             } | ||||
|             R.id.action_filter_downloaded -> { | ||||
|                 item.isChecked = !item.isChecked | ||||
|                 presenter.setDownloadedFilter(item.isChecked) | ||||
|             } | ||||
|             R.id.action_filter_bookmarked -> { | ||||
|                 item.isChecked = !item.isChecked | ||||
|                 presenter.setBookmarkedFilter(item.isChecked) | ||||
|             } | ||||
|             R.id.action_filter_empty -> { | ||||
|                 presenter.removeFilters() | ||||
|                 activity?.invalidateOptionsMenu() | ||||
|             } | ||||
|             R.id.action_sort -> presenter.revertSortOrder() | ||||
|             else -> return super.onOptionsItemSelected(item) | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     fun onNextChapters(chapters: List<ChapterItem>) { | ||||
|         // If the list is empty, fetch chapters from source if the conditions are met | ||||
|         // We use presenter chapters instead because they are always unfiltered | ||||
|         if (presenter.chapters.isEmpty()) | ||||
|             initialFetchChapters() | ||||
|  | ||||
|         val adapter = adapter ?: return | ||||
|         adapter.updateDataSet(chapters) | ||||
|  | ||||
|         if (selectedItems.isNotEmpty()) { | ||||
|             adapter.clearSelection() // we need to start from a clean state, index may have changed | ||||
|             createActionModeIfNeeded() | ||||
|             selectedItems.forEach { item -> | ||||
|                 val position = adapter.indexOf(item) | ||||
|                 if (position != -1 && !adapter.isSelected(position)) { | ||||
|                     adapter.toggleSelection(position) | ||||
|                 } | ||||
|             } | ||||
|             actionMode?.invalidate() | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     private fun initialFetchChapters() { | ||||
|         // Only fetch if this view is from the catalog and it hasn't requested previously | ||||
|         if ((parentController as MangaController).fromCatalogue && !presenter.hasRequested) { | ||||
|             fetchChaptersFromSource() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun fetchChaptersFromSource() { | ||||
|         swipe_refresh?.isRefreshing = true | ||||
|         presenter.fetchChaptersFromSource() | ||||
|     } | ||||
|  | ||||
|     fun onFetchChaptersDone() { | ||||
|         swipe_refresh?.isRefreshing = false | ||||
|     } | ||||
|  | ||||
|     fun onFetchChaptersError(error: Throwable) { | ||||
|         swipe_refresh?.isRefreshing = false | ||||
|         activity?.toast(error.message) | ||||
|     } | ||||
|  | ||||
|     fun onChapterStatusChange(download: Download) { | ||||
|         getHolder(download.chapter)?.notifyStatus(download.status) | ||||
|     } | ||||
|  | ||||
|     private fun getHolder(chapter: Chapter): ChapterHolder? { | ||||
|         return recycler?.findViewHolderForItemId(chapter.id!!) as? ChapterHolder | ||||
|     } | ||||
|  | ||||
|     fun openChapter(chapter: Chapter, hasAnimation: Boolean = false) { | ||||
|         val activity = activity ?: return | ||||
|         val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter) | ||||
|         if (hasAnimation) { | ||||
|             intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) | ||||
|         } | ||||
|         startActivity(intent) | ||||
|     } | ||||
|  | ||||
|     override fun onItemClick(position: Int): Boolean { | ||||
|         val adapter = adapter ?: return false | ||||
|         val item = adapter.getItem(position) ?: return false | ||||
|         if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { | ||||
|             toggleSelection(position) | ||||
|             return true | ||||
|         } else { | ||||
|             openChapter(item.chapter) | ||||
|             return false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onItemLongClick(position: Int) { | ||||
|         createActionModeIfNeeded() | ||||
|         toggleSelection(position) | ||||
|     } | ||||
|  | ||||
|     // SELECTIONS & ACTION MODE | ||||
|  | ||||
|     private fun toggleSelection(position: Int) { | ||||
|         val adapter = adapter ?: return | ||||
|         val item = adapter.getItem(position) ?: return | ||||
|         adapter.toggleSelection(position) | ||||
|         if (adapter.isSelected(position)) { | ||||
|             selectedItems.add(item) | ||||
|         } else { | ||||
|             selectedItems.remove(item) | ||||
|         } | ||||
|         actionMode?.invalidate() | ||||
|     } | ||||
|  | ||||
|     private fun getSelectedChapters(): List<ChapterItem> { | ||||
|         val adapter = adapter ?: return emptyList() | ||||
|         return adapter.selectedPositions.mapNotNull { adapter.getItem(it) } | ||||
|     } | ||||
|  | ||||
|     private fun createActionModeIfNeeded() { | ||||
|         if (actionMode == null) { | ||||
|             actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun destroyActionModeIfNeeded() { | ||||
|         actionMode?.finish() | ||||
|     } | ||||
|  | ||||
|     override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { | ||||
|         mode.menuInflater.inflate(R.menu.chapter_selection, menu) | ||||
|         adapter?.mode = SelectableAdapter.Mode.MULTI | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     @SuppressLint("StringFormatInvalid") | ||||
|     override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { | ||||
|         val count = adapter?.selectedItemCount ?: 0 | ||||
|         if (count == 0) { | ||||
|             // Destroy action mode if there are no items selected. | ||||
|             destroyActionModeIfNeeded() | ||||
|         } else { | ||||
|             mode.title = resources?.getString(R.string.label_selected, count) | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { | ||||
|         when (item.itemId) { | ||||
|             R.id.action_select_all -> selectAll() | ||||
|             R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) | ||||
|             R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) | ||||
|             R.id.action_download -> downloadChapters(getSelectedChapters()) | ||||
|             R.id.action_delete -> showDeleteChaptersConfirmationDialog() | ||||
|             else -> return false | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyActionMode(mode: ActionMode) { | ||||
|         adapter?.mode = SelectableAdapter.Mode.SINGLE | ||||
|         adapter?.clearSelection() | ||||
|         selectedItems.clear() | ||||
|         actionMode = null | ||||
|     } | ||||
|  | ||||
|     override fun onMenuItemClick(position: Int, item: MenuItem) { | ||||
|         val chapter = adapter?.getItem(position) ?: return | ||||
|         val chapters = listOf(chapter) | ||||
|  | ||||
|         when (item.itemId) { | ||||
|             R.id.action_download -> downloadChapters(chapters) | ||||
|             R.id.action_bookmark -> bookmarkChapters(chapters, true) | ||||
|             R.id.action_remove_bookmark -> bookmarkChapters(chapters, false) | ||||
|             R.id.action_delete -> deleteChapters(chapters) | ||||
|             R.id.action_mark_as_read -> markAsRead(chapters) | ||||
|             R.id.action_mark_as_unread -> markAsUnread(chapters) | ||||
|             R.id.action_mark_previous_as_read -> markPreviousAsRead(chapter) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // SELECTION MODE ACTIONS | ||||
|  | ||||
|     private fun selectAll() { | ||||
|         val adapter = adapter ?: return | ||||
|         adapter.selectAll() | ||||
|         selectedItems.addAll(adapter.items) | ||||
|         actionMode?.invalidate() | ||||
|     } | ||||
|  | ||||
|     private fun markAsRead(chapters: List<ChapterItem>) { | ||||
|         presenter.markChaptersRead(chapters, true) | ||||
|         if (presenter.preferences.removeAfterMarkedAsRead()) { | ||||
|             deleteChapters(chapters) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun markAsUnread(chapters: List<ChapterItem>) { | ||||
|         presenter.markChaptersRead(chapters, false) | ||||
|     } | ||||
|  | ||||
|     private fun downloadChapters(chapters: List<ChapterItem>) { | ||||
|         val view = view | ||||
|         destroyActionModeIfNeeded() | ||||
|         presenter.downloadChapters(chapters) | ||||
|         if (view != null && !presenter.manga.favorite) { | ||||
|             recycler?.snack(view.context.getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) { | ||||
|                 setAction(R.string.action_add) { | ||||
|                     presenter.addToLibrary() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     private fun showDeleteChaptersConfirmationDialog() { | ||||
|         DeleteChaptersDialog(this).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     override fun deleteChapters() { | ||||
|         deleteChapters(getSelectedChapters()) | ||||
|     } | ||||
|  | ||||
|     private fun markPreviousAsRead(chapter: ChapterItem) { | ||||
|         val adapter = adapter ?: return | ||||
|         val chapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items | ||||
|         val chapterPos = chapters.indexOf(chapter) | ||||
|         if (chapterPos != -1) { | ||||
|             markAsRead(chapters.take(chapterPos)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun bookmarkChapters(chapters: List<ChapterItem>, bookmarked: Boolean) { | ||||
|         destroyActionModeIfNeeded() | ||||
|         presenter.bookmarkChapters(chapters, bookmarked) | ||||
|     } | ||||
|  | ||||
|     fun deleteChapters(chapters: List<ChapterItem>) { | ||||
|         destroyActionModeIfNeeded() | ||||
|         if (chapters.isEmpty()) return | ||||
|  | ||||
|         DeletingChaptersDialog().showDialog(router) | ||||
|         presenter.deleteChapters(chapters) | ||||
|     } | ||||
|  | ||||
|     fun onChaptersDeleted() { | ||||
|         dismissDeletingDialog() | ||||
|         adapter?.notifyDataSetChanged() | ||||
|     } | ||||
|  | ||||
|     fun onChaptersDeletedError(error: Throwable) { | ||||
|         dismissDeletingDialog() | ||||
|         Timber.e(error) | ||||
|     } | ||||
|  | ||||
|     private fun dismissDeletingDialog() { | ||||
|         router.popControllerWithTag(DeletingChaptersDialog.TAG) | ||||
|     } | ||||
|  | ||||
|     // OVERFLOW MENU DIALOGS | ||||
|  | ||||
|     private fun showDisplayModeDialog() { | ||||
|         val preselected = if (presenter.manga.displayMode == Manga.DISPLAY_NAME) 0 else 1 | ||||
|         SetDisplayModeDialog(this, preselected).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     override fun setDisplayMode(id: Int) { | ||||
|         presenter.setDisplayMode(id) | ||||
|         adapter?.notifyDataSetChanged() | ||||
|     } | ||||
|  | ||||
|     private fun showSortingDialog() { | ||||
|         val preselected = if (presenter.manga.sorting == Manga.SORTING_SOURCE) 0 else 1 | ||||
|         SetSortingDialog(this, preselected).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     override fun setSorting(id: Int) { | ||||
|         presenter.setSorting(id) | ||||
|     } | ||||
|  | ||||
|     private fun showDownloadDialog() { | ||||
|         DownloadChaptersDialog(this).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     private fun getUnreadChaptersSorted() = presenter.chapters | ||||
|             .filter { !it.read && it.status == Download.NOT_DOWNLOADED } | ||||
|             .distinctBy { it.name } | ||||
|             .sortedByDescending { it.source_order } | ||||
|  | ||||
|     override fun downloadCustomChapters(amount: Int) { | ||||
|         val chaptersToDownload = getUnreadChaptersSorted().take(amount) | ||||
|         if (chaptersToDownload.isNotEmpty()) { | ||||
|             downloadChapters(chaptersToDownload) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun showCustomDownloadDialog() { | ||||
|         DownloadCustomChaptersDialog(this, presenter.chapters.size).showDialog(router) | ||||
|     } | ||||
|  | ||||
|  | ||||
|     override fun downloadChapters(choice: Int) { | ||||
|         // i = 0: Download 1 | ||||
|         // i = 1: Download 5 | ||||
|         // i = 2: Download 10 | ||||
|         // i = 3: Download x | ||||
|         // i = 4: Download unread | ||||
|         // i = 5: Download all | ||||
|         val chaptersToDownload = when (choice) { | ||||
|             0 -> getUnreadChaptersSorted().take(1) | ||||
|             1 -> getUnreadChaptersSorted().take(5) | ||||
|             2 -> getUnreadChaptersSorted().take(10) | ||||
|             3 -> { | ||||
|                 showCustomDownloadDialog() | ||||
|                 return | ||||
|             } | ||||
|             4 -> presenter.chapters.filter { !it.read } | ||||
|             5 -> presenter.chapters | ||||
|             else -> emptyList() | ||||
|         } | ||||
|         if (chaptersToDownload.isNotEmpty()) { | ||||
|             downloadChapters(chaptersToDownload) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,418 +1,418 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.os.Bundle | ||||
| import com.jakewharton.rxrelay.BehaviorRelay | ||||
| import com.jakewharton.rxrelay.PublishRelay | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.download.DownloadManager | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.source.LocalSource | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter | ||||
| import eu.kanade.tachiyomi.util.isNullOrUnsubscribed | ||||
| import eu.kanade.tachiyomi.util.syncChaptersWithSource | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.schedulers.Schedulers | ||||
| import timber.log.Timber | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.Date | ||||
|  | ||||
| /** | ||||
|  * Presenter of [ChaptersController]. | ||||
|  */ | ||||
| class ChaptersPresenter( | ||||
|         val manga: Manga, | ||||
|         val source: Source, | ||||
|         private val chapterCountRelay: BehaviorRelay<Float>, | ||||
|         private val lastUpdateRelay: BehaviorRelay<Date>, | ||||
|         private val mangaFavoriteRelay: PublishRelay<Boolean>, | ||||
|         val preferences: PreferencesHelper = Injekt.get(), | ||||
|         private val db: DatabaseHelper = Injekt.get(), | ||||
|         private val downloadManager: DownloadManager = Injekt.get() | ||||
| ) : BasePresenter<ChaptersController>() { | ||||
|  | ||||
|     /** | ||||
|      * List of chapters of the manga. It's always unfiltered and unsorted. | ||||
|      */ | ||||
|     var chapters: List<ChapterItem> = emptyList() | ||||
|         private set | ||||
|  | ||||
|     /** | ||||
|      * Subject of list of chapters to allow updating the view without going to DB. | ||||
|      */ | ||||
|     val chaptersRelay: PublishRelay<List<ChapterItem>> | ||||
|             by lazy { PublishRelay.create<List<ChapterItem>>() } | ||||
|  | ||||
|     /** | ||||
|      * Whether the chapter list has been requested to the source. | ||||
|      */ | ||||
|     var hasRequested = false | ||||
|         private set | ||||
|  | ||||
|     /** | ||||
|      * Subscription to retrieve the new list of chapters from the source. | ||||
|      */ | ||||
|     private var fetchChaptersSubscription: Subscription? = null | ||||
|  | ||||
|     /** | ||||
|      * Subscription to observe download status changes. | ||||
|      */ | ||||
|     private var observeDownloadsSubscription: Subscription? = null | ||||
|  | ||||
|     override fun onCreate(savedState: Bundle?) { | ||||
|         super.onCreate(savedState) | ||||
|  | ||||
|         // Prepare the relay. | ||||
|         chaptersRelay.flatMap { applyChapterFilters(it) } | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeLatestCache(ChaptersController::onNextChapters, | ||||
|                         { _, error -> Timber.e(error) }) | ||||
|  | ||||
|         // Add the subscription that retrieves the chapters from the database, keeps subscribed to | ||||
|         // changes, and sends the list of chapters to the relay. | ||||
|         add(db.getChapters(manga).asRxObservable() | ||||
|                 .map { chapters -> | ||||
|                     // Convert every chapter to a model. | ||||
|                     chapters.map { it.toModel() } | ||||
|                 } | ||||
|                 .doOnNext { chapters -> | ||||
|                     // Find downloaded chapters | ||||
|                     setDownloadedChapters(chapters) | ||||
|  | ||||
|                     // Store the last emission | ||||
|                     this.chapters = chapters | ||||
|  | ||||
|                     // Listen for download status changes | ||||
|                     observeDownloads() | ||||
|  | ||||
|                     // Emit the number of chapters to the info tab. | ||||
|                     chapterCountRelay.call(chapters.maxBy { it.chapter_number }?.chapter_number | ||||
|                             ?: 0f) | ||||
|  | ||||
|                     // Emit the upload date of the most recent chapter | ||||
|                     lastUpdateRelay.call(Date(chapters.maxBy { it.date_upload }?.date_upload | ||||
|                             ?: 0)) | ||||
|  | ||||
|                 } | ||||
|                 .subscribe { chaptersRelay.call(it) }) | ||||
|     } | ||||
|  | ||||
|     private fun observeDownloads() { | ||||
|         observeDownloadsSubscription?.let { remove(it) } | ||||
|         observeDownloadsSubscription = downloadManager.queue.getStatusObservable() | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .filter { download -> download.manga.id == manga.id } | ||||
|                 .doOnNext { onDownloadStatusChange(it) } | ||||
|                 .subscribeLatestCache(ChaptersController::onChapterStatusChange, | ||||
|                         { _, error -> Timber.e(error) }) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Converts a chapter from the database to an extended model, allowing to store new fields. | ||||
|      */ | ||||
|     private fun Chapter.toModel(): ChapterItem { | ||||
|         // Create the model object. | ||||
|         val model = ChapterItem(this, manga) | ||||
|  | ||||
|         // Find an active download for this chapter. | ||||
|         val download = downloadManager.queue.find { it.chapter.id == id } | ||||
|  | ||||
|         if (download != null) { | ||||
|             // If there's an active download, assign it. | ||||
|             model.download = download | ||||
|         } | ||||
|         return model | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Finds and assigns the list of downloaded chapters. | ||||
|      * | ||||
|      * @param chapters the list of chapter from the database. | ||||
|      */ | ||||
|     private fun setDownloadedChapters(chapters: List<ChapterItem>) { | ||||
|         for (chapter in chapters) { | ||||
|             if (downloadManager.isChapterDownloaded(chapter, manga)) { | ||||
|                 chapter.status = Download.DOWNLOADED | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Requests an updated list of chapters from the source. | ||||
|      */ | ||||
|     fun fetchChaptersFromSource() { | ||||
|         hasRequested = true | ||||
|  | ||||
|         if (!fetchChaptersSubscription.isNullOrUnsubscribed()) return | ||||
|         fetchChaptersSubscription = Observable.defer { source.fetchChapterList(manga) } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .map { syncChaptersWithSource(db, it, manga, source) } | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeFirst({ view, _ -> | ||||
|                     view.onFetchChaptersDone() | ||||
|                 }, ChaptersController::onFetchChaptersError) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Updates the UI after applying the filters. | ||||
|      */ | ||||
|     private fun refreshChapters() { | ||||
|         chaptersRelay.call(chapters) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Applies the view filters to the list of chapters obtained from the database. | ||||
|      * @param chapters the list of chapters from the database | ||||
|      * @return an observable of the list of chapters filtered and sorted. | ||||
|      */ | ||||
|     private fun applyChapterFilters(chapters: List<ChapterItem>): Observable<List<ChapterItem>> { | ||||
|         var observable = Observable.from(chapters).subscribeOn(Schedulers.io()) | ||||
|         if (onlyUnread()) { | ||||
|             observable = observable.filter { !it.read } | ||||
|         } | ||||
|         else if (onlyRead()) { | ||||
|             observable = observable.filter { it.read } | ||||
|         } | ||||
|         if (onlyDownloaded()) { | ||||
|             observable = observable.filter { it.isDownloaded || it.manga.source == LocalSource.ID } | ||||
|         } | ||||
|         if (onlyBookmarked()) { | ||||
|             observable = observable.filter { it.bookmark } | ||||
|         } | ||||
|         val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { | ||||
|             Manga.SORTING_SOURCE -> when (sortDescending()) { | ||||
|                 true -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) } | ||||
|                 false -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) } | ||||
|             } | ||||
|             Manga.SORTING_NUMBER -> when (sortDescending()) { | ||||
|                 true -> { c1, c2 -> c2.chapter_number.compareTo(c1.chapter_number) } | ||||
|                 false -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) } | ||||
|             } | ||||
|             else -> throw NotImplementedError("Unimplemented sorting method") | ||||
|         } | ||||
|         return observable.toSortedList(sortFunction) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when a download for the active manga changes status. | ||||
|      * @param download the download whose status changed. | ||||
|      */ | ||||
|     fun onDownloadStatusChange(download: Download) { | ||||
|         // Assign the download to the model object. | ||||
|         if (download.status == Download.QUEUE) { | ||||
|             chapters.find { it.id == download.chapter.id }?.let { | ||||
|                 if (it.download == null) { | ||||
|                     it.download = download | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Force UI update if downloaded filter active and download finished. | ||||
|         if (onlyDownloaded() && download.status == Download.DOWNLOADED) | ||||
|             refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the next unread chapter or null if everything is read. | ||||
|      */ | ||||
|     fun getNextUnreadChapter(): ChapterItem? { | ||||
|         return chapters.sortedByDescending { it.source_order }.find { !it.read } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Mark the selected chapter list as read/unread. | ||||
|      * @param selectedChapters the list of selected chapters. | ||||
|      * @param read whether to mark chapters as read or unread. | ||||
|      */ | ||||
|     fun markChaptersRead(selectedChapters: List<ChapterItem>, read: Boolean) { | ||||
|         Observable.from(selectedChapters) | ||||
|                 .doOnNext { chapter -> | ||||
|                     chapter.read = read | ||||
|                     if (!read) { | ||||
|                         chapter.last_page_read = 0 | ||||
|                     } | ||||
|                 } | ||||
|                 .toList() | ||||
|                 .flatMap { db.updateChaptersProgress(it).asRxObservable() } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .subscribe() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Downloads the given list of chapters with the manager. | ||||
|      * @param chapters the list of chapters to download. | ||||
|      */ | ||||
|     fun downloadChapters(chapters: List<ChapterItem>) { | ||||
|         downloadManager.downloadChapters(manga, chapters) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Bookmarks the given list of chapters. | ||||
|      * @param selectedChapters the list of chapters to bookmark. | ||||
|      */ | ||||
|     fun bookmarkChapters(selectedChapters: List<ChapterItem>, bookmarked: Boolean) { | ||||
|         Observable.from(selectedChapters) | ||||
|                 .doOnNext { chapter -> | ||||
|                     chapter.bookmark = bookmarked | ||||
|                 } | ||||
|                 .toList() | ||||
|                 .flatMap { db.updateChaptersProgress(it).asRxObservable() } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .subscribe() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Deletes the given list of chapter. | ||||
|      * @param chapters the list of chapters to delete. | ||||
|      */ | ||||
|     fun deleteChapters(chapters: List<ChapterItem>) { | ||||
|         Observable.just(chapters) | ||||
|                 .doOnNext { deleteChaptersInternal(chapters) } | ||||
|                 .doOnNext { if (onlyDownloaded()) refreshChapters() } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeFirst({ view, _ -> | ||||
|                     view.onChaptersDeleted() | ||||
|                 }, ChaptersController::onChaptersDeletedError) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Deletes a list of chapters from disk. This method is called in a background thread. | ||||
|      * @param chapters the chapters to delete. | ||||
|      */ | ||||
|     private fun deleteChaptersInternal(chapters: List<ChapterItem>) { | ||||
|         downloadManager.deleteChapters(chapters, manga, source) | ||||
|         chapters.forEach { | ||||
|             it.status = Download.NOT_DOWNLOADED | ||||
|             it.download = null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Reverses the sorting and requests an UI update. | ||||
|      */ | ||||
|     fun revertSortOrder() { | ||||
|         manga.setChapterOrder(if (sortDescending()) Manga.SORT_ASC else Manga.SORT_DESC) | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|         refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets the read filter and requests an UI update. | ||||
|      * @param onlyUnread whether to display only unread chapters or all chapters. | ||||
|      */ | ||||
|     fun setUnreadFilter(onlyUnread: Boolean) { | ||||
|         manga.readFilter = if (onlyUnread) Manga.SHOW_UNREAD else Manga.SHOW_ALL | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|         refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets the read filter and requests an UI update. | ||||
|      * @param onlyRead whether to display only read chapters or all chapters. | ||||
|      */ | ||||
|     fun setReadFilter(onlyRead: Boolean) { | ||||
|         manga.readFilter = if (onlyRead) Manga.SHOW_READ else Manga.SHOW_ALL | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|         refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets the download filter and requests an UI update. | ||||
|      * @param onlyDownloaded whether to display only downloaded chapters or all chapters. | ||||
|      */ | ||||
|     fun setDownloadedFilter(onlyDownloaded: Boolean) { | ||||
|         manga.downloadedFilter = if (onlyDownloaded) Manga.SHOW_DOWNLOADED else Manga.SHOW_ALL | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|         refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets the bookmark filter and requests an UI update. | ||||
|      * @param onlyBookmarked whether to display only bookmarked chapters or all chapters. | ||||
|      */ | ||||
|     fun setBookmarkedFilter(onlyBookmarked: Boolean) { | ||||
|         manga.bookmarkedFilter = if (onlyBookmarked) Manga.SHOW_BOOKMARKED else Manga.SHOW_ALL | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|         refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Removes all filters and requests an UI update. | ||||
|      */ | ||||
|     fun removeFilters() { | ||||
|         manga.readFilter = Manga.SHOW_ALL | ||||
|         manga.downloadedFilter = Manga.SHOW_ALL | ||||
|         manga.bookmarkedFilter = Manga.SHOW_ALL | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|         refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Adds manga to library | ||||
|      */ | ||||
|     fun addToLibrary() { | ||||
|         mangaFavoriteRelay.call(true) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets the active display mode. | ||||
|      * @param mode the mode to set. | ||||
|      */ | ||||
|     fun setDisplayMode(mode: Int) { | ||||
|         manga.displayMode = mode | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets the sorting method and requests an UI update. | ||||
|      * @param sort the sorting mode. | ||||
|      */ | ||||
|     fun setSorting(sort: Int) { | ||||
|         manga.sorting = sort | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|         refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Whether the display only downloaded filter is enabled. | ||||
|      */ | ||||
|     fun onlyDownloaded(): Boolean { | ||||
|         return manga.downloadedFilter == Manga.SHOW_DOWNLOADED | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Whether the display only downloaded filter is enabled. | ||||
|      */ | ||||
|     fun onlyBookmarked(): Boolean { | ||||
|         return manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Whether the display only unread filter is enabled. | ||||
|      */ | ||||
|     fun onlyUnread(): Boolean { | ||||
|         return manga.readFilter == Manga.SHOW_UNREAD | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Whether the display only read filter is enabled. | ||||
|      */ | ||||
|     fun onlyRead(): Boolean { | ||||
|         return manga.readFilter == Manga.SHOW_READ | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Whether the sorting method is descending or ascending. | ||||
|      */ | ||||
|     fun sortDescending(): Boolean { | ||||
|         return manga.sortDescending() | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.os.Bundle | ||||
| import com.jakewharton.rxrelay.BehaviorRelay | ||||
| import com.jakewharton.rxrelay.PublishRelay | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.download.DownloadManager | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.source.LocalSource | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter | ||||
| import eu.kanade.tachiyomi.util.isNullOrUnsubscribed | ||||
| import eu.kanade.tachiyomi.util.syncChaptersWithSource | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.schedulers.Schedulers | ||||
| import timber.log.Timber | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.Date | ||||
|  | ||||
| /** | ||||
|  * Presenter of [ChaptersController]. | ||||
|  */ | ||||
| class ChaptersPresenter( | ||||
|         val manga: Manga, | ||||
|         val source: Source, | ||||
|         private val chapterCountRelay: BehaviorRelay<Float>, | ||||
|         private val lastUpdateRelay: BehaviorRelay<Date>, | ||||
|         private val mangaFavoriteRelay: PublishRelay<Boolean>, | ||||
|         val preferences: PreferencesHelper = Injekt.get(), | ||||
|         private val db: DatabaseHelper = Injekt.get(), | ||||
|         private val downloadManager: DownloadManager = Injekt.get() | ||||
| ) : BasePresenter<ChaptersController>() { | ||||
|  | ||||
|     /** | ||||
|      * List of chapters of the manga. It's always unfiltered and unsorted. | ||||
|      */ | ||||
|     var chapters: List<ChapterItem> = emptyList() | ||||
|         private set | ||||
|  | ||||
|     /** | ||||
|      * Subject of list of chapters to allow updating the view without going to DB. | ||||
|      */ | ||||
|     val chaptersRelay: PublishRelay<List<ChapterItem>> | ||||
|             by lazy { PublishRelay.create<List<ChapterItem>>() } | ||||
|  | ||||
|     /** | ||||
|      * Whether the chapter list has been requested to the source. | ||||
|      */ | ||||
|     var hasRequested = false | ||||
|         private set | ||||
|  | ||||
|     /** | ||||
|      * Subscription to retrieve the new list of chapters from the source. | ||||
|      */ | ||||
|     private var fetchChaptersSubscription: Subscription? = null | ||||
|  | ||||
|     /** | ||||
|      * Subscription to observe download status changes. | ||||
|      */ | ||||
|     private var observeDownloadsSubscription: Subscription? = null | ||||
|  | ||||
|     override fun onCreate(savedState: Bundle?) { | ||||
|         super.onCreate(savedState) | ||||
|  | ||||
|         // Prepare the relay. | ||||
|         chaptersRelay.flatMap { applyChapterFilters(it) } | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeLatestCache(ChaptersController::onNextChapters, | ||||
|                         { _, error -> Timber.e(error) }) | ||||
|  | ||||
|         // Add the subscription that retrieves the chapters from the database, keeps subscribed to | ||||
|         // changes, and sends the list of chapters to the relay. | ||||
|         add(db.getChapters(manga).asRxObservable() | ||||
|                 .map { chapters -> | ||||
|                     // Convert every chapter to a model. | ||||
|                     chapters.map { it.toModel() } | ||||
|                 } | ||||
|                 .doOnNext { chapters -> | ||||
|                     // Find downloaded chapters | ||||
|                     setDownloadedChapters(chapters) | ||||
|  | ||||
|                     // Store the last emission | ||||
|                     this.chapters = chapters | ||||
|  | ||||
|                     // Listen for download status changes | ||||
|                     observeDownloads() | ||||
|  | ||||
|                     // Emit the number of chapters to the info tab. | ||||
|                     chapterCountRelay.call(chapters.maxBy { it.chapter_number }?.chapter_number | ||||
|                             ?: 0f) | ||||
|  | ||||
|                     // Emit the upload date of the most recent chapter | ||||
|                     lastUpdateRelay.call(Date(chapters.maxBy { it.date_upload }?.date_upload | ||||
|                             ?: 0)) | ||||
|  | ||||
|                 } | ||||
|                 .subscribe { chaptersRelay.call(it) }) | ||||
|     } | ||||
|  | ||||
|     private fun observeDownloads() { | ||||
|         observeDownloadsSubscription?.let { remove(it) } | ||||
|         observeDownloadsSubscription = downloadManager.queue.getStatusObservable() | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .filter { download -> download.manga.id == manga.id } | ||||
|                 .doOnNext { onDownloadStatusChange(it) } | ||||
|                 .subscribeLatestCache(ChaptersController::onChapterStatusChange, | ||||
|                         { _, error -> Timber.e(error) }) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Converts a chapter from the database to an extended model, allowing to store new fields. | ||||
|      */ | ||||
|     private fun Chapter.toModel(): ChapterItem { | ||||
|         // Create the model object. | ||||
|         val model = ChapterItem(this, manga) | ||||
|  | ||||
|         // Find an active download for this chapter. | ||||
|         val download = downloadManager.queue.find { it.chapter.id == id } | ||||
|  | ||||
|         if (download != null) { | ||||
|             // If there's an active download, assign it. | ||||
|             model.download = download | ||||
|         } | ||||
|         return model | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Finds and assigns the list of downloaded chapters. | ||||
|      * | ||||
|      * @param chapters the list of chapter from the database. | ||||
|      */ | ||||
|     private fun setDownloadedChapters(chapters: List<ChapterItem>) { | ||||
|         for (chapter in chapters) { | ||||
|             if (downloadManager.isChapterDownloaded(chapter, manga)) { | ||||
|                 chapter.status = Download.DOWNLOADED | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Requests an updated list of chapters from the source. | ||||
|      */ | ||||
|     fun fetchChaptersFromSource() { | ||||
|         hasRequested = true | ||||
|  | ||||
|         if (!fetchChaptersSubscription.isNullOrUnsubscribed()) return | ||||
|         fetchChaptersSubscription = Observable.defer { source.fetchChapterList(manga) } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .map { syncChaptersWithSource(db, it, manga, source) } | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeFirst({ view, _ -> | ||||
|                     view.onFetchChaptersDone() | ||||
|                 }, ChaptersController::onFetchChaptersError) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Updates the UI after applying the filters. | ||||
|      */ | ||||
|     private fun refreshChapters() { | ||||
|         chaptersRelay.call(chapters) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Applies the view filters to the list of chapters obtained from the database. | ||||
|      * @param chapters the list of chapters from the database | ||||
|      * @return an observable of the list of chapters filtered and sorted. | ||||
|      */ | ||||
|     private fun applyChapterFilters(chapters: List<ChapterItem>): Observable<List<ChapterItem>> { | ||||
|         var observable = Observable.from(chapters).subscribeOn(Schedulers.io()) | ||||
|         if (onlyUnread()) { | ||||
|             observable = observable.filter { !it.read } | ||||
|         } | ||||
|         else if (onlyRead()) { | ||||
|             observable = observable.filter { it.read } | ||||
|         } | ||||
|         if (onlyDownloaded()) { | ||||
|             observable = observable.filter { it.isDownloaded || it.manga.source == LocalSource.ID } | ||||
|         } | ||||
|         if (onlyBookmarked()) { | ||||
|             observable = observable.filter { it.bookmark } | ||||
|         } | ||||
|         val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { | ||||
|             Manga.SORTING_SOURCE -> when (sortDescending()) { | ||||
|                 true -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) } | ||||
|                 false -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) } | ||||
|             } | ||||
|             Manga.SORTING_NUMBER -> when (sortDescending()) { | ||||
|                 true -> { c1, c2 -> c2.chapter_number.compareTo(c1.chapter_number) } | ||||
|                 false -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) } | ||||
|             } | ||||
|             else -> throw NotImplementedError("Unimplemented sorting method") | ||||
|         } | ||||
|         return observable.toSortedList(sortFunction) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when a download for the active manga changes status. | ||||
|      * @param download the download whose status changed. | ||||
|      */ | ||||
|     fun onDownloadStatusChange(download: Download) { | ||||
|         // Assign the download to the model object. | ||||
|         if (download.status == Download.QUEUE) { | ||||
|             chapters.find { it.id == download.chapter.id }?.let { | ||||
|                 if (it.download == null) { | ||||
|                     it.download = download | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Force UI update if downloaded filter active and download finished. | ||||
|         if (onlyDownloaded() && download.status == Download.DOWNLOADED) | ||||
|             refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the next unread chapter or null if everything is read. | ||||
|      */ | ||||
|     fun getNextUnreadChapter(): ChapterItem? { | ||||
|         return chapters.sortedByDescending { it.source_order }.find { !it.read } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Mark the selected chapter list as read/unread. | ||||
|      * @param selectedChapters the list of selected chapters. | ||||
|      * @param read whether to mark chapters as read or unread. | ||||
|      */ | ||||
|     fun markChaptersRead(selectedChapters: List<ChapterItem>, read: Boolean) { | ||||
|         Observable.from(selectedChapters) | ||||
|                 .doOnNext { chapter -> | ||||
|                     chapter.read = read | ||||
|                     if (!read) { | ||||
|                         chapter.last_page_read = 0 | ||||
|                     } | ||||
|                 } | ||||
|                 .toList() | ||||
|                 .flatMap { db.updateChaptersProgress(it).asRxObservable() } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .subscribe() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Downloads the given list of chapters with the manager. | ||||
|      * @param chapters the list of chapters to download. | ||||
|      */ | ||||
|     fun downloadChapters(chapters: List<ChapterItem>) { | ||||
|         downloadManager.downloadChapters(manga, chapters) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Bookmarks the given list of chapters. | ||||
|      * @param selectedChapters the list of chapters to bookmark. | ||||
|      */ | ||||
|     fun bookmarkChapters(selectedChapters: List<ChapterItem>, bookmarked: Boolean) { | ||||
|         Observable.from(selectedChapters) | ||||
|                 .doOnNext { chapter -> | ||||
|                     chapter.bookmark = bookmarked | ||||
|                 } | ||||
|                 .toList() | ||||
|                 .flatMap { db.updateChaptersProgress(it).asRxObservable() } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .subscribe() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Deletes the given list of chapter. | ||||
|      * @param chapters the list of chapters to delete. | ||||
|      */ | ||||
|     fun deleteChapters(chapters: List<ChapterItem>) { | ||||
|         Observable.just(chapters) | ||||
|                 .doOnNext { deleteChaptersInternal(chapters) } | ||||
|                 .doOnNext { if (onlyDownloaded()) refreshChapters() } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeFirst({ view, _ -> | ||||
|                     view.onChaptersDeleted() | ||||
|                 }, ChaptersController::onChaptersDeletedError) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Deletes a list of chapters from disk. This method is called in a background thread. | ||||
|      * @param chapters the chapters to delete. | ||||
|      */ | ||||
|     private fun deleteChaptersInternal(chapters: List<ChapterItem>) { | ||||
|         downloadManager.deleteChapters(chapters, manga, source) | ||||
|         chapters.forEach { | ||||
|             it.status = Download.NOT_DOWNLOADED | ||||
|             it.download = null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Reverses the sorting and requests an UI update. | ||||
|      */ | ||||
|     fun revertSortOrder() { | ||||
|         manga.setChapterOrder(if (sortDescending()) Manga.SORT_ASC else Manga.SORT_DESC) | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|         refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets the read filter and requests an UI update. | ||||
|      * @param onlyUnread whether to display only unread chapters or all chapters. | ||||
|      */ | ||||
|     fun setUnreadFilter(onlyUnread: Boolean) { | ||||
|         manga.readFilter = if (onlyUnread) Manga.SHOW_UNREAD else Manga.SHOW_ALL | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|         refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets the read filter and requests an UI update. | ||||
|      * @param onlyRead whether to display only read chapters or all chapters. | ||||
|      */ | ||||
|     fun setReadFilter(onlyRead: Boolean) { | ||||
|         manga.readFilter = if (onlyRead) Manga.SHOW_READ else Manga.SHOW_ALL | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|         refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets the download filter and requests an UI update. | ||||
|      * @param onlyDownloaded whether to display only downloaded chapters or all chapters. | ||||
|      */ | ||||
|     fun setDownloadedFilter(onlyDownloaded: Boolean) { | ||||
|         manga.downloadedFilter = if (onlyDownloaded) Manga.SHOW_DOWNLOADED else Manga.SHOW_ALL | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|         refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets the bookmark filter and requests an UI update. | ||||
|      * @param onlyBookmarked whether to display only bookmarked chapters or all chapters. | ||||
|      */ | ||||
|     fun setBookmarkedFilter(onlyBookmarked: Boolean) { | ||||
|         manga.bookmarkedFilter = if (onlyBookmarked) Manga.SHOW_BOOKMARKED else Manga.SHOW_ALL | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|         refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Removes all filters and requests an UI update. | ||||
|      */ | ||||
|     fun removeFilters() { | ||||
|         manga.readFilter = Manga.SHOW_ALL | ||||
|         manga.downloadedFilter = Manga.SHOW_ALL | ||||
|         manga.bookmarkedFilter = Manga.SHOW_ALL | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|         refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Adds manga to library | ||||
|      */ | ||||
|     fun addToLibrary() { | ||||
|         mangaFavoriteRelay.call(true) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets the active display mode. | ||||
|      * @param mode the mode to set. | ||||
|      */ | ||||
|     fun setDisplayMode(mode: Int) { | ||||
|         manga.displayMode = mode | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sets the sorting method and requests an UI update. | ||||
|      * @param sort the sorting mode. | ||||
|      */ | ||||
|     fun setSorting(sort: Int) { | ||||
|         manga.sorting = sort | ||||
|         db.updateFlags(manga).executeAsBlocking() | ||||
|         refreshChapters() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Whether the display only downloaded filter is enabled. | ||||
|      */ | ||||
|     fun onlyDownloaded(): Boolean { | ||||
|         return manga.downloadedFilter == Manga.SHOW_DOWNLOADED | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Whether the display only downloaded filter is enabled. | ||||
|      */ | ||||
|     fun onlyBookmarked(): Boolean { | ||||
|         return manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Whether the display only unread filter is enabled. | ||||
|      */ | ||||
|     fun onlyUnread(): Boolean { | ||||
|         return manga.readFilter == Manga.SHOW_UNREAD | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Whether the display only read filter is enabled. | ||||
|      */ | ||||
|     fun onlyRead(): Boolean { | ||||
|         return manga.readFilter == Manga.SHOW_READ | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Whether the sorting method is descending or ascending. | ||||
|      */ | ||||
|     fun sortDescending(): Boolean { | ||||
|         return manga.sortDescending() | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,32 +1,32 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
|  | ||||
| class DeleteChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle) | ||||
|         where T : Controller, T : DeleteChaptersDialog.Listener { | ||||
|  | ||||
|     constructor(target: T) : this() { | ||||
|         targetController = target | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         return MaterialDialog.Builder(activity!!) | ||||
|                 .content(R.string.confirm_delete_chapters) | ||||
|                 .positiveText(android.R.string.yes) | ||||
|                 .negativeText(android.R.string.no) | ||||
|                 .onPositive { _, _ -> | ||||
|                     (targetController as? Listener)?.deleteChapters() | ||||
|                 } | ||||
|                 .show() | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun deleteChapters() | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
|  | ||||
| class DeleteChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle) | ||||
|         where T : Controller, T : DeleteChaptersDialog.Listener { | ||||
|  | ||||
|     constructor(target: T) : this() { | ||||
|         targetController = target | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         return MaterialDialog.Builder(activity!!) | ||||
|                 .content(R.string.confirm_delete_chapters) | ||||
|                 .positiveText(android.R.string.yes) | ||||
|                 .negativeText(android.R.string.no) | ||||
|                 .onPositive { _, _ -> | ||||
|                     (targetController as? Listener)?.deleteChapters() | ||||
|                 } | ||||
|                 .show() | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun deleteChapters() | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,27 +1,27 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Router | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
|  | ||||
| class DeletingChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) { | ||||
|  | ||||
|     companion object { | ||||
|         const val TAG = "deleting_dialog" | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedState: Bundle?): Dialog { | ||||
|         return MaterialDialog.Builder(activity!!) | ||||
|                 .progress(true, 0) | ||||
|                 .content(R.string.deleting) | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     override fun showDialog(router: Router) { | ||||
|         showDialog(router, TAG) | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Router | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
|  | ||||
| class DeletingChaptersDialog(bundle: Bundle? = null) : DialogController(bundle) { | ||||
|  | ||||
|     companion object { | ||||
|         const val TAG = "deleting_dialog" | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedState: Bundle?): Dialog { | ||||
|         return MaterialDialog.Builder(activity!!) | ||||
|                 .progress(true, 0) | ||||
|                 .content(R.string.deleting) | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     override fun showDialog(router: Router) { | ||||
|         showDialog(router, TAG) | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,42 +1,42 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
|  | ||||
| class DownloadChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle) | ||||
|         where T : Controller, T : DownloadChaptersDialog.Listener { | ||||
|  | ||||
|     constructor(target: T) : this() { | ||||
|         targetController = target | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val activity = activity!! | ||||
|  | ||||
|         val choices = intArrayOf( | ||||
|                 R.string.download_1, | ||||
|                 R.string.download_5, | ||||
|                 R.string.download_10, | ||||
|                 R.string.download_custom, | ||||
|                 R.string.download_unread, | ||||
|                 R.string.download_all | ||||
|         ).map { activity.getString(it) } | ||||
|  | ||||
|         return MaterialDialog.Builder(activity) | ||||
|                 .negativeText(android.R.string.cancel) | ||||
|                 .items(choices) | ||||
|                 .itemsCallback { _, _, position, _ -> | ||||
|                     (targetController as? Listener)?.downloadChapters(position) | ||||
|                 } | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun downloadChapters(choice: Int) | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
|  | ||||
| class DownloadChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle) | ||||
|         where T : Controller, T : DownloadChaptersDialog.Listener { | ||||
|  | ||||
|     constructor(target: T) : this() { | ||||
|         targetController = target | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val activity = activity!! | ||||
|  | ||||
|         val choices = intArrayOf( | ||||
|                 R.string.download_1, | ||||
|                 R.string.download_5, | ||||
|                 R.string.download_10, | ||||
|                 R.string.download_custom, | ||||
|                 R.string.download_unread, | ||||
|                 R.string.download_all | ||||
|         ).map { activity.getString(it) } | ||||
|  | ||||
|         return MaterialDialog.Builder(activity) | ||||
|                 .negativeText(android.R.string.cancel) | ||||
|                 .items(choices) | ||||
|                 .itemsCallback { _, _, position, _ -> | ||||
|                     (targetController as? Listener)?.downloadChapters(position) | ||||
|                 } | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun downloadChapters(choice: Int) | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,43 +1,43 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
|  | ||||
| class SetDisplayModeDialog<T>(bundle: Bundle? = null) : DialogController(bundle) | ||||
|         where T : Controller, T : SetDisplayModeDialog.Listener { | ||||
|  | ||||
|     private val selectedIndex = args.getInt("selected", -1) | ||||
|  | ||||
|     constructor(target: T, selectedIndex: Int = -1) : this(Bundle().apply { | ||||
|         putInt("selected", selectedIndex) | ||||
|     }) { | ||||
|         targetController = target | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val activity = activity!! | ||||
|         val ids = intArrayOf(Manga.DISPLAY_NAME, Manga.DISPLAY_NUMBER) | ||||
|         val choices = intArrayOf(R.string.show_title, R.string.show_chapter_number) | ||||
|                 .map { activity.getString(it) } | ||||
|  | ||||
|         return MaterialDialog.Builder(activity) | ||||
|                 .title(R.string.action_display_mode) | ||||
|                 .items(choices) | ||||
|                 .itemsIds(ids) | ||||
|                 .itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ -> | ||||
|                     (targetController as? Listener)?.setDisplayMode(itemView.id) | ||||
|                     true | ||||
|                 } | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun setDisplayMode(id: Int) | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
|  | ||||
| class SetDisplayModeDialog<T>(bundle: Bundle? = null) : DialogController(bundle) | ||||
|         where T : Controller, T : SetDisplayModeDialog.Listener { | ||||
|  | ||||
|     private val selectedIndex = args.getInt("selected", -1) | ||||
|  | ||||
|     constructor(target: T, selectedIndex: Int = -1) : this(Bundle().apply { | ||||
|         putInt("selected", selectedIndex) | ||||
|     }) { | ||||
|         targetController = target | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val activity = activity!! | ||||
|         val ids = intArrayOf(Manga.DISPLAY_NAME, Manga.DISPLAY_NUMBER) | ||||
|         val choices = intArrayOf(R.string.show_title, R.string.show_chapter_number) | ||||
|                 .map { activity.getString(it) } | ||||
|  | ||||
|         return MaterialDialog.Builder(activity) | ||||
|                 .title(R.string.action_display_mode) | ||||
|                 .items(choices) | ||||
|                 .itemsIds(ids) | ||||
|                 .itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ -> | ||||
|                     (targetController as? Listener)?.setDisplayMode(itemView.id) | ||||
|                     true | ||||
|                 } | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun setDisplayMode(id: Int) | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,43 +1,43 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
|  | ||||
| class SetSortingDialog<T>(bundle: Bundle? = null) : DialogController(bundle) | ||||
|         where T : Controller, T : SetSortingDialog.Listener { | ||||
|  | ||||
|     private val selectedIndex = args.getInt("selected", -1) | ||||
|  | ||||
|     constructor(target: T, selectedIndex: Int = -1) : this(Bundle().apply { | ||||
|         putInt("selected", selectedIndex) | ||||
|     }) { | ||||
|         targetController = target | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val activity = activity!! | ||||
|         val ids = intArrayOf(Manga.SORTING_SOURCE, Manga.SORTING_NUMBER) | ||||
|         val choices = intArrayOf(R.string.sort_by_source, R.string.sort_by_number) | ||||
|                 .map { activity.getString(it) } | ||||
|  | ||||
|         return MaterialDialog.Builder(activity) | ||||
|                 .title(R.string.sorting_mode) | ||||
|                 .items(choices) | ||||
|                 .itemsIds(ids) | ||||
|                 .itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ -> | ||||
|                     (targetController as? Listener)?.setSorting(itemView.id) | ||||
|                     true | ||||
|                 } | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun setSorting(id: Int) | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.manga.chapter | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
|  | ||||
| class SetSortingDialog<T>(bundle: Bundle? = null) : DialogController(bundle) | ||||
|         where T : Controller, T : SetSortingDialog.Listener { | ||||
|  | ||||
|     private val selectedIndex = args.getInt("selected", -1) | ||||
|  | ||||
|     constructor(target: T, selectedIndex: Int = -1) : this(Bundle().apply { | ||||
|         putInt("selected", selectedIndex) | ||||
|     }) { | ||||
|         targetController = target | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val activity = activity!! | ||||
|         val ids = intArrayOf(Manga.SORTING_SOURCE, Manga.SORTING_NUMBER) | ||||
|         val choices = intArrayOf(R.string.sort_by_source, R.string.sort_by_number) | ||||
|                 .map { activity.getString(it) } | ||||
|  | ||||
|         return MaterialDialog.Builder(activity) | ||||
|                 .title(R.string.sorting_mode) | ||||
|                 .items(choices) | ||||
|                 .itemsIds(ids) | ||||
|                 .itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ -> | ||||
|                     (targetController as? Listener)?.setSorting(itemView.id) | ||||
|                     true | ||||
|                 } | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun setSorting(id: Int) | ||||
|     } | ||||
|  | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,173 +1,173 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.info | ||||
|  | ||||
| import android.os.Bundle | ||||
| import com.jakewharton.rxrelay.BehaviorRelay | ||||
| import com.jakewharton.rxrelay.PublishRelay | ||||
| import eu.kanade.tachiyomi.data.cache.CoverCache | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.Category | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaCategory | ||||
| import eu.kanade.tachiyomi.data.download.DownloadManager | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter | ||||
| import eu.kanade.tachiyomi.util.isNullOrUnsubscribed | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.schedulers.Schedulers | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.* | ||||
|  | ||||
| /** | ||||
|  * Presenter of MangaInfoFragment. | ||||
|  * Contains information and data for fragment. | ||||
|  * Observable updates should be called from here. | ||||
|  */ | ||||
| class MangaInfoPresenter( | ||||
|         val manga: Manga, | ||||
|         val source: Source, | ||||
|         private val chapterCountRelay: BehaviorRelay<Float>, | ||||
|         private val lastUpdateRelay: BehaviorRelay<Date>, | ||||
|         private val mangaFavoriteRelay: PublishRelay<Boolean>, | ||||
|         private val db: DatabaseHelper = Injekt.get(), | ||||
|         private val downloadManager: DownloadManager = Injekt.get(), | ||||
|         private val coverCache: CoverCache = Injekt.get() | ||||
| ) : BasePresenter<MangaInfoController>() { | ||||
|  | ||||
|     /** | ||||
|      * Subscription to send the manga to the view. | ||||
|      */ | ||||
|     private var viewMangaSubscription: Subscription? = null | ||||
|  | ||||
|     /** | ||||
|      * Subscription to update the manga from the source. | ||||
|      */ | ||||
|     private var fetchMangaSubscription: Subscription? = null | ||||
|  | ||||
|     override fun onCreate(savedState: Bundle?) { | ||||
|         super.onCreate(savedState) | ||||
|         sendMangaToView() | ||||
|  | ||||
|         // Update chapter count | ||||
|         chapterCountRelay.observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeLatestCache(MangaInfoController::setChapterCount) | ||||
|  | ||||
|         // Update favorite status | ||||
|         mangaFavoriteRelay.observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe { setFavorite(it) } | ||||
|                 .apply { add(this) } | ||||
|  | ||||
|         //update last update date | ||||
|         lastUpdateRelay.observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeLatestCache(MangaInfoController::setLastUpdateDate) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sends the active manga to the view. | ||||
|      */ | ||||
|     fun sendMangaToView() { | ||||
|         viewMangaSubscription?.let { remove(it) } | ||||
|         viewMangaSubscription = Observable.just(manga) | ||||
|                 .subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) }) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Fetch manga information from source. | ||||
|      */ | ||||
|     fun fetchMangaFromSource() { | ||||
|         if (!fetchMangaSubscription.isNullOrUnsubscribed()) return | ||||
|         fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) } | ||||
|                 .map { networkManga -> | ||||
|                     manga.copyFrom(networkManga) | ||||
|                     manga.initialized = true | ||||
|                     db.insertManga(manga).executeAsBlocking() | ||||
|                     manga | ||||
|                 } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .doOnNext { sendMangaToView() } | ||||
|                 .subscribeFirst({ view, _ -> | ||||
|                     view.onFetchMangaDone() | ||||
|                 }, MangaInfoController::onFetchMangaError) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update favorite status of manga, (removes / adds) manga (to / from) library. | ||||
|      * | ||||
|      * @return the new status of the manga. | ||||
|      */ | ||||
|     fun toggleFavorite(): Boolean { | ||||
|         manga.favorite = !manga.favorite | ||||
|         if (!manga.favorite) { | ||||
|             coverCache.deleteFromCache(manga.thumbnail_url) | ||||
|         } | ||||
|         db.insertManga(manga).executeAsBlocking() | ||||
|         sendMangaToView() | ||||
|         return manga.favorite | ||||
|     } | ||||
|  | ||||
|     private fun setFavorite(favorite: Boolean) { | ||||
|         if (manga.favorite == favorite) { | ||||
|             return | ||||
|         } | ||||
|         toggleFavorite() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns true if the manga has any downloads. | ||||
|      */ | ||||
|     fun hasDownloads(): Boolean { | ||||
|         return downloadManager.getDownloadCount(manga) > 0 | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Deletes all the downloads for the manga. | ||||
|      */ | ||||
|     fun deleteDownloads() { | ||||
|         downloadManager.deleteManga(manga, source) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get user categories. | ||||
|      * | ||||
|      * @return List of categories, not including the default category | ||||
|      */ | ||||
|     fun getCategories(): List<Category> { | ||||
|         return db.getCategories().executeAsBlocking() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Gets the category id's the manga is in, if the manga is not in a category, returns the default id. | ||||
|      * | ||||
|      * @param manga the manga to get categories from. | ||||
|      * @return Array of category ids the manga is in, if none returns default id | ||||
|      */ | ||||
|     fun getMangaCategoryIds(manga: Manga): Array<Int> { | ||||
|         val categories = db.getCategoriesForManga(manga).executeAsBlocking() | ||||
|         return categories.mapNotNull { it.id }.toTypedArray() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Move the given manga to categories. | ||||
|      * | ||||
|      * @param manga the manga to move. | ||||
|      * @param categories the selected categories. | ||||
|      */ | ||||
|     fun moveMangaToCategories(manga: Manga, categories: List<Category>) { | ||||
|         val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) } | ||||
|         db.setMangaCategories(mc, listOf(manga)) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Move the given manga to the category. | ||||
|      * | ||||
|      * @param manga the manga to move. | ||||
|      * @param category the selected category, or null for default category. | ||||
|      */ | ||||
|     fun moveMangaToCategory(manga: Manga, category: Category?) { | ||||
|         moveMangaToCategories(manga, listOfNotNull(category)) | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.manga.info | ||||
|  | ||||
| import android.os.Bundle | ||||
| import com.jakewharton.rxrelay.BehaviorRelay | ||||
| import com.jakewharton.rxrelay.PublishRelay | ||||
| import eu.kanade.tachiyomi.data.cache.CoverCache | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.Category | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaCategory | ||||
| import eu.kanade.tachiyomi.data.download.DownloadManager | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter | ||||
| import eu.kanade.tachiyomi.util.isNullOrUnsubscribed | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.schedulers.Schedulers | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.* | ||||
|  | ||||
| /** | ||||
|  * Presenter of MangaInfoFragment. | ||||
|  * Contains information and data for fragment. | ||||
|  * Observable updates should be called from here. | ||||
|  */ | ||||
| class MangaInfoPresenter( | ||||
|         val manga: Manga, | ||||
|         val source: Source, | ||||
|         private val chapterCountRelay: BehaviorRelay<Float>, | ||||
|         private val lastUpdateRelay: BehaviorRelay<Date>, | ||||
|         private val mangaFavoriteRelay: PublishRelay<Boolean>, | ||||
|         private val db: DatabaseHelper = Injekt.get(), | ||||
|         private val downloadManager: DownloadManager = Injekt.get(), | ||||
|         private val coverCache: CoverCache = Injekt.get() | ||||
| ) : BasePresenter<MangaInfoController>() { | ||||
|  | ||||
|     /** | ||||
|      * Subscription to send the manga to the view. | ||||
|      */ | ||||
|     private var viewMangaSubscription: Subscription? = null | ||||
|  | ||||
|     /** | ||||
|      * Subscription to update the manga from the source. | ||||
|      */ | ||||
|     private var fetchMangaSubscription: Subscription? = null | ||||
|  | ||||
|     override fun onCreate(savedState: Bundle?) { | ||||
|         super.onCreate(savedState) | ||||
|         sendMangaToView() | ||||
|  | ||||
|         // Update chapter count | ||||
|         chapterCountRelay.observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeLatestCache(MangaInfoController::setChapterCount) | ||||
|  | ||||
|         // Update favorite status | ||||
|         mangaFavoriteRelay.observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe { setFavorite(it) } | ||||
|                 .apply { add(this) } | ||||
|  | ||||
|         //update last update date | ||||
|         lastUpdateRelay.observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeLatestCache(MangaInfoController::setLastUpdateDate) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sends the active manga to the view. | ||||
|      */ | ||||
|     fun sendMangaToView() { | ||||
|         viewMangaSubscription?.let { remove(it) } | ||||
|         viewMangaSubscription = Observable.just(manga) | ||||
|                 .subscribeLatestCache({ view, manga -> view.onNextManga(manga, source) }) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Fetch manga information from source. | ||||
|      */ | ||||
|     fun fetchMangaFromSource() { | ||||
|         if (!fetchMangaSubscription.isNullOrUnsubscribed()) return | ||||
|         fetchMangaSubscription = Observable.defer { source.fetchMangaDetails(manga) } | ||||
|                 .map { networkManga -> | ||||
|                     manga.copyFrom(networkManga) | ||||
|                     manga.initialized = true | ||||
|                     db.insertManga(manga).executeAsBlocking() | ||||
|                     manga | ||||
|                 } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .doOnNext { sendMangaToView() } | ||||
|                 .subscribeFirst({ view, _ -> | ||||
|                     view.onFetchMangaDone() | ||||
|                 }, MangaInfoController::onFetchMangaError) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update favorite status of manga, (removes / adds) manga (to / from) library. | ||||
|      * | ||||
|      * @return the new status of the manga. | ||||
|      */ | ||||
|     fun toggleFavorite(): Boolean { | ||||
|         manga.favorite = !manga.favorite | ||||
|         if (!manga.favorite) { | ||||
|             coverCache.deleteFromCache(manga.thumbnail_url) | ||||
|         } | ||||
|         db.insertManga(manga).executeAsBlocking() | ||||
|         sendMangaToView() | ||||
|         return manga.favorite | ||||
|     } | ||||
|  | ||||
|     private fun setFavorite(favorite: Boolean) { | ||||
|         if (manga.favorite == favorite) { | ||||
|             return | ||||
|         } | ||||
|         toggleFavorite() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns true if the manga has any downloads. | ||||
|      */ | ||||
|     fun hasDownloads(): Boolean { | ||||
|         return downloadManager.getDownloadCount(manga) > 0 | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Deletes all the downloads for the manga. | ||||
|      */ | ||||
|     fun deleteDownloads() { | ||||
|         downloadManager.deleteManga(manga, source) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get user categories. | ||||
|      * | ||||
|      * @return List of categories, not including the default category | ||||
|      */ | ||||
|     fun getCategories(): List<Category> { | ||||
|         return db.getCategories().executeAsBlocking() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Gets the category id's the manga is in, if the manga is not in a category, returns the default id. | ||||
|      * | ||||
|      * @param manga the manga to get categories from. | ||||
|      * @return Array of category ids the manga is in, if none returns default id | ||||
|      */ | ||||
|     fun getMangaCategoryIds(manga: Manga): Array<Int> { | ||||
|         val categories = db.getCategoriesForManga(manga).executeAsBlocking() | ||||
|         return categories.mapNotNull { it.id }.toTypedArray() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Move the given manga to categories. | ||||
|      * | ||||
|      * @param manga the manga to move. | ||||
|      * @param categories the selected categories. | ||||
|      */ | ||||
|     fun moveMangaToCategories(manga: Manga, categories: List<Category>) { | ||||
|         val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) } | ||||
|         db.setMangaCategories(mc, listOf(manga)) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Move the given manga to the category. | ||||
|      * | ||||
|      * @param manga the manga to move. | ||||
|      * @param category the selected category, or null for default category. | ||||
|      */ | ||||
|     fun moveMangaToCategory(manga: Manga, category: Category?) { | ||||
|         moveMangaToCategories(manga, listOfNotNull(category)) | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,74 +1,74 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import android.widget.NumberPicker | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class SetTrackChaptersDialog<T> : DialogController | ||||
|         where T : Controller, T : SetTrackChaptersDialog.Listener { | ||||
|  | ||||
|     private val item: TrackItem | ||||
|  | ||||
|     constructor(target: T, item: TrackItem) : super(Bundle().apply { | ||||
|         putSerializable(KEY_ITEM_TRACK, item.track) | ||||
|     }) { | ||||
|         targetController = target | ||||
|         this.item = item | ||||
|     } | ||||
|  | ||||
|     @Suppress("unused") | ||||
|     constructor(bundle: Bundle) : super(bundle) { | ||||
|         val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track | ||||
|         val service = Injekt.get<TrackManager>().getService(track.sync_id)!! | ||||
|         item = TrackItem(track, service) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val item = item | ||||
|  | ||||
|         val dialog = MaterialDialog.Builder(activity!!) | ||||
|                 .title(R.string.chapters) | ||||
|                 .customView(R.layout.track_chapters_dialog, false) | ||||
|                 .positiveText(android.R.string.ok) | ||||
|                 .negativeText(android.R.string.cancel) | ||||
|                 .onPositive { dialog, _ -> | ||||
|                     val view = dialog.customView | ||||
|                     if (view != null) { | ||||
|                         // Remove focus to update selected number | ||||
|                         val np: NumberPicker = view.findViewById(R.id.chapters_picker) | ||||
|                         np.clearFocus() | ||||
|  | ||||
|                         (targetController as? Listener)?.setChaptersRead(item, np.value) | ||||
|                     } | ||||
|                 } | ||||
|                 .build() | ||||
|  | ||||
|         val view = dialog.customView | ||||
|         if (view != null) { | ||||
|             val np: NumberPicker = view.findViewById(R.id.chapters_picker) | ||||
|             // Set initial value | ||||
|             np.value = item.track?.last_chapter_read ?: 0 | ||||
|             // Don't allow to go from 0 to 9999 | ||||
|             np.wrapSelectorWheel = false | ||||
|         } | ||||
|  | ||||
|         return dialog | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun setChaptersRead(item: TrackItem, chaptersRead: Int) | ||||
|     } | ||||
|  | ||||
|     private companion object { | ||||
|         const val KEY_ITEM_TRACK = "SetTrackChaptersDialog.item.track" | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import android.widget.NumberPicker | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class SetTrackChaptersDialog<T> : DialogController | ||||
|         where T : Controller, T : SetTrackChaptersDialog.Listener { | ||||
|  | ||||
|     private val item: TrackItem | ||||
|  | ||||
|     constructor(target: T, item: TrackItem) : super(Bundle().apply { | ||||
|         putSerializable(KEY_ITEM_TRACK, item.track) | ||||
|     }) { | ||||
|         targetController = target | ||||
|         this.item = item | ||||
|     } | ||||
|  | ||||
|     @Suppress("unused") | ||||
|     constructor(bundle: Bundle) : super(bundle) { | ||||
|         val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track | ||||
|         val service = Injekt.get<TrackManager>().getService(track.sync_id)!! | ||||
|         item = TrackItem(track, service) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val item = item | ||||
|  | ||||
|         val dialog = MaterialDialog.Builder(activity!!) | ||||
|                 .title(R.string.chapters) | ||||
|                 .customView(R.layout.track_chapters_dialog, false) | ||||
|                 .positiveText(android.R.string.ok) | ||||
|                 .negativeText(android.R.string.cancel) | ||||
|                 .onPositive { dialog, _ -> | ||||
|                     val view = dialog.customView | ||||
|                     if (view != null) { | ||||
|                         // Remove focus to update selected number | ||||
|                         val np: NumberPicker = view.findViewById(R.id.chapters_picker) | ||||
|                         np.clearFocus() | ||||
|  | ||||
|                         (targetController as? Listener)?.setChaptersRead(item, np.value) | ||||
|                     } | ||||
|                 } | ||||
|                 .build() | ||||
|  | ||||
|         val view = dialog.customView | ||||
|         if (view != null) { | ||||
|             val np: NumberPicker = view.findViewById(R.id.chapters_picker) | ||||
|             // Set initial value | ||||
|             np.value = item.track?.last_chapter_read ?: 0 | ||||
|             // Don't allow to go from 0 to 9999 | ||||
|             np.wrapSelectorWheel = false | ||||
|         } | ||||
|  | ||||
|         return dialog | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun setChaptersRead(item: TrackItem, chaptersRead: Int) | ||||
|     } | ||||
|  | ||||
|     private companion object { | ||||
|         const val KEY_ITEM_TRACK = "SetTrackChaptersDialog.item.track" | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,80 +1,80 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import android.widget.NumberPicker | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class SetTrackScoreDialog<T> : DialogController | ||||
|         where T : Controller, T : SetTrackScoreDialog.Listener { | ||||
|  | ||||
|     private val item: TrackItem | ||||
|  | ||||
|     constructor(target: T, item: TrackItem) : super(Bundle().apply { | ||||
|         putSerializable(KEY_ITEM_TRACK, item.track) | ||||
|     }) { | ||||
|         targetController = target | ||||
|         this.item = item | ||||
|     } | ||||
|  | ||||
|     @Suppress("unused") | ||||
|     constructor(bundle: Bundle) : super(bundle) { | ||||
|         val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track | ||||
|         val service = Injekt.get<TrackManager>().getService(track.sync_id)!! | ||||
|         item = TrackItem(track, service) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val item = item | ||||
|  | ||||
|         val dialog = MaterialDialog.Builder(activity!!) | ||||
|                 .title(R.string.score) | ||||
|                 .customView(R.layout.track_score_dialog, false) | ||||
|                 .positiveText(android.R.string.ok) | ||||
|                 .negativeText(android.R.string.cancel) | ||||
|                 .onPositive { dialog, _ -> | ||||
|                     val view = dialog.customView | ||||
|                     if (view != null) { | ||||
|                         // Remove focus to update selected number | ||||
|                         val np: NumberPicker = view.findViewById(R.id.score_picker) | ||||
|                         np.clearFocus() | ||||
|  | ||||
|                         (targetController as? Listener)?.setScore(item, np.value) | ||||
|                     } | ||||
|                 } | ||||
|                 .show() | ||||
|  | ||||
|         val view = dialog.customView | ||||
|         if (view != null) { | ||||
|             val np: NumberPicker = view.findViewById(R.id.score_picker) | ||||
|             val scores = item.service.getScoreList().toTypedArray() | ||||
|             np.maxValue = scores.size - 1 | ||||
|             np.displayedValues = scores | ||||
|  | ||||
|             // Set initial value | ||||
|             val displayedScore = item.service.displayScore(item.track!!) | ||||
|             if (displayedScore != "-") { | ||||
|                 val index = scores.indexOf(displayedScore) | ||||
|                 np.value = if (index != -1) index else 0 | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return dialog | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun setScore(item: TrackItem, score: Int) | ||||
|     } | ||||
|  | ||||
|     private companion object { | ||||
|         const val KEY_ITEM_TRACK = "SetTrackScoreDialog.item.track" | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import android.widget.NumberPicker | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class SetTrackScoreDialog<T> : DialogController | ||||
|         where T : Controller, T : SetTrackScoreDialog.Listener { | ||||
|  | ||||
|     private val item: TrackItem | ||||
|  | ||||
|     constructor(target: T, item: TrackItem) : super(Bundle().apply { | ||||
|         putSerializable(KEY_ITEM_TRACK, item.track) | ||||
|     }) { | ||||
|         targetController = target | ||||
|         this.item = item | ||||
|     } | ||||
|  | ||||
|     @Suppress("unused") | ||||
|     constructor(bundle: Bundle) : super(bundle) { | ||||
|         val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track | ||||
|         val service = Injekt.get<TrackManager>().getService(track.sync_id)!! | ||||
|         item = TrackItem(track, service) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val item = item | ||||
|  | ||||
|         val dialog = MaterialDialog.Builder(activity!!) | ||||
|                 .title(R.string.score) | ||||
|                 .customView(R.layout.track_score_dialog, false) | ||||
|                 .positiveText(android.R.string.ok) | ||||
|                 .negativeText(android.R.string.cancel) | ||||
|                 .onPositive { dialog, _ -> | ||||
|                     val view = dialog.customView | ||||
|                     if (view != null) { | ||||
|                         // Remove focus to update selected number | ||||
|                         val np: NumberPicker = view.findViewById(R.id.score_picker) | ||||
|                         np.clearFocus() | ||||
|  | ||||
|                         (targetController as? Listener)?.setScore(item, np.value) | ||||
|                     } | ||||
|                 } | ||||
|                 .show() | ||||
|  | ||||
|         val view = dialog.customView | ||||
|         if (view != null) { | ||||
|             val np: NumberPicker = view.findViewById(R.id.score_picker) | ||||
|             val scores = item.service.getScoreList().toTypedArray() | ||||
|             np.maxValue = scores.size - 1 | ||||
|             np.displayedValues = scores | ||||
|  | ||||
|             // Set initial value | ||||
|             val displayedScore = item.service.displayScore(item.track!!) | ||||
|             if (displayedScore != "-") { | ||||
|                 val index = scores.indexOf(displayedScore) | ||||
|                 np.value = if (index != -1) index else 0 | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return dialog | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun setScore(item: TrackItem, score: Int) | ||||
|     } | ||||
|  | ||||
|     private companion object { | ||||
|         const val KEY_ITEM_TRACK = "SetTrackScoreDialog.item.track" | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,58 +1,58 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class SetTrackStatusDialog<T> : DialogController | ||||
|         where T : Controller, T : SetTrackStatusDialog.Listener { | ||||
|  | ||||
|     private val item: TrackItem | ||||
|  | ||||
|     constructor(target: T, item: TrackItem) : super(Bundle().apply { | ||||
|         putSerializable(KEY_ITEM_TRACK, item.track) | ||||
|     }) { | ||||
|         targetController = target | ||||
|         this.item = item | ||||
|     } | ||||
|  | ||||
|     @Suppress("unused") | ||||
|     constructor(bundle: Bundle) : super(bundle) { | ||||
|         val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track | ||||
|         val service = Injekt.get<TrackManager>().getService(track.sync_id)!! | ||||
|         item = TrackItem(track, service) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val item = item | ||||
|         val statusList = item.service.getStatusList().orEmpty() | ||||
|         val statusString = statusList.mapNotNull { item.service.getStatus(it) } | ||||
|         val selectedIndex = statusList.indexOf(item.track?.status) | ||||
|  | ||||
|         return MaterialDialog.Builder(activity!!) | ||||
|                 .title(R.string.status) | ||||
|                 .negativeText(android.R.string.cancel) | ||||
|                 .items(statusString) | ||||
|                 .itemsCallbackSingleChoice(selectedIndex, { _, _, i, _ -> | ||||
|                     (targetController as? Listener)?.setStatus(item, i) | ||||
|                     true | ||||
|                 }) | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun setStatus(item: TrackItem, selection: Int) | ||||
|     } | ||||
|  | ||||
|     private companion object { | ||||
|         const val KEY_ITEM_TRACK = "SetTrackStatusDialog.item.track" | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.bluelinelabs.conductor.Controller | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class SetTrackStatusDialog<T> : DialogController | ||||
|         where T : Controller, T : SetTrackStatusDialog.Listener { | ||||
|  | ||||
|     private val item: TrackItem | ||||
|  | ||||
|     constructor(target: T, item: TrackItem) : super(Bundle().apply { | ||||
|         putSerializable(KEY_ITEM_TRACK, item.track) | ||||
|     }) { | ||||
|         targetController = target | ||||
|         this.item = item | ||||
|     } | ||||
|  | ||||
|     @Suppress("unused") | ||||
|     constructor(bundle: Bundle) : super(bundle) { | ||||
|         val track = bundle.getSerializable(KEY_ITEM_TRACK) as Track | ||||
|         val service = Injekt.get<TrackManager>().getService(track.sync_id)!! | ||||
|         item = TrackItem(track, service) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedViewState: Bundle?): Dialog { | ||||
|         val item = item | ||||
|         val statusList = item.service.getStatusList().orEmpty() | ||||
|         val statusString = statusList.mapNotNull { item.service.getStatus(it) } | ||||
|         val selectedIndex = statusList.indexOf(item.track?.status) | ||||
|  | ||||
|         return MaterialDialog.Builder(activity!!) | ||||
|                 .title(R.string.status) | ||||
|                 .negativeText(android.R.string.cancel) | ||||
|                 .items(statusString) | ||||
|                 .itemsCallbackSingleChoice(selectedIndex, { _, _, i, _ -> | ||||
|                     (targetController as? Listener)?.setStatus(item, i) | ||||
|                     true | ||||
|                 }) | ||||
|                 .build() | ||||
|     } | ||||
|  | ||||
|     interface Listener { | ||||
|         fun setStatus(item: TrackItem, selection: Int) | ||||
|     } | ||||
|  | ||||
|     private companion object { | ||||
|         const val KEY_ITEM_TRACK = "SetTrackStatusDialog.item.track" | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,45 +1,45 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.support.v7.widget.RecyclerView | ||||
| import android.view.ViewGroup | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.util.inflate | ||||
|  | ||||
| class TrackAdapter(controller: TrackController) : RecyclerView.Adapter<TrackHolder>() { | ||||
|  | ||||
|     var items = emptyList<TrackItem>() | ||||
|         set(value) { | ||||
|             if (field !== value) { | ||||
|                 field = value | ||||
|                 notifyDataSetChanged() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|     val rowClickListener: OnClickListener = controller | ||||
|  | ||||
|     fun getItem(index: Int): TrackItem? { | ||||
|         return items.getOrNull(index) | ||||
|     } | ||||
|  | ||||
|     override fun getItemCount(): Int { | ||||
|         return items.size | ||||
|     } | ||||
|  | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackHolder { | ||||
|         val view = parent.inflate(R.layout.track_item) | ||||
|         return TrackHolder(view, this) | ||||
|     } | ||||
|  | ||||
|     override fun onBindViewHolder(holder: TrackHolder, position: Int) { | ||||
|         holder.bind(items[position]) | ||||
|     } | ||||
|  | ||||
|     interface OnClickListener { | ||||
|         fun onLogoClick(position: Int) | ||||
|         fun onTitleClick(position: Int) | ||||
|         fun onStatusClick(position: Int) | ||||
|         fun onChaptersClick(position: Int) | ||||
|         fun onScoreClick(position: Int) | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.support.v7.widget.RecyclerView | ||||
| import android.view.ViewGroup | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.util.inflate | ||||
|  | ||||
| class TrackAdapter(controller: TrackController) : RecyclerView.Adapter<TrackHolder>() { | ||||
|  | ||||
|     var items = emptyList<TrackItem>() | ||||
|         set(value) { | ||||
|             if (field !== value) { | ||||
|                 field = value | ||||
|                 notifyDataSetChanged() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|     val rowClickListener: OnClickListener = controller | ||||
|  | ||||
|     fun getItem(index: Int): TrackItem? { | ||||
|         return items.getOrNull(index) | ||||
|     } | ||||
|  | ||||
|     override fun getItemCount(): Int { | ||||
|         return items.size | ||||
|     } | ||||
|  | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackHolder { | ||||
|         val view = parent.inflate(R.layout.track_item) | ||||
|         return TrackHolder(view, this) | ||||
|     } | ||||
|  | ||||
|     override fun onBindViewHolder(holder: TrackHolder, position: Int) { | ||||
|         holder.bind(items[position]) | ||||
|     } | ||||
|  | ||||
|     interface OnClickListener { | ||||
|         fun onLogoClick(position: Int) | ||||
|         fun onTitleClick(position: Int) | ||||
|         fun onStatusClick(position: Int) | ||||
|         fun onChaptersClick(position: Int) | ||||
|         fun onScoreClick(position: Int) | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,142 +1,142 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.content.Intent | ||||
| import android.net.Uri | ||||
| import android.support.v7.widget.LinearLayoutManager | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import com.jakewharton.rxbinding.support.v4.widget.refreshes | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.ui.base.controller.NucleusController | ||||
| import eu.kanade.tachiyomi.ui.manga.MangaController | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import kotlinx.android.synthetic.main.track_controller.* | ||||
| import timber.log.Timber | ||||
|  | ||||
| class TrackController : NucleusController<TrackPresenter>(), | ||||
|         TrackAdapter.OnClickListener, | ||||
|         SetTrackStatusDialog.Listener, | ||||
|         SetTrackChaptersDialog.Listener, | ||||
|         SetTrackScoreDialog.Listener { | ||||
|  | ||||
|     private var adapter: TrackAdapter? = null | ||||
|  | ||||
|     init { | ||||
|         // There's no menu, but this avoids a bug when coming from the catalogue, where the menu | ||||
|         // disappears if the searchview is expanded | ||||
|         setHasOptionsMenu(true) | ||||
|     } | ||||
|  | ||||
|     override fun createPresenter(): TrackPresenter { | ||||
|         return TrackPresenter((parentController as MangaController).manga!!) | ||||
|     } | ||||
|  | ||||
|     override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { | ||||
|         return inflater.inflate(R.layout.track_controller, container, false) | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|  | ||||
|         adapter = TrackAdapter(this) | ||||
|         with(view) { | ||||
|             track_recycler.layoutManager = LinearLayoutManager(context) | ||||
|             track_recycler.adapter = adapter | ||||
|             swipe_refresh.isEnabled = false | ||||
|             swipe_refresh.refreshes().subscribeUntilDestroy { presenter.refresh() } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         adapter = null | ||||
|         super.onDestroyView(view) | ||||
|     } | ||||
|  | ||||
|     fun onNextTrackings(trackings: List<TrackItem>) { | ||||
|         val atLeastOneLink = trackings.any { it.track != null } | ||||
|         adapter?.items = trackings | ||||
|         swipe_refresh?.isEnabled = atLeastOneLink | ||||
|         (parentController as? MangaController)?.setTrackingIcon(atLeastOneLink) | ||||
|     } | ||||
|  | ||||
|     fun onSearchResults(results: List<TrackSearch>) { | ||||
|         getSearchDialog()?.onSearchResults(results) | ||||
|     } | ||||
|  | ||||
|     @Suppress("UNUSED_PARAMETER") | ||||
|     fun onSearchResultsError(error: Throwable) { | ||||
|         Timber.e(error) | ||||
|         getSearchDialog()?.onSearchResultsError() | ||||
|     } | ||||
|  | ||||
|     private fun getSearchDialog(): TrackSearchDialog? { | ||||
|         return router.getControllerWithTag(TAG_SEARCH_CONTROLLER) as? TrackSearchDialog | ||||
|     } | ||||
|  | ||||
|     fun onRefreshDone() { | ||||
|         swipe_refresh?.isRefreshing = false | ||||
|     } | ||||
|  | ||||
|     fun onRefreshError(error: Throwable) { | ||||
|         swipe_refresh?.isRefreshing = false | ||||
|         activity?.toast(error.message) | ||||
|     } | ||||
|  | ||||
|     override fun onLogoClick(position: Int) { | ||||
|         val track = adapter?.getItem(position)?.track ?: return | ||||
|  | ||||
|         if (track.tracking_url.isNullOrBlank()) { | ||||
|             activity?.toast(R.string.url_not_set) | ||||
|         } else { | ||||
|             activity?.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(track.tracking_url))) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onTitleClick(position: Int) { | ||||
|         val item = adapter?.getItem(position) ?: return | ||||
|         TrackSearchDialog(this, item.service).showDialog(router, TAG_SEARCH_CONTROLLER) | ||||
|     } | ||||
|  | ||||
|     override fun onStatusClick(position: Int) { | ||||
|         val item = adapter?.getItem(position) ?: return | ||||
|         if (item.track == null) return | ||||
|  | ||||
|         SetTrackStatusDialog(this, item).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     override fun onChaptersClick(position: Int) { | ||||
|         val item = adapter?.getItem(position) ?: return | ||||
|         if (item.track == null) return | ||||
|  | ||||
|         SetTrackChaptersDialog(this, item).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     override fun onScoreClick(position: Int) { | ||||
|         val item = adapter?.getItem(position) ?: return | ||||
|         if (item.track == null) return | ||||
|  | ||||
|         SetTrackScoreDialog(this, item).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     override fun setStatus(item: TrackItem, selection: Int) { | ||||
|         presenter.setStatus(item, selection) | ||||
|         swipe_refresh?.isRefreshing = true | ||||
|     } | ||||
|  | ||||
|     override fun setScore(item: TrackItem, score: Int) { | ||||
|         presenter.setScore(item, score) | ||||
|         swipe_refresh?.isRefreshing = true | ||||
|     } | ||||
|  | ||||
|     override fun setChaptersRead(item: TrackItem, chaptersRead: Int) { | ||||
|         presenter.setLastChapterRead(item, chaptersRead) | ||||
|         swipe_refresh?.isRefreshing = true | ||||
|     } | ||||
|  | ||||
|     private companion object { | ||||
|         const val TAG_SEARCH_CONTROLLER = "track_search_controller" | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.content.Intent | ||||
| import android.net.Uri | ||||
| import android.support.v7.widget.LinearLayoutManager | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import com.jakewharton.rxbinding.support.v4.widget.refreshes | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.ui.base.controller.NucleusController | ||||
| import eu.kanade.tachiyomi.ui.manga.MangaController | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import kotlinx.android.synthetic.main.track_controller.* | ||||
| import timber.log.Timber | ||||
|  | ||||
| class TrackController : NucleusController<TrackPresenter>(), | ||||
|         TrackAdapter.OnClickListener, | ||||
|         SetTrackStatusDialog.Listener, | ||||
|         SetTrackChaptersDialog.Listener, | ||||
|         SetTrackScoreDialog.Listener { | ||||
|  | ||||
|     private var adapter: TrackAdapter? = null | ||||
|  | ||||
|     init { | ||||
|         // There's no menu, but this avoids a bug when coming from the catalogue, where the menu | ||||
|         // disappears if the searchview is expanded | ||||
|         setHasOptionsMenu(true) | ||||
|     } | ||||
|  | ||||
|     override fun createPresenter(): TrackPresenter { | ||||
|         return TrackPresenter((parentController as MangaController).manga!!) | ||||
|     } | ||||
|  | ||||
|     override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { | ||||
|         return inflater.inflate(R.layout.track_controller, container, false) | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|  | ||||
|         adapter = TrackAdapter(this) | ||||
|         with(view) { | ||||
|             track_recycler.layoutManager = LinearLayoutManager(context) | ||||
|             track_recycler.adapter = adapter | ||||
|             swipe_refresh.isEnabled = false | ||||
|             swipe_refresh.refreshes().subscribeUntilDestroy { presenter.refresh() } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         adapter = null | ||||
|         super.onDestroyView(view) | ||||
|     } | ||||
|  | ||||
|     fun onNextTrackings(trackings: List<TrackItem>) { | ||||
|         val atLeastOneLink = trackings.any { it.track != null } | ||||
|         adapter?.items = trackings | ||||
|         swipe_refresh?.isEnabled = atLeastOneLink | ||||
|         (parentController as? MangaController)?.setTrackingIcon(atLeastOneLink) | ||||
|     } | ||||
|  | ||||
|     fun onSearchResults(results: List<TrackSearch>) { | ||||
|         getSearchDialog()?.onSearchResults(results) | ||||
|     } | ||||
|  | ||||
|     @Suppress("UNUSED_PARAMETER") | ||||
|     fun onSearchResultsError(error: Throwable) { | ||||
|         Timber.e(error) | ||||
|         getSearchDialog()?.onSearchResultsError() | ||||
|     } | ||||
|  | ||||
|     private fun getSearchDialog(): TrackSearchDialog? { | ||||
|         return router.getControllerWithTag(TAG_SEARCH_CONTROLLER) as? TrackSearchDialog | ||||
|     } | ||||
|  | ||||
|     fun onRefreshDone() { | ||||
|         swipe_refresh?.isRefreshing = false | ||||
|     } | ||||
|  | ||||
|     fun onRefreshError(error: Throwable) { | ||||
|         swipe_refresh?.isRefreshing = false | ||||
|         activity?.toast(error.message) | ||||
|     } | ||||
|  | ||||
|     override fun onLogoClick(position: Int) { | ||||
|         val track = adapter?.getItem(position)?.track ?: return | ||||
|  | ||||
|         if (track.tracking_url.isNullOrBlank()) { | ||||
|             activity?.toast(R.string.url_not_set) | ||||
|         } else { | ||||
|             activity?.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(track.tracking_url))) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onTitleClick(position: Int) { | ||||
|         val item = adapter?.getItem(position) ?: return | ||||
|         TrackSearchDialog(this, item.service).showDialog(router, TAG_SEARCH_CONTROLLER) | ||||
|     } | ||||
|  | ||||
|     override fun onStatusClick(position: Int) { | ||||
|         val item = adapter?.getItem(position) ?: return | ||||
|         if (item.track == null) return | ||||
|  | ||||
|         SetTrackStatusDialog(this, item).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     override fun onChaptersClick(position: Int) { | ||||
|         val item = adapter?.getItem(position) ?: return | ||||
|         if (item.track == null) return | ||||
|  | ||||
|         SetTrackChaptersDialog(this, item).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     override fun onScoreClick(position: Int) { | ||||
|         val item = adapter?.getItem(position) ?: return | ||||
|         if (item.track == null) return | ||||
|  | ||||
|         SetTrackScoreDialog(this, item).showDialog(router) | ||||
|     } | ||||
|  | ||||
|     override fun setStatus(item: TrackItem, selection: Int) { | ||||
|         presenter.setStatus(item, selection) | ||||
|         swipe_refresh?.isRefreshing = true | ||||
|     } | ||||
|  | ||||
|     override fun setScore(item: TrackItem, score: Int) { | ||||
|         presenter.setScore(item, score) | ||||
|         swipe_refresh?.isRefreshing = true | ||||
|     } | ||||
|  | ||||
|     override fun setChaptersRead(item: TrackItem, chaptersRead: Int) { | ||||
|         presenter.setLastChapterRead(item, chaptersRead) | ||||
|         swipe_refresh?.isRefreshing = true | ||||
|     } | ||||
|  | ||||
|     private companion object { | ||||
|         const val TAG_SEARCH_CONTROLLER = "track_search_controller" | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,42 +1,42 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.view.View | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder | ||||
| import kotlinx.android.synthetic.main.track_item.* | ||||
|  | ||||
| class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) { | ||||
|  | ||||
|     init { | ||||
|         val listener = adapter.rowClickListener | ||||
|         logo_container.setOnClickListener { listener.onLogoClick(adapterPosition) } | ||||
|         title_container.setOnClickListener { listener.onTitleClick(adapterPosition) } | ||||
|         status_container.setOnClickListener { listener.onStatusClick(adapterPosition) } | ||||
|         chapters_container.setOnClickListener { listener.onChaptersClick(adapterPosition) } | ||||
|         score_container.setOnClickListener { listener.onScoreClick(adapterPosition) } | ||||
|     } | ||||
|  | ||||
|     @SuppressLint("SetTextI18n") | ||||
|     @Suppress("DEPRECATION") | ||||
|     fun bind(item: TrackItem) { | ||||
|         val track = item.track | ||||
|         track_logo.setImageResource(item.service.getLogo()) | ||||
|         logo_container.setBackgroundColor(item.service.getLogoColor()) | ||||
|         if (track != null) { | ||||
|             track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Regular_Body1_Secondary) | ||||
|             track_title.setAllCaps(false) | ||||
|             track_title.text = track.title | ||||
|             track_chapters.text = "${track.last_chapter_read}/" + | ||||
|                     if (track.total_chapters > 0) track.total_chapters else "-" | ||||
|             track_status.text = item.service.getStatus(track.status) | ||||
|             track_score.text = if (track.score == 0f) "-" else item.service.displayScore(track) | ||||
|         } else { | ||||
|             track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Medium_Button) | ||||
|             track_title.setText(R.string.action_edit) | ||||
|             track_chapters.text = "" | ||||
|             track_score.text = "" | ||||
|             track_status.text = "" | ||||
|         } | ||||
|     } | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.view.View | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder | ||||
| import kotlinx.android.synthetic.main.track_item.* | ||||
|  | ||||
| class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) { | ||||
|  | ||||
|     init { | ||||
|         val listener = adapter.rowClickListener | ||||
|         logo_container.setOnClickListener { listener.onLogoClick(adapterPosition) } | ||||
|         title_container.setOnClickListener { listener.onTitleClick(adapterPosition) } | ||||
|         status_container.setOnClickListener { listener.onStatusClick(adapterPosition) } | ||||
|         chapters_container.setOnClickListener { listener.onChaptersClick(adapterPosition) } | ||||
|         score_container.setOnClickListener { listener.onScoreClick(adapterPosition) } | ||||
|     } | ||||
|  | ||||
|     @SuppressLint("SetTextI18n") | ||||
|     @Suppress("DEPRECATION") | ||||
|     fun bind(item: TrackItem) { | ||||
|         val track = item.track | ||||
|         track_logo.setImageResource(item.service.getLogo()) | ||||
|         logo_container.setBackgroundColor(item.service.getLogoColor()) | ||||
|         if (track != null) { | ||||
|             track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Regular_Body1_Secondary) | ||||
|             track_title.setAllCaps(false) | ||||
|             track_title.text = track.title | ||||
|             track_chapters.text = "${track.last_chapter_read}/" + | ||||
|                     if (track.total_chapters > 0) track.total_chapters else "-" | ||||
|             track_status.text = item.service.getStatus(track.status) | ||||
|             track_score.text = if (track.score == 0f) "-" else item.service.displayScore(track) | ||||
|         } else { | ||||
|             track_title.setTextAppearance(itemView.context, R.style.TextAppearance_Medium_Button) | ||||
|             track_title.setText(R.string.action_edit) | ||||
|             track_chapters.text = "" | ||||
|             track_score.text = "" | ||||
|             track_status.text = "" | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
|  | ||||
| data class TrackItem(val track: Track?, val service: TrackService) | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
|  | ||||
| data class TrackItem(val track: Track?, val service: TrackService) | ||||
|   | ||||
| @@ -1,130 +1,130 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.os.Bundle | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.schedulers.Schedulers | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
|  | ||||
| class TrackPresenter( | ||||
|         val manga: Manga, | ||||
|         preferences: PreferencesHelper = Injekt.get(), | ||||
|         private val db: DatabaseHelper = Injekt.get(), | ||||
|         private val trackManager: TrackManager = Injekt.get() | ||||
| ) : BasePresenter<TrackController>() { | ||||
|  | ||||
|     private val context = preferences.context | ||||
|  | ||||
|     private var trackList: List<TrackItem> = emptyList() | ||||
|  | ||||
|     private val loggedServices by lazy { trackManager.services.filter { it.isLogged } } | ||||
|  | ||||
|     private var trackSubscription: Subscription? = null | ||||
|  | ||||
|     private var searchSubscription: Subscription? = null | ||||
|  | ||||
|     private var refreshSubscription: Subscription? = null | ||||
|  | ||||
|     override fun onCreate(savedState: Bundle?) { | ||||
|         super.onCreate(savedState) | ||||
|         fetchTrackings() | ||||
|     } | ||||
|  | ||||
|     fun fetchTrackings() { | ||||
|         trackSubscription?.let { remove(it) } | ||||
|         trackSubscription = db.getTracks(manga) | ||||
|                 .asRxObservable() | ||||
|                 .map { tracks -> | ||||
|                     loggedServices.map { service -> | ||||
|                         TrackItem(tracks.find { it.sync_id == service.id }, service) | ||||
|                     } | ||||
|                 } | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .doOnNext { trackList = it } | ||||
|                 .subscribeLatestCache(TrackController::onNextTrackings) | ||||
|     } | ||||
|  | ||||
|     fun refresh() { | ||||
|         refreshSubscription?.let { remove(it) } | ||||
|         refreshSubscription = Observable.from(trackList) | ||||
|                 .filter { it.track != null } | ||||
|                 .concatMap { item -> | ||||
|                     item.service.refresh(item.track!!) | ||||
|                             .flatMap { db.insertTrack(it).asRxObservable() } | ||||
|                             .map { item } | ||||
|                             .onErrorReturn { item } | ||||
|                 } | ||||
|                 .toList() | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeFirst({ view, _ -> view.onRefreshDone() }, | ||||
|                         TrackController::onRefreshError) | ||||
|     } | ||||
|  | ||||
|     fun search(query: String, service: TrackService) { | ||||
|         searchSubscription?.let { remove(it) } | ||||
|         searchSubscription = service.search(query) | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeLatestCache(TrackController::onSearchResults, | ||||
|                         TrackController::onSearchResultsError) | ||||
|     } | ||||
|  | ||||
|     fun registerTracking(item: Track?, service: TrackService) { | ||||
|         if (item != null) { | ||||
|             item.manga_id = manga.id!! | ||||
|             add(service.bind(item) | ||||
|                     .flatMap { db.insertTrack(item).asRxObservable() } | ||||
|                     .subscribeOn(Schedulers.io()) | ||||
|                     .observeOn(AndroidSchedulers.mainThread()) | ||||
|                     .subscribe({ }, | ||||
|                             { error -> context.toast(error.message) })) | ||||
|         } else { | ||||
|             db.deleteTrackForManga(manga, service).executeAsBlocking() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun updateRemote(track: Track, service: TrackService) { | ||||
|         service.update(track) | ||||
|                 .flatMap { db.insertTrack(track).asRxObservable() } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeFirst({ view, _ -> view.onRefreshDone() }, | ||||
|                         { view, error -> | ||||
|                             view.onRefreshError(error) | ||||
|  | ||||
|                             // Restart on error to set old values | ||||
|                             fetchTrackings() | ||||
|                         }) | ||||
|     } | ||||
|  | ||||
|     fun setStatus(item: TrackItem, index: Int) { | ||||
|         val track = item.track!! | ||||
|         track.status = item.service.getStatusList()[index] | ||||
|         updateRemote(track, item.service) | ||||
|     } | ||||
|  | ||||
|     fun setScore(item: TrackItem, index: Int) { | ||||
|         val track = item.track!! | ||||
|         track.score = item.service.indexToScore(index) | ||||
|         updateRemote(track, item.service) | ||||
|     } | ||||
|  | ||||
|     fun setLastChapterRead(item: TrackItem, chapterNumber: Int) { | ||||
|         val track = item.track!! | ||||
|         track.last_chapter_read = chapterNumber | ||||
|         updateRemote(track, item.service) | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.os.Bundle | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.schedulers.Schedulers | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
|  | ||||
| class TrackPresenter( | ||||
|         val manga: Manga, | ||||
|         preferences: PreferencesHelper = Injekt.get(), | ||||
|         private val db: DatabaseHelper = Injekt.get(), | ||||
|         private val trackManager: TrackManager = Injekt.get() | ||||
| ) : BasePresenter<TrackController>() { | ||||
|  | ||||
|     private val context = preferences.context | ||||
|  | ||||
|     private var trackList: List<TrackItem> = emptyList() | ||||
|  | ||||
|     private val loggedServices by lazy { trackManager.services.filter { it.isLogged } } | ||||
|  | ||||
|     private var trackSubscription: Subscription? = null | ||||
|  | ||||
|     private var searchSubscription: Subscription? = null | ||||
|  | ||||
|     private var refreshSubscription: Subscription? = null | ||||
|  | ||||
|     override fun onCreate(savedState: Bundle?) { | ||||
|         super.onCreate(savedState) | ||||
|         fetchTrackings() | ||||
|     } | ||||
|  | ||||
|     fun fetchTrackings() { | ||||
|         trackSubscription?.let { remove(it) } | ||||
|         trackSubscription = db.getTracks(manga) | ||||
|                 .asRxObservable() | ||||
|                 .map { tracks -> | ||||
|                     loggedServices.map { service -> | ||||
|                         TrackItem(tracks.find { it.sync_id == service.id }, service) | ||||
|                     } | ||||
|                 } | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .doOnNext { trackList = it } | ||||
|                 .subscribeLatestCache(TrackController::onNextTrackings) | ||||
|     } | ||||
|  | ||||
|     fun refresh() { | ||||
|         refreshSubscription?.let { remove(it) } | ||||
|         refreshSubscription = Observable.from(trackList) | ||||
|                 .filter { it.track != null } | ||||
|                 .concatMap { item -> | ||||
|                     item.service.refresh(item.track!!) | ||||
|                             .flatMap { db.insertTrack(it).asRxObservable() } | ||||
|                             .map { item } | ||||
|                             .onErrorReturn { item } | ||||
|                 } | ||||
|                 .toList() | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeFirst({ view, _ -> view.onRefreshDone() }, | ||||
|                         TrackController::onRefreshError) | ||||
|     } | ||||
|  | ||||
|     fun search(query: String, service: TrackService) { | ||||
|         searchSubscription?.let { remove(it) } | ||||
|         searchSubscription = service.search(query) | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeLatestCache(TrackController::onSearchResults, | ||||
|                         TrackController::onSearchResultsError) | ||||
|     } | ||||
|  | ||||
|     fun registerTracking(item: Track?, service: TrackService) { | ||||
|         if (item != null) { | ||||
|             item.manga_id = manga.id!! | ||||
|             add(service.bind(item) | ||||
|                     .flatMap { db.insertTrack(item).asRxObservable() } | ||||
|                     .subscribeOn(Schedulers.io()) | ||||
|                     .observeOn(AndroidSchedulers.mainThread()) | ||||
|                     .subscribe({ }, | ||||
|                             { error -> context.toast(error.message) })) | ||||
|         } else { | ||||
|             db.deleteTrackForManga(manga, service).executeAsBlocking() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun updateRemote(track: Track, service: TrackService) { | ||||
|         service.update(track) | ||||
|                 .flatMap { db.insertTrack(track).asRxObservable() } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribeFirst({ view, _ -> view.onRefreshDone() }, | ||||
|                         { view, error -> | ||||
|                             view.onRefreshError(error) | ||||
|  | ||||
|                             // Restart on error to set old values | ||||
|                             fetchTrackings() | ||||
|                         }) | ||||
|     } | ||||
|  | ||||
|     fun setStatus(item: TrackItem, index: Int) { | ||||
|         val track = item.track!! | ||||
|         track.status = item.service.getStatusList()[index] | ||||
|         updateRemote(track, item.service) | ||||
|     } | ||||
|  | ||||
|     fun setScore(item: TrackItem, index: Int) { | ||||
|         val track = item.track!! | ||||
|         track.score = item.service.indexToScore(index) | ||||
|         updateRemote(track, item.service) | ||||
|     } | ||||
|  | ||||
|     fun setLastChapterRead(item: TrackItem, chapterNumber: Int) { | ||||
|         val track = item.track!! | ||||
|         track.last_chapter_read = chapterNumber | ||||
|         updateRemote(track, item.service) | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,79 +1,79 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.content.Context | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import android.widget.ArrayAdapter | ||||
| import com.bumptech.glide.load.engine.DiskCacheStrategy | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.glide.GlideApp | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.util.gone | ||||
| import eu.kanade.tachiyomi.util.inflate | ||||
| import kotlinx.android.synthetic.main.track_search_item.view.* | ||||
| import java.util.* | ||||
|  | ||||
| class TrackSearchAdapter(context: Context) | ||||
|     : ArrayAdapter<TrackSearch>(context, R.layout.track_search_item, ArrayList<TrackSearch>()) { | ||||
|  | ||||
|     override fun getView(position: Int, view: View?, parent: ViewGroup): View { | ||||
|         var v = view | ||||
|         // Get the data item for this position | ||||
|         val track = getItem(position) | ||||
|         // Check if an existing view is being reused, otherwise inflate the view | ||||
|         val holder: TrackSearchHolder // view lookup cache stored in tag | ||||
|         if (v == null) { | ||||
|             v = parent.inflate(R.layout.track_search_item) | ||||
|             holder = TrackSearchHolder(v) | ||||
|             v.tag = holder | ||||
|         } else { | ||||
|             holder = v.tag as TrackSearchHolder | ||||
|         } | ||||
|         holder.onSetValues(track) | ||||
|         return v | ||||
|     } | ||||
|  | ||||
|     fun setItems(syncs: List<TrackSearch>) { | ||||
|         setNotifyOnChange(false) | ||||
|         clear() | ||||
|         addAll(syncs) | ||||
|         notifyDataSetChanged() | ||||
|     } | ||||
|  | ||||
|     class TrackSearchHolder(private val view: View) { | ||||
|  | ||||
|         fun onSetValues(track: TrackSearch) { | ||||
|             view.track_search_title.text = track.title | ||||
|             view.track_search_summary.text = track.summary | ||||
|             GlideApp.with(view.context).clear(view.track_search_cover) | ||||
|             if (!track.cover_url.isNullOrEmpty()) { | ||||
|                 GlideApp.with(view.context) | ||||
|                         .load(track.cover_url) | ||||
|                         .diskCacheStrategy(DiskCacheStrategy.RESOURCE) | ||||
|                         .centerCrop() | ||||
|                         .into(view.track_search_cover) | ||||
|             } | ||||
|  | ||||
|             if (track.publishing_status.isNullOrBlank()) { | ||||
|                 view.track_search_status.gone() | ||||
|                 view.track_search_status_result.gone() | ||||
|             } else { | ||||
|                 view.track_search_status_result.text = track.publishing_status.capitalize() | ||||
|             } | ||||
|  | ||||
|             if (track.publishing_type.isNullOrBlank()) { | ||||
|                 view.track_search_type.gone() | ||||
|                 view.track_search_type_result.gone() | ||||
|             } else { | ||||
|                 view.track_search_type_result.text = track.publishing_type.capitalize() | ||||
|             } | ||||
|  | ||||
|             if (track.start_date.isNullOrBlank()) { | ||||
|                 view.track_search_start.gone() | ||||
|                 view.track_search_start_result.gone() | ||||
|             } else { | ||||
|                 view.track_search_start_result.text = track.start_date | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.content.Context | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import android.widget.ArrayAdapter | ||||
| import com.bumptech.glide.load.engine.DiskCacheStrategy | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.glide.GlideApp | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.util.gone | ||||
| import eu.kanade.tachiyomi.util.inflate | ||||
| import kotlinx.android.synthetic.main.track_search_item.view.* | ||||
| import java.util.* | ||||
|  | ||||
| class TrackSearchAdapter(context: Context) | ||||
|     : ArrayAdapter<TrackSearch>(context, R.layout.track_search_item, ArrayList<TrackSearch>()) { | ||||
|  | ||||
|     override fun getView(position: Int, view: View?, parent: ViewGroup): View { | ||||
|         var v = view | ||||
|         // Get the data item for this position | ||||
|         val track = getItem(position) | ||||
|         // Check if an existing view is being reused, otherwise inflate the view | ||||
|         val holder: TrackSearchHolder // view lookup cache stored in tag | ||||
|         if (v == null) { | ||||
|             v = parent.inflate(R.layout.track_search_item) | ||||
|             holder = TrackSearchHolder(v) | ||||
|             v.tag = holder | ||||
|         } else { | ||||
|             holder = v.tag as TrackSearchHolder | ||||
|         } | ||||
|         holder.onSetValues(track) | ||||
|         return v | ||||
|     } | ||||
|  | ||||
|     fun setItems(syncs: List<TrackSearch>) { | ||||
|         setNotifyOnChange(false) | ||||
|         clear() | ||||
|         addAll(syncs) | ||||
|         notifyDataSetChanged() | ||||
|     } | ||||
|  | ||||
|     class TrackSearchHolder(private val view: View) { | ||||
|  | ||||
|         fun onSetValues(track: TrackSearch) { | ||||
|             view.track_search_title.text = track.title | ||||
|             view.track_search_summary.text = track.summary | ||||
|             GlideApp.with(view.context).clear(view.track_search_cover) | ||||
|             if (!track.cover_url.isNullOrEmpty()) { | ||||
|                 GlideApp.with(view.context) | ||||
|                         .load(track.cover_url) | ||||
|                         .diskCacheStrategy(DiskCacheStrategy.RESOURCE) | ||||
|                         .centerCrop() | ||||
|                         .into(view.track_search_cover) | ||||
|             } | ||||
|  | ||||
|             if (track.publishing_status.isNullOrBlank()) { | ||||
|                 view.track_search_status.gone() | ||||
|                 view.track_search_status_result.gone() | ||||
|             } else { | ||||
|                 view.track_search_status_result.text = track.publishing_status.capitalize() | ||||
|             } | ||||
|  | ||||
|             if (track.publishing_type.isNullOrBlank()) { | ||||
|                 view.track_search_type.gone() | ||||
|                 view.track_search_type_result.gone() | ||||
|             } else { | ||||
|                 view.track_search_type_result.text = track.publishing_type.capitalize() | ||||
|             } | ||||
|  | ||||
|             if (track.start_date.isNullOrBlank()) { | ||||
|                 view.track_search_start.gone() | ||||
|                 view.track_search_start_result.gone() | ||||
|             } else { | ||||
|                 view.track_search_start_result.text = track.start_date | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,144 +1,144 @@ | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import android.view.View | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.jakewharton.rxbinding.widget.itemClicks | ||||
| import com.jakewharton.rxbinding.widget.textChanges | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
| import eu.kanade.tachiyomi.util.plusAssign | ||||
| import kotlinx.android.synthetic.main.track_search_dialog.view.* | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.subscriptions.CompositeSubscription | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| class TrackSearchDialog : DialogController { | ||||
|  | ||||
|     private var dialogView: View? = null | ||||
|  | ||||
|     private var adapter: TrackSearchAdapter? = null | ||||
|  | ||||
|     private var selectedItem: Track? = null | ||||
|  | ||||
|     private val service: TrackService | ||||
|  | ||||
|     private var subscriptions = CompositeSubscription() | ||||
|  | ||||
|     private var searchTextSubscription: Subscription? = null | ||||
|  | ||||
|     private val trackController | ||||
|         get() = targetController as TrackController | ||||
|  | ||||
|     constructor(target: TrackController, service: TrackService) : super(Bundle().apply { | ||||
|         putInt(KEY_SERVICE, service.id) | ||||
|     }) { | ||||
|         targetController = target | ||||
|         this.service = service | ||||
|     } | ||||
|  | ||||
|     @Suppress("unused") | ||||
|     constructor(bundle: Bundle) : super(bundle) { | ||||
|         service = Injekt.get<TrackManager>().getService(bundle.getInt(KEY_SERVICE))!! | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedState: Bundle?): Dialog { | ||||
|         val dialog = MaterialDialog.Builder(activity!!) | ||||
|                 .customView(R.layout.track_search_dialog, false) | ||||
|                 .positiveText(android.R.string.ok) | ||||
|                 .negativeText(android.R.string.cancel) | ||||
|                 .onPositive { _, _ -> onPositiveButtonClick() } | ||||
|                 .build() | ||||
|  | ||||
|         if (subscriptions.isUnsubscribed) { | ||||
|             subscriptions = CompositeSubscription() | ||||
|         } | ||||
|  | ||||
|         dialogView = dialog.view | ||||
|         onViewCreated(dialog.view, savedState) | ||||
|  | ||||
|         return dialog | ||||
|     } | ||||
|  | ||||
|     fun onViewCreated(view: View, savedState: Bundle?) { | ||||
|         // Create adapter | ||||
|         val adapter = TrackSearchAdapter(view.context) | ||||
|         this.adapter = adapter | ||||
|         view.track_search_list.adapter = adapter | ||||
|  | ||||
|         // Set listeners | ||||
|         selectedItem = null | ||||
|  | ||||
|         subscriptions += view.track_search_list.itemClicks().subscribe { position -> | ||||
|             selectedItem = adapter.getItem(position) | ||||
|         } | ||||
|  | ||||
|         // Do an initial search based on the manga's title | ||||
|         if (savedState == null) { | ||||
|             val title = trackController.presenter.manga.title | ||||
|             view.track_search.append(title) | ||||
|             search(title) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         super.onDestroyView(view) | ||||
|         subscriptions.unsubscribe() | ||||
|         dialogView = null | ||||
|         adapter = null | ||||
|     } | ||||
|  | ||||
|     override fun onAttach(view: View) { | ||||
|         super.onAttach(view) | ||||
|         searchTextSubscription = dialogView!!.track_search.textChanges() | ||||
|                 .skip(1) | ||||
|                 .debounce(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) | ||||
|                 .map { it.toString() } | ||||
|                 .filter(String::isNotBlank) | ||||
|                 .subscribe { search(it) } | ||||
|     } | ||||
|  | ||||
|     override fun onDetach(view: View) { | ||||
|         super.onDetach(view) | ||||
|         searchTextSubscription?.unsubscribe() | ||||
|     } | ||||
|  | ||||
|     private fun search(query: String) { | ||||
|         val view = dialogView ?: return | ||||
|         view.progress.visibility = View.VISIBLE | ||||
|         view.track_search_list.visibility = View.INVISIBLE | ||||
|         trackController.presenter.search(query, service) | ||||
|     } | ||||
|  | ||||
|     fun onSearchResults(results: List<TrackSearch>) { | ||||
|         selectedItem = null | ||||
|         val view = dialogView ?: return | ||||
|         view.progress.visibility = View.INVISIBLE | ||||
|         view.track_search_list.visibility = View.VISIBLE | ||||
|         adapter?.setItems(results) | ||||
|     } | ||||
|  | ||||
|     fun onSearchResultsError() { | ||||
|         val view = dialogView ?: return | ||||
|         view.progress.visibility = View.VISIBLE | ||||
|         view.track_search_list.visibility = View.INVISIBLE | ||||
|         adapter?.setItems(emptyList()) | ||||
|     } | ||||
|  | ||||
|     private fun onPositiveButtonClick() { | ||||
|         trackController.presenter.registerTracking(selectedItem, service) | ||||
|     } | ||||
|  | ||||
|     private companion object { | ||||
|         const val KEY_SERVICE = "service_id" | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.manga.track | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.os.Bundle | ||||
| import android.view.View | ||||
| import com.afollestad.materialdialogs.MaterialDialog | ||||
| import com.jakewharton.rxbinding.widget.itemClicks | ||||
| import com.jakewharton.rxbinding.widget.textChanges | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.ui.base.controller.DialogController | ||||
| import eu.kanade.tachiyomi.util.plusAssign | ||||
| import kotlinx.android.synthetic.main.track_search_dialog.view.* | ||||
| import rx.Subscription | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.subscriptions.CompositeSubscription | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| class TrackSearchDialog : DialogController { | ||||
|  | ||||
|     private var dialogView: View? = null | ||||
|  | ||||
|     private var adapter: TrackSearchAdapter? = null | ||||
|  | ||||
|     private var selectedItem: Track? = null | ||||
|  | ||||
|     private val service: TrackService | ||||
|  | ||||
|     private var subscriptions = CompositeSubscription() | ||||
|  | ||||
|     private var searchTextSubscription: Subscription? = null | ||||
|  | ||||
|     private val trackController | ||||
|         get() = targetController as TrackController | ||||
|  | ||||
|     constructor(target: TrackController, service: TrackService) : super(Bundle().apply { | ||||
|         putInt(KEY_SERVICE, service.id) | ||||
|     }) { | ||||
|         targetController = target | ||||
|         this.service = service | ||||
|     } | ||||
|  | ||||
|     @Suppress("unused") | ||||
|     constructor(bundle: Bundle) : super(bundle) { | ||||
|         service = Injekt.get<TrackManager>().getService(bundle.getInt(KEY_SERVICE))!! | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedState: Bundle?): Dialog { | ||||
|         val dialog = MaterialDialog.Builder(activity!!) | ||||
|                 .customView(R.layout.track_search_dialog, false) | ||||
|                 .positiveText(android.R.string.ok) | ||||
|                 .negativeText(android.R.string.cancel) | ||||
|                 .onPositive { _, _ -> onPositiveButtonClick() } | ||||
|                 .build() | ||||
|  | ||||
|         if (subscriptions.isUnsubscribed) { | ||||
|             subscriptions = CompositeSubscription() | ||||
|         } | ||||
|  | ||||
|         dialogView = dialog.view | ||||
|         onViewCreated(dialog.view, savedState) | ||||
|  | ||||
|         return dialog | ||||
|     } | ||||
|  | ||||
|     fun onViewCreated(view: View, savedState: Bundle?) { | ||||
|         // Create adapter | ||||
|         val adapter = TrackSearchAdapter(view.context) | ||||
|         this.adapter = adapter | ||||
|         view.track_search_list.adapter = adapter | ||||
|  | ||||
|         // Set listeners | ||||
|         selectedItem = null | ||||
|  | ||||
|         subscriptions += view.track_search_list.itemClicks().subscribe { position -> | ||||
|             selectedItem = adapter.getItem(position) | ||||
|         } | ||||
|  | ||||
|         // Do an initial search based on the manga's title | ||||
|         if (savedState == null) { | ||||
|             val title = trackController.presenter.manga.title | ||||
|             view.track_search.append(title) | ||||
|             search(title) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         super.onDestroyView(view) | ||||
|         subscriptions.unsubscribe() | ||||
|         dialogView = null | ||||
|         adapter = null | ||||
|     } | ||||
|  | ||||
|     override fun onAttach(view: View) { | ||||
|         super.onAttach(view) | ||||
|         searchTextSubscription = dialogView!!.track_search.textChanges() | ||||
|                 .skip(1) | ||||
|                 .debounce(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) | ||||
|                 .map { it.toString() } | ||||
|                 .filter(String::isNotBlank) | ||||
|                 .subscribe { search(it) } | ||||
|     } | ||||
|  | ||||
|     override fun onDetach(view: View) { | ||||
|         super.onDetach(view) | ||||
|         searchTextSubscription?.unsubscribe() | ||||
|     } | ||||
|  | ||||
|     private fun search(query: String) { | ||||
|         val view = dialogView ?: return | ||||
|         view.progress.visibility = View.VISIBLE | ||||
|         view.track_search_list.visibility = View.INVISIBLE | ||||
|         trackController.presenter.search(query, service) | ||||
|     } | ||||
|  | ||||
|     fun onSearchResults(results: List<TrackSearch>) { | ||||
|         selectedItem = null | ||||
|         val view = dialogView ?: return | ||||
|         view.progress.visibility = View.INVISIBLE | ||||
|         view.track_search_list.visibility = View.VISIBLE | ||||
|         adapter?.setItems(results) | ||||
|     } | ||||
|  | ||||
|     fun onSearchResultsError() { | ||||
|         val view = dialogView ?: return | ||||
|         view.progress.visibility = View.VISIBLE | ||||
|         view.track_search_list.visibility = View.INVISIBLE | ||||
|         adapter?.setItems(emptyList()) | ||||
|     } | ||||
|  | ||||
|     private fun onPositiveButtonClick() { | ||||
|         trackController.presenter.registerTracking(selectedItem, service) | ||||
|     } | ||||
|  | ||||
|     private companion object { | ||||
|         const val KEY_SERVICE = "service_id" | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,333 +1,333 @@ | ||||
| package eu.kanade.tachiyomi.ui.recent_updates | ||||
|  | ||||
| import android.support.v7.app.AppCompatActivity | ||||
| import android.support.v7.view.ActionMode | ||||
| import android.support.v7.widget.DividerItemDecoration | ||||
| import android.support.v7.widget.LinearLayoutManager | ||||
| import android.view.* | ||||
| import com.jakewharton.rxbinding.support.v4.widget.refreshes | ||||
| import com.jakewharton.rxbinding.support.v7.widget.scrollStateChanges | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.SelectableAdapter | ||||
| import eu.davidea.flexibleadapter.items.IFlexible | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateService | ||||
| import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.NucleusController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag | ||||
| import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction | ||||
| import eu.kanade.tachiyomi.ui.manga.MangaController | ||||
| import eu.kanade.tachiyomi.ui.reader.ReaderActivity | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import kotlinx.android.synthetic.main.recent_chapters_controller.* | ||||
| import timber.log.Timber | ||||
|  | ||||
| /** | ||||
|  * Fragment that shows recent chapters. | ||||
|  * Uses [R.layout.recent_chapters_controller]. | ||||
|  * UI related actions should be called from here. | ||||
|  */ | ||||
| class RecentChaptersController : NucleusController<RecentChaptersPresenter>(), | ||||
|         NoToolbarElevationController, | ||||
|         ActionMode.Callback, | ||||
|         FlexibleAdapter.OnItemClickListener, | ||||
|         FlexibleAdapter.OnItemLongClickListener, | ||||
|         FlexibleAdapter.OnUpdateListener, | ||||
|         ConfirmDeleteChaptersDialog.Listener, | ||||
|         RecentChaptersAdapter.OnCoverClickListener { | ||||
|  | ||||
|     /** | ||||
|      * Action mode for multiple selection. | ||||
|      */ | ||||
|     private var actionMode: ActionMode? = null | ||||
|  | ||||
|     /** | ||||
|      * Adapter containing the recent chapters. | ||||
|      */ | ||||
|     var adapter: RecentChaptersAdapter? = null | ||||
|         private set | ||||
|  | ||||
|     override fun getTitle(): String? { | ||||
|         return resources?.getString(R.string.label_recent_updates) | ||||
|     } | ||||
|  | ||||
|     override fun createPresenter(): RecentChaptersPresenter { | ||||
|         return RecentChaptersPresenter() | ||||
|     } | ||||
|  | ||||
|     override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { | ||||
|         return inflater.inflate(R.layout.recent_chapters_controller, container, false) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when view is created | ||||
|      * @param view created view | ||||
|      */ | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|  | ||||
|         // Init RecyclerView and adapter | ||||
|         val layoutManager = LinearLayoutManager(view.context) | ||||
|         recycler.layoutManager = layoutManager | ||||
|         recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) | ||||
|         recycler.setHasFixedSize(true) | ||||
|         adapter = RecentChaptersAdapter(this@RecentChaptersController) | ||||
|         recycler.adapter = adapter | ||||
|  | ||||
|         recycler.scrollStateChanges().subscribeUntilDestroy { | ||||
|             // Disable swipe refresh when view is not at the top | ||||
|             val firstPos = layoutManager.findFirstCompletelyVisibleItemPosition() | ||||
|             swipe_refresh.isEnabled = firstPos <= 0 | ||||
|         } | ||||
|  | ||||
|         swipe_refresh.setDistanceToTriggerSync((2 * 64 * view.resources.displayMetrics.density).toInt()) | ||||
|         swipe_refresh.refreshes().subscribeUntilDestroy { | ||||
|             if (!LibraryUpdateService.isRunning(view.context)) { | ||||
|                 LibraryUpdateService.start(view.context) | ||||
|                 view.context.toast(R.string.action_update_library) | ||||
|             } | ||||
|             // It can be a very long operation, so we disable swipe refresh and show a toast. | ||||
|             swipe_refresh.isRefreshing = false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         adapter = null | ||||
|         actionMode = null | ||||
|         super.onDestroyView(view) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns selected chapters | ||||
|      * @return list of selected chapters | ||||
|      */ | ||||
|     fun getSelectedChapters(): List<RecentChapterItem> { | ||||
|         val adapter = adapter ?: return emptyList() | ||||
|         return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? RecentChapterItem } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when item in list is clicked | ||||
|      * @param position position of clicked item | ||||
|      */ | ||||
|     override fun onItemClick(position: Int): Boolean { | ||||
|         val adapter = adapter ?: return false | ||||
|  | ||||
|         // Get item from position | ||||
|         val item = adapter.getItem(position) as? RecentChapterItem ?: return false | ||||
|         if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { | ||||
|             toggleSelection(position) | ||||
|             return true | ||||
|         } else { | ||||
|             openChapter(item) | ||||
|             return false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when item in list is long clicked | ||||
|      * @param position position of clicked item | ||||
|      */ | ||||
|     override fun onItemLongClick(position: Int) { | ||||
|         if (actionMode == null) | ||||
|             actionMode = (activity as AppCompatActivity).startSupportActionMode(this) | ||||
|  | ||||
|         toggleSelection(position) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called to toggle selection | ||||
|      * @param position position of selected item | ||||
|      */ | ||||
|     private fun toggleSelection(position: Int) { | ||||
|         val adapter = adapter ?: return | ||||
|         adapter.toggleSelection(position) | ||||
|         actionMode?.invalidate() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Open chapter in reader | ||||
|      * @param chapter selected chapter | ||||
|      */ | ||||
|     private fun openChapter(item: RecentChapterItem) { | ||||
|         val activity = activity ?: return | ||||
|         val intent = ReaderActivity.newIntent(activity, item.manga, item.chapter) | ||||
|         startActivity(intent) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Download selected items | ||||
|      * @param chapters list of selected [RecentChapter]s | ||||
|      */ | ||||
|     fun downloadChapters(chapters: List<RecentChapterItem>) { | ||||
|         destroyActionModeIfNeeded() | ||||
|         presenter.downloadChapters(chapters) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Populate adapter with chapters | ||||
|      * @param chapters list of [Any] | ||||
|      */ | ||||
|     fun onNextRecentChapters(chapters: List<IFlexible<*>>) { | ||||
|         destroyActionModeIfNeeded() | ||||
|         adapter?.updateDataSet(chapters) | ||||
|     } | ||||
|  | ||||
|     override fun onUpdateEmptyView(size: Int) { | ||||
|         if (size > 0) { | ||||
|             empty_view?.hide() | ||||
|         } else { | ||||
|             empty_view?.show(R.drawable.ic_update_black_128dp, R.string.information_no_recent) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update download status of chapter | ||||
|      * @param download [Download] object containing download progress. | ||||
|      */ | ||||
|     fun onChapterStatusChange(download: Download) { | ||||
|         getHolder(download)?.notifyStatus(download.status) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns holder belonging to chapter | ||||
|      * @param download [Download] object containing download progress. | ||||
|      */ | ||||
|     private fun getHolder(download: Download): RecentChapterHolder? { | ||||
|         return recycler?.findViewHolderForItemId(download.chapter.id!!) as? RecentChapterHolder | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Mark chapter as read | ||||
|      * @param chapters list of chapters | ||||
|      */ | ||||
|     fun markAsRead(chapters: List<RecentChapterItem>) { | ||||
|         presenter.markChapterRead(chapters, true) | ||||
|         if (presenter.preferences.removeAfterMarkedAsRead()) { | ||||
|             deleteChapters(chapters) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun deleteChapters(chaptersToDelete: List<RecentChapterItem>) { | ||||
|         destroyActionModeIfNeeded() | ||||
|         DeletingChaptersDialog().showDialog(router) | ||||
|         presenter.deleteChapters(chaptersToDelete) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Destory [ActionMode] if it's shown | ||||
|      */ | ||||
|     fun destroyActionModeIfNeeded() { | ||||
|         actionMode?.finish() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Mark chapter as unread | ||||
|      * @param chapters list of selected [RecentChapter] | ||||
|      */ | ||||
|     fun markAsUnread(chapters: List<RecentChapterItem>) { | ||||
|         presenter.markChapterRead(chapters, false) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Start downloading chapter | ||||
|      * @param chapter selected chapter with manga | ||||
|      */ | ||||
|     fun downloadChapter(chapter: RecentChapterItem) { | ||||
|         presenter.downloadChapters(listOf(chapter)) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Start deleting chapter | ||||
|      * @param chapter selected chapter with manga | ||||
|      */ | ||||
|     fun deleteChapter(chapter: RecentChapterItem) { | ||||
|         DeletingChaptersDialog().showDialog(router) | ||||
|         presenter.deleteChapters(listOf(chapter)) | ||||
|     } | ||||
|  | ||||
|     override fun onCoverClick(position: Int) { | ||||
|         val chapterClicked = adapter?.getItem(position) as? RecentChapterItem ?: return | ||||
|         openManga(chapterClicked) | ||||
|  | ||||
|     } | ||||
|  | ||||
|     fun openManga(chapter: RecentChapterItem) { | ||||
|         router.pushController(MangaController(chapter.manga).withFadeTransaction()) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when chapters are deleted | ||||
|      */ | ||||
|     fun onChaptersDeleted() { | ||||
|         dismissDeletingDialog() | ||||
|         adapter?.notifyDataSetChanged() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when error while deleting | ||||
|      * @param error error message | ||||
|      */ | ||||
|     fun onChaptersDeletedError(error: Throwable) { | ||||
|         dismissDeletingDialog() | ||||
|         Timber.e(error) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called to dismiss deleting dialog | ||||
|      */ | ||||
|     fun dismissDeletingDialog() { | ||||
|         router.popControllerWithTag(DeletingChaptersDialog.TAG) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when ActionMode created. | ||||
|      * @param mode the ActionMode object | ||||
|      * @param menu menu object of ActionMode | ||||
|      */ | ||||
|     override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { | ||||
|         mode.menuInflater.inflate(R.menu.chapter_recent_selection, menu) | ||||
|         adapter?.mode = SelectableAdapter.Mode.MULTI | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { | ||||
|         val count = adapter?.selectedItemCount ?: 0 | ||||
|         if (count == 0) { | ||||
|             // Destroy action mode if there are no items selected. | ||||
|             destroyActionModeIfNeeded() | ||||
|         } else { | ||||
|             mode.title = resources?.getString(R.string.label_selected, count) | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when ActionMode item clicked | ||||
|      * @param mode the ActionMode object | ||||
|      * @param item item from ActionMode. | ||||
|      */ | ||||
|     override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { | ||||
|         when (item.itemId) { | ||||
|             R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) | ||||
|             R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) | ||||
|             R.id.action_download -> downloadChapters(getSelectedChapters()) | ||||
|             R.id.action_delete -> ConfirmDeleteChaptersDialog(this, getSelectedChapters()) | ||||
|                     .showDialog(router) | ||||
|             else -> return false | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when ActionMode destroyed | ||||
|      * @param mode the ActionMode object | ||||
|      */ | ||||
|     override fun onDestroyActionMode(mode: ActionMode?) { | ||||
|         adapter?.mode = SelectableAdapter.Mode.IDLE | ||||
|         adapter?.clearSelection() | ||||
|         actionMode = null | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.ui.recent_updates | ||||
|  | ||||
| import android.support.v7.app.AppCompatActivity | ||||
| import android.support.v7.view.ActionMode | ||||
| import android.support.v7.widget.DividerItemDecoration | ||||
| import android.support.v7.widget.LinearLayoutManager | ||||
| import android.view.* | ||||
| import com.jakewharton.rxbinding.support.v4.widget.refreshes | ||||
| import com.jakewharton.rxbinding.support.v7.widget.scrollStateChanges | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.SelectableAdapter | ||||
| import eu.davidea.flexibleadapter.items.IFlexible | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateService | ||||
| import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.NucleusController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.popControllerWithTag | ||||
| import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction | ||||
| import eu.kanade.tachiyomi.ui.manga.MangaController | ||||
| import eu.kanade.tachiyomi.ui.reader.ReaderActivity | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import kotlinx.android.synthetic.main.recent_chapters_controller.* | ||||
| import timber.log.Timber | ||||
|  | ||||
| /** | ||||
|  * Fragment that shows recent chapters. | ||||
|  * Uses [R.layout.recent_chapters_controller]. | ||||
|  * UI related actions should be called from here. | ||||
|  */ | ||||
| class RecentChaptersController : NucleusController<RecentChaptersPresenter>(), | ||||
|         NoToolbarElevationController, | ||||
|         ActionMode.Callback, | ||||
|         FlexibleAdapter.OnItemClickListener, | ||||
|         FlexibleAdapter.OnItemLongClickListener, | ||||
|         FlexibleAdapter.OnUpdateListener, | ||||
|         ConfirmDeleteChaptersDialog.Listener, | ||||
|         RecentChaptersAdapter.OnCoverClickListener { | ||||
|  | ||||
|     /** | ||||
|      * Action mode for multiple selection. | ||||
|      */ | ||||
|     private var actionMode: ActionMode? = null | ||||
|  | ||||
|     /** | ||||
|      * Adapter containing the recent chapters. | ||||
|      */ | ||||
|     var adapter: RecentChaptersAdapter? = null | ||||
|         private set | ||||
|  | ||||
|     override fun getTitle(): String? { | ||||
|         return resources?.getString(R.string.label_recent_updates) | ||||
|     } | ||||
|  | ||||
|     override fun createPresenter(): RecentChaptersPresenter { | ||||
|         return RecentChaptersPresenter() | ||||
|     } | ||||
|  | ||||
|     override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { | ||||
|         return inflater.inflate(R.layout.recent_chapters_controller, container, false) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when view is created | ||||
|      * @param view created view | ||||
|      */ | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|  | ||||
|         // Init RecyclerView and adapter | ||||
|         val layoutManager = LinearLayoutManager(view.context) | ||||
|         recycler.layoutManager = layoutManager | ||||
|         recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) | ||||
|         recycler.setHasFixedSize(true) | ||||
|         adapter = RecentChaptersAdapter(this@RecentChaptersController) | ||||
|         recycler.adapter = adapter | ||||
|  | ||||
|         recycler.scrollStateChanges().subscribeUntilDestroy { | ||||
|             // Disable swipe refresh when view is not at the top | ||||
|             val firstPos = layoutManager.findFirstCompletelyVisibleItemPosition() | ||||
|             swipe_refresh.isEnabled = firstPos <= 0 | ||||
|         } | ||||
|  | ||||
|         swipe_refresh.setDistanceToTriggerSync((2 * 64 * view.resources.displayMetrics.density).toInt()) | ||||
|         swipe_refresh.refreshes().subscribeUntilDestroy { | ||||
|             if (!LibraryUpdateService.isRunning(view.context)) { | ||||
|                 LibraryUpdateService.start(view.context) | ||||
|                 view.context.toast(R.string.action_update_library) | ||||
|             } | ||||
|             // It can be a very long operation, so we disable swipe refresh and show a toast. | ||||
|             swipe_refresh.isRefreshing = false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         adapter = null | ||||
|         actionMode = null | ||||
|         super.onDestroyView(view) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns selected chapters | ||||
|      * @return list of selected chapters | ||||
|      */ | ||||
|     fun getSelectedChapters(): List<RecentChapterItem> { | ||||
|         val adapter = adapter ?: return emptyList() | ||||
|         return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? RecentChapterItem } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when item in list is clicked | ||||
|      * @param position position of clicked item | ||||
|      */ | ||||
|     override fun onItemClick(position: Int): Boolean { | ||||
|         val adapter = adapter ?: return false | ||||
|  | ||||
|         // Get item from position | ||||
|         val item = adapter.getItem(position) as? RecentChapterItem ?: return false | ||||
|         if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { | ||||
|             toggleSelection(position) | ||||
|             return true | ||||
|         } else { | ||||
|             openChapter(item) | ||||
|             return false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when item in list is long clicked | ||||
|      * @param position position of clicked item | ||||
|      */ | ||||
|     override fun onItemLongClick(position: Int) { | ||||
|         if (actionMode == null) | ||||
|             actionMode = (activity as AppCompatActivity).startSupportActionMode(this) | ||||
|  | ||||
|         toggleSelection(position) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called to toggle selection | ||||
|      * @param position position of selected item | ||||
|      */ | ||||
|     private fun toggleSelection(position: Int) { | ||||
|         val adapter = adapter ?: return | ||||
|         adapter.toggleSelection(position) | ||||
|         actionMode?.invalidate() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Open chapter in reader | ||||
|      * @param chapter selected chapter | ||||
|      */ | ||||
|     private fun openChapter(item: RecentChapterItem) { | ||||
|         val activity = activity ?: return | ||||
|         val intent = ReaderActivity.newIntent(activity, item.manga, item.chapter) | ||||
|         startActivity(intent) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Download selected items | ||||
|      * @param chapters list of selected [RecentChapter]s | ||||
|      */ | ||||
|     fun downloadChapters(chapters: List<RecentChapterItem>) { | ||||
|         destroyActionModeIfNeeded() | ||||
|         presenter.downloadChapters(chapters) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Populate adapter with chapters | ||||
|      * @param chapters list of [Any] | ||||
|      */ | ||||
|     fun onNextRecentChapters(chapters: List<IFlexible<*>>) { | ||||
|         destroyActionModeIfNeeded() | ||||
|         adapter?.updateDataSet(chapters) | ||||
|     } | ||||
|  | ||||
|     override fun onUpdateEmptyView(size: Int) { | ||||
|         if (size > 0) { | ||||
|             empty_view?.hide() | ||||
|         } else { | ||||
|             empty_view?.show(R.drawable.ic_update_black_128dp, R.string.information_no_recent) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update download status of chapter | ||||
|      * @param download [Download] object containing download progress. | ||||
|      */ | ||||
|     fun onChapterStatusChange(download: Download) { | ||||
|         getHolder(download)?.notifyStatus(download.status) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns holder belonging to chapter | ||||
|      * @param download [Download] object containing download progress. | ||||
|      */ | ||||
|     private fun getHolder(download: Download): RecentChapterHolder? { | ||||
|         return recycler?.findViewHolderForItemId(download.chapter.id!!) as? RecentChapterHolder | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Mark chapter as read | ||||
|      * @param chapters list of chapters | ||||
|      */ | ||||
|     fun markAsRead(chapters: List<RecentChapterItem>) { | ||||
|         presenter.markChapterRead(chapters, true) | ||||
|         if (presenter.preferences.removeAfterMarkedAsRead()) { | ||||
|             deleteChapters(chapters) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun deleteChapters(chaptersToDelete: List<RecentChapterItem>) { | ||||
|         destroyActionModeIfNeeded() | ||||
|         DeletingChaptersDialog().showDialog(router) | ||||
|         presenter.deleteChapters(chaptersToDelete) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Destory [ActionMode] if it's shown | ||||
|      */ | ||||
|     fun destroyActionModeIfNeeded() { | ||||
|         actionMode?.finish() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Mark chapter as unread | ||||
|      * @param chapters list of selected [RecentChapter] | ||||
|      */ | ||||
|     fun markAsUnread(chapters: List<RecentChapterItem>) { | ||||
|         presenter.markChapterRead(chapters, false) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Start downloading chapter | ||||
|      * @param chapter selected chapter with manga | ||||
|      */ | ||||
|     fun downloadChapter(chapter: RecentChapterItem) { | ||||
|         presenter.downloadChapters(listOf(chapter)) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Start deleting chapter | ||||
|      * @param chapter selected chapter with manga | ||||
|      */ | ||||
|     fun deleteChapter(chapter: RecentChapterItem) { | ||||
|         DeletingChaptersDialog().showDialog(router) | ||||
|         presenter.deleteChapters(listOf(chapter)) | ||||
|     } | ||||
|  | ||||
|     override fun onCoverClick(position: Int) { | ||||
|         val chapterClicked = adapter?.getItem(position) as? RecentChapterItem ?: return | ||||
|         openManga(chapterClicked) | ||||
|  | ||||
|     } | ||||
|  | ||||
|     fun openManga(chapter: RecentChapterItem) { | ||||
|         router.pushController(MangaController(chapter.manga).withFadeTransaction()) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when chapters are deleted | ||||
|      */ | ||||
|     fun onChaptersDeleted() { | ||||
|         dismissDeletingDialog() | ||||
|         adapter?.notifyDataSetChanged() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when error while deleting | ||||
|      * @param error error message | ||||
|      */ | ||||
|     fun onChaptersDeletedError(error: Throwable) { | ||||
|         dismissDeletingDialog() | ||||
|         Timber.e(error) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called to dismiss deleting dialog | ||||
|      */ | ||||
|     fun dismissDeletingDialog() { | ||||
|         router.popControllerWithTag(DeletingChaptersDialog.TAG) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when ActionMode created. | ||||
|      * @param mode the ActionMode object | ||||
|      * @param menu menu object of ActionMode | ||||
|      */ | ||||
|     override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { | ||||
|         mode.menuInflater.inflate(R.menu.chapter_recent_selection, menu) | ||||
|         adapter?.mode = SelectableAdapter.Mode.MULTI | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { | ||||
|         val count = adapter?.selectedItemCount ?: 0 | ||||
|         if (count == 0) { | ||||
|             // Destroy action mode if there are no items selected. | ||||
|             destroyActionModeIfNeeded() | ||||
|         } else { | ||||
|             mode.title = resources?.getString(R.string.label_selected, count) | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when ActionMode item clicked | ||||
|      * @param mode the ActionMode object | ||||
|      * @param item item from ActionMode. | ||||
|      */ | ||||
|     override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { | ||||
|         when (item.itemId) { | ||||
|             R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) | ||||
|             R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) | ||||
|             R.id.action_download -> downloadChapters(getSelectedChapters()) | ||||
|             R.id.action_delete -> ConfirmDeleteChaptersDialog(this, getSelectedChapters()) | ||||
|                     .showDialog(router) | ||||
|             else -> return false | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when ActionMode destroyed | ||||
|      * @param mode the ActionMode object | ||||
|      */ | ||||
|     override fun onDestroyActionMode(mode: ActionMode?) { | ||||
|         adapter?.mode = SelectableAdapter.Mode.IDLE | ||||
|         adapter?.clearSelection() | ||||
|         actionMode = null | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,87 +1,87 @@ | ||||
| package eu.kanade.tachiyomi.ui.setting | ||||
|  | ||||
| import android.content.Context | ||||
| import android.os.Bundle | ||||
| import android.support.v7.app.AppCompatActivity | ||||
| import android.support.v7.preference.PreferenceController | ||||
| import android.support.v7.preference.PreferenceScreen | ||||
| import android.util.TypedValue | ||||
| import android.view.ContextThemeWrapper | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import com.bluelinelabs.conductor.ControllerChangeHandler | ||||
| import com.bluelinelabs.conductor.ControllerChangeType | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.ui.base.controller.BaseController | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.subscriptions.CompositeSubscription | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| abstract class SettingsController : PreferenceController() { | ||||
|  | ||||
|     val preferences: PreferencesHelper = Injekt.get() | ||||
|  | ||||
|     var untilDestroySubscriptions = CompositeSubscription() | ||||
|         private set | ||||
|  | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View { | ||||
|         if (untilDestroySubscriptions.isUnsubscribed) { | ||||
|             untilDestroySubscriptions = CompositeSubscription() | ||||
|         } | ||||
|         return super.onCreateView(inflater, container, savedInstanceState) | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         super.onDestroyView(view) | ||||
|         untilDestroySubscriptions.unsubscribe() | ||||
|     } | ||||
|  | ||||
|     override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { | ||||
|         val screen = preferenceManager.createPreferenceScreen(getThemedContext()) | ||||
|         preferenceScreen = screen | ||||
|         setupPreferenceScreen(screen) | ||||
|     } | ||||
|  | ||||
|     abstract fun setupPreferenceScreen(screen: PreferenceScreen): Any? | ||||
|  | ||||
|     private fun getThemedContext(): Context { | ||||
|         val tv = TypedValue() | ||||
|         activity!!.theme.resolveAttribute(R.attr.preferenceTheme, tv, true) | ||||
|         return ContextThemeWrapper(activity, tv.resourceId) | ||||
|     } | ||||
|  | ||||
|     open fun getTitle(): String? { | ||||
|         return preferenceScreen?.title?.toString() | ||||
|     } | ||||
|  | ||||
|     fun setTitle() { | ||||
|         var parentController = parentController | ||||
|         while (parentController != null) { | ||||
|             if (parentController is BaseController && parentController.getTitle() != null) { | ||||
|                 return | ||||
|             } | ||||
|             parentController = parentController.parentController | ||||
|         } | ||||
|  | ||||
|         (activity as? AppCompatActivity)?.supportActionBar?.title = getTitle() | ||||
|     } | ||||
|  | ||||
|     override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { | ||||
|         if (type.isEnter) { | ||||
|             setTitle() | ||||
|         } | ||||
|         super.onChangeStarted(handler, type) | ||||
|     } | ||||
|  | ||||
|     fun <T> Observable<T>.subscribeUntilDestroy(): Subscription { | ||||
|         return subscribe().also { untilDestroySubscriptions.add(it) } | ||||
|     } | ||||
|  | ||||
|     fun <T> Observable<T>.subscribeUntilDestroy(onNext: (T) -> Unit): Subscription { | ||||
|         return subscribe(onNext).also { untilDestroySubscriptions.add(it) } | ||||
|     } | ||||
| package eu.kanade.tachiyomi.ui.setting | ||||
|  | ||||
| import android.content.Context | ||||
| import android.os.Bundle | ||||
| import android.support.v7.app.AppCompatActivity | ||||
| import android.support.v7.preference.PreferenceController | ||||
| import android.support.v7.preference.PreferenceScreen | ||||
| import android.util.TypedValue | ||||
| import android.view.ContextThemeWrapper | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import com.bluelinelabs.conductor.ControllerChangeHandler | ||||
| import com.bluelinelabs.conductor.ControllerChangeType | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.ui.base.controller.BaseController | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.subscriptions.CompositeSubscription | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| abstract class SettingsController : PreferenceController() { | ||||
|  | ||||
|     val preferences: PreferencesHelper = Injekt.get() | ||||
|  | ||||
|     var untilDestroySubscriptions = CompositeSubscription() | ||||
|         private set | ||||
|  | ||||
|     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View { | ||||
|         if (untilDestroySubscriptions.isUnsubscribed) { | ||||
|             untilDestroySubscriptions = CompositeSubscription() | ||||
|         } | ||||
|         return super.onCreateView(inflater, container, savedInstanceState) | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         super.onDestroyView(view) | ||||
|         untilDestroySubscriptions.unsubscribe() | ||||
|     } | ||||
|  | ||||
|     override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { | ||||
|         val screen = preferenceManager.createPreferenceScreen(getThemedContext()) | ||||
|         preferenceScreen = screen | ||||
|         setupPreferenceScreen(screen) | ||||
|     } | ||||
|  | ||||
|     abstract fun setupPreferenceScreen(screen: PreferenceScreen): Any? | ||||
|  | ||||
|     private fun getThemedContext(): Context { | ||||
|         val tv = TypedValue() | ||||
|         activity!!.theme.resolveAttribute(R.attr.preferenceTheme, tv, true) | ||||
|         return ContextThemeWrapper(activity, tv.resourceId) | ||||
|     } | ||||
|  | ||||
|     open fun getTitle(): String? { | ||||
|         return preferenceScreen?.title?.toString() | ||||
|     } | ||||
|  | ||||
|     fun setTitle() { | ||||
|         var parentController = parentController | ||||
|         while (parentController != null) { | ||||
|             if (parentController is BaseController && parentController.getTitle() != null) { | ||||
|                 return | ||||
|             } | ||||
|             parentController = parentController.parentController | ||||
|         } | ||||
|  | ||||
|         (activity as? AppCompatActivity)?.supportActionBar?.title = getTitle() | ||||
|     } | ||||
|  | ||||
|     override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { | ||||
|         if (type.isEnter) { | ||||
|             setTitle() | ||||
|         } | ||||
|         super.onChangeStarted(handler, type) | ||||
|     } | ||||
|  | ||||
|     fun <T> Observable<T>.subscribeUntilDestroy(): Subscription { | ||||
|         return subscribe().also { untilDestroySubscriptions.add(it) } | ||||
|     } | ||||
|  | ||||
|     fun <T> Observable<T>.subscribeUntilDestroy(onNext: (T) -> Unit): Subscription { | ||||
|         return subscribe(onNext).also { untilDestroySubscriptions.add(it) } | ||||
|     } | ||||
| } | ||||
| @@ -1,61 +1,61 @@ | ||||
| package eu.kanade.tachiyomi.ui.setting | ||||
|  | ||||
| import android.support.v7.preference.PreferenceScreen | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction | ||||
| import eu.kanade.tachiyomi.util.getResourceColor | ||||
|  | ||||
| class SettingsMainController : SettingsController() { | ||||
|     override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { | ||||
|         titleRes = R.string.label_settings | ||||
|  | ||||
|         val tintColor = context.getResourceColor(R.attr.colorAccent) | ||||
|  | ||||
|         preference { | ||||
|             iconRes = R.drawable.ic_tune_black_24dp | ||||
|             iconTint = tintColor | ||||
|             titleRes = R.string.pref_category_general | ||||
|             onClick { navigateTo(SettingsGeneralController()) } | ||||
|         } | ||||
|         preference { | ||||
|             iconRes = R.drawable.ic_chrome_reader_mode_black_24dp | ||||
|             iconTint = tintColor | ||||
|             titleRes = R.string.pref_category_reader | ||||
|             onClick { navigateTo(SettingsReaderController()) } | ||||
|         } | ||||
|         preference { | ||||
|             iconRes = R.drawable.ic_file_download_black_24dp | ||||
|             iconTint = tintColor | ||||
|             titleRes = R.string.pref_category_downloads | ||||
|             onClick { navigateTo(SettingsDownloadController()) } | ||||
|         } | ||||
|         preference { | ||||
|             iconRes = R.drawable.ic_sync_black_24dp | ||||
|             iconTint = tintColor | ||||
|             titleRes = R.string.pref_category_tracking | ||||
|             onClick { navigateTo(SettingsTrackingController()) } | ||||
|         } | ||||
|         preference { | ||||
|             iconRes = R.drawable.ic_backup_black_24dp | ||||
|             iconTint = tintColor | ||||
|             titleRes = R.string.backup | ||||
|             onClick { navigateTo(SettingsBackupController()) } | ||||
|         } | ||||
|         preference { | ||||
|             iconRes = R.drawable.ic_code_black_24dp | ||||
|             iconTint = tintColor | ||||
|             titleRes = R.string.pref_category_advanced | ||||
|             onClick { navigateTo(SettingsAdvancedController()) } | ||||
|         } | ||||
|         preference { | ||||
|             iconRes = R.drawable.ic_help_black_24dp | ||||
|             iconTint = tintColor | ||||
|             titleRes = R.string.pref_category_about | ||||
|             onClick { navigateTo(SettingsAboutController()) } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun navigateTo(controller: SettingsController) { | ||||
|         router.pushController(controller.withFadeTransaction()) | ||||
|     } | ||||
| package eu.kanade.tachiyomi.ui.setting | ||||
|  | ||||
| import android.support.v7.preference.PreferenceScreen | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction | ||||
| import eu.kanade.tachiyomi.util.getResourceColor | ||||
|  | ||||
| class SettingsMainController : SettingsController() { | ||||
|     override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { | ||||
|         titleRes = R.string.label_settings | ||||
|  | ||||
|         val tintColor = context.getResourceColor(R.attr.colorAccent) | ||||
|  | ||||
|         preference { | ||||
|             iconRes = R.drawable.ic_tune_black_24dp | ||||
|             iconTint = tintColor | ||||
|             titleRes = R.string.pref_category_general | ||||
|             onClick { navigateTo(SettingsGeneralController()) } | ||||
|         } | ||||
|         preference { | ||||
|             iconRes = R.drawable.ic_chrome_reader_mode_black_24dp | ||||
|             iconTint = tintColor | ||||
|             titleRes = R.string.pref_category_reader | ||||
|             onClick { navigateTo(SettingsReaderController()) } | ||||
|         } | ||||
|         preference { | ||||
|             iconRes = R.drawable.ic_file_download_black_24dp | ||||
|             iconTint = tintColor | ||||
|             titleRes = R.string.pref_category_downloads | ||||
|             onClick { navigateTo(SettingsDownloadController()) } | ||||
|         } | ||||
|         preference { | ||||
|             iconRes = R.drawable.ic_sync_black_24dp | ||||
|             iconTint = tintColor | ||||
|             titleRes = R.string.pref_category_tracking | ||||
|             onClick { navigateTo(SettingsTrackingController()) } | ||||
|         } | ||||
|         preference { | ||||
|             iconRes = R.drawable.ic_backup_black_24dp | ||||
|             iconTint = tintColor | ||||
|             titleRes = R.string.backup | ||||
|             onClick { navigateTo(SettingsBackupController()) } | ||||
|         } | ||||
|         preference { | ||||
|             iconRes = R.drawable.ic_code_black_24dp | ||||
|             iconTint = tintColor | ||||
|             titleRes = R.string.pref_category_advanced | ||||
|             onClick { navigateTo(SettingsAdvancedController()) } | ||||
|         } | ||||
|         preference { | ||||
|             iconRes = R.drawable.ic_help_black_24dp | ||||
|             iconTint = tintColor | ||||
|             titleRes = R.string.pref_category_about | ||||
|             onClick { navigateTo(SettingsAboutController()) } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun navigateTo(controller: SettingsController) { | ||||
|         router.pushController(controller.withFadeTransaction()) | ||||
|     } | ||||
| } | ||||
| @@ -1,239 +1,239 @@ | ||||
| package eu.kanade.tachiyomi.widget | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.drawable.Drawable | ||||
| import android.support.annotation.CallSuper | ||||
| import android.support.graphics.drawable.VectorDrawableCompat | ||||
| import android.support.v4.content.ContextCompat | ||||
| import android.support.v7.widget.RecyclerView | ||||
| import android.util.AttributeSet | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.util.getResourceColor | ||||
|  | ||||
| /** | ||||
|  * An alternative implementation of [android.support.design.widget.NavigationView], without menu | ||||
|  * inflation and allowing customizable items (multiple selections, custom views, etc). | ||||
|  */ | ||||
| open class ExtendedNavigationView @JvmOverloads constructor( | ||||
|         context: Context, | ||||
|         attrs: AttributeSet? = null, | ||||
|         defStyleAttr: Int = 0) | ||||
|     : SimpleNavigationView(context, attrs, defStyleAttr) { | ||||
|  | ||||
|     /** | ||||
|      * Every item of the nav view. Generic items must belong to this list, custom items could be | ||||
|      * implemented by an abstract class. If more customization is needed in the future, this can be | ||||
|      * changed to an interface instead of sealed class. | ||||
|      */ | ||||
|     sealed class Item { | ||||
|         /** | ||||
|          * A view separator. | ||||
|          */ | ||||
|         class Separator(val paddingTop: Int = 0, val paddingBottom: Int = 0) : Item() | ||||
|  | ||||
|         /** | ||||
|          * A header with a title. | ||||
|          */ | ||||
|         class Header(val resTitle: Int) : Item() | ||||
|  | ||||
|         /** | ||||
|          * A checkbox. | ||||
|          */ | ||||
|         open class Checkbox(val resTitle: Int, var checked: Boolean = false) : Item() | ||||
|  | ||||
|         /** | ||||
|          * A checkbox belonging to a group. The group must handle selections and restrictions. | ||||
|          */ | ||||
|         class CheckboxGroup(resTitle: Int, override val group: Group, checked: Boolean = false) | ||||
|             : Checkbox(resTitle, checked), GroupedItem | ||||
|  | ||||
|         /** | ||||
|          * A radio belonging to a group (a sole radio makes no sense). The group must handle | ||||
|          * selections and restrictions. | ||||
|          */ | ||||
|         class Radio(val resTitle: Int, override val group: Group, var checked: Boolean = false) | ||||
|             : Item(), GroupedItem | ||||
|  | ||||
|         /** | ||||
|          * An item with which needs more than two states (selected/deselected). | ||||
|          */ | ||||
|         abstract class MultiState(val resTitle: Int, var state: Int = 0) : Item() { | ||||
|  | ||||
|             /** | ||||
|              * Returns the drawable associated to every possible each state. | ||||
|              */ | ||||
|             abstract fun getStateDrawable(context: Context): Drawable? | ||||
|  | ||||
|             /** | ||||
|              * Creates a vector tinted with the accent color. | ||||
|              * | ||||
|              * @param context any context. | ||||
|              * @param resId the vector resource to load and tint | ||||
|              */ | ||||
|             fun tintVector(context: Context, resId: Int): Drawable { | ||||
|                 return VectorDrawableCompat.create(context.resources, resId, context.theme)!!.apply { | ||||
|                     setTint(context.getResourceColor(R.attr.colorAccent)) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * An item with which needs more than two states (selected/deselected) belonging to a group. | ||||
|          * The group must handle selections and restrictions. | ||||
|          */ | ||||
|         abstract class MultiStateGroup(resTitle: Int, override val group: Group, state: Int = 0) | ||||
|             : MultiState(resTitle, state), GroupedItem | ||||
|  | ||||
|         /** | ||||
|          * A multistate item for sorting lists (unselected, ascending, descending). | ||||
|          */ | ||||
|         class MultiSort(resId: Int, group: Group) : MultiStateGroup(resId, group) { | ||||
|  | ||||
|             companion object { | ||||
|                 const val SORT_NONE = 0 | ||||
|                 const val SORT_ASC = 1 | ||||
|                 const val SORT_DESC = 2 | ||||
|             } | ||||
|  | ||||
|             override fun getStateDrawable(context: Context): Drawable? { | ||||
|                 return when (state) { | ||||
|                     SORT_ASC -> tintVector(context, R.drawable.ic_arrow_up_white_32dp) | ||||
|                     SORT_DESC -> tintVector(context, R.drawable.ic_arrow_down_white_32dp) | ||||
|                     SORT_NONE -> ContextCompat.getDrawable(context, R.drawable.empty_drawable_32dp) | ||||
|                     else -> null | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Interface for an item belonging to a group. | ||||
|      */ | ||||
|     interface GroupedItem { | ||||
|         val group: Group | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * A group containing a list of items. | ||||
|      */ | ||||
|     interface Group { | ||||
|  | ||||
|         /** | ||||
|          * An optional header for the group, typically a [Item.Header]. | ||||
|          */ | ||||
|         val header: Item? | ||||
|  | ||||
|         /** | ||||
|          * An optional footer for the group, typically a [Item.Separator]. | ||||
|          */ | ||||
|         val footer: Item? | ||||
|  | ||||
|         /** | ||||
|          * The items of the group, excluding header and footer. | ||||
|          */ | ||||
|         val items: List<Item> | ||||
|  | ||||
|         /** | ||||
|          * Creates all the elements of this group. Implementations can override this method for more | ||||
|          * customization. | ||||
|          */ | ||||
|         fun createItems() = (mutableListOf<Item>() + header + items + footer).filterNotNull() | ||||
|  | ||||
|         /** | ||||
|          * Called after creating the list of items. Implementations should load the current values | ||||
|          * into the models. | ||||
|          */ | ||||
|         fun initModels() | ||||
|  | ||||
|         /** | ||||
|          * Called when an item of this group is clicked. The group is responsible for all the | ||||
|          * selections of its items. | ||||
|          */ | ||||
|         fun onItemClicked(item: Item) | ||||
|  | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Base adapter for the navigation view. It knows how to create and render every subclass of | ||||
|      * [Item]. | ||||
|      */ | ||||
|     abstract inner class Adapter(private val items: List<Item>) : RecyclerView.Adapter<Holder>() { | ||||
|  | ||||
|         private val onClick = View.OnClickListener { | ||||
|             val pos = recycler.getChildAdapterPosition(it) | ||||
|             val item = items[pos] | ||||
|             onItemClicked(item) | ||||
|         } | ||||
|  | ||||
|         fun notifyItemChanged(item: Item) { | ||||
|             val pos = items.indexOf(item) | ||||
|             if (pos != -1) notifyItemChanged(pos) | ||||
|         } | ||||
|  | ||||
|         override fun getItemCount(): Int { | ||||
|             return items.size | ||||
|         } | ||||
|  | ||||
|         @CallSuper | ||||
|         override fun getItemViewType(position: Int): Int { | ||||
|             val item = items[position] | ||||
|             return when (item) { | ||||
|                 is Item.Header -> VIEW_TYPE_HEADER | ||||
|                 is Item.Separator -> VIEW_TYPE_SEPARATOR | ||||
|                 is Item.Radio -> VIEW_TYPE_RADIO | ||||
|                 is Item.Checkbox -> VIEW_TYPE_CHECKBOX | ||||
|                 is Item.MultiState -> VIEW_TYPE_MULTISTATE | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         @CallSuper | ||||
|         override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { | ||||
|             return when (viewType) { | ||||
|                 VIEW_TYPE_HEADER -> HeaderHolder(parent) | ||||
|                 VIEW_TYPE_SEPARATOR -> SeparatorHolder(parent) | ||||
|                 VIEW_TYPE_RADIO -> RadioHolder(parent, onClick) | ||||
|                 VIEW_TYPE_CHECKBOX -> CheckboxHolder(parent, onClick) | ||||
|                 VIEW_TYPE_MULTISTATE -> MultiStateHolder(parent, onClick) | ||||
|                 else -> throw Exception("Unknown view type") | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         @CallSuper | ||||
|         override fun onBindViewHolder(holder: Holder, position: Int) { | ||||
|             when (holder) { | ||||
|                 is HeaderHolder -> { | ||||
|                     val item = items[position] as Item.Header | ||||
|                     holder.title.setText(item.resTitle) | ||||
|                 } | ||||
|                 is SeparatorHolder -> { | ||||
|                     val view = holder.itemView | ||||
|                     val item = items[position] as Item.Separator | ||||
|                     view.setPadding(0, item.paddingTop, 0, item.paddingBottom) | ||||
|                 } | ||||
|                 is RadioHolder -> { | ||||
|                     val item = items[position] as Item.Radio | ||||
|                     holder.radio.setText(item.resTitle) | ||||
|                     holder.radio.isChecked = item.checked | ||||
|                 } | ||||
|                 is CheckboxHolder -> { | ||||
|                     val item = items[position] as Item.CheckboxGroup | ||||
|                     holder.check.setText(item.resTitle) | ||||
|                     holder.check.isChecked = item.checked | ||||
|                 } | ||||
|                 is MultiStateHolder -> { | ||||
|                     val item = items[position] as Item.MultiStateGroup | ||||
|                     val drawable = item.getStateDrawable(context) | ||||
|                     holder.text.setText(item.resTitle) | ||||
|                     holder.text.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         abstract fun onItemClicked(item: Item) | ||||
|  | ||||
|     } | ||||
|  | ||||
| package eu.kanade.tachiyomi.widget | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.drawable.Drawable | ||||
| import android.support.annotation.CallSuper | ||||
| import android.support.graphics.drawable.VectorDrawableCompat | ||||
| import android.support.v4.content.ContextCompat | ||||
| import android.support.v7.widget.RecyclerView | ||||
| import android.util.AttributeSet | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.util.getResourceColor | ||||
|  | ||||
| /** | ||||
|  * An alternative implementation of [android.support.design.widget.NavigationView], without menu | ||||
|  * inflation and allowing customizable items (multiple selections, custom views, etc). | ||||
|  */ | ||||
| open class ExtendedNavigationView @JvmOverloads constructor( | ||||
|         context: Context, | ||||
|         attrs: AttributeSet? = null, | ||||
|         defStyleAttr: Int = 0) | ||||
|     : SimpleNavigationView(context, attrs, defStyleAttr) { | ||||
|  | ||||
|     /** | ||||
|      * Every item of the nav view. Generic items must belong to this list, custom items could be | ||||
|      * implemented by an abstract class. If more customization is needed in the future, this can be | ||||
|      * changed to an interface instead of sealed class. | ||||
|      */ | ||||
|     sealed class Item { | ||||
|         /** | ||||
|          * A view separator. | ||||
|          */ | ||||
|         class Separator(val paddingTop: Int = 0, val paddingBottom: Int = 0) : Item() | ||||
|  | ||||
|         /** | ||||
|          * A header with a title. | ||||
|          */ | ||||
|         class Header(val resTitle: Int) : Item() | ||||
|  | ||||
|         /** | ||||
|          * A checkbox. | ||||
|          */ | ||||
|         open class Checkbox(val resTitle: Int, var checked: Boolean = false) : Item() | ||||
|  | ||||
|         /** | ||||
|          * A checkbox belonging to a group. The group must handle selections and restrictions. | ||||
|          */ | ||||
|         class CheckboxGroup(resTitle: Int, override val group: Group, checked: Boolean = false) | ||||
|             : Checkbox(resTitle, checked), GroupedItem | ||||
|  | ||||
|         /** | ||||
|          * A radio belonging to a group (a sole radio makes no sense). The group must handle | ||||
|          * selections and restrictions. | ||||
|          */ | ||||
|         class Radio(val resTitle: Int, override val group: Group, var checked: Boolean = false) | ||||
|             : Item(), GroupedItem | ||||
|  | ||||
|         /** | ||||
|          * An item with which needs more than two states (selected/deselected). | ||||
|          */ | ||||
|         abstract class MultiState(val resTitle: Int, var state: Int = 0) : Item() { | ||||
|  | ||||
|             /** | ||||
|              * Returns the drawable associated to every possible each state. | ||||
|              */ | ||||
|             abstract fun getStateDrawable(context: Context): Drawable? | ||||
|  | ||||
|             /** | ||||
|              * Creates a vector tinted with the accent color. | ||||
|              * | ||||
|              * @param context any context. | ||||
|              * @param resId the vector resource to load and tint | ||||
|              */ | ||||
|             fun tintVector(context: Context, resId: Int): Drawable { | ||||
|                 return VectorDrawableCompat.create(context.resources, resId, context.theme)!!.apply { | ||||
|                     setTint(context.getResourceColor(R.attr.colorAccent)) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * An item with which needs more than two states (selected/deselected) belonging to a group. | ||||
|          * The group must handle selections and restrictions. | ||||
|          */ | ||||
|         abstract class MultiStateGroup(resTitle: Int, override val group: Group, state: Int = 0) | ||||
|             : MultiState(resTitle, state), GroupedItem | ||||
|  | ||||
|         /** | ||||
|          * A multistate item for sorting lists (unselected, ascending, descending). | ||||
|          */ | ||||
|         class MultiSort(resId: Int, group: Group) : MultiStateGroup(resId, group) { | ||||
|  | ||||
|             companion object { | ||||
|                 const val SORT_NONE = 0 | ||||
|                 const val SORT_ASC = 1 | ||||
|                 const val SORT_DESC = 2 | ||||
|             } | ||||
|  | ||||
|             override fun getStateDrawable(context: Context): Drawable? { | ||||
|                 return when (state) { | ||||
|                     SORT_ASC -> tintVector(context, R.drawable.ic_arrow_up_white_32dp) | ||||
|                     SORT_DESC -> tintVector(context, R.drawable.ic_arrow_down_white_32dp) | ||||
|                     SORT_NONE -> ContextCompat.getDrawable(context, R.drawable.empty_drawable_32dp) | ||||
|                     else -> null | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Interface for an item belonging to a group. | ||||
|      */ | ||||
|     interface GroupedItem { | ||||
|         val group: Group | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * A group containing a list of items. | ||||
|      */ | ||||
|     interface Group { | ||||
|  | ||||
|         /** | ||||
|          * An optional header for the group, typically a [Item.Header]. | ||||
|          */ | ||||
|         val header: Item? | ||||
|  | ||||
|         /** | ||||
|          * An optional footer for the group, typically a [Item.Separator]. | ||||
|          */ | ||||
|         val footer: Item? | ||||
|  | ||||
|         /** | ||||
|          * The items of the group, excluding header and footer. | ||||
|          */ | ||||
|         val items: List<Item> | ||||
|  | ||||
|         /** | ||||
|          * Creates all the elements of this group. Implementations can override this method for more | ||||
|          * customization. | ||||
|          */ | ||||
|         fun createItems() = (mutableListOf<Item>() + header + items + footer).filterNotNull() | ||||
|  | ||||
|         /** | ||||
|          * Called after creating the list of items. Implementations should load the current values | ||||
|          * into the models. | ||||
|          */ | ||||
|         fun initModels() | ||||
|  | ||||
|         /** | ||||
|          * Called when an item of this group is clicked. The group is responsible for all the | ||||
|          * selections of its items. | ||||
|          */ | ||||
|         fun onItemClicked(item: Item) | ||||
|  | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Base adapter for the navigation view. It knows how to create and render every subclass of | ||||
|      * [Item]. | ||||
|      */ | ||||
|     abstract inner class Adapter(private val items: List<Item>) : RecyclerView.Adapter<Holder>() { | ||||
|  | ||||
|         private val onClick = View.OnClickListener { | ||||
|             val pos = recycler.getChildAdapterPosition(it) | ||||
|             val item = items[pos] | ||||
|             onItemClicked(item) | ||||
|         } | ||||
|  | ||||
|         fun notifyItemChanged(item: Item) { | ||||
|             val pos = items.indexOf(item) | ||||
|             if (pos != -1) notifyItemChanged(pos) | ||||
|         } | ||||
|  | ||||
|         override fun getItemCount(): Int { | ||||
|             return items.size | ||||
|         } | ||||
|  | ||||
|         @CallSuper | ||||
|         override fun getItemViewType(position: Int): Int { | ||||
|             val item = items[position] | ||||
|             return when (item) { | ||||
|                 is Item.Header -> VIEW_TYPE_HEADER | ||||
|                 is Item.Separator -> VIEW_TYPE_SEPARATOR | ||||
|                 is Item.Radio -> VIEW_TYPE_RADIO | ||||
|                 is Item.Checkbox -> VIEW_TYPE_CHECKBOX | ||||
|                 is Item.MultiState -> VIEW_TYPE_MULTISTATE | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         @CallSuper | ||||
|         override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { | ||||
|             return when (viewType) { | ||||
|                 VIEW_TYPE_HEADER -> HeaderHolder(parent) | ||||
|                 VIEW_TYPE_SEPARATOR -> SeparatorHolder(parent) | ||||
|                 VIEW_TYPE_RADIO -> RadioHolder(parent, onClick) | ||||
|                 VIEW_TYPE_CHECKBOX -> CheckboxHolder(parent, onClick) | ||||
|                 VIEW_TYPE_MULTISTATE -> MultiStateHolder(parent, onClick) | ||||
|                 else -> throw Exception("Unknown view type") | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         @CallSuper | ||||
|         override fun onBindViewHolder(holder: Holder, position: Int) { | ||||
|             when (holder) { | ||||
|                 is HeaderHolder -> { | ||||
|                     val item = items[position] as Item.Header | ||||
|                     holder.title.setText(item.resTitle) | ||||
|                 } | ||||
|                 is SeparatorHolder -> { | ||||
|                     val view = holder.itemView | ||||
|                     val item = items[position] as Item.Separator | ||||
|                     view.setPadding(0, item.paddingTop, 0, item.paddingBottom) | ||||
|                 } | ||||
|                 is RadioHolder -> { | ||||
|                     val item = items[position] as Item.Radio | ||||
|                     holder.radio.setText(item.resTitle) | ||||
|                     holder.radio.isChecked = item.checked | ||||
|                 } | ||||
|                 is CheckboxHolder -> { | ||||
|                     val item = items[position] as Item.CheckboxGroup | ||||
|                     holder.check.setText(item.resTitle) | ||||
|                     holder.check.isChecked = item.checked | ||||
|                 } | ||||
|                 is MultiStateHolder -> { | ||||
|                     val item = items[position] as Item.MultiStateGroup | ||||
|                     val drawable = item.getStateDrawable(context) | ||||
|                     holder.text.setText(item.resTitle) | ||||
|                     holder.text.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         abstract fun onItemClicked(item: Item) | ||||
|  | ||||
|     } | ||||
|  | ||||
| } | ||||
		Reference in New Issue
	
	Block a user