UI with Conductor (#784)

This commit is contained in:
inorichi 2017-05-06 15:49:39 +02:00 committed by GitHub
parent 89b293fecd
commit 2eeac0bf8b
110 changed files with 7463 additions and 5807 deletions

View file

@ -100,6 +100,16 @@ android {
dependencies {
compile "com.bluelinelabs:conductor:2.1.3"
final rxbindings_version = '1.0.1'
compile "com.jakewharton.rxbinding:rxbinding-kotlin:$rxbindings_version"
compile "com.jakewharton.rxbinding:rxbinding-appcompat-v7-kotlin:$rxbindings_version"
compile "com.jakewharton.rxbinding:rxbinding-support-v4-kotlin:$rxbindings_version"
compile "com.jakewharton.rxbinding:rxbinding-recyclerview-v7-kotlin:$rxbindings_version"
compile 'com.nightlynexus.viewstatepageradapter:viewstatepageradapter:1.0.4'
// Modified dependencies
compile 'com.github.inorichi:subsampling-scale-image-view:01e5385'
compile 'com.github.inorichi:junrar-android:634c1f5'
@ -212,7 +222,7 @@ dependencies {
}
buildscript {
ext.kotlin_version = '1.1.1'
ext.kotlin_version = '1.1.2'
repositories {
mavenCentral()
}

View file

@ -32,10 +32,6 @@
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts"/>
</activity>
<activity
android:name=".ui.manga.MangaActivity"
android:exported="true"
android:parentActivityName=".ui.main.MainActivity" />
<activity
android:name=".ui.reader.ReaderActivity"
android:theme="@style/Theme.Reader" />
@ -43,10 +39,6 @@
android:name=".ui.setting.SettingsActivity"
android:label="@string/label_settings"
android:parentActivityName=".ui.main.MainActivity" />
<activity
android:name=".ui.category.CategoryActivity"
android:label="@string/label_categories"
android:parentActivityName=".ui.main.MainActivity" />
<activity
android:name=".widget.CustomLayoutPickerActivity"
android:label="@string/app_name"

View file

@ -7,4 +7,4 @@ package eu.kanade.tachiyomi.data.database.models
* @param chapter object containing chater
* @param history object containing history
*/
class MangaChapterHistory(val manga: Manga, val chapter: Chapter, val history: History)
data class MangaChapterHistory(val manga: Manga, val chapter: Chapter, val history: History)

View file

@ -1,7 +1,5 @@
package eu.kanade.tachiyomi.ui.base.activity
import android.os.Bundle
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.LocaleHelper
import nucleus.view.NucleusAppCompatActivity
@ -14,17 +12,6 @@ abstract class BaseRxActivity<P : BasePresenter<*>> : NucleusAppCompatActivity<P
LocaleHelper.updateConfiguration(this)
}
override fun onCreate(savedState: Bundle?) {
val superFactory = presenterFactory
setPresenterFactory {
superFactory.createPresenter().apply {
val app = application as App
context = app.applicationContext
}
}
super.onCreate(savedState)
}
override fun getActivity() = this
override fun onResume() {

View file

@ -0,0 +1,57 @@
package eu.kanade.tachiyomi.ui.base.controller
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import com.bluelinelabs.conductor.RestoreViewOnCreateController
import com.bluelinelabs.conductor.Router
abstract class BaseController(bundle: Bundle? = null) : RestoreViewOnCreateController(bundle) {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedViewState: Bundle?): View {
val view = inflateView(inflater, container)
onViewCreated(view, savedViewState)
return view
}
abstract fun inflateView(inflater: LayoutInflater, container: ViewGroup): View
open fun onViewCreated(view: View, savedViewState: Bundle?) { }
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
if (type.isEnter) {
setTitle()
}
super.onChangeStarted(handler, type)
}
open fun getTitle(): String? {
return null
}
private 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()
}
fun Router.popControllerWithTag(tag: String): Boolean {
val controller = getControllerWithTag(tag)
if (controller != null) {
popController(controller)
return true
}
return false
}
}

View file

@ -0,0 +1,139 @@
package eu.kanade.tachiyomi.ui.base.controller;
import android.app.Dialog;
import android.content.DialogInterface;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import com.bluelinelabs.conductor.RestoreViewOnCreateController;
import com.bluelinelabs.conductor.Router;
import com.bluelinelabs.conductor.RouterTransaction;
import com.bluelinelabs.conductor.changehandler.SimpleSwapChangeHandler;
/**
* A controller that displays a dialog window, floating on top of its activity's window.
* This is a wrapper over {@link Dialog} object like {@link android.app.DialogFragment}.
*
* <p>Implementations should override this class and implement {@link #onCreateDialog(Bundle)} to create a custom dialog, such as an {@link android.app.AlertDialog}
*/
public abstract class DialogController extends RestoreViewOnCreateController {
private static final String SAVED_DIALOG_STATE_TAG = "android:savedDialogState";
private Dialog dialog;
private boolean dismissed;
/**
* Convenience constructor for use when no arguments are needed.
*/
protected DialogController() {
super(null);
}
/**
* Constructor that takes arguments that need to be retained across restarts.
*
* @param args Any arguments that need to be retained.
*/
protected DialogController(@Nullable Bundle args) {
super(args);
}
@NonNull
@Override
final protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container, @Nullable Bundle savedViewState) {
dialog = onCreateDialog(savedViewState);
//noinspection ConstantConditions
dialog.setOwnerActivity(getActivity());
dialog.setOnDismissListener(new DialogInterface.OnDismissListener() {
@Override
public void onDismiss(DialogInterface dialog) {
dismissDialog();
}
});
if (savedViewState != null) {
Bundle dialogState = savedViewState.getBundle(SAVED_DIALOG_STATE_TAG);
if (dialogState != null) {
dialog.onRestoreInstanceState(dialogState);
}
}
return new View(getActivity());//stub view
}
@Override
protected void onSaveViewState(@NonNull View view, @NonNull Bundle outState) {
super.onSaveViewState(view, outState);
Bundle dialogState = dialog.onSaveInstanceState();
outState.putBundle(SAVED_DIALOG_STATE_TAG, dialogState);
}
@Override
protected void onAttach(@NonNull View view) {
super.onAttach(view);
dialog.show();
}
@Override
protected void onDetach(@NonNull View view) {
super.onDetach(view);
dialog.hide();
}
@Override
protected void onDestroyView(@NonNull View view) {
super.onDestroyView(view);
dialog.setOnDismissListener(null);
dialog.dismiss();
dialog = null;
}
/**
* Display the dialog, create a transaction and pushing the controller.
* @param router The router on which the transaction will be applied
*/
public void showDialog(@NonNull Router router) {
showDialog(router, null);
}
/**
* Display the dialog, create a transaction and pushing the controller.
* @param router The router on which the transaction will be applied
* @param tag The tag for this controller
*/
public void showDialog(@NonNull Router router, @Nullable String tag) {
dismissed = false;
router.pushController(RouterTransaction.with(this)
.pushChangeHandler(new SimpleSwapChangeHandler(false))
.popChangeHandler(new SimpleSwapChangeHandler(false))
.tag(tag));
}
/**
* Dismiss the dialog and pop this controller
*/
public void dismissDialog() {
if (dismissed) {
return;
}
getRouter().popController(this);
dismissed = true;
}
@Nullable
protected Dialog getDialog() {
return dialog;
}
/**
* Build your own custom Dialog container such as an {@link android.app.AlertDialog}
*
* @param savedViewState A bundle for the view's state, which would have been created in {@link #onSaveViewState(View, Bundle)} or {@code null} if no saved state exists.
* @return Return a new Dialog instance to be displayed by the Controller
*/
@NonNull
protected abstract Dialog onCreateDialog(@Nullable Bundle savedViewState);
}

View file

@ -0,0 +1,3 @@
package eu.kanade.tachiyomi.ui.base.controller
interface NoToolbarElevationController

View file

@ -0,0 +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(),
PresenterFactory<P> {
private val delegate = NucleusConductorDelegate(this)
val presenter: P
get() = delegate.presenter
init {
addLifecycleListener(NucleusConductorLifecycleListener(delegate))
}
}

View file

@ -0,0 +1,186 @@
package eu.kanade.tachiyomi.ui.base.controller;
import android.os.Bundle;
import android.os.Parcelable;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.view.PagerAdapter;
import android.util.SparseArray;
import android.view.View;
import android.view.ViewGroup;
import com.bluelinelabs.conductor.Controller;
import com.bluelinelabs.conductor.Router;
import com.bluelinelabs.conductor.RouterTransaction;
import java.util.ArrayList;
import java.util.List;
/**
* An adapter for ViewPagers that uses Routers as pages
*/
public abstract class RouterPagerAdapter extends PagerAdapter {
private static final String KEY_SAVED_PAGES = "RouterPagerAdapter.savedStates";
private static final String KEY_MAX_PAGES_TO_STATE_SAVE = "RouterPagerAdapter.maxPagesToStateSave";
private static final String KEY_SAVE_PAGE_HISTORY = "RouterPagerAdapter.savedPageHistory";
private final Controller host;
private int maxPagesToStateSave = Integer.MAX_VALUE;
private SparseArray<Bundle> savedPages = new SparseArray<>();
private SparseArray<Router> visibleRouters = new SparseArray<>();
private ArrayList<Integer> savedPageHistory = new ArrayList<>();
private Router primaryRouter;
/**
* Creates a new RouterPagerAdapter using the passed host.
*/
public RouterPagerAdapter(@NonNull Controller host) {
this.host = host;
}
/**
* Called when a router is instantiated. Here the router's root should be set if needed.
*
* @param router The router used for the page
* @param position The page position to be instantiated.
*/
public abstract void configureRouter(@NonNull Router router, int position);
/**
* Sets the maximum number of pages that will have their states saved. When this number is exceeded,
* the page that was state saved least recently will have its state removed from the save data.
*/
public void setMaxPagesToStateSave(int maxPagesToStateSave) {
if (maxPagesToStateSave < 0) {
throw new IllegalArgumentException("Only positive integers may be passed for maxPagesToStateSave.");
}
this.maxPagesToStateSave = maxPagesToStateSave;
ensurePagesSaved();
}
@Override
public Object instantiateItem(ViewGroup container, int position) {
final String name = makeRouterName(container.getId(), getItemId(position));
Router router = host.getChildRouter(container, name);
if (!router.hasRootController()) {
Bundle routerSavedState = savedPages.get(position);
if (routerSavedState != null) {
router.restoreInstanceState(routerSavedState);
savedPages.remove(position);
}
}
router.rebindIfNeeded();
configureRouter(router, position);
if (router != primaryRouter) {
for (RouterTransaction transaction : router.getBackstack()) {
transaction.controller().setOptionsMenuHidden(true);
}
}
visibleRouters.put(position, router);
return router;
}
@Override
public void destroyItem(ViewGroup container, int position, Object object) {
Router router = (Router)object;
Bundle savedState = new Bundle();
router.saveInstanceState(savedState);
savedPages.put(position, savedState);
savedPageHistory.remove((Integer)position);
savedPageHistory.add(position);
ensurePagesSaved();
host.removeChildRouter(router);
visibleRouters.remove(position);
}
@Override
public void setPrimaryItem(ViewGroup container, int position, Object object) {
Router router = (Router)object;
if (router != primaryRouter) {
if (primaryRouter != null) {
for (RouterTransaction transaction : primaryRouter.getBackstack()) {
transaction.controller().setOptionsMenuHidden(true);
}
}
if (router != null) {
for (RouterTransaction transaction : router.getBackstack()) {
transaction.controller().setOptionsMenuHidden(false);
}
}
primaryRouter = router;
}
}
@Override
public boolean isViewFromObject(View view, Object object) {
Router router = (Router)object;
final List<RouterTransaction> backstack = router.getBackstack();
for (RouterTransaction transaction : backstack) {
if (transaction.controller().getView() == view) {
return true;
}
}
return false;
}
@Override
public Parcelable saveState() {
Bundle bundle = new Bundle();
bundle.putSparseParcelableArray(KEY_SAVED_PAGES, savedPages);
bundle.putInt(KEY_MAX_PAGES_TO_STATE_SAVE, maxPagesToStateSave);
bundle.putIntegerArrayList(KEY_SAVE_PAGE_HISTORY, savedPageHistory);
return bundle;
}
@Override
public void restoreState(Parcelable state, ClassLoader loader) {
Bundle bundle = (Bundle)state;
if (state != null) {
savedPages = bundle.getSparseParcelableArray(KEY_SAVED_PAGES);
maxPagesToStateSave = bundle.getInt(KEY_MAX_PAGES_TO_STATE_SAVE);
savedPageHistory = bundle.getIntegerArrayList(KEY_SAVE_PAGE_HISTORY);
}
}
/**
* Returns the already instantiated Router in the specified position or {@code null} if there
* is no router associated with this position.
*/
@Nullable
public Router getRouter(int position) {
return visibleRouters.get(position);
}
public long getItemId(int position) {
return position;
}
SparseArray<Bundle> getSavedPages() {
return savedPages;
}
private void ensurePagesSaved() {
while (savedPages.size() > maxPagesToStateSave) {
int positionToRemove = savedPageHistory.remove(0);
savedPages.remove(positionToRemove);
}
}
private static String makeRouterName(int viewId, long id) {
return viewId + ":" + id;
}
}

View file

@ -0,0 +1,92 @@
package eu.kanade.tachiyomi.ui.base.controller
import android.os.Bundle
import android.support.annotation.CallSuper
import android.view.View
import rx.Observable
import rx.Subscription
import rx.subscriptions.CompositeSubscription
abstract class RxController(bundle: Bundle? = null) : BaseController(bundle) {
var untilDetachSubscriptions = CompositeSubscription()
private set
var untilDestroySubscriptions = CompositeSubscription()
private set
@CallSuper
override fun onAttach(view: View) {
super.onAttach(view)
if (untilDetachSubscriptions.isUnsubscribed) {
untilDetachSubscriptions = CompositeSubscription()
}
}
@CallSuper
override fun onDetach(view: View) {
super.onDetach(view)
untilDetachSubscriptions.unsubscribe()
}
@CallSuper
override fun onViewCreated(view: View, savedViewState: Bundle?) {
if (untilDestroySubscriptions.isUnsubscribed) {
untilDestroySubscriptions = CompositeSubscription()
}
}
@CallSuper
override fun onDestroyView(view: View) {
super.onDestroyView(view)
untilDestroySubscriptions.unsubscribe()
}
fun <T> Observable<T>.subscribeUntilDetach(): Subscription {
return subscribe().also { untilDetachSubscriptions.add(it) }
}
fun <T> Observable<T>.subscribeUntilDetach(onNext: (T) -> Unit): Subscription {
return subscribe(onNext).also { untilDetachSubscriptions.add(it) }
}
fun <T> Observable<T>.subscribeUntilDetach(onNext: (T) -> Unit,
onError: (Throwable) -> Unit): Subscription {
return subscribe(onNext, onError).also { untilDetachSubscriptions.add(it) }
}
fun <T> Observable<T>.subscribeUntilDetach(onNext: (T) -> Unit,
onError: (Throwable) -> Unit,
onCompleted: () -> Unit): Subscription {
return subscribe(onNext, onError, onCompleted).also { untilDetachSubscriptions.add(it) }
}
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) }
}
fun <T> Observable<T>.subscribeUntilDestroy(onNext: (T) -> Unit,
onError: (Throwable) -> Unit): Subscription {
return subscribe(onNext, onError).also { untilDestroySubscriptions.add(it) }
}
fun <T> Observable<T>.subscribeUntilDestroy(onNext: (T) -> Unit,
onError: (Throwable) -> Unit,
onCompleted: () -> Unit): Subscription {
return subscribe(onNext, onError, onCompleted).also { untilDestroySubscriptions.add(it) }
}
}

View file

@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.ui.base.controller
import android.support.v4.widget.DrawerLayout
import android.view.ViewGroup
interface SecondaryDrawerController {
fun createSecondaryDrawer(drawer: DrawerLayout): ViewGroup?
fun cleanupSecondaryDrawer(drawer: DrawerLayout)
}

View file

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.ui.base.controller
import android.support.design.widget.TabLayout
interface TabbedController {
fun configureTabs(tabs: TabLayout) {}
fun cleanupTabs(tabs: TabLayout) {}
}

View file

@ -1,7 +0,0 @@
package eu.kanade.tachiyomi.ui.base.fragment
import android.support.v4.app.Fragment
abstract class BaseFragment : Fragment(), FragmentMixin {
}

View file

@ -1,20 +0,0 @@
package eu.kanade.tachiyomi.ui.base.fragment
import android.os.Bundle
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import nucleus.view.NucleusSupportFragment
abstract class BaseRxFragment<P : BasePresenter<*>> : NucleusSupportFragment<P>(), FragmentMixin {
override fun onCreate(savedState: Bundle?) {
val superFactory = presenterFactory
setPresenterFactory {
superFactory.createPresenter().apply {
val app = activity.application as App
context = app.applicationContext
}
}
super.onCreate(savedState)
}
}

View file

@ -1,19 +0,0 @@
package eu.kanade.tachiyomi.ui.base.fragment
import android.support.v4.app.FragmentActivity
import eu.kanade.tachiyomi.ui.base.activity.ActivityMixin
interface FragmentMixin {
fun setToolbarTitle(title: String) {
(getActivity() as ActivityMixin).setToolbarTitle(title)
}
fun setToolbarTitle(resourceId: Int) {
(getActivity() as ActivityMixin).setToolbarTitle(getString(resourceId))
}
fun getActivity(): FragmentActivity
fun getString(resource: Int): String
}

View file

