mirror of
https://github.com/aniyomiorg/aniyomi.git
synced 2024-11-24 21:58:34 +03:00
Update files to use unix line endings
cmd: `find . -type f -print0 | xargs -0 dos2unix`
This commit is contained in:
parent
b4aedb5f84
commit
600fbb2ef8
95 changed files with 9766 additions and 9766 deletions
|
@ -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 androidx.annotation.CallSuper
|
||||
import androidx.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 androidx.annotation.CallSuper
|
||||
import androidx.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,291 +1,291 @@
|
|||
package eu.kanade.tachiyomi.data.track.anilist
|
||||
|
||||
import android.net.Uri
|
||||
import com.github.salomonbrys.kotson.array
|
||||
import com.github.salomonbrys.kotson.get
|
||||
import com.github.salomonbrys.kotson.jsonObject
|
||||
import com.github.salomonbrys.kotson.nullInt
|
||||
import com.github.salomonbrys.kotson.nullString
|
||||
import com.github.salomonbrys.kotson.obj
|
||||
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.Companion.toMediaTypeOrNull
|
||||
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 = "application/json; charset=utf-8".toMediaTypeOrNull()
|
||||
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.array
|
||||
import com.github.salomonbrys.kotson.get
|
||||
import com.github.salomonbrys.kotson.jsonObject
|
||||
import com.github.salomonbrys.kotson.nullInt
|
||||
import com.github.salomonbrys.kotson.nullString
|
||||
import com.github.salomonbrys.kotson.obj
|
||||
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.Companion.toMediaTypeOrNull
|
||||
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 = "application/json; charset=utf-8".toMediaTypeOrNull()
|
||||
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,163 +1,163 @@
|
|||
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.Companion.toHttpUrlOrNull
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
|
||||
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(BASE_URL.toHttpUrlOrNull()!!)
|
||||
}
|
||||
|
||||
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 = BASE_URL.toHttpUrlOrNull()!!
|
||||
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.Companion.toHttpUrlOrNull
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
|
||||
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(BASE_URL.toHttpUrlOrNull()!!)
|
||||
}
|
||||
|
||||
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 = BASE_URL.toHttpUrlOrNull()!!
|
||||
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 {
|
||||
source(responseBody.source()).buffer()
|
||||
}
|
||||
|
||||
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) {
|
||||
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 {
|
||||
source(responseBody.source()).buffer()
|
||||
}
|
||||
|
||||
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) {
|
||||
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 androidx.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 androidx.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 androidx.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 androidx.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,54 +1,54 @@
|
|||
package eu.kanade.tachiyomi.ui.catalogue.filter
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
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<IFlexible<RecyclerView.ViewHolder>>): Holder {
|
||||
return Holder(view, adapter)
|
||||
}
|
||||
|
||||
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, 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 androidx.recyclerview.widget.RecyclerView
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
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<IFlexible<RecyclerView.ViewHolder>>): Holder {
|
||||
return Holder(view, adapter)
|
||||
}
|
||||
|
||||
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, 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,37 +1,37 @@
|
|||
package eu.kanade.tachiyomi.ui.catalogue.global_search
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
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<IFlexible<RecyclerView.ViewHolder>>): CatalogueSearchCardHolder {
|
||||
return CatalogueSearchCardHolder(view, adapter as CatalogueSearchCardAdapter)
|
||||
}
|
||||
|
||||
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, 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 androidx.recyclerview.widget.RecyclerView
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
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<IFlexible<RecyclerView.ViewHolder>>): CatalogueSearchCardHolder {
|
||||
return CatalogueSearchCardHolder(view, adapter as CatalogueSearchCardAdapter)
|
||||
}
|
||||
|
||||
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, 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 androidx.recyclerview.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 androidx.recyclerview.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,48 +1,48 @@
|
|||
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() {
|
||||
var s = getFilter(String::class.java)
|
||||
if (s == null) {
|
||||
s = ""
|
||||
}
|
||||
updateDataSet(mangas.filter { it.filter(s) })
|
||||
}
|
||||
|
||||
}
|
||||
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() {
|
||||
var s = getFilter(String::class.java)
|
||||
if (s == null) {
|
||||
s = ""
|
||||
}
|
||||
updateDataSet(mangas.filter { it.filter(s) })
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,248 +1,248 @@
|
|||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import android.content.Context
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
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.setFilter(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(view: View, 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 androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
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.setFilter(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(view: View, 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,75 +1,75 @@
|
|||
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 androidx.recyclerview.widget.RecyclerView
|
||||
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.davidea.flexibleadapter.items.IFlexible
|
||||
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<String> {
|
||||
|
||||
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<IFlexible<RecyclerView.ViewHolder>>): 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<IFlexible<RecyclerView.ViewHolder>>,
|
||||
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 androidx.recyclerview.widget.RecyclerView
|
||||
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.davidea.flexibleadapter.items.IFlexible
|
||||
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<String> {
|
||||
|
||||
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<IFlexible<RecyclerView.ViewHolder>>): 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<IFlexible<RecyclerView.ViewHolder>>,
|
||||
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 androidx.core.view.GravityCompat
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.appcompat.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 androidx.core.view.GravityCompat
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.appcompat.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 com.google.android.material.tabs.TabLayout
|
||||
import androidx.vectordrawable.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 com.google.android.material.tabs.TabLayout
|
||||
import androidx.vectordrawable.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,55 +1,55 @@
|
|||
package eu.kanade.tachiyomi.ui.manga.chapter
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
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<IFlexible<RecyclerView.ViewHolder>>): ChapterHolder {
|
||||
return ChapterHolder(view, adapter as ChaptersAdapter)
|
||||
}
|
||||
|
||||
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
||||
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 androidx.recyclerview.widget.RecyclerView
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
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<IFlexible<RecyclerView.ViewHolder>>): ChapterHolder {
|
||||
return ChapterHolder(view, adapter as ChaptersAdapter)
|
||||
}
|
||||
|
||||
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
|
||||
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 com.google.android.material.snackbar.Snackbar
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.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(view: View, 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 com.google.android.material.snackbar.Snackbar
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.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(view: View, 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 androidx.recyclerview.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 androidx.recyclerview.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 androidx.recyclerview.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 androidx.recyclerview.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 androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.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(view: View, 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 androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.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(view: View, 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 androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.preference.PreferenceController
|
||||
import androidx.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 androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.preference.PreferenceController
|
||||
import androidx.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 androidx.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 androidx.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 androidx.annotation.CallSuper
|
||||
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.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 androidx.annotation.CallSuper
|
||||
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.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)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<shape
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@android:color/transparent"/>
|
||||
<size
|
||||
android:width="32dp"
|
||||
android:height="32dp" />
|
||||
<shape
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@android:color/transparent"/>
|
||||
<size
|
||||
android:width="32dp"
|
||||
android:height="32dp" />
|
||||
</shape>
|
|
@ -1,9 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z"/>
|
||||
</vector>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z"/>
|
||||
</vector>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,2C6.5,2 2,6.5 2,12s4.5,10 10,10 10,-4.5 10,-10S17.5,2 12,2zM16.2,16.2L11,13L11,7h1.5v5.2l4.5,2.7 -0.8,1.3z"/>
|
||||
</vector>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,2C6.5,2 2,6.5 2,12s4.5,10 10,10 10,-4.5 10,-10S17.5,2 12,2zM16.2,16.2L11,13L11,7h1.5v5.2l4.5,2.7 -0.8,1.3z"/>
|
||||
</vector>
|
||||
|
|
|
@ -1,23 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||
android:paddingLeft="?attr/listPreferredItemPaddingLeft"
|
||||
android:paddingRight="?attr/listPreferredItemPaddingRight"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:focusable="true">
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/nav_view_item"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:paddingLeft="@dimen/material_component_lists_icon_left_padding"
|
||||
android:background="@android:color/transparent"
|
||||
android:gravity="center_vertical|start"
|
||||
android:maxLines="1"
|
||||
android:clickable="false"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Body2" />
|
||||
|
||||
</LinearLayout>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||
android:paddingLeft="?attr/listPreferredItemPaddingLeft"
|
||||
android:paddingRight="?attr/listPreferredItemPaddingRight"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:focusable="true">
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/nav_view_item"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:paddingLeft="@dimen/material_component_lists_icon_left_padding"
|
||||
android:background="@android:color/transparent"
|
||||
android:gravity="center_vertical|start"
|
||||
android:maxLines="1"
|
||||
android:clickable="false"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Body2" />
|
||||
|
||||
</LinearLayout>
|
||||
|
|
|
@ -1,30 +1,30 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||
android:background="?colorPrimary"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingLeft="?attr/listPreferredItemPaddingLeft"
|
||||
android:paddingRight="?attr/listPreferredItemPaddingRight"
|
||||
android:elevation="2dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
|
||||
android:textColor="@color/textColorPrimaryDark"
|
||||
tools:text="Header"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/expand_icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||
android:background="?colorPrimary"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingLeft="?attr/listPreferredItemPaddingLeft"
|
||||
android:paddingRight="?attr/listPreferredItemPaddingRight"
|
||||
android:elevation="2dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Body2"
|
||||
android:textColor="@color/textColorPrimaryDark"
|
||||
tools:text="Header"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/expand_icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
</LinearLayout>
|
|
@ -1,62 +1,62 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackground"
|
||||
android:baselineAligned="false"
|
||||
android:clipToPadding="false"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="42dp"
|
||||
android:paddingLeft="?listPreferredItemPaddingLeft"
|
||||
android:paddingRight="?listPreferredItemPaddingRight"
|
||||
tools:ignore="RtlHardcoded">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@android:id/widget_frame"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:gravity="start|center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"/>
|
||||
|
||||
<TextView
|
||||
android:id="@android:id/title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="marquee"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?textAppearanceListItem"/>
|
||||
|
||||
<!-- Hidden view -->
|
||||
<TextView
|
||||
android:id="@android:id/summary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/login_frame"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="-16dp"
|
||||
android:layout_marginRight="-16dp"
|
||||
android:clipToPadding="false"
|
||||
android:gravity="end|center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/login" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackground"
|
||||
android:baselineAligned="false"
|
||||
android:clipToPadding="false"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="42dp"
|
||||
android:paddingLeft="?listPreferredItemPaddingLeft"
|
||||
android:paddingRight="?listPreferredItemPaddingRight"
|
||||
tools:ignore="RtlHardcoded">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@android:id/widget_frame"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:gravity="start|center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"/>
|
||||
|
||||
<TextView
|
||||
android:id="@android:id/title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="marquee"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="?textAppearanceListItem"/>
|
||||
|
||||
<!-- Hidden view -->
|
||||
<TextView
|
||||
android:id="@android:id/summary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/login_frame"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="-16dp"
|
||||
android:layout_marginRight="-16dp"
|
||||
android:clipToPadding="false"
|
||||
android:gravity="end|center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/login" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</LinearLayout>
|
|
@ -1,191 +1,191 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/track"
|
||||
style="@style/Theme.Widget.CardView.Item"
|
||||
android:padding="0dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/logo_container"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:clickable="true"
|
||||
tools:background="#2E51A2">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/track_logo"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
tools:src="@drawable/mal" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/title_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectable_list_drawable"
|
||||
android:clickable="true"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
style="@style/TextAppearance.Regular.Body1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_title"
|
||||
style="@style/TextAppearance.Medium.Button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:ellipsize="middle"
|
||||
android:gravity="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/action_edit" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:id="@+id/divider1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginLeft="16dp"
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:background="?android:attr/divider"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/title_container" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/status_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectable_list_drawable"
|
||||
android:clickable="true"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/divider1">
|
||||
|
||||
<TextView
|
||||
style="@style/TextAppearance.Regular.Body1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/status" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_status"
|
||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:gravity="end"
|
||||
tools:text="Reading" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:id="@+id/divider2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginLeft="16dp"
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:background="?android:attr/divider"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/status_container" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/chapters_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectable_list_drawable"
|
||||
android:clickable="true"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/divider2">
|
||||
|
||||
<TextView
|
||||
style="@style/TextAppearance.Regular.Body1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/chapters" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_chapters"
|
||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:gravity="end"
|
||||
tools:text="12/24" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:id="@+id/divider3"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginLeft="16dp"
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:background="?android:attr/divider"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/chapters_container" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/score_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectable_list_drawable"
|
||||
android:clickable="true"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/divider3">
|
||||
|
||||
<TextView
|
||||
style="@style/TextAppearance.Regular.Body1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/score" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_score"
|
||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:gravity="end"
|
||||
tools:text="10" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/track"
|
||||
style="@style/Theme.Widget.CardView.Item"
|
||||
android:padding="0dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/logo_container"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:clickable="true"
|
||||
tools:background="#2E51A2">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/track_logo"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
tools:src="@drawable/mal" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/title_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectable_list_drawable"
|
||||
android:clickable="true"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
style="@style/TextAppearance.Regular.Body1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_title"
|
||||
style="@style/TextAppearance.Medium.Button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:ellipsize="middle"
|
||||
android:gravity="end"
|
||||
android:maxLines="1"
|
||||
android:text="@string/action_edit" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:id="@+id/divider1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginLeft="16dp"
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:background="?android:attr/divider"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/title_container" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/status_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectable_list_drawable"
|
||||
android:clickable="true"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/divider1">
|
||||
|
||||
<TextView
|
||||
style="@style/TextAppearance.Regular.Body1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/status" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_status"
|
||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:gravity="end"
|
||||
tools:text="Reading" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:id="@+id/divider2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginLeft="16dp"
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:background="?android:attr/divider"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/status_container" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/chapters_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectable_list_drawable"
|
||||
android:clickable="true"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/divider2">
|
||||
|
||||
<TextView
|
||||
style="@style/TextAppearance.Regular.Body1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/chapters" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_chapters"
|
||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:gravity="end"
|
||||
tools:text="12/24" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:id="@+id/divider3"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginLeft="16dp"
|
||||
android:layout_marginRight="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:background="?android:attr/divider"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/chapters_container" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/score_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectable_list_drawable"
|
||||
android:clickable="true"
|
||||
android:padding="16dp"
|
||||
app:layout_constraintLeft_toRightOf="@+id/logo_container"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/divider3">
|
||||
|
||||
<TextView
|
||||
style="@style/TextAppearance.Regular.Body1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/score" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/track_score"
|
||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:gravity="end"
|
||||
tools:text="10" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
|
Loading…
Reference in a new issue