@ -1,13 +1,9 @@
package eu.kanade.tachiyomi.ui.base.presenter
import android.content.Context
import nucleus.presenter.RxPresenter
import nucleus.view.ViewWithPresenter
import rx.Observable
open class BasePresenter<V : ViewWithPresenter<*>> : RxPresenter<V>() {
lateinit var context: Context
open class BasePresenter<V> : RxPresenter<V>() {
/**
* Subscribes an observable with [deliverFirst] and adds it to the presenter's lifecycle

View file

@ -0,0 +1,67 @@
package eu.kanade.tachiyomi.ui.base.presenter;
import android.os.Bundle;
import android.support.annotation.Nullable;
import nucleus.factory.PresenterFactory;
import nucleus.presenter.Presenter;
public class NucleusConductorDelegate<P extends Presenter> {
@Nullable private P presenter;
@Nullable private Bundle bundle;
private boolean presenterHasView = false;
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();
if (presenter != null) {
presenter.save(bundle);
}
return bundle;
}
void onRestoreInstanceState(Bundle presenterState) {
if (presenter != null)
throw new IllegalArgumentException("onRestoreInstanceState() should be called before onResume()");
bundle = presenterState;
}
void onTakeView(Object view) {
getPresenter();
if (presenter != null && !presenterHasView) {
//noinspection unchecked
presenter.takeView(view);
presenterHasView = true;
}
}
void onDropView() {
if (presenter != null && presenterHasView) {
presenter.dropView();
presenterHasView = false;
}
}
void onDestroy() {
if (presenter != null) {
presenter.destroy();
presenter = null;
}
}
}

View file

@ -0,0 +1,44 @@
package eu.kanade.tachiyomi.ui.base.presenter;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.view.View;
import com.bluelinelabs.conductor.Controller;
public class NucleusConductorLifecycleListener extends Controller.LifecycleListener {
private static final String PRESENTER_STATE_KEY = "presenter_state";
private NucleusConductorDelegate delegate;
public NucleusConductorLifecycleListener(NucleusConductorDelegate delegate) {
this.delegate = delegate;
}
@Override
public void postCreateView(@NonNull Controller controller, @NonNull View view) {
delegate.onTakeView(controller);
}
@Override
public void preDestroyView(@NonNull Controller controller, @NonNull View view) {
delegate.onDropView();
}
@Override
public void preDestroy(@NonNull Controller controller) {
delegate.onDestroy();
}
@Override
public void onSaveInstanceState(@NonNull Controller controller, @NonNull Bundle outState) {
outState.putBundle(PRESENTER_STATE_KEY, delegate.onSaveInstanceState());
}
@Override
public void onRestoreInstanceState(@NonNull Controller controller, @NonNull Bundle savedInstanceState) {
delegate.onRestoreInstanceState(savedInstanceState.getBundle(PRESENTER_STATE_KEY));
}
}

View file

@ -3,10 +3,10 @@ package eu.kanade.tachiyomi.ui.catalogue
import android.view.Gravity
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
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
import eu.kanade.tachiyomi.util.inflate
@ -19,11 +19,16 @@ class CatalogueItem(val manga: Manga) : AbstractFlexibleItem<CatalogueHolder>()
return R.layout.item_catalogue_grid
}
override fun createViewHolder(adapter: FlexibleAdapter<IFlexible<*>>, inflater: LayoutInflater, parent: ViewGroup): CatalogueHolder {
override fun createViewHolder(adapter: FlexibleAdapter<*>,
inflater: LayoutInflater,
parent: ViewGroup): CatalogueHolder {
if (parent is AutofitRecyclerView) {
val view = parent.inflate(R.layout.item_catalogue_grid).apply {
card.layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, parent.itemWidth / 3 * 4)
gradient.layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, parent.itemWidth / 3 * 4 / 2, Gravity.BOTTOM)
card.layoutParams = FrameLayout.LayoutParams(
MATCH_PARENT, parent.itemWidth / 3 * 4)
gradient.layoutParams = FrameLayout.LayoutParams(
MATCH_PARENT, parent.itemWidth / 3 * 4 / 2, Gravity.BOTTOM)
}
return CatalogueGridHolder(view, adapter)
} else {
@ -32,7 +37,11 @@ class CatalogueItem(val manga: Manga) : AbstractFlexibleItem<CatalogueHolder>()
}
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<*>>, holder: CatalogueHolder, position: Int, payloads: List<Any?>?) {
override fun bindViewHolder(adapter: FlexibleAdapter<*>,
holder: CatalogueHolder,
position: Int,
payloads: List<Any?>?) {
holder.onSetValues(manga)
}

View file

@ -25,32 +25,18 @@ import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subjects.PublishSubject
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Presenter of [CatalogueFragment].
* Presenter of [CatalogueController].
*/
open class CataloguePresenter : BasePresenter<CatalogueFragment>() {
/**
* Source manager.
*/
val sourceManager: SourceManager by injectLazy()
/**
* Database.
*/
val db: DatabaseHelper by injectLazy()
/**
* Preferences.
*/
val prefs: PreferencesHelper by injectLazy()
/**
* Cover cache.
*/
val coverCache: CoverCache by injectLazy()
open class CataloguePresenter(
val sourceManager: SourceManager = Injekt.get(),
val db: DatabaseHelper = Injekt.get(),
val prefs: PreferencesHelper = Injekt.get(),
val coverCache: CoverCache = Injekt.get()
) : BasePresenter<CatalogueController>() {
/**
* Enabled sources.
@ -182,7 +168,7 @@ open class CataloguePresenter : BasePresenter<CatalogueFragment>() {
pageSubscription = Observable.defer { pager.requestNext() }
.subscribeFirst({ view, page ->
// Nothing to do when onNext is emitted.
}, CatalogueFragment::onAddPageError)
}, CatalogueController::onAddPageError)
}
/**
@ -404,7 +390,7 @@ open class CataloguePresenter : BasePresenter<CatalogueFragment>() {
* @return List of categories, default plus user categories
*/
fun getCategories(): List<Category> {
return arrayListOf(Category.createDefault()) + db.getCategories().executeAsBlocking()
return db.getCategories().executeAsBlocking()
}
/**
@ -415,10 +401,7 @@ open class CataloguePresenter : BasePresenter<CatalogueFragment>() {
*/
fun getMangaCategoryIds(manga: Manga): Array<Int?> {
val categories = db.getCategoriesForManga(manga).executeAsBlocking()
if (categories.isEmpty()) {
return arrayListOf(Category.createDefault().id).toTypedArray()
}
return categories.map { it.id }.toTypedArray()
return categories.mapNotNull { it.id }.toTypedArray()
}
/**
@ -427,10 +410,9 @@ open class CataloguePresenter : BasePresenter<CatalogueFragment>() {
* @param categories the selected categories.
* @param manga the manga to move.
*/
fun moveMangaToCategories(categories: List<Category>, manga: Manga) {
val mc = categories.map { MangaCategory.create(manga, it) }
db.setMangaCategories(mc, arrayListOf(manga))
fun moveMangaToCategories(manga: Manga, categories: List<Category>) {
val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) }
db.setMangaCategories(mc, listOf(manga))
}
/**
@ -439,8 +421,8 @@ open class CataloguePresenter : BasePresenter<CatalogueFragment>() {
* @param category the selected category.
* @param manga the manga to move.
*/
fun moveMangaToCategory(category: Category, manga: Manga) {
moveMangaToCategories(arrayListOf(category), manga)
fun moveMangaToCategory(manga: Manga, category: Category?) {
moveMangaToCategories(manga, listOfNotNull(category))
}
/**
@ -454,7 +436,7 @@ open class CataloguePresenter : BasePresenter<CatalogueFragment>() {
if (!manga.favorite)
changeMangaFavorite(manga)
moveMangaToCategories(selectedCategories.filter { it.id != 0 }, manga)
moveMangaToCategories(manga, selectedCategories.filter { it.id != 0 })
} else {
changeMangaFavorite(manga)
}

View file

@ -1,265 +0,0 @@
package eu.kanade.tachiyomi.ui.category
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.support.v7.view.ActionMode
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.RecyclerView
import android.view.Menu
import android.view.MenuItem
import android.view.View
import com.afollestad.materialdialogs.MaterialDialog
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.helpers.UndoHelper
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity
import kotlinx.android.synthetic.main.activity_edit_categories.*
import kotlinx.android.synthetic.main.toolbar.*
import nucleus.factory.RequiresPresenter
/**
* Activity that shows categories.
* Uses R.layout.activity_edit_categories.
* UI related actions should be called from here.
*/
@RequiresPresenter(CategoryPresenter::class)
class CategoryActivity :
BaseRxActivity<CategoryPresenter>(),
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
UndoHelper.OnUndoListener {
/**
* Object used to show actionMode toolbar.
*/
var actionMode: ActionMode? = null
/**
* Adapter containing category items.
*/
private lateinit var adapter: CategoryAdapter
companion object {
/**
* Create new CategoryActivity intent.
*
* @param context context information.
*/
fun newIntent(context: Context): Intent {
return Intent(context, CategoryActivity::class.java)
}
}
override fun onCreate(savedState: Bundle?) {
setAppTheme()
super.onCreate(savedState)
// Inflate activity_edit_categories.xml.
setContentView(R.layout.activity_edit_categories)
// Setup the toolbar.
setupToolbar(toolbar)
// Get new adapter.
adapter = CategoryAdapter(this)
// Create view and inject category items into view
recycler.layoutManager = LinearLayoutManager(this)
recycler.setHasFixedSize(true)
recycler.adapter = adapter
adapter.isHandleDragEnabled = true
// Create OnClickListener for creating new category
fab.setOnClickListener {
MaterialDialog.Builder(this)
.title(R.string.action_add_category)
.negativeText(android.R.string.cancel)
.input(R.string.name, 0, false)
{ dialog, input -> presenter.createCategory(input.toString()) }
.show()
}
}
/**
* Fill adapter with category items
*
* @param categories list containing categories
*/
fun setCategories(categories: List<CategoryItem>) {
actionMode?.finish()
adapter.updateDataSet(categories.toMutableList())
val selected = categories.filter { it.isSelected }
if (selected.isNotEmpty()) {
selected.forEach { onItemLongClick(categories.indexOf(it)) }
}
}
/**
* Show MaterialDialog which let user change category name.
*
* @param category category that will be edited.
*/
private fun editCategory(category: Category) {
MaterialDialog.Builder(this)
.title(R.string.action_rename_category)
.negativeText(android.R.string.cancel)
.input(getString(R.string.name), category.name, false)
{ dialog, input -> presenter.renameCategory(category, input.toString()) }
.show()
}
/**
* Called when action mode item clicked.
*
* @param actionMode action mode toolbar.
* @param menuItem selected menu item.
*
* @return action mode item clicked exist status
*/
override fun onActionItemClicked(actionMode: ActionMode, menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_delete -> {
UndoHelper(adapter, this)
.withAction(UndoHelper.ACTION_REMOVE, object : UndoHelper.OnActionListener {
override fun onPreAction(): Boolean {
adapter.selectedPositions.forEach { adapter.getItem(it).isSelected = false }
return false
}
override fun onPostAction() {
actionMode.finish()
}
})
.remove(adapter.selectedPositions, recycler.parent as View,
R.string.snack_categories_deleted, R.string.action_undo, 3000)
}
R.id.action_edit -> {
// Edit selected category
if (adapter.selectedItemCount == 1) {
val position = adapter.selectedPositions.first()
editCategory(adapter.getItem(position).category)
}
}
else -> return false
}
return true
}
/**
* Inflate menu when action mode selected.
*
* @param mode ActionMode object
* @param menu Menu object
*
* @return true
*/
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
// Inflate menu.
mode.menuInflater.inflate(R.menu.category_selection, menu)
// Enable adapter multi selection.
adapter.mode = FlexibleAdapter.MODE_MULTI
return true
}
/**
* Called each time the action mode is shown.
* Always called after onCreateActionMode
*
* @return false
*/
override fun onPrepareActionMode(actionMode: ActionMode, menu: Menu): Boolean {
val count = adapter.selectedItemCount
actionMode.title = getString(R.string.label_selected, count)
// Show edit button only when one item is selected
val editItem = actionMode.menu.findItem(R.id.action_edit)
editItem.isVisible = count == 1
return true
}
/**
* Called when action mode destroyed.
*
* @param mode ActionMode object.
*/
override fun onDestroyActionMode(mode: ActionMode?) {
// Reset adapter to single selection
adapter.mode = FlexibleAdapter.MODE_IDLE
adapter.clearSelection()
actionMode = null
}
/**
* Called when item in list is clicked.
*
* @param position position of clicked item.
*/
override fun onItemClick(position: Int): Boolean {
// Check if action mode is initialized and selected item exist.
if (actionMode != null && position != RecyclerView.NO_POSITION) {
toggleSelection(position)
return true
} else {
return false
}
}
/**
* Called when item long clicked
*
* @param position position of clicked item.
*/
override fun onItemLongClick(position: Int) {
// Check if action mode is initialized.
if (actionMode == null) {
// Initialize action mode
actionMode = startSupportActionMode(this)
}
// Set item as selected
toggleSelection(position)
}
/**
* Toggle the selection state of an item.
* If the item was the last one in the selection and is unselected, the ActionMode is finished.
*/
private fun toggleSelection(position: Int) {
//Mark the position selected
adapter.toggleSelection(position)
if (adapter.selectedItemCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
}
}
/**
* Called when an item is released from a drag.
*/
fun onItemReleased() {
val categories = (0..adapter.itemCount-1).map { adapter.getItem(it).category }
presenter.reorderCategories(categories)
}
/**
* Called when the undo action is clicked in the snackbar.
*/
override fun onUndoConfirmed(action: Int) {
adapter.restoreDeletedItems()
}
/**
* Called when the time to restore the items expires.
*/
override fun onDeleteConfirmed(action: Int) {
presenter.deleteCategories(adapter.deletedItems.map { it.category })
}
}

View file

@ -3,31 +3,48 @@ package eu.kanade.tachiyomi.ui.category
import eu.davidea.flexibleadapter.FlexibleAdapter
/**
* Adapter of CategoryHolder.
* Connection between Activity and Holder
* Holder updates should be called from here.
* Custom adapter for categories.
*
* @param activity activity that created adapter
* @constructor Creates a CategoryAdapter object
* @param controller The containing controller.
*/
class CategoryAdapter(private val activity: CategoryActivity) :
FlexibleAdapter<CategoryItem>(null, activity, true) {
class CategoryAdapter(controller: CategoryController) :
FlexibleAdapter<CategoryItem>(null, controller, true) {
/**
* Called when item is released.
* Listener called when an item of the list is released.
*/
fun onItemReleased() {
activity.onItemReleased()
}
val onItemReleaseListener: OnItemReleaseListener = controller
/**
* Clears the active selections from the list and the model.
*/
override fun clearSelection() {
super.clearSelection()
(0..itemCount-1).forEach { getItem(it).isSelected = false }
(0 until itemCount).forEach { getItem(it).isSelected = false }
}
/**
* Clears the active selections from the model.
*/
fun clearModelSelection() {
selectedPositions.forEach { getItem(it).isSelected = false }
}
/**
* Toggles the selection of the given position.
*
* @param position The position to toggle.
*/
override fun toggleSelection(position: Int) {
super.toggleSelection(position)
getItem(position).isSelected = isSelected(position)
}
interface OnItemReleaseListener {
/**
* Called when an item of the list is released.
*/
fun onItemReleased(position: Int)
}
}

View file

@ -0,0 +1,321 @@
package eu.kanade.tachiyomi.ui.category
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import android.support.v7.view.ActionMode
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.RecyclerView
import android.view.*
import com.jakewharton.rxbinding.view.clicks
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.widget.UndoHelper
import kotlinx.android.synthetic.main.categories_controller.view.*
/**
* Controller to manage the categories for the users' library.
*/
class CategoryController : NucleusController<CategoryPresenter>(),
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
CategoryAdapter.OnItemReleaseListener,
CategoryCreateDialog.Listener,
CategoryRenameDialog.Listener,
UndoHelper.OnUndoListener {
/**
* Object used to show ActionMode toolbar.
*/
private var actionMode: ActionMode? = null
/**
* Adapter containing category items.
*/
private var adapter: CategoryAdapter? = null
/**
* Undo helper for deleting categories.
*/
private var undoHelper: UndoHelper? = null
/**
* Creates the presenter for this controller. Not to be manually called.
*/
override fun createPresenter() = CategoryPresenter()
/**
* Returns the toolbar title to show when this controller is attached.
*/
override fun getTitle(): String? {
return resources?.getString(R.string.action_edit_categories)
}
/**
* Returns the view of this controller.
*
* @param inflater The layout inflater to create the view from XML.
* @param container The parent view for this one.
*/
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.categories_controller, container, false)
}
/**
* Called after view inflation. Used to initialize the view.
*
* @param view The view of this controller.
* @param savedViewState The saved state of the view.
*/
override fun onViewCreated(view: View, savedViewState: Bundle?) {
super.onViewCreated(view, savedViewState)
with(view) {
adapter = CategoryAdapter(this@CategoryController)
recycler.layoutManager = LinearLayoutManager(context)
recycler.setHasFixedSize(true)
recycler.adapter = adapter
adapter?.isHandleDragEnabled = true
fab.clicks().subscribeUntilDestroy {
CategoryCreateDialog(this@CategoryController).showDialog(router, null)
}
}
}
/**
* Called when the view is being destroyed. Used to release references and remove callbacks.
*
* @param view The view of this controller.
*/
override fun onDestroyView(view: View) {
super.onDestroyView(view)
undoHelper?.dismissNow() // confirm categories deletion if required
undoHelper = null
actionMode = null
adapter = null
}
/**
* Called from the presenter when the categories are updated.
*
* @param categories The new list of categories to display.
*/
fun setCategories(categories: List<CategoryItem>) {
actionMode?.finish()
adapter?.updateDataSet(categories.toMutableList())
val selected = categories.filter { it.isSelected }
if (selected.isNotEmpty()) {
selected.forEach { onItemLongClick(categories.indexOf(it)) }
}
}
/**
* Called when action mode is first created. The menu supplied will be used to generate action
* buttons for the action mode.
*
* @param mode ActionMode being created.
* @param menu Menu used to populate action buttons.
* @return true if the action mode should be created, false if entering this mode should be
* aborted.
*/
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
// Inflate menu.
mode.menuInflater.inflate(R.menu.category_selection, menu)
// Enable adapter multi selection.
adapter?.mode = FlexibleAdapter.MODE_MULTI
return true
}
/**
* Called to refresh an action mode's action menu whenever it is invalidated.
*
* @param mode ActionMode being prepared.
* @param menu Menu used to populate action buttons.
* @return true if the menu or action mode was updated, false otherwise.
*/
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val adapter = adapter ?: return false
val count = adapter.selectedItemCount
mode.title = resources?.getString(R.string.label_selected, count)
// Show edit button only when one item is selected
val editItem = mode.menu.findItem(R.id.action_edit)
editItem.isVisible = count == 1
return true
}
/**
* Called to report a user click on an action button.
*
* @param mode The current ActionMode.
* @param item The item that was clicked.
* @return true if this callback handled the event, false if the standard MenuItem invocation
* should continue.
*/
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
val adapter = adapter ?: return false
when (item.itemId) {
R.id.action_delete -> {
undoHelper = UndoHelper(adapter, this).apply {
withAction(UndoHelper.ACTION_REMOVE, object : UndoHelper.OnActionListener {
override fun onPreAction(): Boolean {
adapter.clearModelSelection()
return false
}
override fun onPostAction() {
mode.finish()
}
})
remove(adapter.selectedPositions, view!!,
R.string.snack_categories_deleted, R.string.action_undo, 3000)
}
}
R.id.action_edit -> {
// Edit selected category
if (adapter.selectedItemCount == 1) {
val position = adapter.selectedPositions.first()
editCategory(adapter.getItem(position).category)
}
}
else -> return false
}
return true
}
/**
* Called when an action mode is about to be exited and destroyed.
*
* @param mode The current ActionMode being destroyed.
*/
override fun onDestroyActionMode(mode: ActionMode) {
// Reset adapter to single selection
adapter?.mode = FlexibleAdapter.MODE_IDLE
adapter?.clearSelection()
actionMode = null
}
/**
* Called when an item in the list is clicked.
*
* @param position The position of the clicked item.
* @return true if this click should enable selection mode.
*/
override fun onItemClick(position: Int): Boolean {
// Check if action mode is initialized and selected item exist.
if (actionMode != null && position != RecyclerView.NO_POSITION) {
toggleSelection(position)
return true
} else {
return false
}
}
/**
* Called when an item in the list is long clicked.
*
* @param position The position of the clicked item.
*/
override fun onItemLongClick(position: Int) {
val activity = activity as? AppCompatActivity ?: return
// Check if action mode is initialized.
if (actionMode == null) {
// Initialize action mode
actionMode = activity.startSupportActionMode(this)
}
// Set item as selected
toggleSelection(position)
}
/**
* Toggle the selection state of an item.
* If the item was the last one in the selection and is unselected, the ActionMode is finished.
*
* @param position The position of the item to toggle.
*/
private fun toggleSelection(position: Int) {
val adapter = adapter ?: return
//Mark the position selected
adapter.toggleSelection(position)
if (adapter.selectedItemCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
}
}
/**
* Called when an item is released from a drag.
*
* @param position The position of the released item.
*/
override fun onItemReleased(position: Int) {
val adapter = adapter ?: return
val categories = (0..adapter.itemCount-1).map { adapter.getItem(it).category }
presenter.reorderCategories(categories)
}
/**
* Called when the undo action is clicked in the snackbar.
*
* @param action The action performed.
*/
override fun onUndoConfirmed(action: Int) {
adapter?.restoreDeletedItems()
}
/**
* Called when the time to restore the items expires.
*
* @param action The action performed.
*/
override fun onDeleteConfirmed(action: Int) {
val adapter = adapter ?: return
presenter.deleteCategories(adapter.deletedItems.map { it.category })
}
/**
* Show a dialog to let the user change the category name.
*
* @param category The category to be edited.
*/
private fun editCategory(category: Category) {
CategoryRenameDialog(this, category).showDialog(router)
}
/**
* Renames the given category with the given name.
*
* @param category The category to rename.
* @param name The new name of the category.
*/
override fun renameCategory(category: Category, name: String) {
presenter.renameCategory(category, name)
}
/**
* Creates a new category with the given name.
*
* @param name The name of the new category.
*/
override fun createCategory(name: String) {
presenter.createCategory(name)
}
/**
* Called from the presenter when a category with the given name already exists.
*/
fun onCategoryExistsError() {
activity?.toast(R.string.error_category_exists)
}
}

View file

@ -0,0 +1,47 @@
package eu.kanade.tachiyomi.ui.category
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
/**
* Dialog to create a new category for the library.
*/
class CategoryCreateDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : CategoryCreateDialog.Listener {
/**
* Name of the new category. Value updated with each input from the user.
*/
private var currentName = ""
constructor(target: T) : this() {
targetController = target
}
/**
* Called when creating the dialog for this controller.
*
* @param savedViewState The saved state of this dialog.
* @return a new dialog instance.
*/
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialDialog.Builder(activity!!)
.title(R.string.action_add_category)
.negativeText(android.R.string.cancel)
.alwaysCallInputCallback()
.input(resources?.getString(R.string.name), currentName, false, { _, input ->
currentName = input.toString()
})
.onPositive { _, _ -> (targetController as? Listener)?.createCategory(currentName) }
.build()
}
interface Listener {
fun createCategory(name: String)
}
}

View file

@ -10,14 +10,10 @@ import eu.kanade.tachiyomi.data.database.models.Category
import kotlinx.android.synthetic.main.item_edit_categories.view.*
/**
* Holder that contains category item.
* Uses R.layout.item_edit_categories.
* UI related actions should be called from here.
* Holder used to display category items.
*
* @param view view of category item.
* @param adapter adapter belonging to holder.
*
* @constructor Create CategoryHolder object
* @param view The view used by category items.
* @param adapter The adapter containing this holder.
*/
class CategoryHolder(view: View, val adapter: CategoryAdapter) : FlexibleViewHolder(view, adapter) {
@ -32,9 +28,9 @@ class CategoryHolder(view: View, val adapter: CategoryAdapter) : FlexibleViewHol
}
/**
* Update category item values.
* Binds this holder with the given category.
*
* @param category category of item.
* @param category The category to bind.
*/
fun bind(category: Category) {
// Set capitalized title.
@ -47,9 +43,9 @@ class CategoryHolder(view: View, val adapter: CategoryAdapter) : FlexibleViewHol
}
/**
* Returns circle letter image
* Returns circle letter image.
*
* @param text first letter of string
* @param text The first letter of string.
*/
private fun getRound(text: String): TextDrawable {
val size = Math.min(itemView.image.width, itemView.image.height)
@ -63,9 +59,14 @@ class CategoryHolder(view: View, val adapter: CategoryAdapter) : FlexibleViewHol
.buildRound(text, ColorGenerator.MATERIAL.getColor(text))
}
/**
* Called when an item is released.
*
* @param position The position of the released item.
*/
override fun onItemReleased(position: Int) {
super.onItemReleased(position)
adapter.onItemReleased()
adapter.onItemReleaseListener.onItemReleased(position)
}
}

View file

@ -8,29 +8,62 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.util.inflate
/**
* Category item for a recycler view.
*/
class CategoryItem(val category: Category) : AbstractFlexibleItem<CategoryHolder>() {
/**
* Whether this item is currently selected.
*/
var isSelected = false
/**
* Returns the layout resource for this item.
*/
override fun getLayoutRes(): Int {
return R.layout.item_edit_categories
}
override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater,
/**
* Returns a new view holder for this item.
*
* @param adapter The adapter of this item.
* @param inflater The layout inflater for XML inflation.
* @param parent The container view.
*/
override fun createViewHolder(adapter: FlexibleAdapter<*>,
inflater: LayoutInflater,
parent: ViewGroup): CategoryHolder {
return CategoryHolder(parent.inflate(layoutRes), adapter as CategoryAdapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: CategoryHolder,
position: Int, payloads: List<Any?>?) {
/**
* Binds the given view holder with this item.
*
* @param adapter The adapter of this item.
* @param holder The holder to bind.
* @param position The position of this item in the adapter.
* @param payloads List of partial changes.
*/
override fun bindViewHolder(adapter: FlexibleAdapter<*>,
holder: CategoryHolder,
position: Int,
payloads: List<Any?>?) {
holder.bind(category)
}
/**
* Returns true if this item is draggable.
*/
override fun isDraggable(): Boolean {
return true
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is CategoryItem) {
return category.id == other.category.id
}

View file

@ -1,31 +1,31 @@
package eu.kanade.tachiyomi.ui.category
import android.os.Bundle
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.toast
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.injectLazy
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Presenter of CategoryActivity.
* Contains information and data for activity.
* Observable updates should be called from here.
* Presenter of [CategoryController]. Used to manage the categories of the library.
*/
class CategoryPresenter : BasePresenter<CategoryActivity>() {
/**
* Used to connect to database.
*/
private val db: DatabaseHelper by injectLazy()
class CategoryPresenter(
private val db: DatabaseHelper = Injekt.get()
) : BasePresenter<CategoryController>() {
/**
* List containing categories.
*/
private var categories: List<Category> = emptyList()
/**
* Called when the presenter is created.
*
* @param savedState The saved state of this presenter.
*/
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
@ -33,18 +33,18 @@ class CategoryPresenter : BasePresenter<CategoryActivity>() {
.doOnNext { categories = it }
.map { it.map(::CategoryItem) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(CategoryActivity::setCategories)
.subscribeLatestCache(CategoryController::setCategories)
}
/**
* Create category and add it to database
* Creates and adds a new category to the database.
*
* @param name name of category
* @param name The name of the category to create.
*/
fun createCategory(name: String) {
// Do not allow duplicate categories.
if (categories.any { it.name.equals(name, true) }) {
context.toast(R.string.error_category_exists)
if (categoryExists(name)) {
Observable.just(Unit).subscribeFirst({ view, _ -> view.onCategoryExistsError() })
return
}
@ -59,18 +59,18 @@ class CategoryPresenter : BasePresenter<CategoryActivity>() {
}
/**
* Delete category from database
* Deletes the given categories from the database.
*
* @param categories list of categories
* @param categories The list of categories to delete.
*/
fun deleteCategories(categories: List<Category>) {
db.deleteCategories(categories).asRxObservable().subscribe()
}
/**
* Reorder categories in database
* Reorders the given categories in the database.
*
* @param categories list of categories
* @param categories The list of categories to reorder.
*/
fun reorderCategories(categories: List<Category>) {
categories.forEachIndexed { i, category ->
@ -81,19 +81,27 @@ class CategoryPresenter : BasePresenter<CategoryActivity>() {
}
/**
* Rename a category
* Renames a category.
*
* @param category category that gets renamed
* @param name new name of category
* @param category The category to rename.
* @param name The new name of the category.
*/
fun renameCategory(category: Category, name: String) {
// Do not allow duplicate categories.
if (categories.any { it.name.equals(name, true) }) {
context.toast(R.string.error_category_exists)
if (categoryExists(name)) {
Observable.just(Unit).subscribeFirst({ view, _ -> view.onCategoryExistsError() })
return
}
category.name = name
db.insertCategory(category).asRxObservable().subscribe()
}
/**
* Returns true if a category with the given name already exists.
*/
fun categoryExists(name: String): Boolean {
return categories.any { it.name.equals(name, true) }
}
}

View file

@ -0,0 +1,86 @@
package eu.kanade.tachiyomi.ui.category
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.ui.base.controller.DialogController
/**
* Dialog to rename an existing category of the library.
*/
class CategoryRenameDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : CategoryRenameDialog.Listener {
private var category: Category? = null
/**
* Name of the new category. Value updated with each input from the user.
*/
private var currentName = ""
constructor(target: T, category: Category) : this() {
targetController = target
this.category = category
currentName = category.name
}
/**
* Called when creating the dialog for this controller.
*
* @param savedViewState The saved state of this dialog.
* @return a new dialog instance.
*/
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialDialog.Builder(activity!!)
.title(R.string.action_rename_category)
.negativeText(android.R.string.cancel)
.alwaysCallInputCallback()
.input(resources!!.getString(R.string.name), currentName, false, { _, input ->
currentName = input.toString()
})
.onPositive { _, _ -> onPositive() }
.build()
}
/**
* Called to save this Controller's state in the event that its host Activity is destroyed.
*
* @param outState The Bundle into which data should be saved
*/
override fun onSaveInstanceState(outState: Bundle) {
outState.putSerializable(CATEGORY_KEY, category)
super.onSaveInstanceState(outState)
}
/**
* Restores data that was saved in the [onSaveInstanceState] method.
*
* @param savedInstanceState The bundle that has data to be restored
*/
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
category = savedInstanceState.getSerializable(CATEGORY_KEY) as? Category
}
/**
* Called when the positive button of the dialog is clicked.
*/
private fun onPositive() {
val target = targetController as? Listener ?: return
val category = category ?: return
target.renameCategory(category, currentName)
}
interface Listener {
fun renameCategory(category: Category, name: String)
}
private companion object {
const val CATEGORY_KEY = "CategoryRenameDialog.category"
}
}

View file

@ -10,7 +10,6 @@ import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity
import eu.kanade.tachiyomi.util.plusAssign
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.fragment_download_queue.*
import kotlinx.android.synthetic.main.toolbar.*
import nucleus.factory.RequiresPresenter
@ -242,6 +241,6 @@ class DownloadActivity : BaseRxActivity<DownloadPresenter>() {
}
fun updateEmptyView(show: Boolean, textResource: Int, drawable: Int) {
if (show) empty_view.show(drawable, textResource) else empty_view.hide()
// if (show) empty_view.show(drawable, textResource) else empty_view.hide()
}
}

View file

@ -0,0 +1,33 @@
package eu.kanade.tachiyomi.ui.latest_updates
import android.support.v4.widget.DrawerLayout
import android.view.Menu
import android.view.ViewGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
import eu.kanade.tachiyomi.ui.catalogue.CataloguePresenter
/**
* Fragment that shows the manga from the catalogue. Inherit CatalogueFragment.
*/
class LatestUpdatesController : CatalogueController() {
override fun createPresenter(): CataloguePresenter {
return LatestUpdatesPresenter()
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.action_search).isVisible = false
menu.findItem(R.id.action_set_filter).isVisible = false
}
override fun createSecondaryDrawer(drawer: DrawerLayout): ViewGroup? {
return null
}
override fun cleanupSecondaryDrawer(drawer: DrawerLayout) {
}
}

View file

@ -1,29 +0,0 @@
package eu.kanade.tachiyomi.ui.latest_updates
import android.view.Menu
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment
import nucleus.factory.RequiresPresenter
/**
* Fragment that shows the manga from the catalogue. Inherit CatalogueFragment.
*/
@RequiresPresenter(LatestUpdatesPresenter::class)
class LatestUpdatesFragment : CatalogueFragment() {
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.action_search).isVisible = false
menu.findItem(R.id.action_set_filter).isVisible = false
}
companion object {
fun newInstance(): LatestUpdatesFragment {
return LatestUpdatesFragment()
}
}
}

View file

@ -7,7 +7,7 @@ import eu.kanade.tachiyomi.ui.catalogue.CataloguePresenter
import eu.kanade.tachiyomi.ui.catalogue.Pager
/**
* Presenter of [LatestUpdatesFragment]. Inherit CataloguePresenter.
* Presenter of [LatestUpdatesController]. Inherit CataloguePresenter.
*/
class LatestUpdatesPresenter : CataloguePresenter() {

View file

@ -0,0 +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>)
}
}

View file

@ -0,0 +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)
}
}

View file

@ -1,88 +1,88 @@
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 fragment: LibraryFragment) : 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()
}
}
/**
* Creates a new view for this adapter.
*
* @return a new view.
*/
override fun createView(container: ViewGroup): View {
val view = container.inflate(R.layout.item_library_category) as LibraryCategoryView
view.onCreate(fragment)
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])
}
/**
* 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()
}
/**
* 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
}
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()
}
}
/**
* Creates a new view for this adapter.
*
* @return a new view.
*/
override fun createView(container: ViewGroup): View {
val view = container.inflate(R.layout.item_library_category2) 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])
}
/**
* 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()
}
/**
* 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
}
}

View file

@ -1,122 +1,44 @@
package eu.kanade.tachiyomi.ui.library
import android.view.Gravity
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import eu.davidea.flexibleadapter4.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
import java.util.*
/**
* Adapter storing a list of manga in a certain category.
*
* @param fragment the fragment containing this adapter.
*/
class LibraryCategoryAdapter(val fragment: LibraryCategoryView) :
FlexibleAdapter<LibraryHolder, Manga>() {
/**
* The list of manga in this category.
*/
private var mangas: List<Manga> = emptyList()
init {
setHasStableIds(true)
}
/**
* Sets a list of manga in the adapter.
*
* @param list the list to set.
*/
fun setItems(list: List<Manga>) {
mItems = list
// A copy of manga always unfiltered.
mangas = ArrayList(list)
updateDataSet(null)
}
/**
* Returns the identifier for a manga.
*
* @param position the position in the adapter.
* @return an identifier for the item.
*/
override fun getItemId(position: Int): Long {
return mItems[position].id!!
}
/**
* Filters the list of manga applying [filterObject] for each element.
*
* @param param the filter. Not used.
*/
override fun updateDataSet(param: String?) {
filterItems(mangas)
notifyDataSetChanged()
}
/**
* Filters a manga depending on a query.
*
* @param manga the manga to filter.
* @param query the query to apply.
* @return true if the manga should be included, false otherwise.
*/
override fun filterObject(manga: Manga, query: String): Boolean = with(manga) {
title.toLowerCase().contains(query) ||
author != null && author!!.toLowerCase().contains(query)
}
/**
* Creates a new view holder.
*
* @param parent the parent view.
* @param viewType the type of the holder.
* @return a new view holder for a manga.
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LibraryHolder {
// Depending on preferences, display a list or display a grid
if (parent is AutofitRecyclerView) {
val view = parent.inflate(R.layout.item_catalogue_grid).apply {
val coverHeight = parent.itemWidth / 3 * 4
card.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight)
gradient.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight / 2, Gravity.BOTTOM)
}
return LibraryGridHolder(view, this, fragment)
} else {
val view = parent.inflate(R.layout.item_catalogue_list)
return LibraryListHolder(view, this, fragment)
}
}
/**
* Binds a holder with a new position.
*
* @param holder the holder to bind.
* @param position the position to bind.
*/
override fun onBindViewHolder(holder: LibraryHolder, position: Int) {
val manga = getItem(position)
holder.onSetValues(manga)
// When user scrolls this bind the correct selection status
holder.itemView.isActivated = isSelected(position)
}
/**
* Returns the position in the adapter for the given manga.
*
* @param manga the manga to find.
*/
fun indexOf(manga: Manga): Int {
return mangas.orEmpty().indexOfFirst { it.id == manga.id }
}
}
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 mangas.indexOfFirst { it.manga.id == manga.id }
}
fun performFilter() {
updateDataSet(mangas.filter { it.filter(searchText) })
}
}

View file

@ -1,266 +1,248 @@
package eu.kanade.tachiyomi.ui.library
import android.content.Context
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.RecyclerView
import android.util.AttributeSet
import android.widget.FrameLayout
import eu.davidea.flexibleadapter4.FlexibleAdapter
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.ui.base.adapter.FlexibleViewHolder
import eu.kanade.tachiyomi.ui.manga.MangaActivity
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import kotlinx.android.synthetic.main.item_library_category.view.*
import rx.Subscription
import uy.kohesive.injekt.injectLazy
/**
* Fragment containing the library manga for a certain category.
* Uses R.layout.fragment_library_category.
*/
class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
: FrameLayout(context, attrs), FlexibleViewHolder.OnListItemClickListener {
/**
* Preferences.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* The fragment containing this view.
*/
private lateinit var fragment: LibraryFragment
/**
* 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
/**
* Subscription for the library manga.
*/
private var libraryMangaSubscription: Subscription? = null
/**
* Subscription of the library search.
*/
private var searchSubscription: Subscription? = null
/**
* Subscription of the library selections.
*/
private var selectionSubscription: Subscription? = null
fun onCreate(fragment: LibraryFragment) {
this.fragment = fragment
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 = fragment.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
val presenter = fragment.presenter
searchSubscription = presenter.searchSubject.subscribe { text ->
adapter.searchText = text
adapter.updateDataSet()
}
adapter.mode = if (presenter.selectedMangas.isNotEmpty()) {
FlexibleAdapter.MODE_MULTI
} else {
FlexibleAdapter.MODE_SINGLE
}
libraryMangaSubscription = presenter.libraryMangaSubject
.subscribe { onNextLibraryManga(it) }
selectionSubscription = presenter.selectionSubject
.subscribe { onSelectionChanged(it) }
}
fun onRecycle() {
adapter.setItems(emptyList())
adapter.clearSelection()
}
override fun onDetachedFromWindow() {
searchSubscription?.unsubscribe()
libraryMangaSubscription?.unsubscribe()
selectionSubscription?.unsubscribe()
super.onDetachedFromWindow()
}
/**
* 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 == FlexibleAdapter.MODE_MULTI) {
fragment.presenter.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 != FlexibleAdapter.MODE_MULTI) {
adapter.mode = FlexibleAdapter.MODE_MULTI
}
findAndToggleSelection(event.manga)
}
is LibrarySelectionEvent.Unselected -> {
findAndToggleSelection(event.manga)
if (fragment.presenter.selectedMangas.isEmpty()) {
adapter.mode = FlexibleAdapter.MODE_SINGLE
}
}
is LibrarySelectionEvent.Cleared -> {
adapter.mode = FlexibleAdapter.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 onListItemClick(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 == FlexibleAdapter.MODE_MULTI) {
toggleSelection(position)
return true
} else {
openManga(item)
return false
}
}
/**
* Called when a manga is long clicked.
*
* @param position the position of the element clicked.
*/
override fun onListItemLongClick(position: Int) {
fragment.createActionModeIfNeeded()
toggleSelection(position)
}
/**
* Opens a manga.
*
* @param manga the manga to open.
*/
private fun openManga(manga: Manga) {
// Notify the presenter a manga is being opened.
fragment.presenter.onOpenManga()
// Create a new activity with the manga.
val intent = MangaActivity.newIntent(context, manga)
fragment.startActivity(intent)
}
/**
* Tells the presenter to toggle the selection for the given position.
*
* @param position the position to toggle.
*/
private fun toggleSelection(position: Int) {
val manga = adapter.getItem(position) ?: return
fragment.presenter.setSelection(manga, !adapter.isSelected(position))
fragment.invalidateActionMode()
}
}
package eu.kanade.tachiyomi.ui.library
import android.content.Context
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.RecyclerView
import android.util.AttributeSet
import android.widget.FrameLayout
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.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.item_library_category.view.*
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.injectLazy
/**
* Fragment containing the library manga for a certain category.
* Uses R.layout.fragment_library_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()) {
FlexibleAdapter.MODE_MULTI
} else {
FlexibleAdapter.MODE_SINGLE
}
subscriptions += controller.searchRelay
.doOnNext { adapter.searchText = it }
.skip(1)
.subscribe { adapter.performFilter() }
subscriptions += controller.libraryMangaRelay
.subscribe { onNextLibraryManga(it) }
subscriptions += controller.selectionRelay
.subscribe { onSelectionChanged(it) }
}
fun onRecycle() {
adapter.setItems(emptyList())
adapter.clearSelection()
subscriptions.clear()
}
override fun onDetachedFromWindow() {
subscriptions.clear()
super.onDetachedFromWindow()
}
/**
* 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 == FlexibleAdapter.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 != FlexibleAdapter.MODE_MULTI) {
adapter.mode = FlexibleAdapter.MODE_MULTI
}
findAndToggleSelection(event.manga)
}
is LibrarySelectionEvent.Unselected -> {
findAndToggleSelection(event.manga)
if (controller.selectedMangas.isEmpty()) {
adapter.mode = FlexibleAdapter.MODE_SINGLE
}
}
is LibrarySelectionEvent.Cleared -> {
adapter.mode = FlexibleAdapter.MODE_SINGLE
adapter.clearSelection()
}
}
}
/**
* Toggles the selection for the given manga and updates the view if needed.
*
* @param manga the manga to toggle.
*/
private fun findAndToggleSelection(manga: Manga) {
val position = adapter.indexOf(manga)
if (position != -1) {
adapter.toggleSelection(position)
(recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation()
}
}
/**
* Called when a manga is clicked.
*
* @param position the position of the element clicked.
* @return true if the item should be selected, false otherwise.
*/
override fun onItemClick(position: Int): Boolean {
// If the action mode is created and the position is valid, toggle the selection.
val item = adapter.getItem(position) ?: return false
if (adapter.mode == FlexibleAdapter.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()
}
}

View file

@ -0,0 +1,510 @@
package eu.kanade.tachiyomi.ui.library
import android.app.Activity
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
import android.support.design.widget.TabLayout
import android.support.v4.graphics.drawable.DrawableCompat
import android.support.v4.widget.DrawerLayout
import android.support.v7.app.AppCompatActivity
import android.support.v7.view.ActionMode
import android.support.v7.widget.SearchView
import android.view.*
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import com.bluelinelabs.conductor.RouterTransaction
import com.bluelinelabs.conductor.changehandler.FadeChangeHandler
import com.f2prateek.rx.preferences.Preference
import com.jakewharton.rxbinding.support.v4.view.pageSelections
import com.jakewharton.rxbinding.support.v7.widget.queryTextChanges
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
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.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.SecondaryDrawerController
import eu.kanade.tachiyomi.ui.base.controller.TabbedController
import eu.kanade.tachiyomi.ui.category.CategoryController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.widget.DrawerSwipeCloseListener
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.library_controller.view.*
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.IOException
class LibraryController(
bundle: Bundle? = null,
private val preferences: PreferencesHelper = Injekt.get()
) : NucleusController<LibraryPresenter>(bundle),
TabbedController,
SecondaryDrawerController,
ActionMode.Callback,
ChangeMangaCategoriesDialog.Listener,
DeleteLibraryMangasDialog.Listener {
/**
* Position of the active category.
*/
var activeCategory: Int = preferences.lastUsedCategory().getOrDefault()
private set
/**
* Action mode for selections.
*/
private var actionMode: ActionMode? = null
/**
* Library search query.
*/
private var query = ""
/**
* Currently selected mangas.
*/
val selectedMangas = mutableListOf<Manga>()
private var selectedCoverManga: Manga? = null
/**
* Relay to notify the UI of selection updates.
*/
val selectionRelay: PublishRelay<LibrarySelectionEvent> = PublishRelay.create()
/**
* Relay to notify search query changes.
*/
val searchRelay: BehaviorRelay<String> = BehaviorRelay.create()
/**
* Relay to notify the library's viewpager for updates.
*/
val libraryMangaRelay: BehaviorRelay<LibraryMangaEvent> = BehaviorRelay.create()
/**
* Number of manga per row in grid mode.
*/
var mangaPerRow = 0
private set
/**
* TabLayout of the categories.
*/
private val tabs: TabLayout?
get() = activity?.tabs
private val drawer: DrawerLayout?
get() = activity?.drawer
private var adapter: LibraryAdapter? = null
/**
* Navigation view containing filter/sort/display items.
*/
private var navView: LibraryNavigationView? = null
/**
* Drawer listener to allow swipe only for closing the drawer.
*/
private var drawerListener: DrawerLayout.DrawerListener? = null
init {
setHasOptionsMenu(true)
}
override fun getTitle(): String? {
return resources?.getString(R.string.label_library)
}
override fun createPresenter(): LibraryPresenter {
return LibraryPresenter()
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.library_controller, container, false)
}
override fun onViewCreated(view: View, savedViewState: Bundle?) {
super.onViewCreated(view, savedViewState)
adapter = LibraryAdapter(this)
with(view) {
view_pager.adapter = adapter
view_pager.pageSelections().skip(1).subscribeUntilDestroy {
preferences.lastUsedCategory().set(it)
activeCategory = it
}
getColumnsPreferenceForCurrentOrientation().asObservable()
.doOnNext { mangaPerRow = it }
.skip(1)
// Set again the adapter to recalculate the covers height
.subscribeUntilDestroy { reattachAdapter() }
if (selectedMangas.isNotEmpty()) {
createActionModeIfNeeded()
}
}
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type)
if (type.isEnter) {
activity?.tabs?.setupWithViewPager(view?.view_pager)
}
}
override fun onAttach(view: View) {
super.onAttach(view)
presenter.subscribeLibrary()
}
override fun onDestroyView(view: View) {
super.onDestroyView(view)
adapter = null
actionMode = null
}
override fun createSecondaryDrawer(drawer: DrawerLayout): ViewGroup {
val view = drawer.inflate(R.layout.library_drawer) as LibraryNavigationView
drawerListener = DrawerSwipeCloseListener(drawer, view).also {
drawer.addDrawerListener(it)
}
navView = view
navView?.post {
if (isAttached && drawer.isDrawerOpen(navView))
drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, navView)
}
navView?.onGroupClicked = { group ->
when (group) {
is LibraryNavigationView.FilterGroup -> onFilterChanged()
is LibraryNavigationView.SortGroup -> onSortChanged()
is LibraryNavigationView.DisplayGroup -> reattachAdapter()
}
}
return view
}
override fun cleanupSecondaryDrawer(drawer: DrawerLayout) {
drawerListener?.let { drawer.removeDrawerListener(it) }
drawerListener = null
navView = null
}
fun onNextLibraryUpdate(categories: List<Category>, mangaMap: Map<Int, List<LibraryItem>>) {
val view = view ?: return
val adapter = adapter ?: return
// Show empty view if needed
if (mangaMap.isNotEmpty()) {
view.empty_view.hide()
} else {
view.empty_view.show(R.drawable.ic_book_black_128dp, R.string.information_empty_library)
}
// Get the current active category.
val activeCat = if (adapter.categories.isNotEmpty())
view.view_pager.currentItem
else
activeCategory
// Set the categories
adapter.categories = categories
// Restore active category.
view.view_pager.setCurrentItem(activeCat, false)
tabs?.visibility = if (categories.size <= 1) View.GONE else View.VISIBLE
// Delay the scroll position to allow the view to be properly measured.
view.post {
if (isAttached) {
tabs?.setScrollPosition(view.view_pager.currentItem, 0f, true)
}
}
// Send the manga map to child fragments after the adapter is updated.
libraryMangaRelay.call(LibraryMangaEvent(mangaMap))
}
/**
* Returns a preference for the number of manga per row based on the current orientation.
*
* @return the preference.
*/
private fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> {
return if (resources?.configuration?.orientation == Configuration.ORIENTATION_PORTRAIT)
preferences.portraitColumns()
else
preferences.landscapeColumns()
}
/**
* Called when a filter is changed.
*/
private fun onFilterChanged() {
presenter.requestFilterUpdate()
(activity as? AppCompatActivity)?.supportInvalidateOptionsMenu()
}
/**
* Called when the sorting mode is changed.
*/
private fun onSortChanged() {
presenter.requestSortUpdate()
}
/**
* Reattaches the adapter to the view pager to recreate fragments
*/
private fun reattachAdapter() {
val pager = view?.view_pager ?: return
val adapter = adapter ?: return
val position = pager.currentItem
adapter.recycle = false
pager.adapter = adapter
pager.currentItem = position
adapter.recycle = true
}
override fun configureTabs(tabs: TabLayout) {
with(tabs) {
tabGravity = TabLayout.GRAVITY_CENTER
tabMode = TabLayout.MODE_SCROLLABLE
}
}
/**
* Creates the action mode if it's not created already.
*/
fun createActionModeIfNeeded() {
if (actionMode == null) {
actionMode = (activity as AppCompatActivity).startSupportActionMode(this)
}
}
/**
* Destroys the action mode.
*/
fun destroyActionModeIfNeeded() {
actionMode?.finish()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.library, menu)
val searchItem = menu.findItem(R.id.action_search)
val searchView = searchItem.actionView as SearchView
if (!query.isNullOrEmpty()) {
searchItem.expandActionView()
searchView.setQuery(query, true)
searchView.clearFocus()
}
// Mutate the filter icon because it needs to be tinted and the resource is shared.
menu.findItem(R.id.action_filter).icon.mutate()
searchView.queryTextChanges().subscribeUntilDestroy {
query = it.toString()
searchRelay.call(query)
}
}
override fun onPrepareOptionsMenu(menu: Menu) {
val navView = navView ?: return
val filterItem = menu.findItem(R.id.action_filter)
// Tint icon if there's a filter active
val filterColor = if (navView.hasActiveFilters()) Color.rgb(255, 238, 7) else Color.WHITE
DrawableCompat.setTint(filterItem.icon, filterColor)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_filter -> {
navView?.let { drawer?.openDrawer(Gravity.END) }
}
R.id.action_update_library -> {
activity?.let { LibraryUpdateService.start(it) }
}
R.id.action_edit_categories -> {
router.pushController(RouterTransaction.with(CategoryController())
.pushChangeHandler(FadeChangeHandler())
.popChangeHandler(FadeChangeHandler()))
}
else -> return super.onOptionsItemSelected(item)
}
return true
}
/**
* Invalidates the action mode, forcing it to refresh its content.
*/
fun invalidateActionMode() {
actionMode?.invalidate()
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.library_selection, menu)
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = selectedMangas.size
if (count == 0) {
// Destroy action mode if there are no items selected.
destroyActionModeIfNeeded()
} else {
mode.title = resources?.getString(R.string.label_selected, count)
menu.findItem(R.id.action_edit_cover)?.isVisible = count == 1
}
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_edit_cover -> {
changeSelectedCover()
destroyActionModeIfNeeded()
}
R.id.action_move_to_category -> showChangeMangaCategoriesDialog()
R.id.action_delete -> showDeleteMangaDialog()
else -> return false
}
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
// Clear all the manga selections and notify child views.
selectedMangas.clear()
selectionRelay.call(LibrarySelectionEvent.Cleared())
actionMode = null
}
fun openManga(manga: Manga) {
// Notify the presenter a manga is being opened.
presenter.onOpenManga()
router.pushController(RouterTransaction.with(MangaController(manga))
.pushChangeHandler(FadeChangeHandler())
.popChangeHandler(FadeChangeHandler()))
}
/**
* Sets the selection for a given manga.
*
* @param manga the manga whose selection has changed.
* @param selected whether it's now selected or not.
*/
fun setSelection(manga: Manga, selected: Boolean) {
if (selected) {
selectedMangas.add(manga)
selectionRelay.call(LibrarySelectionEvent.Selected(manga))
} else {
selectedMangas.remove(manga)
selectionRelay.call(LibrarySelectionEvent.Unselected(manga))
}
}
/**
* Move the selected manga to a list of categories.
*/
private fun showChangeMangaCategoriesDialog() {
// Create a copy of selected manga
val mangas = selectedMangas.toList()
// Hide the default category because it has a different behavior than the ones from db.
val categories = presenter.categories.filter { it.id != 0 }
// Get indexes of the common categories to preselect.
val commonCategoriesIndexes = presenter.getCommonCategories(mangas)
.map { categories.indexOf(it) }
.toTypedArray()
ChangeMangaCategoriesDialog(this, mangas, categories, commonCategoriesIndexes)
.showDialog(router, null)
}
private fun showDeleteMangaDialog() {
DeleteLibraryMangasDialog(this, selectedMangas.toList()).showDialog(router, null)
}
override fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) {
presenter.moveMangasToCategories(categories, mangas)
destroyActionModeIfNeeded()
}
override fun deleteMangasFromLibrary(mangas: List<Manga>, deleteChapters: Boolean) {
presenter.removeMangaFromLibrary(mangas, deleteChapters)
destroyActionModeIfNeeded()
}
/**
* Changes the cover for the selected manga.
*
* @param mangas a list of selected manga.
*/
private fun changeSelectedCover() {
val manga = selectedMangas.firstOrNull() ?: return
selectedCoverManga = manga
if (manga.favorite) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "image/*"
startActivityForResult(Intent.createChooser(intent,
resources?.getString(R.string.file_select_cover)), REQUEST_IMAGE_OPEN)
} else {
activity?.toast(R.string.notification_first_add_to_library)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_IMAGE_OPEN) {
if (data == null || resultCode != Activity.RESULT_OK) return
val activity = activity ?: return
val manga = selectedCoverManga ?: return
try {
// Get the file's input stream from the incoming Intent
activity.contentResolver.openInputStream(data.data).use {
// Update cover to selected file, show error if something went wrong
if (presenter.editCoverWithStream(it, manga)) {
// TODO refresh cover
} else {
activity.toast(R.string.notification_cover_update_failed)
}
}
} catch (error: IOException) {
activity.toast(R.string.notification_cover_update_failed)
Timber.e(error)
}
selectedCoverManga = null
}
}
private companion object {
/**
* Key to change the cover of a manga in [onActivityResult].
*/
const val REQUEST_IMAGE_OPEN = 101
}
}

View file

@ -1,503 +0,0 @@
package eu.kanade.tachiyomi.ui.library
import android.app.Activity
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
import android.support.design.widget.TabLayout
import android.support.v4.graphics.drawable.DrawableCompat
import android.support.v4.view.ViewPager
import android.support.v4.widget.DrawerLayout
import android.support.v7.app.AppCompatActivity
import android.support.v7.view.ActionMode
import android.support.v7.widget.SearchView
import android.view.*
import com.afollestad.materialdialogs.MaterialDialog
import com.f2prateek.rx.preferences.Preference
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.ui.base.fragment.BaseRxFragment
import eu.kanade.tachiyomi.ui.category.CategoryActivity
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.widget.DialogCheckboxView
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.fragment_library.*
import nucleus.factory.RequiresPresenter
import rx.Subscription
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.io.IOException
/**
* Fragment that shows the manga from the library.
* Uses R.layout.fragment_library.
*/
@RequiresPresenter(LibraryPresenter::class)
class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback {
/**
* Adapter containing the categories of the library.
*/
lateinit var adapter: LibraryAdapter
private set
/**
* Preferences.
*/
val preferences: PreferencesHelper by injectLazy()
/**
* TabLayout of the categories.
*/
private val tabs: TabLayout
get() = (activity as MainActivity).tabs
/**
* Position of the active category.
*/
private var activeCategory: Int = 0
/**
* Query of the search box.
*/
private var query: String? = null
/**
* Action mode for manga selection.
*/
private var actionMode: ActionMode? = null
/**
* Selected manga for editing its cover.
*/
private var selectedCoverManga: Manga? = null
/**
* Number of manga per row in grid mode.
*/
var mangaPerRow = 0
private set
/**
* Navigation view containing filter/sort/display items.
*/
private lateinit var navView: LibraryNavigationView
/**
* Drawer listener to allow swipe only for closing the drawer.
*/
private val drawerListener by lazy {
object : DrawerLayout.SimpleDrawerListener() {
override fun onDrawerClosed(drawerView: View) {
if (drawerView == navView) {
activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, navView)
}
}
override fun onDrawerOpened(drawerView: View) {
if (drawerView == navView) {
activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, navView)
}
}
}
}
/**
* Subscription for the number of manga per row.
*/
private var numColumnsSubscription: Subscription? = null
companion object {
/**
* Key to change the cover of a manga in [onActivityResult].
*/
const val REQUEST_IMAGE_OPEN = 101
/**
* Key to save and restore [query] from a [Bundle].
*/
const val QUERY_KEY = "query_key"
/**
* Key to save and restore [activeCategory] from a [Bundle].
*/
const val CATEGORY_KEY = "category_key"
/**
* Creates a new instance of this fragment.
*
* @return a new instance of [LibraryFragment].
*/
fun newInstance(): LibraryFragment {
return LibraryFragment()
}
}
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_library, container, false)
}
override fun onViewCreated(view: View, savedState: Bundle?) {
setToolbarTitle(getString(R.string.label_library))
adapter = LibraryAdapter(this)
view_pager.adapter = adapter
view_pager.addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
preferences.lastUsedCategory().set(position)
}
})
tabs.setupWithViewPager(view_pager)
if (savedState != null) {
activeCategory = savedState.getInt(CATEGORY_KEY)
query = savedState.getString(QUERY_KEY)
presenter.searchSubject.call(query)
if (presenter.selectedMangas.isNotEmpty()) {
createActionModeIfNeeded()
}
} else {
activeCategory = preferences.lastUsedCategory().getOrDefault()
}
numColumnsSubscription = getColumnsPreferenceForCurrentOrientation().asObservable()
.doOnNext { mangaPerRow = it }
.skip(1)
// Set again the adapter to recalculate the covers height
.subscribe { reattachAdapter() }
// Inflate and prepare drawer
navView = activity.drawer.inflate(R.layout.library_drawer) as LibraryNavigationView
activity.drawer.addView(navView)
activity.drawer.addDrawerListener(drawerListener)
navView.post {
if (isAdded && !activity.drawer.isDrawerOpen(navView))
activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, navView)
}
navView.onGroupClicked = { group ->
when (group) {
is LibraryNavigationView.FilterGroup -> onFilterChanged()
is LibraryNavigationView.SortGroup -> onSortChanged()
is LibraryNavigationView.DisplayGroup -> reattachAdapter()
}
}
}
override fun onResume() {
super.onResume()
presenter.subscribeLibrary()
}
override fun onDestroyView() {
activity.drawer.removeDrawerListener(drawerListener)
activity.drawer.removeView(navView)
numColumnsSubscription?.unsubscribe()
tabs.setupWithViewPager(null)
tabs.visibility = View.GONE
super.onDestroyView()
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putInt(CATEGORY_KEY, view_pager.currentItem)
outState.putString(QUERY_KEY, query)
super.onSaveInstanceState(outState)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.library, menu)
val searchItem = menu.findItem(R.id.action_search)
val searchView = searchItem.actionView as SearchView
if (!query.isNullOrEmpty()) {
searchItem.expandActionView()
searchView.setQuery(query, true)
searchView.clearFocus()
}
// Mutate the filter icon because it needs to be tinted and the resource is shared.
menu.findItem(R.id.action_filter).icon.mutate()
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
onSearchTextChange(query)
return true
}
override fun onQueryTextChange(newText: String): Boolean {
onSearchTextChange(newText)
return true
}
})
}
override fun onPrepareOptionsMenu(menu: Menu) {
val filterItem = menu.findItem(R.id.action_filter)
// Tint icon if there's a filter active
val filterColor = if (navView.hasActiveFilters()) Color.rgb(255, 238, 7) else Color.WHITE
DrawableCompat.setTint(filterItem.icon, filterColor)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_filter -> {
activity.drawer.openDrawer(Gravity.END)
}
R.id.action_update_library -> {
LibraryUpdateService.start(activity)
}
R.id.action_edit_categories -> {
val intent = CategoryActivity.newIntent(activity)
startActivity(intent)
}
else -> return super.onOptionsItemSelected(item)
}
return true
}
/**
* Called when a filter is changed.
*/
private fun onFilterChanged() {
presenter.requestFilterUpdate()
activity.supportInvalidateOptionsMenu()
}
/**
* Called when the sorting mode is changed.
*/
private fun onSortChanged() {
presenter.requestSortUpdate()
}
/**
* Reattaches the adapter to the view pager to recreate fragments
*/
private fun reattachAdapter() {
val position = view_pager.currentItem
adapter.recycle = false
view_pager.adapter = adapter
view_pager.currentItem = position
adapter.recycle = true
}
/**
* Returns a preference for the number of manga per row based on the current orientation.
*
* @return the preference.
*/
private fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> {
return if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
preferences.portraitColumns()
else
preferences.landscapeColumns()
}
/**
* Updates the query.
*
* @param query the new value of the query.
*/
private fun onSearchTextChange(query: String?) {
this.query = query
// Notify the subject the query has changed.
if (isResumed) {
presenter.searchSubject.call(query)
}
}
/**
* Called when the library is updated. It sets the new data and updates the view.
*
* @param categories the categories of the library.
* @param mangaMap a map containing the manga for each category.
*/
fun onNextLibraryUpdate(categories: List<Category>, mangaMap: Map<Int, List<Manga>>) {
// Check if library is empty and update information accordingly.
(activity as MainActivity).updateEmptyView(mangaMap.isEmpty(),
R.string.information_empty_library, R.drawable.ic_book_black_128dp)
// Get the current active category.
val activeCat = if (adapter.categories.isNotEmpty()) view_pager.currentItem else activeCategory
// Set the categories
adapter.categories = categories
tabs.visibility = if (categories.size <= 1) View.GONE else View.VISIBLE
// Restore active category.
view_pager.setCurrentItem(activeCat, false)
// Delay the scroll position to allow the view to be properly measured.
view_pager.post { if (isAdded) tabs.setScrollPosition(view_pager.currentItem, 0f, true) }
// Send the manga map to child fragments after the adapter is updated.
presenter.libraryMangaSubject.call(LibraryMangaEvent(mangaMap))
}
/**
* Creates the action mode if it's not created already.
*/
fun createActionModeIfNeeded() {
if (actionMode == null) {
actionMode = (activity as AppCompatActivity).startSupportActionMode(this)
}
}
/**
* Destroys the action mode.
*/
fun destroyActionModeIfNeeded() {
actionMode?.finish()
}
/**
* Invalidates the action mode, forcing it to refresh its content.
*/
fun invalidateActionMode() {
actionMode?.invalidate()
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.library_selection, menu)
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = presenter.selectedMangas.size
if (count == 0) {
// Destroy action mode if there are no items selected.
destroyActionModeIfNeeded()
} else {
mode.title = getString(R.string.label_selected, count)
menu.findItem(R.id.action_edit_cover)?.isVisible = count == 1
}
return false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_edit_cover -> {
changeSelectedCover(presenter.selectedMangas)
destroyActionModeIfNeeded()
}
R.id.action_move_to_category -> moveMangasToCategories(presenter.selectedMangas)
R.id.action_delete -> showDeleteMangaDialog()
else -> return false
}
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
presenter.clearSelections()
actionMode = null
}
/**
* Changes the cover for the selected manga.
*
* @param mangas a list of selected manga.
*/
private fun changeSelectedCover(mangas: List<Manga>) {
if (mangas.size == 1) {
selectedCoverManga = mangas[0]
if (selectedCoverManga?.favorite ?: false) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "image/*"
startActivityForResult(Intent.createChooser(intent,
getString(R.string.file_select_cover)), REQUEST_IMAGE_OPEN)
} else {
context.toast(R.string.notification_first_add_to_library)
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (data != null && resultCode == Activity.RESULT_OK && requestCode == REQUEST_IMAGE_OPEN) {
selectedCoverManga?.let { manga ->
try {
// Get the file's input stream from the incoming Intent
context.contentResolver.openInputStream(data.data).use {
// Update cover to selected file, show error if something went wrong
if (presenter.editCoverWithStream(it, manga)) {
// TODO refresh cover
} else {
context.toast(R.string.notification_cover_update_failed)
}
}
} catch (error: IOException) {
context.toast(R.string.notification_cover_update_failed)
Timber.e(error)
}
}
}
}
/**
* Move the selected manga to a list of categories.
*
* @param mangas the manga list to move.
*/
private fun moveMangasToCategories(mangas: List<Manga>) {
// Hide the default category because it has a different behavior than the ones from db.
val categories = presenter.categories.filter { it.id != 0 }
// Get indexes of the common categories to preselect.
val commonCategoriesIndexes = presenter.getCommonCategories(mangas)
.map { categories.indexOf(it) }
.toTypedArray()
MaterialDialog.Builder(activity)
.title(R.string.action_move_category)
.items(categories.map { it.name })
.itemsCallbackMultiChoice(commonCategoriesIndexes) { dialog, positions, text ->
val selectedCategories = positions.map { categories[it] }
presenter.moveMangasToCategories(selectedCategories, mangas)
destroyActionModeIfNeeded()
true
}
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.show()
}
private fun showDeleteMangaDialog() {
val view = DialogCheckboxView(context).apply {
setDescription(R.string.confirm_delete_manga)
setOptionDescription(R.string.also_delete_chapters)
}
MaterialDialog.Builder(activity)
.title(R.string.action_remove)
.customView(view, true)
.positiveText(android.R.string.yes)
.negativeText(android.R.string.no)
.onPositive { dialog, action ->
val deleteChapters = view.isChecked()
presenter.removeMangaFromLibrary(deleteChapters)
destroyActionModeIfNeeded()
}
.show()
}
}

View file

@ -1,49 +1,49 @@
package eu.kanade.tachiyomi.ui.library
import android.view.View
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
/**
* 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: LibraryCategoryAdapter,
listener: FlexibleViewHolder.OnListItemClickListener)
: LibraryHolder(view, adapter, listener) {
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param manga the manga to bind.
*/
override fun onSetValues(manga: Manga) {
// Update the title of the manga.
view.title.text = manga.title
// Update the unread count and its visibility.
with(view.unread_text) {
visibility = if (manga.unread > 0) View.VISIBLE else View.GONE
text = manga.unread.toString()
}
// Update the cover.
Glide.clear(view.thumbnail)
Glide.with(view.context)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.RESULT)
.centerCrop()
.into(view.thumbnail)
}
}
package eu.kanade.tachiyomi.ui.library
import android.view.View
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.database.models.Manga
import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
/**
* 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 manga the manga to bind.
*/
override fun onSetValues(manga: Manga) {
// Update the title of the manga.
view.title.text = manga.title
// Update the unread count and its visibility.
with(view.unread_text) {
visibility = if (manga.unread > 0) View.VISIBLE else View.GONE
text = manga.unread.toString()
}
// Update the cover.
Glide.clear(view.thumbnail)
Glide.with(view.context)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.RESULT)
.centerCrop()
.into(view.thumbnail)
}
}

View file

@ -1,27 +1,28 @@
package eu.kanade.tachiyomi.ui.library
import android.view.View
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
/**
* 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(private val view: View,
adapter: LibraryCategoryAdapter,
listener: FlexibleViewHolder.OnListItemClickListener)
: FlexibleViewHolder(view, adapter, listener) {
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param manga the manga to bind.
*/
abstract fun onSetValues(manga: Manga)
}
package eu.kanade.tachiyomi.ui.library
import android.view.View
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.data.database.models.Manga
/**
* 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<*>
) : FlexibleViewHolder(view, adapter) {
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param manga the manga to bind.
*/
abstract fun onSetValues(manga: Manga)
}

View file

@ -0,0 +1,70 @@
package eu.kanade.tachiyomi.ui.library
import android.view.Gravity
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.davidea.flexibleadapter.items.IFilterable
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
class LibraryItem(val manga: Manga) : AbstractFlexibleItem<LibraryHolder>(), IFilterable {
override fun getLayoutRes(): Int {
return R.layout.item_catalogue_grid
}
override fun createViewHolder(adapter: FlexibleAdapter<*>,
inflater: LayoutInflater,
parent: ViewGroup): LibraryHolder {
return if (parent is AutofitRecyclerView) {
val view = parent.inflate(R.layout.item_catalogue_grid).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 {
val view = parent.inflate(R.layout.item_catalogue_list)
LibraryListHolder(view, adapter)
}
}
override fun bindViewHolder(adapter: FlexibleAdapter<*>,
holder: LibraryHolder,
position: Int,
payloads: List<Any?>?) {
holder.onSetValues(manga)
}
/**
* 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()
}
}

View file

@ -1,57 +1,57 @@
package eu.kanade.tachiyomi.ui.library
import android.view.View
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
import kotlinx.android.synthetic.main.item_catalogue_list.view.*
/**
* 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: LibraryCategoryAdapter,
listener: FlexibleViewHolder.OnListItemClickListener)
: LibraryHolder(view, adapter, listener) {
/**
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
* holder with the given manga.
*
* @param manga the manga to bind.
*/
override fun onSetValues(manga: Manga) {
// Update the title of the manga.
itemView.title.text = manga.title
// Update the unread count and its visibility.
with(itemView.unread_text) {
visibility = if (manga.unread > 0) View.VISIBLE else View.GONE
text = manga.unread.toString()
}
// Create thumbnail onclick to simulate long click
itemView.thumbnail.setOnClickListener {
// Simulate long click on this view to enter selection mode
onLongClick(itemView)
}
// Update the cover.
Glide.clear(itemView.thumbnail)
Glide.with(itemView.context)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.RESULT)
.centerCrop()
.dontAnimate()
.into(itemView.thumbnail)
}
package eu.kanade.tachiyomi.ui.library
import android.view.View
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.data.database.models.Manga
import kotlinx.android.synthetic.main.item_catalogue_list.view.*
/**
* 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 manga the manga to bind.
*/
override fun onSetValues(manga: Manga) {
// Update the title of the manga.
itemView.title.text = manga.title
// Update the unread count and its visibility.
with(itemView.unread_text) {
visibility = if (manga.unread > 0) View.VISIBLE else View.GONE
text = manga.unread.toString()
}
// Create thumbnail onclick to simulate long click
itemView.thumbnail.setOnClickListener {
// Simulate long click on this view to enter selection mode
onLongClick(itemView)
}
// Update the cover.
Glide.clear(itemView.thumbnail)
Glide.with(itemView.context)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.RESULT)
.centerCrop()
.dontAnimate()
.into(itemView.thumbnail)
}
}

View file

@ -1,11 +1,10 @@
package eu.kanade.tachiyomi.ui.library
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
class LibraryMangaEvent(val mangas: Map<Int, List<Manga>>) {
class LibraryMangaEvent(val mangas: Map<Int, List<LibraryItem>>) {
fun getMangaForCategory(category: Category): List<Manga>? {
fun getMangaForCategory(category: Category): List<LibraryItem>? {
return mangas[category.id]
}
}

View file

@ -1,373 +1,315 @@
package eu.kanade.tachiyomi.ui.library
import android.os.Bundle
import android.util.Pair
import com.hippo.unifile.UniFile
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.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.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.injectLazy
import java.io.IOException
import java.io.InputStream
import java.util.*
/**
* Presenter of [LibraryFragment].
*/
class LibraryPresenter : BasePresenter<LibraryFragment>() {
/**
* Database.
*/
private val db: DatabaseHelper by injectLazy()
/**
* Preferences.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* Cover cache.
*/
private val coverCache: CoverCache by injectLazy()
/**
* Source manager.
*/
private val sourceManager: SourceManager by injectLazy()
/**
* Download manager.
*/
private val downloadManager: DownloadManager by injectLazy()
/**
* Categories of the library.
*/
var categories: List<Category> = emptyList()
/**
* Currently selected manga.
*/
val selectedMangas = mutableListOf<Manga>()
/**
* Search query of the library.
*/
val searchSubject: BehaviorRelay<String> = BehaviorRelay.create()
/**
* Subject to notify the library's viewpager for updates.
*/
val libraryMangaSubject: BehaviorRelay<LibraryMangaEvent> = BehaviorRelay.create()
/**
* Subject to notify the UI of selection updates.
*/
val selectionSubject: PublishRelay<LibrarySelectionEvent> = PublishRelay.create()
/**
* 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 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(filterTriggerRelay.observeOn(Schedulers.io()),
{ lib, tick -> Pair(lib.first, applyFilters(lib.second)) })
.combineLatest(sortTriggerRelay.observeOn(Schedulers.io()),
{ lib, tick -> Pair(lib.first, applySort(lib.second)) })
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache({ view, pair ->
view.onNextLibraryUpdate(pair.first, pair.second)
})
}
}
/**
* Applies library filters to the given map of manga.
*
* @param map the map to filter.
*/
private fun applyFilters(map: Map<Int, List<Manga>>): Map<Int, List<Manga>> {
// Cached list of downloaded manga directories given a source id.
val mangaDirsForSource = mutableMapOf<Long, Map<String?, UniFile>>()
// Cached list of downloaded chapter directories for a manga.
val chapterDirectories = mutableMapOf<Long, Boolean>()
val filterDownloaded = preferences.filterDownloaded().getOrDefault()
val filterUnread = preferences.filterUnread().getOrDefault()
val filterFn: (Manga) -> Boolean = f@ { manga ->
// Filter out manga without source.
val source = sourceManager.get(manga.source) ?: return@f false
// Filter when there isn't unread chapters.
if (filterUnread && manga.unread == 0) {
return@f false
}
// Filter when the download directory doesn't exist or is null.
if (filterDownloaded) {
// Get the directories for the source of the manga.
val dirsForSource = mangaDirsForSource.getOrPut(source.id) {
val sourceDir = downloadManager.findSourceDir(source)
sourceDir?.listFiles()?.associateBy { it.name }.orEmpty()
}
val mangaDirName = downloadManager.getMangaDirName(manga)
val mangaDir = dirsForSource[mangaDirName] ?: return@f false
val hasDirs = chapterDirectories.getOrPut(manga.id!!) {
mangaDir.listFiles()?.isNotEmpty() ?: false
}
if (!hasDirs) {
return@f false
}
}
true
}
return map.mapValues { entry -> entry.value.filter(filterFn) }
}
/**
* Applies library sorting to the given map of manga.
*
* @param map the map to sort.
*/
private fun applySort(map: Map<Int, List<Manga>>): Map<Int, List<Manga>> {
val sortingMode = preferences.librarySortingMode().getOrDefault()
val lastReadManga by lazy {
var counter = 0
db.getLastReadManga().executeAsBlocking().associate { it.id!! to counter++ }
}
val sortFn: (Manga, Manga) -> Int = { manga1, manga2 ->
when (sortingMode) {
LibrarySort.ALPHA -> manga1.title.compareTo(manga2.title)
LibrarySort.LAST_READ -> {
// Get index of manga, set equal to list if size unknown.
val manga1LastRead = lastReadManga[manga1.id!!] ?: lastReadManga.size
val manga2LastRead = lastReadManga[manga2.id!!] ?: lastReadManga.size
manga1LastRead.compareTo(manga2LastRead)
}
LibrarySort.LAST_UPDATED -> manga2.last_update.compareTo(manga1.last_update)
LibrarySort.UNREAD -> manga1.unread.compareTo(manga2.unread)
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<Pair<List<Category>, Map<Int, List<Manga>>>> {
return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(),
{ dbCategories, libraryManga ->
val categories = if (libraryManga.containsKey(0))
arrayListOf(Category.createDefault()) + dbCategories
else
dbCategories
this.categories = categories
Pair(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<Map<Int, List<Manga>>> {
return db.getLibraryMangas().asRxObservable()
.map { list -> list.groupBy { it.category } }
}
/**
* Requests the library to be filtered.
*/
fun requestFilterUpdate() {
filterTriggerRelay.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) }
}
/**
* Sets the selection for a given manga.
*
* @param manga the manga whose selection has changed.
* @param selected whether it's now selected or not.
*/
fun setSelection(manga: Manga, selected: Boolean) {
if (selected) {
selectedMangas.add(manga)
selectionSubject.call(LibrarySelectionEvent.Selected(manga))
} else {
selectedMangas.remove(manga)
selectionSubject.call(LibrarySelectionEvent.Unselected(manga))
}
}
/**
* Clears all the manga selections and notifies the UI.
*/
fun clearSelections() {
selectedMangas.clear()
selectionSubject.call(LibrarySelectionEvent.Cleared())
}
/**
* 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 deleteChapters whether to also delete downloaded chapters.
*/
fun removeMangaFromLibrary(deleteChapters: Boolean) {
// Create a set of the list
val mangaToDelete = selectedMangas.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.findMangaDir(source, manga)?.delete()
}
}
}
}
.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 android.util.Pair
import com.hippo.unifile.UniFile
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.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.*
/**
* 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 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(filterTriggerRelay.observeOn(Schedulers.io()),
{ lib, _ -> Pair(lib.first, applyFilters(lib.second)) })
.combineLatest(sortTriggerRelay.observeOn(Schedulers.io()),
{ lib, _ -> Pair(lib.first, applySort(lib.second)) })
.map { Pair(it.first, it.second.mapValues { it.value.map(::LibraryItem) }) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache({ view, pair ->
view.onNextLibraryUpdate(pair.first, pair.second)
})
}
}
/**
* Applies library filters to the given map of manga.
*
* @param map the map to filter.
*/
private fun applyFilters(map: Map<Int, List<Manga>>): Map<Int, List<Manga>> {
// Cached list of downloaded manga directories given a source id.
val mangaDirsForSource = mutableMapOf<Long, Map<String?, UniFile>>()
// Cached list of downloaded chapter directories for a manga.
val chapterDirectories = mutableMapOf<Long, Boolean>()
val filterDownloaded = preferences.filterDownloaded().getOrDefault()
val filterUnread = preferences.filterUnread().getOrDefault()
val filterFn: (Manga) -> Boolean = f@ { manga ->
// Filter out manga without source.
val source = sourceManager.get(manga.source) ?: return@f false
// Filter when there isn't unread chapters.
if (filterUnread && manga.unread == 0) {
return@f false
}
// Filter when the download directory doesn't exist or is null.
if (filterDownloaded) {
// Get the directories for the source of the manga.
val dirsForSource = mangaDirsForSource.getOrPut(source.id) {
val sourceDir = downloadManager.findSourceDir(source)
sourceDir?.listFiles()?.associateBy { it.name }.orEmpty()
}
val mangaDirName = downloadManager.getMangaDirName(manga)
val mangaDir = dirsForSource[mangaDirName] ?: return@f false
val hasDirs = chapterDirectories.getOrPut(manga.id!!) {
mangaDir.listFiles()?.isNotEmpty() ?: false
}
if (!hasDirs) {
return@f false
}
}
true
}
return map.mapValues { entry -> entry.value.filter(filterFn) }
}
/**
* Applies library sorting to the given map of manga.
*
* @param map the map to sort.
*/
private fun applySort(map: Map<Int, List<Manga>>): Map<Int, List<Manga>> {
val sortingMode = preferences.librarySortingMode().getOrDefault()
val lastReadManga by lazy {
var counter = 0
db.getLastReadManga().executeAsBlocking().associate { it.id!! to counter++ }
}
val sortFn: (Manga, Manga) -> Int = { manga1, manga2 ->
when (sortingMode) {
LibrarySort.ALPHA -> manga1.title.compareTo(manga2.title)
LibrarySort.LAST_READ -> {
// Get index of manga, set equal to list if size unknown.
val manga1LastRead = lastReadManga[manga1.id!!] ?: lastReadManga.size
val manga2LastRead = lastReadManga[manga2.id!!] ?: lastReadManga.size
manga1LastRead.compareTo(manga2LastRead)
}
LibrarySort.LAST_UPDATED -> manga2.last_update.compareTo(manga1.last_update)
LibrarySort.UNREAD -> manga1.unread.compareTo(manga2.unread)
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<Pair<List<Category>, Map<Int, List<Manga>>>> {
return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(),
{ dbCategories, libraryManga ->
val categories = if (libraryManga.containsKey(0))
arrayListOf(Category.createDefault()) + dbCategories
else
dbCategories
this.categories = categories
Pair(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<Map<Int, List<Manga>>> {
return db.getLibraryMangas().asRxObservable()
.map { list -> list.groupBy { it.category } }
}
/**
* Requests the library to be filtered.
*/
fun requestFilterUpdate() {
filterTriggerRelay.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.findMangaDir(source, manga)?.delete()
}
}
}
}
.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
}
}

View file

@ -1,160 +1,247 @@
package eu.kanade.tachiyomi.ui.main
import android.content.Intent
import android.os.Bundle
import android.support.v4.app.Fragment
import android.support.v4.app.TaskStackBuilder
import android.support.v4.view.GravityCompat
import android.view.MenuItem
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.catalogue.CatalogueFragment
import eu.kanade.tachiyomi.ui.download.DownloadActivity
import eu.kanade.tachiyomi.ui.latest_updates.LatestUpdatesFragment
import eu.kanade.tachiyomi.ui.library.LibraryFragment
import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersFragment
import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadFragment
import eu.kanade.tachiyomi.ui.setting.SettingsActivity
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.toolbar.*
import uy.kohesive.injekt.injectLazy
class MainActivity : BaseActivity() {
val preferences: PreferencesHelper by injectLazy()
private val startScreenId by lazy {
when (preferences.startScreen()) {
1 -> R.id.nav_drawer_library
2 -> R.id.nav_drawer_recently_read
3 -> R.id.nav_drawer_recent_updates
else -> R.id.nav_drawer_library
}
}
override fun onCreate(savedState: Bundle?) {
setAppTheme()
super.onCreate(savedState)
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
if (!isTaskRoot) {
finish()
return
}
// Inflate activity_main.xml.
setContentView(R.layout.activity_main)
// Handle Toolbar
setupToolbar(toolbar, backNavigation = false)
supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_menu_white_24dp)
// Set behavior of Navigation drawer
nav_view.setNavigationItemSelectedListener { item ->
// Make information view invisible
empty_view.hide()
val id = item.itemId
val oldFragment = supportFragmentManager.findFragmentById(R.id.frame_container)
if (oldFragment == null || oldFragment.tag.toInt() != id) {
when (id) {
R.id.nav_drawer_library -> setFragment(LibraryFragment.newInstance(), id)
R.id.nav_drawer_recent_updates -> setFragment(RecentChaptersFragment.newInstance(), id)
R.id.nav_drawer_recently_read -> setFragment(RecentlyReadFragment.newInstance(), id)
R.id.nav_drawer_catalogues -> setFragment(CatalogueFragment.newInstance(), id)
R.id.nav_drawer_latest_updates -> setFragment(LatestUpdatesFragment.newInstance(), id)
R.id.nav_drawer_downloads -> startActivity(Intent(this, DownloadActivity::class.java))
R.id.nav_drawer_settings -> {
val intent = Intent(this, SettingsActivity::class.java)
startActivityForResult(intent, REQUEST_OPEN_SETTINGS)
}
}
}
drawer.closeDrawer(GravityCompat.START)
true
}
if (savedState == null) {
// Set start screen
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)
else -> setSelectedDrawerItem(startScreenId)
}
// Show changelog if needed
ChangelogDialogFragment.show(this, preferences, supportFragmentManager)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> drawer.openDrawer(GravityCompat.START)
else -> return super.onOptionsItemSelected(item)
}
return true
}
override fun onBackPressed() {
val fragment = supportFragmentManager.findFragmentById(R.id.frame_container)
if (drawer.isDrawerOpen(GravityCompat.START) || drawer.isDrawerOpen(GravityCompat.END)) {
drawer.closeDrawers()
} else if (fragment != null && fragment.tag.toInt() != startScreenId) {
if (resumed) {
setSelectedDrawerItem(startScreenId)
}
} else {
super.onBackPressed()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_OPEN_SETTINGS && resultCode != 0) {
if (resultCode and SettingsActivity.FLAG_DATABASE_CLEARED != 0) {
// If database is cleared avoid undefined behavior by recreating the stack.
TaskStackBuilder.create(this)
.addNextIntent(Intent(this, MainActivity::class.java))
.startActivities()
} else if (resultCode and SettingsActivity.FLAG_THEME_CHANGED != 0) {
// Delay activity recreation to avoid fragment leaks.
nav_view.post { recreate() }
} else if (resultCode and SettingsActivity.FLAG_LANG_CHANGED != 0) {
nav_view.post { recreate() }
}
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
private fun setSelectedDrawerItem(itemId: Int, triggerAction: Boolean = true) {
nav_view.setCheckedItem(itemId)
if (triggerAction) {
nav_view.menu.performIdentifierAction(itemId, 0)
}
}
private fun setFragment(fragment: Fragment, itemId: Int) {
supportFragmentManager.beginTransaction()
.replace(R.id.frame_container, fragment, "$itemId")
.commit()
}
fun updateEmptyView(show: Boolean, textResource: Int, drawable: Int) {
if (show) empty_view.show(drawable, textResource) else empty_view.hide()
}
companion object {
private const val REQUEST_OPEN_SETTINGS = 200
// Shortcut actions
private const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY"
private const val SHORTCUT_RECENTLY_UPDATED = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED"
private const val SHORTCUT_RECENTLY_READ = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ"
private const val SHORTCUT_CATALOGUES = "eu.kanade.tachiyomi.SHOW_CATALOGUES"
}
}
package eu.kanade.tachiyomi.ui.main
import android.animation.ObjectAnimator
import android.app.TaskStackBuilder
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.support.v4.view.GravityCompat
import android.support.v4.widget.DrawerLayout
import android.support.v7.graphics.drawable.DrawerArrowDrawable
import android.view.ViewGroup
import com.bluelinelabs.conductor.*
import com.bluelinelabs.conductor.changehandler.FadeChangeHandler
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.DialogController
import eu.kanade.tachiyomi.ui.base.controller.NoToolbarElevationController
import eu.kanade.tachiyomi.ui.base.controller.SecondaryDrawerController
import eu.kanade.tachiyomi.ui.base.controller.TabbedController
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
import eu.kanade.tachiyomi.ui.download.DownloadActivity
import eu.kanade.tachiyomi.ui.latest_updates.LatestUpdatesController
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.SettingsActivity
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.toolbar.*
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()) {
1 -> R.id.nav_drawer_library
2 -> R.id.nav_drawer_recently_read
3 -> R.id.nav_drawer_recent_updates
else -> R.id.nav_drawer_library
}
}
private val tabAnimator by lazy { TabsAnimator(tabs) }
override fun onCreate(savedInstanceState: Bundle?) {
setAppTheme()
super.onCreate(savedInstanceState)
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
if (!isTaskRoot) {
finish()
return
}
setContentView(R.layout.activity_main)
setSupportActionBar(toolbar)
drawerArrow = DrawerArrowDrawable(this)
drawerArrow?.color = Color.WHITE
toolbar.navigationIcon = drawerArrow
// 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_latest_updates -> setRoot(LatestUpdatesController(), id)
R.id.nav_drawer_downloads -> {
startActivity(Intent(this, DownloadActivity::class.java))
}
R.id.nav_drawer_settings -> {
val intent = Intent(this, SettingsActivity::class.java)
startActivityForResult(intent, REQUEST_OPEN_SETTINGS)
}
}
}
drawer.closeDrawer(GravityCompat.START)
true
}
val container = findViewById(R.id.controller_container) as ViewGroup
router = Conductor.attachRouter(this, container, savedInstanceState)
if (!router.hasRootController()) {
// Set start screen
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 -> router.setRoot(
RouterTransaction.with(MangaController(intent.extras)))
else -> 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())
// TODO changelog controller
if (savedInstanceState == null) {
// Show changelog if needed
ChangelogDialogFragment.show(this, preferences, supportFragmentManager)
}
}
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(RouterTransaction.with(controller)
.popChangeHandler(FadeChangeHandler())
.pushChangeHandler(FadeChangeHandler())
.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) {
to.configureTabs(tabs)
tabAnimator.expand()
} 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()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_OPEN_SETTINGS && resultCode != 0) {
if (resultCode and SettingsActivity.FLAG_DATABASE_CLEARED != 0) {
// If database is cleared avoid undefined behavior by recreating the stack.
TaskStackBuilder.create(this)
.addNextIntent(Intent(this, MainActivity::class.java))
.startActivities()
} else if (resultCode and SettingsActivity.FLAG_THEME_CHANGED != 0) {
// Delay activity recreation to avoid fragment leaks.
nav_view.post { recreate() }
} else if (resultCode and SettingsActivity.FLAG_LANG_CHANGED != 0) {
nav_view.post { recreate() }
}
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
companion object {
private const val REQUEST_OPEN_SETTINGS = 200
// Shortcut actions
private const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY"
private const val SHORTCUT_RECENTLY_UPDATED = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED"
private const val SHORTCUT_RECENTLY_READ = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ"
private const val SHORTCUT_CATALOGUES = "eu.kanade.tachiyomi.SHOW_CATALOGUES"
const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA"
}
}

View file

@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.ui.main
import android.support.design.widget.TabLayout
import android.view.animation.Animation
import android.view.animation.DecelerateInterpolator
import android.view.animation.Transformation
import eu.kanade.tachiyomi.util.gone
import eu.kanade.tachiyomi.util.visible
class TabsAnimator(val tabs: TabLayout) {
private var height = 0
private val interpolator = DecelerateInterpolator()
private val duration = 300L
private val expandAnimation = object : Animation() {
override fun applyTransformation(interpolatedTime: Float, t: Transformation) {
tabs.layoutParams.height = (height * interpolatedTime).toInt()
tabs.requestLayout()
}
override fun willChangeBounds(): Boolean {
return true
}
}
private val collapseAnimation = object : Animation() {
override fun applyTransformation(interpolatedTime: Float, t: Transformation) {
if (interpolatedTime == 1f) {
tabs.gone()
} else {
tabs.layoutParams.height = (height * (1 - interpolatedTime)).toInt()
tabs.requestLayout()
}
}
override fun willChangeBounds(): Boolean {
return true
}
}
init {
collapseAnimation.duration = duration
collapseAnimation.interpolator = interpolator
expandAnimation.duration = duration
expandAnimation.interpolator = interpolator
}
fun expand() {
tabs.visible()
if (measure() && tabs.measuredHeight != height) {
tabs.startAnimation(expandAnimation)
}
}
fun collapse() {
if (measure() && tabs.measuredHeight != 0) {
tabs.startAnimation(collapseAnimation)
} else {
tabs.gone()
}
}
/**
* Returns true if the view is measured, otherwise query dimensions and check again.
*/
private fun measure(): Boolean {
if (height > 0) return true
height = tabs.measuredHeight
return height > 0
}
}

View file

@ -1,141 +0,0 @@
package eu.kanade.tachiyomi.ui.manga
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.support.graphics.drawable.VectorDrawableCompat
import android.support.v4.app.Fragment
import android.support.v4.app.FragmentManager
import android.support.v4.app.FragmentPagerAdapter
import android.widget.LinearLayout
import android.widget.TextView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity
import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersFragment
import eu.kanade.tachiyomi.ui.manga.info.MangaInfoFragment
import eu.kanade.tachiyomi.ui.manga.track.TrackFragment
import eu.kanade.tachiyomi.util.SharedData
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.activity_manga.*
import kotlinx.android.synthetic.main.toolbar.*
import nucleus.factory.RequiresPresenter
@RequiresPresenter(MangaPresenter::class)
class MangaActivity : BaseRxActivity<MangaPresenter>() {
companion object {
const val FROM_CATALOGUE_EXTRA = "from_catalogue"
const val MANGA_EXTRA = "manga"
const val FROM_LAUNCHER_EXTRA = "from_launcher"
const val INFO_FRAGMENT = 0
const val CHAPTERS_FRAGMENT = 1
const val TRACK_FRAGMENT = 2
fun newIntent(context: Context, manga: Manga, fromCatalogue: Boolean = false): Intent {
SharedData.put(MangaEvent(manga))
return Intent(context, MangaActivity::class.java).apply {
putExtra(FROM_CATALOGUE_EXTRA, fromCatalogue)
putExtra(MANGA_EXTRA, manga.id)
}
}
}
private lateinit var adapter: MangaDetailAdapter
var fromCatalogue: Boolean = false
private set
override fun onCreate(savedState: Bundle?) {
setAppTheme()
super.onCreate(savedState)
setContentView(R.layout.activity_manga)
val fromLauncher = intent.getBooleanExtra(FROM_LAUNCHER_EXTRA, false)
// Remove any current manga if we are launching from launcher
if (fromLauncher) SharedData.remove(MangaEvent::class.java)
presenter.setMangaEvent(SharedData.getOrPut(MangaEvent::class.java) {
val id = intent.getLongExtra(MANGA_EXTRA, 0)
val dbManga = presenter.db.getManga(id).executeAsBlocking()
if (dbManga != null) {
MangaEvent(dbManga)
} else {
toast(R.string.manga_not_in_db)
finish()
return
}
})
setupToolbar(toolbar)
fromCatalogue = intent.getBooleanExtra(FROM_CATALOGUE_EXTRA, false)
adapter = MangaDetailAdapter(supportFragmentManager, this)
view_pager.offscreenPageLimit = 3
view_pager.adapter = adapter
tabs.setupWithViewPager(view_pager)
if (!fromCatalogue)
view_pager.currentItem = CHAPTERS_FRAGMENT
requestPermissionsOnMarshmallow()
}
fun onSetManga(manga: Manga) {
setToolbarTitle(manga.title)
}
fun setTrackingIcon(visible: Boolean) {
val tab = tabs.getTabAt(TRACK_FRAGMENT) ?: return
val drawable = if (visible)
VectorDrawableCompat.create(resources, R.drawable.ic_done_white_18dp, null)
else null
// I had no choice but to use reflection...
val field = tab.javaClass.getDeclaredField("mView").apply { isAccessible = true }
val view = field.get(tab) as LinearLayout
val textView = view.getChildAt(1) as TextView
textView.setCompoundDrawablesWithIntrinsicBounds(null, null, drawable, null)
textView.compoundDrawablePadding = 4
}
private class MangaDetailAdapter(fm: FragmentManager, activity: MangaActivity)
: FragmentPagerAdapter(fm) {
private var tabCount = 2
private val tabTitles = listOf(
R.string.manga_detail_tab,
R.string.manga_chapters_tab,
R.string.manga_tracking_tab)
.map { activity.getString(it) }
init {
if (!activity.fromCatalogue && activity.presenter.trackManager.hasLoggedServices())
tabCount++
}
override fun getCount(): Int {
return tabCount
}
override fun getItem(position: Int): Fragment {
when (position) {
INFO_FRAGMENT -> return MangaInfoFragment.newInstance()
CHAPTERS_FRAGMENT -> return ChaptersFragment.newInstance()
TRACK_FRAGMENT -> return TrackFragment.newInstance()
else -> throw Exception("Unknown position")
}
}
override fun getPageTitle(position: Int): CharSequence {
return tabTitles[position]
}
}
}

View file

@ -0,0 +1,186 @@
package eu.kanade.tachiyomi.ui.manga
import android.Manifest.permission.READ_EXTERNAL_STORAGE
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.os.Build
import android.os.Bundle
import android.support.design.widget.TabLayout
import android.support.graphics.drawable.VectorDrawableCompat
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import com.bluelinelabs.conductor.Router
import com.bluelinelabs.conductor.RouterTransaction
import com.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.RouterPagerAdapter
import eu.kanade.tachiyomi.ui.base.controller.RxController
import eu.kanade.tachiyomi.ui.base.controller.TabbedController
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.activity_main.*
import kotlinx.android.synthetic.main.manga_controller.view.*
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class MangaController : RxController, TabbedController {
constructor(manga: Manga?, fromCatalogue: Boolean = false) : super(Bundle().apply {
putLong(MANGA_EXTRA, manga?.id!!)
putBoolean(FROM_CATALOGUE_EXTRA, fromCatalogue)
}) {
this.manga = manga
if (manga != null) {
source = Injekt.get<SourceManager>().get(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 chapterCountRelay: BehaviorRelay<Int> = BehaviorRelay.create()
val mangaFavoriteRelay: PublishRelay<Boolean> = PublishRelay.create()
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, savedViewState: Bundle?) {
super.onViewCreated(view, savedViewState)
if (manga == null || source == null) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
requestPermissions(arrayOf(WRITE_EXTERNAL_STORAGE, READ_EXTERNAL_STORAGE), 301)
}
with(view) {
adapter = MangaDetailAdapter()
view_pager.offscreenPageLimit = 3
view_pager.adapter = adapter
if (!fromCatalogue)
view_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(view?.view_pager)
}
}
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) {
setTrackingIcon(false)
}
fun setTrackingIcon(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
// I had no choice but to use reflection...
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("mView")
.apply { isAccessible = true }
}
}

View file

@ -1,5 +0,0 @@
package eu.kanade.tachiyomi.ui.manga
import eu.kanade.tachiyomi.data.database.models.Manga
class MangaEvent(val manga: Manga)

View file

@ -1,55 +0,0 @@
package eu.kanade.tachiyomi.ui.manga
import android.os.Bundle
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.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.manga.info.ChapterCountEvent
import eu.kanade.tachiyomi.ui.manga.info.MangaFavoriteEvent
import eu.kanade.tachiyomi.util.SharedData
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
import rx.Observable
import rx.Subscription
import uy.kohesive.injekt.injectLazy
/**
* Presenter of [MangaActivity].
*/
class MangaPresenter : BasePresenter<MangaActivity>() {
/**
* Database helper.
*/
val db: DatabaseHelper by injectLazy()
/**
* Tracking manager.
*/
val trackManager: TrackManager by injectLazy()
/**
* Manga associated with this instance.
*/
lateinit var manga: Manga
var mangaSubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
// Prepare a subject to communicate the chapters and info presenters for the chapter count.
SharedData.put(ChapterCountEvent())
// Prepare a subject to communicate the chapters and info presenters for the chapter favorite.
SharedData.put(MangaFavoriteEvent())
}
fun setMangaEvent(event: MangaEvent) {
if (mangaSubscription.isNullOrUnsubscribed()) {
manga = event.manga
mangaSubscription = Observable.just(manga)
.subscribeLatestCache(MangaActivity::onSetManga)
}
}
}

View file

@ -6,23 +6,13 @@ import eu.davidea.viewholders.FlexibleViewHolder
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.util.getResourceColor
import kotlinx.android.synthetic.main.item_chapter.view.*
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.*
class ChapterHolder(
private val view: View,
private val adapter: ChaptersAdapter)
: FlexibleViewHolder(view, adapter) {
private val readColor = view.context.getResourceColor(android.R.attr.textColorHint)
private val unreadColor = view.context.getResourceColor(android.R.attr.textColorPrimary)
private val bookmarkedColor = view.context.getResourceColor(R.attr.colorAccent)
private val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' })
private val df = DateFormat.getDateInstance(DateFormat.SHORT)
private val adapter: ChaptersAdapter
) : FlexibleViewHolder(view, adapter) {
init {
// We need to post a Runnable to show the popup to make sure that the PopupMenu is
@ -36,19 +26,19 @@ class ChapterHolder(
chapter_title.text = when (manga.displayMode) {
Manga.DISPLAY_NUMBER -> {
val formattedNumber = decimalFormat.format(chapter.chapter_number.toDouble())
context.getString(R.string.display_mode_chapter, formattedNumber)
val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble())
context.getString(R.string.display_mode_chapter, number)
}
else -> chapter.name
}
// Set correct text color
chapter_title.setTextColor(if (chapter.read) readColor else unreadColor)
if (chapter.bookmark) chapter_title.setTextColor(bookmarkedColor)
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 = df.format(Date(chapter.date_upload))
chapter_date.setTextColor(if (chapter.read) readColor else unreadColor)
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 = ""
}
@ -105,7 +95,7 @@ class ChapterHolder(
// Set a listener so we are notified if a menu item is clicked
popup.setOnMenuItemClickListener { menuItem ->
adapter.menuItemListener(adapterPosition, menuItem)
adapter.menuItemListener.onMenuItemClick(adapterPosition, menuItem)
true
}

View file

@ -1,50 +1,57 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.LayoutInflater
import android.view.ViewGroup
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
class ChapterItem(val chapter: Chapter, val manga: Manga) : AbstractFlexibleItem<ChapterHolder>(),
Chapter by chapter {
private var _status: Int = 0
var status: Int
get() = download?.status ?: _status
set(value) { _status = value }
@Transient var download: Download? = null
val isDownloaded: Boolean
get() = status == Download.DOWNLOADED
override fun getLayoutRes(): Int {
return R.layout.item_chapter
}
override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, parent: ViewGroup): ChapterHolder {
return ChapterHolder(inflater.inflate(layoutRes, parent, false), adapter as ChaptersAdapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: ChapterHolder, position: Int, payloads: List<Any?>?) {
holder.bind(this, manga)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is ChapterItem) {
return chapter.id!! == other.chapter.id!!
}
return false
}
override fun hashCode(): Int {
return chapter.id!!.hashCode()
}
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.LayoutInflater
import android.view.ViewGroup
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
class ChapterItem(val chapter: Chapter, val manga: Manga) : AbstractFlexibleItem<ChapterHolder>(),
Chapter by chapter {
private var _status: Int = 0
var status: Int
get() = download?.status ?: _status
set(value) { _status = value }
@Transient var download: Download? = null
val isDownloaded: Boolean
get() = status == Download.DOWNLOADED
override fun getLayoutRes(): Int {
return R.layout.item_chapter
}
override fun createViewHolder(adapter: FlexibleAdapter<*>,
inflater: LayoutInflater,
parent: ViewGroup): ChapterHolder {
return ChapterHolder(inflater.inflate(layoutRes, parent, false), adapter as ChaptersAdapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<*>,
holder: ChapterHolder,
position: Int,
payloads: List<Any?>?) {
holder.bind(this, manga)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is ChapterItem) {
return chapter.id!! == other.chapter.id!!
}
return false
}
override fun hashCode(): Int {
return chapter.id!!.hashCode()
}
}

View file

@ -1,19 +1,45 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.MenuItem
import eu.davidea.flexibleadapter.FlexibleAdapter
class ChaptersAdapter(val fragment: ChaptersFragment) : FlexibleAdapter<ChapterItem>(null, fragment, true) {
var items: List<ChapterItem> = emptyList()
val menuItemListener: (Int, MenuItem) -> Unit = { position, item ->
fragment.onItemMenuClick(position, item)
}
override fun updateDataSet(items: List<ChapterItem>) {
this.items = items
super.updateDataSet(items.toList())
}
}
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
super.updateDataSet(items.toList())
}
fun indexOf(item: ChapterItem): Int {
return items.indexOf(item)
}
interface OnMenuItemClickListener {
fun onMenuItemClick(position: Int, item: MenuItem)
}
}

View file

@ -0,0 +1,470 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.support.design.widget.Snackbar
import android.support.v7.app.AppCompatActivity
import android.support.v7.view.ActionMode
import android.support.v7.widget.DividerItemDecoration
import android.support.v7.widget.LinearLayoutManager
import android.view.*
import com.jakewharton.rxbinding.support.v4.widget.refreshes
import com.jakewharton.rxbinding.view.clicks
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.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.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.fragment_manga_chapters.view.*
import timber.log.Timber
class ChaptersController : NucleusController<ChaptersPresenter>(),
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
ChaptersAdapter.OnMenuItemClickListener,
SetDisplayModeDialog.Listener,
SetSortingDialog.Listener,
DownloadChaptersDialog.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.mangaFavoriteRelay)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.fragment_manga_chapters, container, false)
}
override fun onViewCreated(view: View, savedViewState: Bundle?) {
super.onViewCreated(view, savedViewState)
// Init RecyclerView and adapter
adapter = ChaptersAdapter(this, view.context)
with(view) {
recycler.adapter = adapter
recycler.layoutManager = LinearLayoutManager(context)
recycler.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
recycler.setHasFixedSize(true)
// TODO enable in a future commit
// adapter.setFastScroller(fast_scroller, context.getResourceColor(R.attr.colorAccent))
// adapter.toggleFastScroller()
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 {
context.toast(R.string.no_next_chapter)
}
}
}
}
override fun onDestroyView(view: View) {
super.onDestroyView(view)
adapter = null
actionMode = null
}
override fun onActivityResumed(activity: Activity) {
val view = view ?: return
// Check if animation view is visible
if (view.reveal_view.visibility == View.VISIBLE) {
// Show the unReveal effect
val coordinates = view.fab.getCoordinates()
view.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()
}
}
fun fetchChaptersFromSource() {
view?.swipe_refresh?.isRefreshing = true
presenter.fetchChaptersFromSource()
}
fun onFetchChaptersDone() {
view?.swipe_refresh?.isRefreshing = false
}
fun onFetchChaptersError(error: Throwable) {
view?.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 view?.recycler?.findViewHolderForItemId(chapter.id!!) as? ChapterHolder
}
fun openChapter(chapter: Chapter, hasAnimation: Boolean = false) {
val activity = activity ?: return
val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter)
if (hasAnimation) {
intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
}
startActivity(intent)
}
override fun onItemClick(position: Int): Boolean {
val adapter = adapter ?: return false
val item = adapter.getItem(position) ?: return false
if (actionMode != null && adapter.mode == FlexibleAdapter.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()
}
fun getSelectedChapters(): List<ChapterItem> {
val adapter = adapter ?: return emptyList()
return adapter.selectedPositions.map { adapter.getItem(it) }
}
fun createActionModeIfNeeded() {
if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
}
}
fun destroyActionModeIfNeeded() {
actionMode?.finish()
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.chapter_selection, menu)
adapter?.mode = FlexibleAdapter.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
}
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 = FlexibleAdapter.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
fun selectAll() {
val adapter = adapter ?: return
adapter.selectAll()
selectedItems.addAll(adapter.items)
actionMode?.invalidate()
}
fun markAsRead(chapters: List<ChapterItem>) {
presenter.markChaptersRead(chapters, true)
if (presenter.preferences.removeAfterMarkedAsRead()) {
deleteChapters(chapters)
}
}
fun markAsUnread(chapters: List<ChapterItem>) {
presenter.markChaptersRead(chapters, false)
}
fun downloadChapters(chapters: List<ChapterItem>) {
val view = view
destroyActionModeIfNeeded()
presenter.downloadChapters(chapters)
if (view != null && !presenter.manga.favorite) {
view.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())
}
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) {
presenter.markChaptersRead(chapters.take(chapterPos), true)
}
}
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)
}
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)
}
override fun downloadChapters(choice: Int) {
fun getUnreadChaptersSorted() = presenter.chapters
.filter { !it.read && it.status == Download.NOT_DOWNLOADED }
.distinctBy { it.name }
.sortedByDescending { it.source_order }
// i = 0: Download 1
// i = 1: Download 5
// i = 2: Download 10
// i = 3: Download unread
// i = 4: Download all
val chaptersToDownload = when (choice) {
0 -> getUnreadChaptersSorted().take(1)
1 -> getUnreadChaptersSorted().take(5)
2 -> getUnreadChaptersSorted().take(10)
3 -> presenter.chapters.filter { !it.read }
4 -> presenter.chapters
else -> emptyList()
}
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
}
}

View file

@ -1,454 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.content.Intent
import android.os.Bundle
import android.support.design.widget.Snackbar
import android.support.v4.app.DialogFragment
import android.support.v7.app.AppCompatActivity
import android.support.v7.view.ActionMode
import android.support.v7.widget.DividerItemDecoration
import android.support.v7.widget.LinearLayoutManager
import android.view.*
import com.afollestad.materialdialogs.MaterialDialog
import eu.davidea.flexibleadapter.FlexibleAdapter
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.fragment.BaseRxFragment
import eu.kanade.tachiyomi.ui.manga.MangaActivity
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 eu.kanade.tachiyomi.widget.DeletingChaptersDialog
import kotlinx.android.synthetic.main.fragment_manga_chapters.*
import nucleus.factory.RequiresPresenter
import timber.log.Timber
@RequiresPresenter(ChaptersPresenter::class)
class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(),
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener {
companion object {
/**
* Creates a new instance of this fragment.
*
* @return a new instance of [ChaptersFragment].
*/
fun newInstance(): ChaptersFragment {
return ChaptersFragment()
}
}
/**
* Adapter containing a list of chapters.
*/
private lateinit var adapter: ChaptersAdapter
/**
* Action mode for multiple selection.
*/
private var actionMode: ActionMode? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_manga_chapters, container, false)
}
override fun onViewCreated(view: View, savedState: Bundle?) {
// Init RecyclerView and adapter
adapter = ChaptersAdapter(this)
recycler.adapter = adapter
recycler.layoutManager = LinearLayoutManager(activity)
recycler.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
recycler.setHasFixedSize(true)
// TODO enable in a future commit
// adapter.setFastScroller(fast_scroller, context.getResourceColor(R.attr.colorAccent))
// adapter.toggleFastScroller()
swipe_refresh.setOnRefreshListener { fetchChapters() }
fab.setOnClickListener {
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 {
context.toast(R.string.no_next_chapter)
}
}
}
override fun onResume() {
// 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.onResume()
}
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.supportInvalidateOptionsMenu()
}
R.id.action_filter_read -> {
item.isChecked = !item.isChecked
presenter.setReadFilter(item.isChecked)
activity.supportInvalidateOptionsMenu()
}
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.supportInvalidateOptionsMenu()
}
R.id.action_sort -> presenter.revertSortOrder()
else -> return super.onOptionsItemSelected(item)
}
return true
}
@Suppress("UNUSED_PARAMETER")
fun onNextManga(manga: Manga) {
// Set initial values
activity.supportInvalidateOptionsMenu()
}
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()
destroyActionModeIfNeeded()
adapter.updateDataSet(chapters)
}
private fun initialFetchChapters() {
// Only fetch if this view is from the catalog and it hasn't requested previously
if (isCatalogueManga && !presenter.hasRequested) {
fetchChapters()
}
}
fun fetchChapters() {
swipe_refresh.isRefreshing = true
presenter.fetchChaptersFromSource()
}
fun onFetchChaptersDone() {
swipe_refresh.isRefreshing = false
}
fun onFetchChaptersError(error: Throwable) {
swipe_refresh.isRefreshing = false
context.toast(error.message)
}
val isCatalogueManga: Boolean
get() = (activity as MangaActivity).fromCatalogue
fun openChapter(chapter: Chapter, hasAnimation: Boolean = false) {
val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter)
if (hasAnimation) {
intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
}
startActivity(intent)
}
private fun showDisplayModeDialog() {
// Get available modes, ids and the selected mode
val modes = intArrayOf(R.string.show_title, R.string.show_chapter_number)
val ids = intArrayOf(Manga.DISPLAY_NAME, Manga.DISPLAY_NUMBER)
val selectedIndex = if (presenter.manga.displayMode == Manga.DISPLAY_NAME) 0 else 1
MaterialDialog.Builder(activity)
.title(R.string.action_display_mode)
.items(modes.map { getString(it) })
.itemsIds(ids)
.itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ ->
// Save the new display mode
presenter.setDisplayMode(itemView.id)
// Refresh ui
adapter.notifyItemRangeChanged(0, adapter.itemCount)
true
}
.show()
}
private fun showSortingDialog() {
// Get available modes, ids and the selected mode
val modes = intArrayOf(R.string.sort_by_source, R.string.sort_by_number)
val ids = intArrayOf(Manga.SORTING_SOURCE, Manga.SORTING_NUMBER)
val selectedIndex = if (presenter.manga.sorting == Manga.SORTING_SOURCE) 0 else 1
MaterialDialog.Builder(activity)
.title(R.string.sorting_mode)
.items(modes.map { getString(it) })
.itemsIds(ids)
.itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ ->
// Save the new sorting mode
presenter.setSorting(itemView.id)
true
}
.show()
}
private fun showDownloadDialog() {
// Get available modes
val modes = intArrayOf(R.string.download_1, R.string.download_5, R.string.download_10,
R.string.download_unread, R.string.download_all)
MaterialDialog.Builder(activity)
.title(R.string.manga_download)
.negativeText(android.R.string.cancel)
.items(modes.map { getString(it) })
.itemsCallback { _, _, i, _ ->
fun getUnreadChaptersSorted() = presenter.chapters
.filter { !it.read && it.status == Download.NOT_DOWNLOADED }
.distinctBy { it.name }
.sortedByDescending { it.source_order }
// i = 0: Download 1
// i = 1: Download 5
// i = 2: Download 10
// i = 3: Download unread
// i = 4: Download all
val chaptersToDownload = when (i) {
0 -> getUnreadChaptersSorted().take(1)
1 -> getUnreadChaptersSorted().take(5)
2 -> getUnreadChaptersSorted().take(10)
3 -> presenter.chapters.filter { !it.read }
4 -> presenter.chapters
else -> emptyList()
}
if (chaptersToDownload.isNotEmpty()) {
downloadChapters(chaptersToDownload)
}
}
.show()
}
fun onChapterStatusChange(download: Download) {
getHolder(download.chapter)?.notifyStatus(download.status)
}
private fun getHolder(chapter: Chapter): ChapterHolder? {
return recycler.findViewHolderForItemId(chapter.id!!) as? ChapterHolder
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.chapter_selection, menu)
adapter.mode = FlexibleAdapter.MODE_MULTI
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
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 -> {
MaterialDialog.Builder(activity)
.content(R.string.confirm_delete_chapters)
.positiveText(android.R.string.yes)
.negativeText(android.R.string.no)
.onPositive { _, _ -> deleteChapters(getSelectedChapters()) }
.show()
}
else -> return false
}
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
adapter.mode = FlexibleAdapter.MODE_SINGLE
adapter.clearSelection()
actionMode = null
}
fun getSelectedChapters(): List<ChapterItem> {
return adapter.selectedPositions.map { adapter.getItem(it) }
}
fun destroyActionModeIfNeeded() {
actionMode?.finish()
}
fun selectAll() {
adapter.selectAll()
setContextTitle(adapter.selectedItemCount)
}
fun markAsRead(chapters: List<ChapterItem>) {
presenter.markChaptersRead(chapters, true)
if (presenter.preferences.removeAfterMarkedAsRead()) {
deleteChapters(chapters)
}
}
fun markAsUnread(chapters: List<ChapterItem>) {
presenter.markChaptersRead(chapters, false)
}
fun markPreviousAsRead(chapter: ChapterItem) {
val chapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items
val chapterPos = chapters.indexOf(chapter)
if (chapterPos != -1) {
presenter.markChaptersRead(chapters.take(chapterPos), true)
}
}
fun downloadChapters(chapters: List<ChapterItem>) {
destroyActionModeIfNeeded()
presenter.downloadChapters(chapters)
if (!presenter.manga.favorite){
recycler.snack(getString(R.string.snack_add_to_library), Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_add) {
presenter.addToLibrary()
}
}
}
}
fun bookmarkChapters(chapters: List<ChapterItem>, bookmarked: Boolean) {
destroyActionModeIfNeeded()
presenter.bookmarkChapters(chapters, bookmarked)
}
fun deleteChapters(chapters: List<ChapterItem>) {
destroyActionModeIfNeeded()
DeletingChaptersDialog().show(childFragmentManager, DeletingChaptersDialog.TAG)
presenter.deleteChapters(chapters)
}
fun onChaptersDeleted() {
dismissDeletingDialog()
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
fun onChaptersDeletedError(error: Throwable) {
dismissDeletingDialog()
Timber.e(error)
}
fun dismissDeletingDialog() {
(childFragmentManager.findFragmentByTag(DeletingChaptersDialog.TAG) as? DialogFragment)
?.dismissAllowingStateLoss()
}
override fun onItemClick(position: Int): Boolean {
val item = adapter.getItem(position) ?: return false
if (actionMode != null && adapter.mode == FlexibleAdapter.MODE_MULTI) {
toggleSelection(position)
return true
} else {
openChapter(item.chapter)
return false
}
}
override fun onItemLongClick(position: Int) {
if (actionMode == null)
actionMode = (activity as AppCompatActivity).startSupportActionMode(this)
toggleSelection(position)
}
fun onItemMenuClick(position: Int, item: MenuItem) {
val chapter = adapter.getItem(position)?.let { listOf(it) } ?: return
when (item.itemId) {
R.id.action_download -> downloadChapters(chapter)
R.id.action_bookmark -> bookmarkChapters(chapter, true)
R.id.action_remove_bookmark -> bookmarkChapters(chapter, false)
R.id.action_delete -> deleteChapters(chapter)
R.id.action_mark_as_read -> markAsRead(chapter)
R.id.action_mark_as_unread -> markAsUnread(chapter)
R.id.action_mark_previous_as_read -> markPreviousAsRead(chapter[0])
}
}
private fun toggleSelection(position: Int) {
adapter.toggleSelection(position)
val count = adapter.selectedItemCount
if (count == 0) {
actionMode?.finish()
} else {
setContextTitle(count)
actionMode?.invalidate()
}
}
private fun setContextTitle(count: Int) {
actionMode?.title = getString(R.string.label_selected, count)
}
}

View file

@ -1,446 +1,415 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.os.Bundle
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.DownloadService
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.manga.MangaEvent
import eu.kanade.tachiyomi.ui.manga.info.ChapterCountEvent
import eu.kanade.tachiyomi.ui.manga.info.MangaFavoriteEvent
import eu.kanade.tachiyomi.util.SharedData
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.injectLazy
/**
* Presenter of [ChaptersFragment].
*/
class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/**
* Database helper.
*/
val db: DatabaseHelper by injectLazy()
/**
* Source manager.
*/
val sourceManager: SourceManager by injectLazy()
/**
* Preferences.
*/
val preferences: PreferencesHelper by injectLazy()
/**
* Downloads manager.
*/
val downloadManager: DownloadManager by injectLazy()
/**
* Active manga.
*/
lateinit var manga: Manga
private set
/**
* Source of the manga.
*/
lateinit var source: Source
private set
/**
* 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)
// Find the active manga from the shared data or return.
manga = SharedData.get(MangaEvent::class.java)?.manga ?: return
source = sourceManager.get(manga.source)!!
Observable.just(manga)
.subscribeLatestCache(ChaptersFragment::onNextManga)
// Prepare the relay.
chaptersRelay.flatMap { applyChapterFilters(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(ChaptersFragment::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.
SharedData.get(ChapterCountEvent::class.java)?.emit(chapters.size)
}
.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(ChaptersFragment::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>) {
val files = downloadManager.findMangaDir(source, manga)?.listFiles() ?: return
val cached = mutableMapOf<Chapter, String>()
files.mapNotNull { it.name }
.mapNotNull { name -> chapters.find {
name == cached.getOrPut(it) { downloadManager.getChapterDirName(it) }
} }
.forEach { it.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()
}, ChaptersFragment::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 }
}
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>) {
DownloadService.start(context)
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.from(chapters)
.doOnNext { deleteChapter(it) }
.toList()
.doOnNext { if (onlyDownloaded()) refreshChapters() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
view.onChaptersDeleted()
}, ChaptersFragment::onChaptersDeletedError)
}
/**
* Deletes a chapter from disk. This method is called in a background thread.
* @param chapter the chapter to delete.
*/
private fun deleteChapter(chapter: ChapterItem) {
downloadManager.queue.remove(chapter)
downloadManager.deleteChapter(source, manga, chapter)
chapter.status = Download.NOT_DOWNLOADED
chapter.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() {
SharedData.get(MangaFavoriteEvent::class.java)?.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.DownloadService
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
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
/**
* Presenter of [ChaptersController].
*/
class ChaptersPresenter(
val manga: Manga,
val source: Source,
private val chapterCountRelay: BehaviorRelay<Int>,
private val mangaFavoriteRelay: PublishRelay<Boolean>,
val preferences: PreferencesHelper = Injekt.get(),
private val db: DatabaseHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get()
) : BasePresenter<ChaptersController>() {
private val context = preferences.context
/**
* 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.size)
}
.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>) {
val files = downloadManager.findMangaDir(source, manga)?.listFiles() ?: return
val cached = mutableMapOf<Chapter, String>()
files.mapNotNull { it.name }
.mapNotNull { name -> chapters.find {
name == cached.getOrPut(it) { downloadManager.getChapterDirName(it) }
} }
.forEach { it.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 }
}
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>) {
DownloadService.start(context)
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.from(chapters)
.doOnNext { deleteChapter(it) }
.toList()
.doOnNext { if (onlyDownloaded()) refreshChapters() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
view.onChaptersDeleted()
}, ChaptersController::onChaptersDeletedError)
}
/**
* Deletes a chapter from disk. This method is called in a background thread.
* @param chapter the chapter to delete.
*/
private fun deleteChapter(chapter: ChapterItem) {
downloadManager.queue.remove(chapter)
downloadManager.deleteChapter(source, manga, chapter)
chapter.status = Download.NOT_DOWNLOADED
chapter.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()
}
}

View file

@ -0,0 +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()
}
}

View file

@ -0,0 +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)
}
}

View file

@ -0,0 +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_unread,
R.string.download_all
).map { activity.getString(it) }
return MaterialDialog.Builder(activity)
.title(R.string.manga_download)
.negativeText(android.R.string.cancel)
.items(choices)
.itemsCallback { _, _, position, _ ->
(targetController as? Listener)?.downloadChapters(position)
}
.build()
}
interface Listener {
fun downloadChapters(choice: Int)
}
}

View file

@ -0,0 +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)
}
}

View file

@ -0,0 +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)
}
}

View file

@ -1,16 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import rx.Observable
import rx.subjects.BehaviorSubject
class ChapterCountEvent {
private val subject = BehaviorSubject.create<Int>()
val observable: Observable<Int>
get() = subject
fun emit(count: Int) {
subject.onNext(count)
}
}

View file

@ -1,16 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import com.jakewharton.rxrelay.PublishRelay
import rx.Observable
class MangaFavoriteEvent {
private val subject = PublishRelay.create<Boolean>()
val observable: Observable<Boolean>
get() = subject
fun call(favorite: Boolean) {
subject.call(favorite)
}
}

View file

@ -0,0 +1,399 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.support.customtabs.CustomTabsIntent
import android.view.*
import com.afollestad.materialdialogs.MaterialDialog
import com.bumptech.glide.BitmapRequestBuilder
import com.bumptech.glide.BitmapTypeRequest
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import com.jakewharton.rxbinding.support.v4.widget.refreshes
import com.jakewharton.rxbinding.view.clicks
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.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.getResourceColor
import eu.kanade.tachiyomi.util.snack
import eu.kanade.tachiyomi.util.toast
import jp.wasabeef.glide.transformations.CropCircleTransformation
import jp.wasabeef.glide.transformations.CropSquareTransformation
import jp.wasabeef.glide.transformations.MaskTransformation
import jp.wasabeef.glide.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.fragment_manga_info.view.*
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subscriptions.Subscriptions
import uy.kohesive.injekt.injectLazy
/**
* Fragment that shows manga information.
* Uses R.layout.fragment_manga_info.
* UI related actions should be called from here.
*/
class MangaInfoController : NucleusController<MangaInfoPresenter>(),
ChangeMangaCategoriesDialog.Listener {
/**
* Preferences helper.
*/
private val preferences: PreferencesHelper by injectLazy()
init {
setHasOptionsMenu(true)
setOptionsMenuHidden(true)
}
override fun createPresenter(): MangaInfoPresenter {
val ctrl = parentController as MangaController
return MangaInfoPresenter(ctrl.manga!!, ctrl.source!!,
ctrl.chapterCountRelay, ctrl.mangaFavoriteRelay)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.fragment_manga_info, container, false)
}
override fun onViewCreated(view: View, savedViewState: Bundle?) {
super.onViewCreated(view, savedViewState)
with(view) {
// Set onclickListener to toggle favorite when FAB clicked.
fab_favorite.clicks().subscribeUntilDestroy { onFabClick() }
// Set SwipeRefresh to refresh manga data.
swipe_refresh.refreshes().subscribeUntilDestroy { fetchMangaFromSource() }
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.manga_info, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_open_in_browser -> openInBrowser()
R.id.action_share -> shareManga()
R.id.action_add_to_home_screen -> addToHomeScreen()
else -> return super.onOptionsItemSelected(item)
}
return true
}
/**
* Check if manga is initialized.
* If true update view with manga information,
* if false fetch manga information
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
fun onNextManga(manga: Manga, source: Source) {
if (manga.initialized) {
// Update view.
setMangaInfo(manga, source)
} else {
// Initialize manga.
fetchMangaFromSource()
}
}
/**
* Update the view with manga information.
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
private fun setMangaInfo(manga: Manga, source: Source?) {
val view = view ?: return
with(view) {
// Update artist TextView.
manga_artist.text = manga.artist
// Update author TextView.
manga_author.text = manga.author
// If manga source is known update source TextView.
if (source != null) {
manga_source.text = source.toString()
}
// Update genres TextView.
manga_genres.text = manga.genre
// Update status TextView.
manga_status.setText(when (manga.status) {
SManga.ONGOING -> R.string.ongoing
SManga.COMPLETED -> R.string.completed
SManga.LICENSED -> R.string.licensed
else -> R.string.unknown
})
// Update description TextView.
manga_summary.text = manga.description
// Set the favorite drawable to the correct one.
setFavoriteDrawable(manga.favorite)
// Set cover if it wasn't already.
if (manga_cover.drawable == null && !manga.thumbnail_url.isNullOrEmpty()) {
Glide.with(context)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.RESULT)
.centerCrop()
.into(manga_cover)
Glide.with(context)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.RESULT)
.centerCrop()
.into(backdrop)
}
}
}
/**
* Update chapter count TextView.
*
* @param count number of chapters.
*/
fun setChapterCount(count: Int) {
view?.manga_chapters?.text = count.toString()
}
/**
* Toggles the favorite status and asks for confirmation to delete downloaded chapters.
*/
fun toggleFavorite() {
val view = view
val isNowFavorite = presenter.toggleFavorite()
if (view != null && !isNowFavorite && presenter.hasDownloads()) {
view.snack(view.context.getString(R.string.delete_downloads_for_manga)) {
setAction(R.string.action_delete) {
presenter.deleteDownloads()
}
}
}
}
/**
* Open the manga in browser.
*/
fun openInBrowser() {
val context = view?.context ?: return
val source = presenter.source as? HttpSource ?: return
try {
val url = Uri.parse(source.mangaDetailsRequest(presenter.manga).url().toString())
val intent = CustomTabsIntent.Builder()
.setToolbarColor(context.getResourceColor(R.attr.colorPrimary))
.build()
intent.launchUrl(activity, url)
} catch (e: Exception) {
context.toast(e.message)
}
}
/**
* Called to run Intent with [Intent.ACTION_SEND], which show share dialog.
*/
private fun shareManga() {
val context = view?.context ?: return
val source = presenter.source as? HttpSource ?: return
try {
val url = source.mangaDetailsRequest(presenter.manga).url().toString()
val title = presenter.manga.title
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, context.getString(R.string.share_text, title, url))
}
startActivity(Intent.createChooser(intent, context.getString(R.string.action_share)))
} catch (e: Exception) {
context.toast(e.message)
}
}
/**
* Update FAB with correct drawable.
*
* @param isFavorite determines if manga is favorite or not.
*/
private fun setFavoriteDrawable(isFavorite: Boolean) {
// Set the Favorite drawable to the correct one.
// Border drawable if false, filled drawable if true.
view?.fab_favorite?.setImageResource(if (isFavorite)
R.drawable.ic_bookmark_white_24dp
else
R.drawable.ic_bookmark_border_white_24dp)
}
/**
* Start fetching manga information from source.
*/
private fun fetchMangaFromSource() {
setRefreshing(true)
// Call presenter and start fetching manga information
presenter.fetchMangaFromSource()
}
/**
* Update swipe refresh to stop showing refresh in progress spinner.
*/
fun onFetchMangaDone() {
setRefreshing(false)
}
/**
* Update swipe refresh to start showing refresh in progress spinner.
*/
fun onFetchMangaError() {
setRefreshing(false)
}
/**
* Set swipe refresh status.
*
* @param value whether it should be refreshing or not.
*/
private fun setRefreshing(value: Boolean) {
view?.swipe_refresh?.isRefreshing = value
}
/**
* Called when the fab is clicked.
*/
private fun onFabClick() {
val manga = presenter.manga
toggleFavorite()
if (manga.favorite) {
val categories = presenter.getCategories()
val defaultCategory = categories.find { it.id == preferences.defaultCategory() }
if (defaultCategory != null) {
presenter.moveMangaToCategory(manga, defaultCategory)
} else if (categories.size <= 1) { // default or the one from the user
presenter.moveMangaToCategory(manga, categories.firstOrNull())
} else {
val ids = presenter.getMangaCategoryIds(manga)
val preselected = ids.mapNotNull { id ->
categories.indexOfFirst { it.id == id }.takeIf { it != -1 }
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
}
}
}
override fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) {
val manga = mangas.firstOrNull() ?: return
presenter.moveMangaToCategories(manga, categories)
}
/**
* Add the manga to the home screen
*/
fun addToHomeScreen() {
val activity = activity ?: return
val mangaControllerArgs = parentController?.args ?: return
val shortcutIntent = activity.intent
.setAction(MainActivity.SHORTCUT_MANGA)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra(MangaController.MANGA_EXTRA,
mangaControllerArgs.getLong(MangaController.MANGA_EXTRA))
val addIntent = Intent("com.android.launcher.action.INSTALL_SHORTCUT")
.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent)
//Set shortcut title
val dialog = MaterialDialog.Builder(activity)
.title(R.string.shortcut_title)
.input("", presenter.manga.title, { _, text ->
//Set shortcut title
addIntent.putExtra(Intent.EXTRA_SHORTCUT_NAME, text.toString())
reshapeIconBitmap(addIntent,
Glide.with(activity).load(presenter.manga).asBitmap())
})
.negativeText(android.R.string.cancel)
.show()
untilDestroySubscriptions.add(Subscriptions.create { dialog.dismiss() })
}
fun reshapeIconBitmap(addIntent: Intent, request: BitmapTypeRequest<out Any>) {
val activity = activity ?: return
val modes = intArrayOf(R.string.circular_icon,
R.string.rounded_icon,
R.string.square_icon,
R.string.star_icon)
fun BitmapRequestBuilder<out Any, Bitmap>.toIcon(): Bitmap {
return this.into(96, 96).get()
}
// i = 0: Circular icon
// i = 1: Rounded icon
// i = 2: Square icon
// i = 3: Star icon (because boredom)
fun getIcon(i: Int): Bitmap? {
return when (i) {
0 -> request.transform(CropCircleTransformation(activity)).toIcon()
1 -> request.transform(RoundedCornersTransformation(activity, 5, 0)).toIcon()
2 -> request.transform(CropSquareTransformation(activity)).toIcon()
3 -> request.transform(CenterCrop(activity),
MaskTransformation(activity, R.drawable.mask_star)).toIcon()
else -> null
}
}
val dialog = MaterialDialog.Builder(activity)
.title(R.string.icon_shape)
.negativeText(android.R.string.cancel)
.items(modes.map { activity.getString(it) })
.itemsCallback { _, _, i, _ ->
Observable.fromCallable { getIcon(i) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ icon ->
if (icon != null) createShortcut(addIntent, icon)
}, {
activity.toast(R.string.icon_creation_fail)
})
}
.show()
untilDestroySubscriptions.add(Subscriptions.create { dialog.dismiss() })
}
fun createShortcut(addIntent: Intent, icon: Bitmap) {
val activity = activity ?: return
//Send shortcut intent
addIntent.putExtra(Intent.EXTRA_SHORTCUT_ICON, icon)
activity.sendBroadcast(addIntent)
//Go to launcher to show this shiny new shortcut!
val startMain = Intent(Intent.ACTION_MAIN)
startMain.addCategory(Intent.CATEGORY_HOME).flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(startMain)
}
}

View file

@ -1,393 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.support.customtabs.CustomTabsIntent
import android.view.*
import android.widget.Toast
import com.afollestad.materialdialogs.MaterialDialog
import com.bumptech.glide.BitmapRequestBuilder
import com.bumptech.glide.BitmapTypeRequest
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
import eu.kanade.tachiyomi.ui.manga.MangaActivity
import eu.kanade.tachiyomi.util.getResourceColor
import eu.kanade.tachiyomi.util.snack
import eu.kanade.tachiyomi.util.toast
import jp.wasabeef.glide.transformations.CropCircleTransformation
import jp.wasabeef.glide.transformations.CropSquareTransformation
import jp.wasabeef.glide.transformations.MaskTransformation
import jp.wasabeef.glide.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.fragment_manga_info.*
import nucleus.factory.RequiresPresenter
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
/**
* Fragment that shows manga information.
* Uses R.layout.fragment_manga_info.
* UI related actions should be called from here.
*/
@RequiresPresenter(MangaInfoPresenter::class)
class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
companion object {
/**
* Create new instance of MangaInfoFragment.
*
* @return MangaInfoFragment.
*/
fun newInstance(): MangaInfoFragment {
return MangaInfoFragment()
}
}
/**
* Preferences helper.
*/
private val preferences: PreferencesHelper by injectLazy()
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_manga_info, container, false)
}
override fun onViewCreated(view: View?, savedState: Bundle?) {
// Set onclickListener to toggle favorite when FAB clicked.
fab_favorite.setOnClickListener {
if(!presenter.manga.favorite) {
val defaultCategory = presenter.getCategories().find { it.id == preferences.defaultCategory()}
if(defaultCategory == null) {
onFabClick()
} else {
toggleFavorite()
presenter.moveMangaToCategory(defaultCategory, presenter.manga)
}
} else {
toggleFavorite()
}
}
// Set SwipeRefresh to refresh manga data.
swipe_refresh.setOnRefreshListener { fetchMangaFromSource() }
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.manga_info, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_open_in_browser -> openInBrowser()
R.id.action_share -> shareManga()
R.id.action_add_to_home_screen -> addToHomeScreen()
else -> return super.onOptionsItemSelected(item)
}
return true
}
/**
* Check if manga is initialized.
* If true update view with manga information,
* if false fetch manga information
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
fun onNextManga(manga: Manga, source: Source) {
if (manga.initialized) {
// Update view.
setMangaInfo(manga, source)
} else {
// Initialize manga.
fetchMangaFromSource()
}
}
/**
* Update the view with manga information.
*
* @param manga manga object containing information about manga.
* @param source the source of the manga.
*/
private fun setMangaInfo(manga: Manga, source: Source?) {
// Update artist TextView.
manga_artist.text = manga.artist
// Update author TextView.
manga_author.text = manga.author
// If manga source is known update source TextView.
if (source != null) {
manga_source.text = source.toString()
}
// Update genres TextView.
manga_genres.text = manga.genre
// Update status TextView.
manga_status.setText(when (manga.status) {
SManga.ONGOING -> R.string.ongoing
SManga.COMPLETED -> R.string.completed
SManga.LICENSED -> R.string.licensed
else -> R.string.unknown
})
// Update description TextView.
manga_summary.text = manga.description
// Set the favorite drawable to the correct one.
setFavoriteDrawable(manga.favorite)
// Set cover if it wasn't already.
if (manga_cover.drawable == null && !manga.thumbnail_url.isNullOrEmpty()) {
Glide.with(this)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.RESULT)
.centerCrop()
.into(manga_cover)
Glide.with(this)
.load(manga)
.diskCacheStrategy(DiskCacheStrategy.RESULT)
.centerCrop()
.into(backdrop)
}
}
/**
* Update chapter count TextView.
*
* @param count number of chapters.
*/
fun setChapterCount(count: Int) {
manga_chapters.text = count.toString()
}
/**
* Toggles the favorite status and asks for confirmation to delete downloaded chapters.
*/
fun toggleFavorite() {
if (!isAdded) return
val isNowFavorite = presenter.toggleFavorite()
if (!isNowFavorite && presenter.hasDownloads()) {
view!!.snack(getString(R.string.delete_downloads_for_manga)) {
setAction(R.string.action_delete) {
presenter.deleteDownloads()
}
}
}
}
/**
* Open the manga in browser.
*/
fun openInBrowser() {
if (!isAdded) return
val source = presenter.source as? HttpSource ?: return
try {
val url = Uri.parse(source.mangaDetailsRequest(presenter.manga).url().toString())
val intent = CustomTabsIntent.Builder()
.setToolbarColor(context.getResourceColor(R.attr.colorPrimary))
.build()
intent.launchUrl(activity, url)
} catch (e: Exception) {
context.toast(e.message)
}
}
/**
* Called to run Intent with [Intent.ACTION_SEND], which show share dialog.
*/
private fun shareManga() {
if (!isAdded) return
val source = presenter.source as? HttpSource ?: return
try {
val url = source.mangaDetailsRequest(presenter.manga).url().toString()
val sharingIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, getString(R.string.share_text, presenter.manga.title, url))
}
startActivity(Intent.createChooser(sharingIntent, getString(R.string.action_share)))
} catch (e: Exception) {
context.toast(e.message)
}
}
/**
* Add the manga to the home screen
*/
fun addToHomeScreen() {
if (!isAdded) return
val shortcutIntent = activity.intent
shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra(MangaActivity.FROM_LAUNCHER_EXTRA, true)
val addIntent = Intent()
addIntent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent)
.action = "com.android.launcher.action.INSTALL_SHORTCUT"
//Set shortcut title
MaterialDialog.Builder(activity)
.title(R.string.shortcut_title)
.input("", presenter.manga.title, { md, text ->
//Set shortcut title
addIntent.putExtra(Intent.EXTRA_SHORTCUT_NAME, text.toString())
reshapeIconBitmap(addIntent,
Glide.with(context).load(presenter.manga).asBitmap())
})
.negativeText(android.R.string.cancel)
.onNegative { materialDialog, dialogAction -> materialDialog.cancel() }
.show()
}
fun reshapeIconBitmap(addIntent: Intent, request: BitmapTypeRequest<out Any>) {
val modes = intArrayOf(R.string.circular_icon,
R.string.rounded_icon,
R.string.square_icon,
R.string.star_icon)
fun BitmapRequestBuilder<out Any, Bitmap>.toIcon(): Bitmap {
return this.into(96, 96).get()
}
MaterialDialog.Builder(activity)
.title(R.string.icon_shape)
.negativeText(android.R.string.cancel)
.items(modes.map { getString(it) })
.itemsCallback { dialog, view, i, charSequence ->
Observable.fromCallable {
// i = 0: Circular icon
// i = 1: Rounded icon
// i = 2: Square icon
// i = 3: Star icon (because boredom)
when (i) {
0 -> request.transform(CropCircleTransformation(context)).toIcon()
1 -> request.transform(RoundedCornersTransformation(context, 5, 0)).toIcon()
2 -> request.transform(CropSquareTransformation(context)).toIcon()
3 -> request.transform(CenterCrop(context), MaskTransformation(context, R.drawable.mask_star)).toIcon()
else -> null
}
}.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ if (it != null) createShortcut(addIntent, it) },
{ context.toast(R.string.icon_creation_fail) })
}.show()
}
fun createShortcut(addIntent: Intent, icon: Bitmap) {
//Send shortcut intent
addIntent.putExtra(Intent.EXTRA_SHORTCUT_ICON, icon)
context.sendBroadcast(addIntent)
//Go to launcher to show this shiny new shortcut!
val startMain = Intent(Intent.ACTION_MAIN)
startMain.addCategory(Intent.CATEGORY_HOME).flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(startMain)
}
/**
* Update FAB with correct drawable.
*
* @param isFavorite determines if manga is favorite or not.
*/
private fun setFavoriteDrawable(isFavorite: Boolean) {
// Set the Favorite drawable to the correct one.
// Border drawable if false, filled drawable if true.
fab_favorite.setImageResource(if (isFavorite)
R.drawable.ic_bookmark_white_24dp
else
R.drawable.ic_bookmark_border_white_24dp)
}
/**
* Start fetching manga information from source.
*/
private fun fetchMangaFromSource() {
setRefreshing(true)
// Call presenter and start fetching manga information
presenter.fetchMangaFromSource()
}
/**
* Update swipe refresh to stop showing refresh in progress spinner.
*/
fun onFetchMangaDone() {
setRefreshing(false)
}
/**
* Update swipe refresh to start showing refresh in progress spinner.
*/
fun onFetchMangaError() {
setRefreshing(false)
}
/**
* Set swipe refresh status.
*
* @param value whether it should be refreshing or not.
*/
private fun setRefreshing(value: Boolean) {
swipe_refresh.isRefreshing = value
}
/**
* Called when the fab is clicked.
*/
private fun onFabClick() {
val categories = presenter.getCategories()
MaterialDialog.Builder(activity)
.title(R.string.action_move_category)
.items(categories.map { it.name })
.itemsCallbackMultiChoice(presenter.getMangaCategoryIds(presenter.manga)) { dialog, position, text ->
if (position.contains(0) && position.count() > 1) {
dialog.setSelectedIndices(position.filter {it > 0}.toTypedArray())
Toast.makeText(dialog.context, R.string.invalid_combination, Toast.LENGTH_SHORT).show()
}
true
}
.alwaysCallMultiChoiceCallback()
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { dialog, _ ->
val selectedCategories = dialog.selectedIndices?.map { categories[it] } ?: emptyList()
if(!selectedCategories.isEmpty()) {
if(!presenter.manga.favorite) {
toggleFavorite()
}
presenter.moveMangaToCategories(selectedCategories.filter { it.id != 0}, presenter.manga)
} else {
toggleFavorite()
}
}
.build()
.show()
}
}

View file

@ -1,201 +1,169 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.os.Bundle
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.source.SourceManager
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.manga.MangaEvent
import eu.kanade.tachiyomi.util.SharedData
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.injectLazy
/**
* Presenter of MangaInfoFragment.
* Contains information and data for fragment.
* Observable updates should be called from here.
*/
class MangaInfoPresenter : BasePresenter<MangaInfoFragment>() {
/**
* Active manga.
*/
lateinit var manga: Manga
private set
/**
* Source of the manga.
*/
lateinit var source: Source
private set
/**
* Used to connect to database.
*/
val db: DatabaseHelper by injectLazy()
/**
* Used to connect to different manga sources.
*/
val sourceManager: SourceManager by injectLazy()
/**
* Used to connect to cache.
*/
val coverCache: CoverCache by injectLazy()
private val downloadManager: DownloadManager by injectLazy()
/**
* Subscription to send the manga to the view.
*/
private var viewMangaSubcription: Subscription? = null
/**
* Subscription to update the manga from the source.
*/
private var fetchMangaSubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
manga = SharedData.get(MangaEvent::class.java)?.manga ?: return
source = sourceManager.get(manga.source)!!
sendMangaToView()
// Update chapter count
SharedData.get(ChapterCountEvent::class.java)?.observable
?.observeOn(AndroidSchedulers.mainThread())
?.subscribeLatestCache(MangaInfoFragment::setChapterCount)
// Update favorite status
SharedData.get(MangaFavoriteEvent::class.java)?.let {
it.observable
.observeOn(AndroidSchedulers.mainThread())
.subscribe { setFavorite(it) }
.apply { add(this) }
}
}
/**
* Sends the active manga to the view.
*/
fun sendMangaToView() {
viewMangaSubcription?.let { remove(it) }
viewMangaSubcription = 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, manga ->
view.onFetchMangaDone()
}, { view, error ->
view.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.findMangaDir(source, manga) != null
}
/**
* Deletes all the downloads for the manga.
*/
fun deleteDownloads() {
downloadManager.findMangaDir(source, manga)?.delete()
}
/**
* Get the default, and user categories.
*
* @return List of categories, default plus user categories
*/
fun getCategories(): List<Category> {
return arrayListOf(Category.createDefault()) + 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()
if(categories.isEmpty()) {
return arrayListOf(Category.createDefault().id).toTypedArray()
}
return categories.map { it.id }.toTypedArray()
}
/**
* Move the given manga to categories.
*
* @param categories the selected categories.
* @param manga the manga to move.
*/
fun moveMangaToCategories(categories: List<Category>, manga: Manga) {
val mc = categories.map { MangaCategory.create(manga, it) }
db.setMangaCategories(mc, arrayListOf(manga))
}
/**
* Move the given manga to the category.
*
* @param category the selected category.
* @param manga the manga to move.
*/
fun moveMangaToCategory(category: Category, manga: Manga) {
moveMangaToCategories(arrayListOf(category), manga)
}
}
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
/**
* 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<Int>,
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 viewMangaSubcription: 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) }
}
/**
* Sends the active manga to the view.
*/
fun sendMangaToView() {
viewMangaSubcription?.let { remove(it) }
viewMangaSubcription = 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()
}, { view, _ ->
view.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.findMangaDir(source, manga) != null
}
/**
* Deletes all the downloads for the manga.
*/
fun deleteDownloads() {
downloadManager.findMangaDir(source, manga)?.delete()
}
/**
* Get the default, and user categories.
*
* @return List of categories, default plus user categories
*/
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))
}
}

View file

@ -0,0 +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.dialog_track_chapters, 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 = view.findViewById(R.id.chapters_picker) as NumberPicker
np.clearFocus()
(targetController as? Listener)?.setChaptersRead(item, np.value)
}
}
.build()
val view = dialog.customView
if (view != null) {
val np = view.findViewById(R.id.chapters_picker) as NumberPicker
// 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"
}
}

View file

@ -0,0 +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.dialog_track_score, 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 = view.findViewById(R.id.score_picker) as NumberPicker
np.clearFocus()
(targetController as? Listener)?.setScore(item, np.value)
}
}
.show()
val view = dialog.customView
if (view != null) {
val np = view.findViewById(R.id.score_picker) as NumberPicker
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"
}
}

View file

@ -0,0 +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"
}
}

View file

@ -1,33 +1,44 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.support.v7.widget.RecyclerView
import android.view.ViewGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.inflate
class TrackAdapter(val fragment: TrackFragment) : RecyclerView.Adapter<TrackHolder>() {
var items = emptyList<TrackItem>()
set(value) {
if (field !== value) {
field = value
notifyDataSetChanged()
}
}
var onClickListener: (TrackItem) -> Unit = {}
override fun getItemCount(): Int {
return items.size
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackHolder {
val view = parent.inflate(R.layout.item_track)
return TrackHolder(view, fragment)
}
override fun onBindViewHolder(holder: TrackHolder, position: Int) {
holder.onSetValues(items[position])
}
}
package eu.kanade.tachiyomi.ui.manga.track
import android.support.v7.widget.RecyclerView
import android.view.ViewGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.inflate
class TrackAdapter(controller: TrackController) : RecyclerView.Adapter<TrackHolder>() {
var items = emptyList<TrackItem>()
set(value) {
if (field !== value) {
field = value
notifyDataSetChanged()
}
}
val rowClickListener: OnRowClickListener = 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.item_track)
return TrackHolder(view, this)
}
override fun onBindViewHolder(holder: TrackHolder, position: Int) {
holder.bind(items[position])
}
interface OnRowClickListener {
fun onTitleClick(position: Int)
fun onStatusClick(position: Int)
fun onChaptersClick(position: Int)
fun onScoreClick(position: Int)
}
}

View file

@ -0,0 +1,123 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.os.Bundle
import android.support.v7.widget.LinearLayoutManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.jakewharton.rxbinding.support.v4.widget.refreshes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
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.fragment_track.view.*
class TrackController : NucleusController<TrackPresenter>(),
TrackAdapter.OnRowClickListener,
SetTrackStatusDialog.Listener,
SetTrackChaptersDialog.Listener,
SetTrackScoreDialog.Listener {
private var adapter: TrackAdapter? = null
override fun createPresenter(): TrackPresenter {
return TrackPresenter((parentController as MangaController).manga!!)
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.fragment_track, container, false)
}
override fun onViewCreated(view: View, savedViewState: Bundle?) {
super.onViewCreated(view, savedViewState)
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) {
super.onDestroyView(view)
adapter = null
}
fun onNextTrackings(trackings: List<TrackItem>) {
val atLeastOneLink = trackings.any { it.track != null }
adapter?.items = trackings
view?.swipe_refresh?.isEnabled = atLeastOneLink
(parentController as? MangaController)?.setTrackingIcon(atLeastOneLink)
}
fun onSearchResults(results: List<Track>) {
getSearchDialog()?.onSearchResults(results)
}
@Suppress("UNUSED_PARAMETER")
fun onSearchResultsError(error: Throwable) {
getSearchDialog()?.onSearchResultsError()
}
private fun getSearchDialog(): TrackSearchDialog? {
return router.getControllerWithTag(TAG_SEARCH_CONTROLLER) as? TrackSearchDialog
}
fun onRefreshDone() {
view?.swipe_refresh?.isRefreshing = false
}
fun onRefreshError(error: Throwable) {
view?.swipe_refresh?.isRefreshing = false
activity?.toast(error.message)
}
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)
view?.swipe_refresh?.isRefreshing = true
}
override fun setScore(item: TrackItem, score: Int) {
presenter.setScore(item, score)
view?.swipe_refresh?.isRefreshing = true
}
override fun setChaptersRead(item: TrackItem, chaptersRead: Int) {
presenter.setLastChapterRead(item, chaptersRead)
view?.swipe_refresh?.isRefreshing = true
}
private companion object {
const val TAG_SEARCH_CONTROLLER = "track_search_controller"
}
}

View file

@ -1,173 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.os.Bundle
import android.support.v7.widget.LinearLayoutManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.NumberPicker
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
import eu.kanade.tachiyomi.ui.manga.MangaActivity
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.fragment_track.*
import nucleus.factory.RequiresPresenter
@RequiresPresenter(TrackPresenter::class)
class TrackFragment : BaseRxFragment<TrackPresenter>() {
companion object {
fun newInstance(): TrackFragment {
return TrackFragment()
}
}
private lateinit var adapter: TrackAdapter
private var dialog: TrackSearchDialog? = null
private val searchFragmentTag: String
get() = "search_fragment"
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View {
return inflater.inflate(R.layout.fragment_track, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = TrackAdapter(this)
recycler.layoutManager = LinearLayoutManager(context)
recycler.adapter = adapter
swipe_refresh.isEnabled = false
swipe_refresh.setOnRefreshListener { presenter.refresh() }
}
private fun findSearchFragmentIfNeeded() {
if (dialog == null) {
dialog = childFragmentManager.findFragmentByTag(searchFragmentTag) as? TrackSearchDialog
}
}
fun onNextTrackings(trackings: List<TrackItem>) {
adapter.items = trackings
swipe_refresh.isEnabled = trackings.any { it.track != null }
(activity as MangaActivity).setTrackingIcon(trackings.any { it.track != null })
}
fun onSearchResults(results: List<Track>) {
if (!isResumed) return
findSearchFragmentIfNeeded()
dialog?.onSearchResults(results)
}
fun onSearchResultsError(error: Throwable) {
if (!isResumed) return
findSearchFragmentIfNeeded()
dialog?.onSearchResultsError()
}
fun onRefreshDone() {
swipe_refresh.isRefreshing = false
}
fun onRefreshError(error: Throwable) {
swipe_refresh.isRefreshing = false
context.toast(error.message)
}
fun onTitleClick(item: TrackItem) {
if (!isResumed) return
if (dialog == null) {
dialog = TrackSearchDialog.newInstance()
}
presenter.selectedService = item.service
dialog?.show(childFragmentManager, searchFragmentTag)
}
fun onStatusClick(item: TrackItem) {
if (!isResumed || item.track == null) return
val statusList = item.service.getStatusList().map { item.service.getStatus(it) }
val selectedIndex = item.service.getStatusList().indexOf(item.track.status)
MaterialDialog.Builder(context)
.title(R.string.status)
.items(statusList)
.itemsCallbackSingleChoice(selectedIndex, { dialog, view, i, charSequence ->
presenter.setStatus(item, i)
swipe_refresh.isRefreshing = true
true
})
.show()
}
fun onChaptersClick(item: TrackItem) {
if (!isResumed || item.track == null) return
val dialog = MaterialDialog.Builder(context)
.title(R.string.chapters)
.customView(R.layout.dialog_track_chapters, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { d, action ->
val view = d.customView
if (view != null) {
val np = view.findViewById(R.id.chapters_picker) as NumberPicker
np.clearFocus()
presenter.setLastChapterRead(item, np.value)
swipe_refresh.isRefreshing = true
}
}
.show()
val view = dialog.customView
if (view != null) {
val np = view.findViewById(R.id.chapters_picker) as NumberPicker
// Set initial value
np.value = item.track.last_chapter_read
// Don't allow to go from 0 to 9999
np.wrapSelectorWheel = false
}
}
fun onScoreClick(item: TrackItem) {
if (!isResumed || item.track == null) return
val dialog = MaterialDialog.Builder(activity)
.title(R.string.score)
.customView(R.layout.dialog_track_score, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { d, action ->
val view = d.customView
if (view != null) {
val np = view.findViewById(R.id.score_picker) as NumberPicker
np.clearFocus()
presenter.setScore(item, np.value)
swipe_refresh.isRefreshing = true
}
}
.show()
val view = dialog.customView
if (view != null) {
val np = view.findViewById(R.id.score_picker) as NumberPicker
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
}
}
}
}

View file

@ -1,42 +1,41 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.support.v7.widget.RecyclerView
import android.view.View
import eu.kanade.tachiyomi.R
import kotlinx.android.synthetic.main.item_track.view.*
class TrackHolder(private val view: View, private val fragment: TrackFragment)
: RecyclerView.ViewHolder(view) {
private lateinit var item: TrackItem
init {
view.title_container.setOnClickListener { fragment.onTitleClick(item) }
view.status_container.setOnClickListener { fragment.onStatusClick(item) }
view.chapters_container.setOnClickListener { fragment.onChaptersClick(item) }
view.score_container.setOnClickListener { fragment.onScoreClick(item) }
}
@Suppress("DEPRECATION")
fun onSetValues(item: TrackItem) = with(view) {
this@TrackHolder.item = item
val track = item.track
track_logo.setImageResource(item.service.getLogo())
logo.setBackgroundColor(item.service.getLogoColor())
if (track != null) {
track_title.setTextAppearance(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(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.support.v7.widget.RecyclerView
import android.view.View
import eu.kanade.tachiyomi.R
import kotlinx.android.synthetic.main.item_track.view.*
class TrackHolder(view: View, adapter: TrackAdapter) : RecyclerView.ViewHolder(view) {
init {
val listener = adapter.rowClickListener
view.title_container.setOnClickListener { listener.onTitleClick(adapterPosition) }
view.status_container.setOnClickListener { listener.onStatusClick(adapterPosition) }
view.chapters_container.setOnClickListener { listener.onChaptersClick(adapterPosition) }
view.score_container.setOnClickListener { listener.onScoreClick(adapterPosition) }
}
@SuppressLint("SetTextI18n")
@Suppress("DEPRECATION")
fun bind(item: TrackItem) = with(itemView) {
val track = item.track
track_logo.setImageResource(item.service.getLogo())
logo.setBackgroundColor(item.service.getLogoColor())
if (track != null) {
track_title.setTextAppearance(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(context, R.style.TextAppearance_Medium_Button)
track_title.setText(R.string.action_edit)
track_chapters.text = ""
track_score.text = ""
track_status.text = ""
}
}
}

View file

@ -1,8 +1,6 @@
package eu.kanade.tachiyomi.ui.manga.track
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
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)

View file

@ -1,137 +1,129 @@
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.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.manga.MangaEvent
import eu.kanade.tachiyomi.util.SharedData
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.injectLazy
class TrackPresenter : BasePresenter<TrackFragment>() {
private val db: DatabaseHelper by injectLazy()
private val trackManager: TrackManager by injectLazy()
lateinit var manga: Manga
private set
private var trackList: List<TrackItem> = emptyList()
private val loggedServices by lazy { trackManager.services.filter { it.isLogged } }
var selectedService: TrackService? = null
private var trackSubscription: Subscription? = null
private var searchSubscription: Subscription? = null
private var refreshSubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
manga = SharedData.get(MangaEvent::class.java)?.manga ?: return
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(TrackFragment::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, result -> view.onRefreshDone() },
TrackFragment::onRefreshError)
}
fun search(query: String) {
val service = selectedService ?: return
searchSubscription?.let { remove(it) }
searchSubscription = service.search(query)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(TrackFragment::onSearchResults,
TrackFragment::onSearchResultsError)
}
fun registerTracking(item: Track?) {
val service = selectedService ?: return
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, result -> 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, result -> 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, result -> 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)
}
}

View file

@ -1,47 +1,47 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.util.inflate
import kotlinx.android.synthetic.main.item_track_search.view.*
import java.util.*
class TrackSearchAdapter(context: Context)
: ArrayAdapter<Track>(context, R.layout.item_track_search, ArrayList<Track>()) {
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.item_track_search)
holder = TrackSearchHolder(v)
v.tag = holder
} else {
holder = v.tag as TrackSearchHolder
}
holder.onSetValues(track)
return v
}
fun setItems(syncs: List<Track>) {
setNotifyOnChange(false)
clear()
addAll(syncs)
notifyDataSetChanged()
}
class TrackSearchHolder(private val view: View) {
fun onSetValues(track: Track) {
view.track_search_title.text = track.title
}
}
package eu.kanade.tachiyomi.ui.manga.track
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.util.inflate
import kotlinx.android.synthetic.main.item_track_search.view.*
import java.util.*
class TrackSearchAdapter(context: Context)
: ArrayAdapter<Track>(context, R.layout.item_track_search, ArrayList<Track>()) {
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.item_track_search)
holder = TrackSearchHolder(v)
v.tag = holder
} else {
holder = v.tag as TrackSearchHolder
}
holder.onSetValues(track)
return v
}
fun setItems(syncs: List<Track>) {
setNotifyOnChange(false)
clear()
addAll(syncs)
notifyDataSetChanged()
}
class TrackSearchHolder(private val view: View) {
fun onSetValues(track: Track) {
view.track_search_title.text = track.title
}
}
}

View file

@ -1,119 +1,144 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.app.Dialog
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.view.View
import com.afollestad.materialdialogs.MaterialDialog
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.widget.SimpleTextWatcher
import kotlinx.android.synthetic.main.dialog_track_search.view.*
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import java.util.concurrent.TimeUnit
class TrackSearchDialog : DialogFragment() {
companion object {
fun newInstance(): TrackSearchDialog {
return TrackSearchDialog()
}
}
private lateinit var v: View
lateinit var adapter: TrackSearchAdapter
private set
private val queryRelay by lazy { PublishRelay.create<String>() }
private var searchDebounceSubscription: Subscription? = null
private var selectedItem: Track? = null
val presenter: TrackPresenter
get() = (parentFragment as TrackFragment).presenter
override fun onCreateDialog(savedState: Bundle?): Dialog {
val dialog = MaterialDialog.Builder(context)
.customView(R.layout.dialog_track_search, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { dialog1, which -> onPositiveButtonClick() }
.build()
onViewCreated(dialog.view, savedState)
return dialog
}
override fun onViewCreated(view: View, savedState: Bundle?) {
v = view
// Create adapter
adapter = TrackSearchAdapter(context)
view.track_search_list.adapter = adapter
// Set listeners
selectedItem = null
view.track_search_list.setOnItemClickListener { parent, viewList, position, id ->
selectedItem = adapter.getItem(position)
}
// Do an initial search based on the manga's title
if (savedState == null) {
val title = presenter.manga.title
view.track_search.append(title)
search(title)
}
view.track_search.addTextChangedListener(object : SimpleTextWatcher() {
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
queryRelay.call(s.toString())
}
})
}
override fun onResume() {
super.onResume()
// Listen to text changes
searchDebounceSubscription = queryRelay.debounce(1, TimeUnit.SECONDS)
.observeOn(AndroidSchedulers.mainThread())
.filter { it.isNotBlank() }
.subscribe { search(it) }
}
override fun onPause() {
searchDebounceSubscription?.unsubscribe()
super.onPause()
}
private fun search(query: String) {
v.progress.visibility = View.VISIBLE
v.track_search_list.visibility = View.GONE
presenter.search(query)
}
fun onSearchResults(results: List<Track>) {
selectedItem = null
v.progress.visibility = View.GONE
v.track_search_list.visibility = View.VISIBLE
adapter.setItems(results)
}
fun onSearchResultsError() {
v.progress.visibility = View.VISIBLE
v.track_search_list.visibility = View.GONE
adapter.setItems(emptyList())
}
private fun onPositiveButtonClick() {
presenter.registerTracking(selectedItem)
}
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.ui.base.controller.DialogController
import eu.kanade.tachiyomi.util.plusAssign
import kotlinx.android.synthetic.main.dialog_track_search.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.dialog_track_search, 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.GONE
trackController.presenter.search(query, service)
}
fun onSearchResults(results: List<Track>) {
selectedItem = null
val view = dialogView ?: return
view.progress.visibility = View.GONE
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.GONE
adapter?.setItems(emptyList())
}
private fun onPositiveButtonClick() {
trackController.presenter.registerTracking(selectedItem, service)
}
private companion object {
const val KEY_SERVICE = "service_id"
}
}

View file

@ -28,7 +28,8 @@ import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.net.URLConnection
import java.util.*
@ -36,41 +37,17 @@ import java.util.*
/**
* Presenter of [ReaderActivity].
*/
class ReaderPresenter : BasePresenter<ReaderActivity>() {
/**
* Preferences.
*/
val prefs: PreferencesHelper by injectLazy()
class ReaderPresenter(
val prefs: PreferencesHelper = Injekt.get(),
val db: DatabaseHelper = Injekt.get(),
val downloadManager: DownloadManager = Injekt.get(),
val trackManager: TrackManager = Injekt.get(),
val sourceManager: SourceManager = Injekt.get(),
val chapterCache: ChapterCache = Injekt.get(),
val coverCache: CoverCache = Injekt.get()
) : BasePresenter<ReaderActivity>() {
/**
* Database.
*/
val db: DatabaseHelper by injectLazy()
/**
* Download manager.
*/
val downloadManager: DownloadManager by injectLazy()
/**
* Tracking manager.
*/
val trackManager: TrackManager by injectLazy()
/**
* Source manager.
*/
val sourceManager: SourceManager by injectLazy()
/**
* Chapter cache.
*/
val chapterCache: ChapterCache by injectLazy()
/**
* Cover cache.
*/
val coverCache: CoverCache by injectLazy()
private val context = prefs.context
/**
* Manga being read.

View file

@ -1,9 +1,9 @@
package eu.kanade.tachiyomi.ui.reader.viewer.base
import android.support.v4.app.Fragment
import com.davemorrissey.labs.subscaleview.decoder.*
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.base.fragment.BaseFragment
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
import java.util.*
@ -12,7 +12,7 @@ import java.util.*
* Base reader containing the common data that can be used by its implementations. It does not
* contain any UI related action.
*/
abstract class BaseReader : BaseFragment() {
abstract class BaseReader : Fragment() {
companion object {
/**

View file

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.ui.recent_updates
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 ConfirmDeleteChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : ConfirmDeleteChaptersDialog.Listener {
private var chaptersToDelete = emptyList<RecentChapterItem>()
constructor(target: T, chaptersToDelete: List<RecentChapterItem>) : this() {
this.chaptersToDelete = chaptersToDelete
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(chaptersToDelete)
}
.build()
}
interface Listener {
fun deleteChapters(chaptersToDelete: List<RecentChapterItem>)
}
}

View file

@ -0,0 +1,27 @@
package eu.kanade.tachiyomi.ui.recent_updates
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)
}
}

View file

@ -115,7 +115,7 @@ class RecentChapterHolder(private val view: View, private val adapter: RecentCha
// Set a listener so we are notified if a menu item is clicked
popup.setOnMenuItemClickListener { menuItem ->
with(adapter.fragment) {
with(adapter.controller) {
when (menuItem.itemId) {
R.id.action_download -> downloadChapter(item)
R.id.action_delete -> deleteChapter(item)

View file

@ -27,11 +27,19 @@ class RecentChapterItem(val chapter: Chapter, val manga: Manga, header: DateItem
return R.layout.item_recent_chapters
}
override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, parent: ViewGroup): RecentChapterHolder {
return RecentChapterHolder(inflater.inflate(layoutRes, parent, false), adapter as RecentChaptersAdapter)
override fun createViewHolder(adapter: FlexibleAdapter<*>,
inflater: LayoutInflater,
parent: ViewGroup): RecentChapterHolder {
val view = inflater.inflate(layoutRes, parent, false)
return RecentChapterHolder(view , adapter as RecentChaptersAdapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: RecentChapterHolder, position: Int, payloads: List<Any?>?) {
override fun bindViewHolder(adapter: FlexibleAdapter<*>,
holder: RecentChapterHolder,
position: Int,
payloads: List<Any?>?) {
holder.bind(this)
}

View file

@ -3,8 +3,8 @@ package eu.kanade.tachiyomi.ui.recent_updates
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
class RecentChaptersAdapter(val fragment: RecentChaptersFragment) :
FlexibleAdapter<IFlexible<*>>(null, fragment, true) {
class RecentChaptersAdapter(val controller: RecentChaptersController) :
FlexibleAdapter<IFlexible<*>>(null, controller, true) {
init {
setDisplayHeadersAtStartUp(true)

View file

@ -1,340 +1,323 @@
package eu.kanade.tachiyomi.ui.recent_updates
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.support.v7.app.AppCompatActivity
import android.support.v7.view.ActionMode
import android.support.v7.widget.DividerItemDecoration
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.RecyclerView
import android.view.*
import com.afollestad.materialdialogs.MaterialDialog
import eu.davidea.flexibleadapter.FlexibleAdapter
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.fragment.BaseRxFragment
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.widget.DeletingChaptersDialog
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.fragment_recent_chapters.*
import nucleus.factory.RequiresPresenter
import timber.log.Timber
/**
* Fragment that shows recent chapters.
* Uses [R.layout.fragment_recent_chapters].
* UI related actions should be called from here.
*/
@RequiresPresenter(RecentChaptersPresenter::class)
class RecentChaptersFragment:
BaseRxFragment<RecentChaptersPresenter>(),
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener{
companion object {
/**
* Create new RecentChaptersFragment.
* @return a new instance of [RecentChaptersFragment].
*/
fun newInstance(): RecentChaptersFragment {
return RecentChaptersFragment()
}
}
/**
* Action mode for multiple selection.
*/
private var actionMode: ActionMode? = null
/**
* Adapter containing the recent chapters.
*/
lateinit var adapter: RecentChaptersAdapter
private set
/**
* Called when view gets created
* @param inflater layout inflater
* @param container view group
* @param savedState status of saved state
*/
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View {
// Inflate view
return inflater.inflate(R.layout.fragment_recent_chapters, container, false)
}
/**
* Called when view is created
* @param view created view
* @param savedState status of saved sate
*/
override fun onViewCreated(view: View, savedState: Bundle?) {
// Init RecyclerView and adapter
recycler.layoutManager = LinearLayoutManager(activity)
recycler.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
recycler.setHasFixedSize(true)
adapter = RecentChaptersAdapter(this)
recycler.adapter = adapter
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
}
})
swipe_refresh.setDistanceToTriggerSync((2 * 64 * resources.displayMetrics.density).toInt())
swipe_refresh.setOnRefreshListener {
if (!LibraryUpdateService.isRunning(activity)) {
LibraryUpdateService.start(activity)
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
}
// Update toolbar text
setToolbarTitle(R.string.label_recent_updates)
// Disable toolbar elevation, it looks better with sticky headers.
activity.appbar.disableElevation()
}
override fun onDestroyView() {
// Restore toolbar elevation.
activity.appbar.enableElevation()
super.onDestroyView()
}
/**
* Returns selected chapters
* @return list of selected chapters
*/
fun getSelectedChapters(): List<RecentChapterItem> {
return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? RecentChapterItem }
}
/**
* Called when item in list is clicked
* @param position position of clicked item
*/
override fun onItemClick(position: Int): Boolean {
// Get item from position
val item = adapter.getItem(position) as? RecentChapterItem ?: return false
if (actionMode != null && adapter.mode == FlexibleAdapter.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) {
adapter.toggleSelection(position)
val count = adapter.selectedItemCount
if (count == 0) {
actionMode?.finish()
} else {
actionMode?.title = getString(R.string.label_selected, count)
}
}
/**
* Open chapter in reader
* @param chapter selected chapter
*/
private fun openChapter(item: RecentChapterItem) {
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<*>>) {
(activity as MainActivity).updateEmptyView(chapters.isEmpty(),
R.string.information_no_recent, R.drawable.ic_update_black_128dp)
destroyActionModeIfNeeded()
adapter.updateDataSet(chapters.toMutableList())
}
/**
* 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)
}
}
/**
* Delete selected chapters
* @param chapters list of [RecentChapter] objects
*/
fun deleteChapters(chapters: List<RecentChapterItem>) {
destroyActionModeIfNeeded()
DeletingChaptersDialog().show(childFragmentManager, DeletingChaptersDialog.TAG)
presenter.deleteChapters(chapters)
}
/**
* 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().show(childFragmentManager, DeletingChaptersDialog.TAG)
presenter.deleteChapters(listOf(chapter))
}
/**
* 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() {
(childFragmentManager.findFragmentByTag(DeletingChaptersDialog.TAG) as? DialogFragment)
?.dismissAllowingStateLoss()
}
/**
* Called when ActionMode item clicked
* @param mode the ActionMode object
* @param item item from ActionMode.
*/
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
if (!isAdded) return true
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 -> {
MaterialDialog.Builder(activity)
.content(R.string.confirm_delete_chapters)
.positiveText(android.R.string.yes)
.negativeText(android.R.string.no)
.onPositive { dialog, action -> deleteChapters(getSelectedChapters()) }
.show()
}
else -> return false
}
return true
}
/**
* 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 = FlexibleAdapter.MODE_MULTI
return true
}
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return false
}
/**
* Called when ActionMode destroyed
* @param mode the ActionMode object
*/
override fun onDestroyActionMode(mode: ActionMode?) {
adapter.mode = FlexibleAdapter.MODE_IDLE
adapter.clearSelection()
actionMode = null
}
package eu.kanade.tachiyomi.ui.recent_updates
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import android.support.v7.view.ActionMode
import android.support.v7.widget.DividerItemDecoration
import android.support.v7.widget.LinearLayoutManager
import android.view.*
import com.jakewharton.rxbinding.support.v4.widget.refreshes
import com.jakewharton.rxbinding.support.v7.widget.scrollStateChanges
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.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.reader.ReaderActivity
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.fragment_recent_chapters.view.*
import timber.log.Timber
/**
* Fragment that shows recent chapters.
* Uses [R.layout.fragment_recent_chapters].
* UI related actions should be called from here.
*/
class RecentChaptersController : NucleusController<RecentChaptersPresenter>(),
NoToolbarElevationController,
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
FlexibleAdapter.OnUpdateListener,
ConfirmDeleteChaptersDialog.Listener {
/**
* 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.fragment_recent_chapters, container, false)
}
/**
* Called when view is created
* @param view created view
* @param savedViewState status of saved sate
*/
override fun onViewCreated(view: View, savedViewState: Bundle?) {
super.onViewCreated(view, savedViewState)
with(view) {
// Init RecyclerView and adapter
val layoutManager = LinearLayoutManager(context)
recycler.layoutManager = layoutManager
recycler.addItemDecoration(DividerItemDecoration(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 * resources.displayMetrics.density).toInt())
swipe_refresh.refreshes().subscribeUntilDestroy {
if (!LibraryUpdateService.isRunning(context)) {
LibraryUpdateService.start(context)
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) {
super.onDestroyView(view)
adapter = null
actionMode = null
}
/**
* Returns selected chapters
* @return list of selected chapters
*/
fun getSelectedChapters(): List<RecentChapterItem> {
val adapter = adapter ?: return emptyList()
return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? RecentChapterItem }
}
/**
* Called when item in list is clicked
* @param position position of clicked item
*/
override fun onItemClick(position: Int): Boolean {
val adapter = adapter ?: return false
// Get item from position
val item = adapter.getItem(position) as? RecentChapterItem ?: return false
if (actionMode != null && adapter.mode == FlexibleAdapter.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.toMutableList())
}
override fun onUpdateEmptyView(size: Int) {
val emptyView = view?.empty_view ?: return
if (size > 0) {
emptyView.hide()
} else {
emptyView.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 view?.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))
}
/**
* 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 = FlexibleAdapter.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 = FlexibleAdapter.MODE_IDLE
adapter?.clearSelection()
actionMode = null
}
}

View file

@ -14,29 +14,18 @@ import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.*
class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
/**
* Used to connect to database
*/
val db: DatabaseHelper by injectLazy()
class RecentChaptersPresenter(
val preferences: PreferencesHelper = Injekt.get(),
private val db: DatabaseHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get()
) : BasePresenter<RecentChaptersController>() {
/**
* Used to get settings
*/
val preferences: PreferencesHelper by injectLazy()
/**
* Used to get information from download manager
*/
val downloadManager: DownloadManager by injectLazy()
/**
* Used to get source from source id
*/
val sourceManager: SourceManager by injectLazy()
private val context = preferences.context
/**
* List containing chapter and manga information
@ -48,11 +37,11 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
getRecentChaptersObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(RecentChaptersFragment::onNextRecentChapters)
.subscribeLatestCache(RecentChaptersController::onNextRecentChapters)
getChapterStatusObservable()
.subscribeLatestCache(RecentChaptersFragment::onChapterStatusChange,
{ view, error -> Timber.e(error) })
.subscribeLatestCache(RecentChaptersController::onChapterStatusChange,
{ _, error -> Timber.e(error) })
}
/**
@ -207,9 +196,9 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
.toList()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, result ->
.subscribeFirst({ view, _ ->
view.onChaptersDeleted()
}, RecentChaptersFragment::onChaptersDeletedError)
}, RecentChaptersController::onChaptersDeletedError)
}
/**

View file

@ -1,57 +1,48 @@
package eu.kanade.tachiyomi.ui.recently_read
import android.view.ViewGroup
import eu.davidea.flexibleadapter4.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.util.inflate
import uy.kohesive.injekt.injectLazy
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
/**
* Adapter of RecentlyReadHolder.
* Connection between Fragment and Holder
* Holder updates should be called from here.
*
* @param fragment a RecentlyReadFragment object
* @param controller a RecentlyReadController object
* @constructor creates an instance of the adapter.
*/
class RecentlyReadAdapter(val fragment: RecentlyReadFragment)
: FlexibleAdapter<RecentlyReadHolder, MangaChapterHistory>() {
class RecentlyReadAdapter(controller: RecentlyReadController)
: FlexibleAdapter<RecentlyReadItem>(null, controller, true) {
val sourceManager by injectLazy<SourceManager>()
/**
* Called when ViewHolder is created
* @param parent parent View
* @param viewType int containing viewType
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecentlyReadHolder {
val view = parent.inflate(R.layout.item_recently_read)
return RecentlyReadHolder(view, this)
}
val resumeClickListener: OnResumeClickListener = controller
val removeClickListener: OnRemoveClickListener = controller
val coverClickListener: OnCoverClickListener = controller
/**
* Called when ViewHolder is bind
* @param holder bind holder
* @param position position of holder
* DecimalFormat used to display correct chapter number
*/
override fun onBindViewHolder(holder: RecentlyReadHolder, position: Int) {
val item = getItem(position)
holder.onSetValues(item)
val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols()
.apply { decimalSeparator = '.' })
val dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
interface OnResumeClickListener {
fun onResumeClick(position: Int)
}
/**
* Update items
* @param items items
*/
fun setItems(items: List<MangaChapterHistory>) {
mItems = items
notifyDataSetChanged()
interface OnRemoveClickListener {
fun onRemoveClick(position: Int)
}
override fun updateDataSet(param: String?) {
// Empty function
interface OnCoverClickListener {
fun onCoverClick(position: Int)
}
}

View file

@ -0,0 +1,134 @@
package eu.kanade.tachiyomi.ui.recently_read
import android.os.Bundle
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.bluelinelabs.conductor.RouterTransaction
import com.bluelinelabs.conductor.changehandler.FadeChangeHandler
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
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.fragment_recently_read.view.*
/**
* Fragment that shows recently read manga.
* Uses R.layout.fragment_recently_read.
* UI related actions should be called from here.
*/
class RecentlyReadController : NucleusController<RecentlyReadPresenter>(),
FlexibleAdapter.OnUpdateListener,
RecentlyReadAdapter.OnRemoveClickListener,
RecentlyReadAdapter.OnResumeClickListener,
RecentlyReadAdapter.OnCoverClickListener,
RemoveHistoryDialog.Listener {
/**
* Adapter containing the recent manga.
*/
var adapter: RecentlyReadAdapter? = null
private set
override fun getTitle(): String? {
return resources?.getString(R.string.label_recent_manga)
}
override fun createPresenter(): RecentlyReadPresenter {
return RecentlyReadPresenter()
}
override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View {
return inflater.inflate(R.layout.fragment_recently_read, container, false)
}
/**
* Called when view is created
*
* @param view created view
* @param savedViewState saved state of the view
*/
override fun onViewCreated(view: View, savedViewState: Bundle?) {
super.onViewCreated(view, savedViewState)
with(view) {
// Initialize adapter
recycler.layoutManager = LinearLayoutManager(context)
adapter = RecentlyReadAdapter(this@RecentlyReadController)
recycler.setHasFixedSize(true)
recycler.adapter = adapter
}
}
override fun onDestroyView(view: View) {
super.onDestroyView(view)
adapter = null
}
/**
* Populate adapter with chapters
*
* @param mangaHistory list of manga history
*/
fun onNextManga(mangaHistory: List<RecentlyReadItem>) {
adapter?.updateDataSet(mangaHistory.toList())
}
override fun onUpdateEmptyView(size: Int) {
val emptyView = view?.empty_view ?: return
if (size > 0) {
emptyView.hide()
} else {
emptyView.show(R.drawable.ic_glasses_black_128dp, R.string.information_no_recent_manga)
}
}
override fun onResumeClick(position: Int) {
val activity = activity ?: return
val adapter = adapter ?: return
if (position == RecyclerView.NO_POSITION) return
val (manga, chapter, _) = adapter.getItem(position).mch
val nextChapter = presenter.getNextChapter(chapter, manga)
if (nextChapter != null) {
val intent = ReaderActivity.newIntent(activity, manga, nextChapter)
startActivity(intent)
} else {
activity.toast(R.string.no_next_chapter)
}
}
override fun onRemoveClick(position: Int) {
val adapter = adapter ?: return
if (position == RecyclerView.NO_POSITION) return
val (manga, _, history) = adapter.getItem(position).mch
RemoveHistoryDialog(this, manga, history).showDialog(router)
}
override fun onCoverClick(position: Int) {
val manga = adapter?.getItem(position)?.mch?.manga ?: return
router.pushController(RouterTransaction.with(MangaController(manga))
.pushChangeHandler(FadeChangeHandler())
.popChangeHandler(FadeChangeHandler()))
}
override fun removeHistory(manga: Manga, history: History, all: Boolean) {
if (all) {
// Reset last read of chapter to 0L
presenter.removeAllFromHistory(manga.id!!)
} else {
// Remove all chapters belonging to manga from library
presenter.removeFromHistory(history)
}
}
}

View file

@ -1,139 +0,0 @@
package eu.kanade.tachiyomi.ui.recently_read
import android.os.Bundle
import android.support.v7.widget.LinearLayoutManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaActivity
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.fragment_recently_read.*
import nucleus.factory.RequiresPresenter
/**
* Fragment that shows recently read manga.
* Uses R.layout.fragment_recently_read.
* UI related actions should be called from here.
*/
@RequiresPresenter(RecentlyReadPresenter::class)
class RecentlyReadFragment : BaseRxFragment<RecentlyReadPresenter>() {
companion object {
/**
* Create new RecentChaptersFragment.
*/
fun newInstance(): RecentlyReadFragment {
return RecentlyReadFragment()
}
}
/**
* Adapter containing the recent manga.
*/
lateinit var adapter: RecentlyReadAdapter
private set
/**
* Called when view gets created
*
* @param inflater layout inflater
* @param container view group
* @param savedState status of saved state
*/
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_recently_read, container, false)
}
/**
* Called when view is created
*
* @param view created view
* @param savedState status of saved sate
*/
override fun onViewCreated(view: View?, savedState: Bundle?) {
// Initialize adapter
recycler.layoutManager = LinearLayoutManager(activity)
adapter = RecentlyReadAdapter(this)
recycler.setHasFixedSize(true)
recycler.adapter = adapter
// Update toolbar text
setToolbarTitle(R.string.label_recent_manga)
}
/**
* Populate adapter with chapters
*
* @param mangaHistory list of manga history
*/
fun onNextManga(mangaHistory: List<MangaChapterHistory>) {
(activity as MainActivity).updateEmptyView(mangaHistory.isEmpty(),
R.string.information_no_recent_manga, R.drawable.ic_glasses_black_128dp)
adapter.setItems(mangaHistory)
}
/**
* Reset last read of chapter to 0L
* @param history history belonging to chapter
*/
fun removeFromHistory(history: History) {
presenter.removeFromHistory(history)
}
/**
* Removes all chapters belonging to manga from library
* @param mangaId id of manga
*/
fun removeAllFromHistory(mangaId: Long) {
presenter.removeAllFromHistory(mangaId)
}
/**
* Open chapter to continue reading
* @param chapter chapter that is opened
* @param manga manga belonging to chapter
*/
fun openChapter(chapter: Chapter, manga: Manga) {
if (!chapter.read) {
val intent = ReaderActivity.newIntent(activity, manga, chapter)
startActivity(intent)
} else {
presenter.openNextChapter(chapter, manga)
}
}
/**
* Called from the presenter when wanting to open the next chapter of the current one.
* @param chapter the next chapter or null if it doesn't exist.
* @param manga the manga of the chapter.
*/
fun onOpenNextChapter(chapter: Chapter?, manga: Manga) {
if (chapter == null) {
context.toast(R.string.no_next_chapter)
}
// Avoid crashes if the fragment isn't resumed, the event will be ignored but it's unlikely
// to happen.
else if (isResumed) {
val intent = ReaderActivity.newIntent(activity, manga, chapter)
startActivity(intent)
}
}
/**
* Open manga info page
* @param manga manga belonging to info page
*/
fun openMangaInfo(manga: Manga) {
val intent = MangaActivity.newIntent(activity, manga, true)
startActivity(intent)
}
}

View file

@ -1,17 +1,12 @@
package eu.kanade.tachiyomi.ui.recently_read
import android.support.v7.widget.RecyclerView
import android.view.View
import com.afollestad.materialdialogs.MaterialDialog
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import eu.kanade.tachiyomi.widget.DialogCheckboxView
import kotlinx.android.synthetic.main.item_recently_read.view.*
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.*
/**
@ -23,39 +18,47 @@ import java.util.*
* @param adapter the adapter handling this holder.
* @constructor creates a new recent chapter holder.
*/
class RecentlyReadHolder(view: View, private val adapter: RecentlyReadAdapter)
: RecyclerView.ViewHolder(view) {
class RecentlyReadHolder(
view: View,
val adapter: RecentlyReadAdapter
) : FlexibleViewHolder(view, adapter) {
/**
* DecimalFormat used to display correct chapter number
*/
private val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' })
init {
itemView.remove.setOnClickListener {
adapter.removeClickListener.onRemoveClick(adapterPosition)
}
private val df = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
itemView.resume.setOnClickListener {
adapter.resumeClickListener.onResumeClick(adapterPosition)
}
itemView.cover.setOnClickListener {
adapter.coverClickListener.onCoverClick(adapterPosition)
}
}
/**
* Set values of view
*
* @param item item containing history information
*/
fun onSetValues(item: MangaChapterHistory) {
fun bind(item: MangaChapterHistory) {
// Retrieve objects
val manga = item.manga
val chapter = item.chapter
val history = item.history
val (manga, chapter, history) = item
// Set manga title
itemView.manga_title.text = manga.title
// Set source + chapter title
val formattedNumber = decimalFormat.format(chapter.chapter_number.toDouble())
val formattedNumber = adapter.decimalFormat.format(chapter.chapter_number.toDouble())
itemView.manga_source.text = itemView.context.getString(R.string.recent_manga_source)
.format(adapter.sourceManager.get(manga.source)?.toString(), formattedNumber)
// Set last read timestamp title
itemView.last_read.text = df.format(Date(history.last_read))
itemView.last_read.text = adapter.dateFormat.format(Date(history.last_read))
// Set cover
Glide.clear(itemView.cover)
if (!manga.thumbnail_url.isNullOrEmpty()) {
Glide.with(itemView.context)
.load(manga)
@ -64,40 +67,6 @@ class RecentlyReadHolder(view: View, private val adapter: RecentlyReadAdapter)
.into(itemView.cover)
}
// Set remove clickListener
itemView.remove.setOnClickListener {
// Create custom view
val dialogCheckboxView = DialogCheckboxView(itemView.context).apply {
setDescription(R.string.dialog_with_checkbox_remove_description)
setOptionDescription(R.string.dialog_with_checkbox_reset)
}
MaterialDialog.Builder(itemView.context)
.title(R.string.action_remove)
.customView(dialogCheckboxView, true)
.positiveText(R.string.action_remove)
.negativeText(android.R.string.cancel)
.onPositive { materialDialog, dialogAction ->
// Check if user wants all chapters reset
if (dialogCheckboxView.isChecked()) {
adapter.fragment.removeAllFromHistory(manga.id!!)
} else {
adapter.fragment.removeFromHistory(history)
}
}
.onNegative { materialDialog, dialogAction ->
materialDialog.dismiss()
}.show()
}
// Set continue reading clickListener
itemView.resume.setOnClickListener {
adapter.fragment.openChapter(chapter, manga)
}
// Set open manga info clickListener
itemView.cover.setOnClickListener {
adapter.fragment.openMangaInfo(manga)
}
}
}

View file

@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.ui.recently_read
import android.view.LayoutInflater
import android.view.ViewGroup
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import eu.kanade.tachiyomi.util.inflate
class RecentlyReadItem(val mch: MangaChapterHistory) : AbstractFlexibleItem<RecentlyReadHolder>() {
override fun getLayoutRes(): Int {
return R.layout.item_recently_read
}
override fun createViewHolder(adapter: FlexibleAdapter<*>,
inflater: LayoutInflater,
parent: ViewGroup): RecentlyReadHolder {
val view = parent.inflate(layoutRes)
return RecentlyReadHolder(view, adapter as RecentlyReadAdapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<*>,
holder: RecentlyReadHolder,
position: Int,
payloads: List<Any?>?) {
holder.bind(mch)
}
override fun equals(other: Any?): Boolean {
if (other is RecentlyReadItem) {
return mch.manga.id == other.mch.manga.id
}
return false
}
override fun hashCode(): Int {
return mch.manga.id!!.hashCode()
}
}

View file

@ -5,11 +5,9 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.util.*
@ -18,7 +16,7 @@ import java.util.*
* Contains information and data for fragment.
* Observable updates should be called from here.
*/
class RecentlyReadPresenter : BasePresenter<RecentlyReadFragment>() {
class RecentlyReadPresenter : BasePresenter<RecentlyReadController>() {
/**
* Used to connect to database
@ -30,22 +28,21 @@ class RecentlyReadPresenter : BasePresenter<RecentlyReadFragment>() {
// Used to get a list of recently read manga
getRecentMangaObservable()
.subscribeLatestCache({ view, historyList ->
view.onNextManga(historyList)
})
.subscribeLatestCache(RecentlyReadController::onNextManga)
}
/**
* Get recent manga observable
* @return list of history
*/
fun getRecentMangaObservable(): Observable<List<MangaChapterHistory>> {
fun getRecentMangaObservable(): Observable<List<RecentlyReadItem>> {
// Set date for recent manga
val cal = Calendar.getInstance()
cal.time = Date()
cal.add(Calendar.MONTH, -1)
return db.getRecentManga(cal.time).asRxObservable()
.map { it.map(::RecentlyReadItem) }
.observeOn(AndroidSchedulers.mainThread())
}
@ -73,50 +70,39 @@ class RecentlyReadPresenter : BasePresenter<RecentlyReadFragment>() {
}
/**
* Open the next chapter instead of the current one.
* Retrieves the next chapter of the given one.
*
* @param chapter the chapter of the history object.
* @param manga the manga of the chapter.
*/
fun openNextChapter(chapter: Chapter, manga: Manga) {
fun getNextChapter(chapter: Chapter, manga: Manga): Chapter? {
if (!chapter.read) {
return chapter
}
val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) {
Manga.SORTING_SOURCE -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) }
Manga.SORTING_NUMBER -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) }
else -> throw NotImplementedError("Unknown sorting method")
}
db.getChapters(manga).asRxSingle()
.map { it.sortedWith(Comparator<Chapter> { c1, c2 -> sortFunction(c1, c2) }) }
.map { chapters ->
val currChapterIndex = chapters.indexOfFirst { chapter.id == it.id }
when (manga.sorting) {
Manga.SORTING_SOURCE -> {
chapters.getOrNull(currChapterIndex + 1)
}
Manga.SORTING_NUMBER -> {
val chapterNumber = chapter.chapter_number
val chapters = db.getChapters(manga).executeAsBlocking()
.sortedWith(Comparator<Chapter> { c1, c2 -> sortFunction(c1, c2) })
var nextChapter: Chapter? = null
for (i in (currChapterIndex + 1) until chapters.size) {
val c = chapters[i]
if (c.chapter_number > chapterNumber &&
c.chapter_number <= chapterNumber + 1) {
val currChapterIndex = chapters.indexOfFirst { chapter.id == it.id }
return when (manga.sorting) {
Manga.SORTING_SOURCE -> chapters.getOrNull(currChapterIndex + 1)
Manga.SORTING_NUMBER -> {
val chapterNumber = chapter.chapter_number
nextChapter = c
break
}
}
nextChapter
((currChapterIndex + 1) until chapters.size)
.map { chapters[it] }
.firstOrNull { it.chapter_number > chapterNumber &&
it.chapter_number <= chapterNumber + 1
}
else -> throw NotImplementedError("Unknown sorting method")
}
}
.toObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, chapter ->
view.onOpenNextChapter(chapter, manga)
}, { view, error ->
Timber.e(error)
})
}
else -> throw NotImplementedError("Unknown sorting method")
}
}
}

View file

@ -0,0 +1,56 @@
package eu.kanade.tachiyomi.ui.recently_read
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.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.widget.DialogCheckboxView
class RemoveHistoryDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T: RemoveHistoryDialog.Listener {
private var manga: Manga? = null
private var history: History? = null
constructor(target: T, manga: Manga, history: History) : this() {
this.manga = manga
this.history = history
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val activity = activity!!
// Create custom view
val dialogCheckboxView = DialogCheckboxView(activity).apply {
setDescription(R.string.dialog_with_checkbox_remove_description)
setOptionDescription(R.string.dialog_with_checkbox_reset)
}
return MaterialDialog.Builder(activity)
.title(R.string.action_remove)
.customView(dialogCheckboxView, true)
.positiveText(R.string.action_remove)
.negativeText(android.R.string.cancel)
.onPositive { _, _ -> onPositive(dialogCheckboxView.isChecked()) }
.build()
}
private fun onPositive(checked: Boolean) {
val target = targetController as? Listener ?: return
val manga = manga ?: return
val history = history ?: return
target.removeHistory(manga, history, checked)
}
interface Listener {
fun removeHistory(manga: Manga, history: History, all: Boolean)
}
}

View file

@ -7,7 +7,6 @@ import android.support.v7.preference.XpPreferenceFragment
import android.view.View
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.LocaleHelper

View file

@ -0,0 +1,23 @@
package eu.kanade.tachiyomi.widget
import android.support.v4.widget.DrawerLayout
import android.view.View
import android.view.ViewGroup
class DrawerSwipeCloseListener(
private val drawer: DrawerLayout,
private val navigationView: ViewGroup
) : DrawerLayout.SimpleDrawerListener() {
override fun onDrawerOpened(drawerView: View) {
if (drawerView == navigationView) {
drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, drawerView)
}
}
override fun onDrawerClosed(drawerView: View) {
if (drawerView == navigationView) {
drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, drawerView)
}
}
}

View file

@ -1,11 +1,11 @@
package eu.kanade.tachiyomi.widget
import android.support.v4.view.PagerAdapter
import android.view.View
import android.view.ViewGroup
import com.nightlynexus.viewstatepageradapter.ViewStatePagerAdapter
import java.util.*
abstract class RecyclerViewPagerAdapter : PagerAdapter() {
abstract class RecyclerViewPagerAdapter : ViewStatePagerAdapter() {
private val pool = Stack<View>()
@ -21,22 +21,16 @@ abstract class RecyclerViewPagerAdapter : PagerAdapter() {
protected open fun recycleView(view: View, position: Int) {}
override fun instantiateItem(container: ViewGroup, position: Int): Any {
override fun createView(container: ViewGroup, position: Int): View {
val view = if (pool.isNotEmpty()) pool.pop() else createView(container)
bindView(view, position)
container.addView(view)
return view
}
override fun destroyItem(container: ViewGroup, position: Int, obj: Any) {
val view = obj as View
override fun destroyView(container: ViewGroup, position: Int, view: View) {
recycleView(view, position)
container.removeView(view)
if (recycle) pool.push(view)
}
override fun isViewFromObject(view: View, obj: Any): Boolean {
return view === obj
}
}

View file

@ -0,0 +1,281 @@
/*
* Copyright 2016 Davide Steduto
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.kanade.tachiyomi.widget;
import android.content.Context;
import android.graphics.Color;
import android.support.annotation.ColorInt;
import android.support.annotation.IntDef;
import android.support.annotation.IntRange;
import android.support.annotation.NonNull;
import android.support.annotation.StringRes;
import android.support.design.widget.Snackbar;
import android.view.View;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.List;
import eu.davidea.flexibleadapter.FlexibleAdapter;
/**
* Helper to simplify the Undo operation with FlexibleAdapter.
*
* @author Davide Steduto
* @since 30/04/2016
*/
@SuppressWarnings("WeakerAccess")
public class UndoHelper extends Snackbar.Callback {
/**
* Default undo-timeout of 5''.
*/
public static final int UNDO_TIMEOUT = 5000;
/**
* Indicates that the Confirmation Listener (Undo and Delete) will perform a deletion.
*/
public static final int ACTION_REMOVE = 0;
/**
* Indicates that the Confirmation Listener (Undo and Delete) will perform an update.
*/
public static final int ACTION_UPDATE = 1;
/**
* Annotation interface for Undo actions.
*/
@IntDef({ACTION_REMOVE, ACTION_UPDATE})
@Retention(RetentionPolicy.SOURCE)
public @interface Action {
}
@Action
private int mAction = ACTION_REMOVE;
private List<Integer> mPositions = null;
private Object mPayload = null;
private FlexibleAdapter mAdapter;
private Snackbar mSnackbar = null;
private OnActionListener mActionListener;
private OnUndoListener mUndoListener;
private @ColorInt int mActionTextColor = Color.TRANSPARENT;
/**
* Default constructor.
* <p>By calling this constructor, {@link FlexibleAdapter#setPermanentDelete(boolean)}
* is set {@code false} automatically.
*
* @param adapter the instance of {@code FlexibleAdapter}
* @param undoListener the callback for the Undo and Delete confirmation
*/
public UndoHelper(FlexibleAdapter adapter, OnUndoListener undoListener) {
this.mAdapter = adapter;
this.mUndoListener = undoListener;
adapter.setPermanentDelete(false);
}
/**
* Sets the payload to inform other linked items about the change in action.
*
* @param payload any non-null user object to notify the parent (the payload will be
* therefore passed to the bind method of the parent ViewHolder),
* pass null to <u>not</u> notify the parent
* @return this object, so it can be chained
*/
public UndoHelper withPayload(Object payload) {
this.mPayload = payload;
return this;
}
/**
* By default {@link UndoHelper#ACTION_REMOVE} is performed.
*
* @param action the action, one of {@link UndoHelper#ACTION_REMOVE}, {@link UndoHelper#ACTION_UPDATE}
* @param actionListener the listener for the custom action to perform before the deletion
* @return this object, so it can be chained
*/
public UndoHelper withAction(@Action int action, @NonNull OnActionListener actionListener) {
this.mAction = action;
this.mActionListener = actionListener;
return this;
}
/**
* Sets the text color of the action.
*
* @param color the color for the action button
* @return this object, so it can be chained
*/
public UndoHelper withActionTextColor(@ColorInt int color) {
this.mActionTextColor = color;
return this;
}
/**
* As {@link #remove(List, View, CharSequence, CharSequence, int)} but with String
* resources instead of CharSequence.
*/
public void remove(List<Integer> positions, @NonNull View mainView,
@StringRes int messageStringResId, @StringRes int actionStringResId,
@IntRange(from = -1) int undoTime) {
Context context = mainView.getContext();
remove(positions, mainView, context.getString(messageStringResId),
context.getString(actionStringResId), undoTime);
}
/**
* Performs the action on the specified positions and displays a SnackBar to Undo
* the operation. To customize the UPDATE event, please set a custom listener with
* {@link #withAction(int, OnActionListener)} method.
* <p>By default the DELETE action will be performed.</p>
*
* @param positions the position to delete or update
* @param mainView the view to find a parent from
* @param message the text to show. Can be formatted text
* @param actionText the action text to display
* @param undoTime How long to display the message. Either {@link Snackbar#LENGTH_SHORT} or
* {@link Snackbar#LENGTH_LONG} or any custom Integer.
* @see #remove(List, View, int, int, int)
*/
@SuppressWarnings("WrongConstant")
public void remove(List<Integer> positions, @NonNull View mainView,
CharSequence message, CharSequence actionText,
@IntRange(from = -1) int undoTime) {
this.mPositions = positions;
Snackbar snackbar;
if (!mAdapter.isPermanentDelete()) {
snackbar = Snackbar.make(mainView, message, undoTime > 0 ? undoTime + 400 : undoTime)
.setAction(actionText, new View.OnClickListener() {
@Override
public void onClick(View v) {
if (mUndoListener != null)
mUndoListener.onUndoConfirmed(mAction);
}
});
} else {
snackbar = Snackbar.make(mainView, message, undoTime);
}
if (mActionTextColor != Color.TRANSPARENT) {
snackbar.setActionTextColor(mActionTextColor);
}
mSnackbar = snackbar;
snackbar.addCallback(this);
snackbar.show();
}
public void dismissNow() {
if (mSnackbar != null) {
mSnackbar.removeCallback(this);
mSnackbar.dismiss();
onDismissed(mSnackbar, Snackbar.Callback.DISMISS_EVENT_MANUAL);
}
}
/**
* {@inheritDoc}
*/
@Override
public void onDismissed(Snackbar snackbar, int event) {
if (mAdapter.isPermanentDelete()) return;
switch (event) {
case DISMISS_EVENT_SWIPE:
case DISMISS_EVENT_MANUAL:
case DISMISS_EVENT_TIMEOUT:
if (mUndoListener != null)
mUndoListener.onDeleteConfirmed(mAction);
mAdapter.emptyBin();
mSnackbar = null;
case DISMISS_EVENT_CONSECUTIVE:
case DISMISS_EVENT_ACTION:
default:
break;
}
}
/**
* {@inheritDoc}
*/
@Override
public void onShown(Snackbar snackbar) {
boolean consumed = false;
// Perform the action before deletion
if (mActionListener != null) consumed = mActionListener.onPreAction();
// Remove selected items from Adapter list after SnackBar is shown
if (!consumed) mAdapter.removeItems(mPositions, mPayload);
// Perform the action after the deletion
if (mActionListener != null) mActionListener.onPostAction();
// Here, we can notify the callback only in case of permanent deletion
if (mAdapter.isPermanentDelete() && mUndoListener != null)
mUndoListener.onDeleteConfirmed(mAction);
}
/**
* Basic implementation of {@link OnActionListener} interface.
* <p>Override the methods as your convenience.</p>
*/
public static class SimpleActionListener implements OnActionListener {
@Override
public boolean onPreAction() {
return false;
}
@Override
public void onPostAction() {
}
}
public interface OnActionListener {
/**
* Performs the custom action before item deletion.
*
* @return true if action has been consumed and should stop the deletion, false to
* continue with the deletion
*/
boolean onPreAction();
/**
* Performs custom action After items deletion. Useful to finish the action mode and perform
* secondary custom actions.
*/
void onPostAction();
}
/**
* @since 30/04/2016
*/
public interface OnUndoListener {
/**
* Called when Undo event is triggered. Perform custom action after restoration.
* <p>Usually for a delete restoration you should call
* {@link FlexibleAdapter#restoreDeletedItems()}.</p>
*
* @param action one of {@link UndoHelper#ACTION_REMOVE}, {@link UndoHelper#ACTION_UPDATE}
*/
void onUndoConfirmed(int action);
/**
* Called when Undo timeout is over and action must be committed in the user Database.
* <p>Due to Java Generic, it's too complicated and not well manageable if we pass the
* List&lt;T&gt; object.<br/>
* So, to get deleted items, use {@link FlexibleAdapter#getDeletedItems()} from the
* implementation of this method.</p>
*
* @param action one of {@link UndoHelper#ACTION_REMOVE}, {@link UndoHelper#ACTION_UPDATE}
*/
void onDeleteConfirmed(int action);
}
}

View file

@ -2,27 +2,17 @@
<android.support.design.widget.CoordinatorLayout
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:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:fitsSystemWindows="true">
<include layout="@layout/toolbar"/>
<android.support.v7.widget.RecyclerView
<com.bluelinelabs.conductor.ChangeHandlerFrameLayout
android:id="@+id/controller_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="?attr/actionBarSize"
android:id="@+id/recycler"
android:choiceMode="multipleChoice"
tools:listitem="@layout/item_edit_categories"
/>
<android.support.design.widget.FloatingActionButton
android:id="@+id/fab"
app:layout_anchor="@id/recycler"
app:srcCompat="@drawable/ic_add_white_24dp"
style="@style/Theme.Widget.FAB"/>
app:layout_behavior="@string/appbar_scrolling_view_behavior"
/>
</android.support.design.widget.CoordinatorLayout>

Some files were not shown because too many files have changed in this diff Show more