Forráskód Böngészése

Catalogue in Kotlin. Support library upgraded to 23.2.0. Downloads directory now shows a list of folders, it should fix #141.

len 9 éve
szülő
commit
ee4bf163ef

+ 1 - 1
app/build.gradle

@@ -98,7 +98,7 @@ apt {
 }
 
 dependencies {
-    final SUPPORT_LIBRARY_VERSION = '23.1.1'
+    final SUPPORT_LIBRARY_VERSION = '23.2.0'
     final DAGGER_VERSION = '2.0.2'
     final OKHTTP_VERSION = '3.2.0'
     final RETROFIT_VERSION = '2.0.0-beta4'

+ 7 - 2
app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.java

@@ -26,6 +26,7 @@ import eu.kanade.tachiyomi.data.source.model.Page;
 import eu.kanade.tachiyomi.event.DownloadChaptersEvent;
 import eu.kanade.tachiyomi.util.DiskUtils;
 import eu.kanade.tachiyomi.util.DynamicConcurrentMergeOperator;
+import eu.kanade.tachiyomi.util.ToastUtil;
 import eu.kanade.tachiyomi.util.UrlUtil;
 import rx.Observable;
 import rx.Subscription;
@@ -84,7 +85,11 @@ public class DownloadManager {
                     if (finished) {
                         DownloadService.stop(context);
                     }
-                }, e -> DownloadService.stop(context));
+                }, e -> {
+                    DownloadService.stop(context);
+                    Timber.e(e, e.getMessage());
+                    ToastUtil.showShort(context, e.getMessage());
+                });
 
         if (!isRunning) {
             isRunning = true;
@@ -410,7 +415,7 @@ public class DownloadManager {
         if (queue.isEmpty())
             return false;
 
-        if (downloadsSubscription == null)
+        if (downloadsSubscription == null || downloadsSubscription.isUnsubscribed())
             initializeSubscriptions();
 
         final List<Download> pending = new ArrayList<>();

+ 0 - 69
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueAdapter.java

@@ -1,69 +0,0 @@
-package eu.kanade.tachiyomi.ui.catalogue;
-
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import eu.davidea.flexibleadapter.FlexibleAdapter;
-import eu.kanade.tachiyomi.R;
-import eu.kanade.tachiyomi.data.database.models.Manga;
-
-public class CatalogueAdapter extends FlexibleAdapter<CatalogueHolder, Manga> {
-
-    private CatalogueFragment fragment;
-
-    public CatalogueAdapter(CatalogueFragment fragment) {
-        this.fragment = fragment;
-        mItems = new ArrayList<>();
-        setHasStableIds(true);
-    }
-
-    public void addItems(List<Manga> list) {
-        mItems.addAll(list);
-        notifyDataSetChanged();
-    }
-
-    public void clear() {
-        mItems.clear();
-        notifyDataSetChanged();
-    }
-
-    public List<Manga> getItems() {
-        return mItems;
-    }
-
-    @Override
-    public long getItemId(int position) {
-        return mItems.get(position).id;
-    }
-
-    @Override
-    public void updateDataSet(String param) {
-
-    }
-
-    @Override
-    public CatalogueHolder onCreateViewHolder(ViewGroup parent, int viewType) {
-        LayoutInflater inflater = fragment.getActivity().getLayoutInflater();
-        if (parent.getId() == R.id.catalogue_grid) {
-            View v = inflater.inflate(R.layout.item_catalogue_grid, parent, false);
-            return new CatalogueGridHolder(v, this, fragment);
-        } else {
-            View v = inflater.inflate(R.layout.item_catalogue_list, parent, false);
-            return new CatalogueListHolder(v, this, fragment);
-        }
-    }
-
-    @Override
-    public void onBindViewHolder(CatalogueHolder holder, int position) {
-        final Manga manga = getItem(position);
-        holder.onSetValues(manga, fragment.getPresenter());
-
-        //When user scrolls this bind the correct selection status
-        //holder.itemView.setActivated(isSelected(position));
-    }
-
-}

+ 89 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueAdapter.kt

@@ -0,0 +1,89 @@
+package eu.kanade.tachiyomi.ui.catalogue
+
+import android.view.ViewGroup
+import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.util.inflate
+import java.util.*
+
+/**
+ * Adapter storing a list of manga from the catalogue.
+ *
+ * @param fragment the fragment containing this adapter.
+ */
+class CatalogueAdapter(private val fragment: CatalogueFragment) : FlexibleAdapter<CatalogueHolder, Manga>() {
+
+    /**
+     * Property to get the list of manga in the adapter.
+     */
+    val items: List<Manga>
+        get() = mItems
+
+    init {
+        mItems = ArrayList<Manga>()
+        setHasStableIds(true)
+    }
+
+    /**
+     * Adds a list of manga to the adapter.
+     *
+     * @param list the list to add.
+     */
+    fun addItems(list: List<Manga>) {
+        mItems.addAll(list)
+        notifyDataSetChanged()
+    }
+
+    /**
+     * Clears the list of manga from the adapter.
+     */
+    fun clear() {
+        mItems.clear()
+        notifyDataSetChanged()
+    }
+
+    /**
+     * 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
+    }
+
+    /**
+     * Used to filter the list. Required but not used.
+     */
+    override fun updateDataSet(param: String) {}
+
+    /**
+     * 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): CatalogueHolder {
+        if (parent.id == R.id.catalogue_grid) {
+            val v = parent.inflate(R.layout.item_catalogue_grid)
+            return CatalogueGridHolder(v, this, fragment)
+        } else {
+            val v = parent.inflate(R.layout.item_catalogue_list)
+            return CatalogueListHolder(v, 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: CatalogueHolder, position: Int) {
+        val manga = getItem(position)
+        holder.onSetValues(manga, fragment.presenter)
+    }
+
+}

+ 0 - 354
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueFragment.java

@@ -1,354 +0,0 @@
-package eu.kanade.tachiyomi.ui.catalogue;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.support.annotation.Nullable;
-import android.support.v4.content.ContextCompat;
-import android.support.v7.widget.GridLayoutManager;
-import android.support.v7.widget.LinearLayoutManager;
-import android.support.v7.widget.RecyclerView;
-import android.support.v7.widget.SearchView;
-import android.support.v7.widget.Toolbar;
-import android.text.TextUtils;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.animation.Animation;
-import android.view.animation.AnimationUtils;
-import android.widget.AdapterView;
-import android.widget.ArrayAdapter;
-import android.widget.ProgressBar;
-import android.widget.Spinner;
-import android.widget.ViewSwitcher;
-
-import com.afollestad.materialdialogs.MaterialDialog;
-
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-
-import butterknife.Bind;
-import butterknife.ButterKnife;
-import eu.kanade.tachiyomi.R;
-import eu.kanade.tachiyomi.data.database.models.Manga;
-import eu.kanade.tachiyomi.data.source.base.Source;
-import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder;
-import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment;
-import eu.kanade.tachiyomi.ui.decoration.DividerItemDecoration;
-import eu.kanade.tachiyomi.ui.main.MainActivity;
-import eu.kanade.tachiyomi.ui.manga.MangaActivity;
-import eu.kanade.tachiyomi.util.ToastUtil;
-import eu.kanade.tachiyomi.widget.AutofitRecyclerView;
-import eu.kanade.tachiyomi.widget.EndlessGridScrollListener;
-import eu.kanade.tachiyomi.widget.EndlessListScrollListener;
-import icepick.State;
-import nucleus.factory.RequiresPresenter;
-import rx.Subscription;
-import rx.android.schedulers.AndroidSchedulers;
-import rx.subjects.PublishSubject;
-import timber.log.Timber;
-
-@RequiresPresenter(CataloguePresenter.class)
-public class CatalogueFragment extends BaseRxFragment<CataloguePresenter>
-        implements FlexibleViewHolder.OnListItemClickListener {
-
-    @Bind(R.id.switcher) ViewSwitcher switcher;
-    @Bind(R.id.catalogue_grid) AutofitRecyclerView catalogueGrid;
-    @Bind(R.id.catalogue_list) RecyclerView catalogueList;
-    @Bind(R.id.progress) ProgressBar progress;
-    @Bind(R.id.progress_grid) ProgressBar progressGrid;
-
-    private Toolbar toolbar;
-    private Spinner spinner;
-    private CatalogueAdapter adapter;
-    private EndlessGridScrollListener gridScrollListener;
-    private EndlessListScrollListener listScrollListener;
-
-    @State String query = "";
-    @State int selectedIndex;
-    private final int SEARCH_TIMEOUT = 1000;
-
-    private PublishSubject<String> queryDebouncerSubject;
-    private Subscription queryDebouncerSubscription;
-
-    private MenuItem displayMode;
-    private MenuItem searchItem;
-
-    public static CatalogueFragment newInstance() {
-        return new CatalogueFragment();
-    }
-
-    @Override
-    public void onCreate(Bundle savedState) {
-        super.onCreate(savedState);
-        setHasOptionsMenu(true);
-    }
-
-    @Override
-    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) {
-        // Inflate the layout for this fragment
-        View view = inflater.inflate(R.layout.fragment_catalogue, container, false);
-        ButterKnife.bind(this, view);
-
-        // Initialize adapter, scroll listener and recycler views
-        adapter = new CatalogueAdapter(this);
-
-        GridLayoutManager glm = (GridLayoutManager) catalogueGrid.getLayoutManager();
-        gridScrollListener = new EndlessGridScrollListener(glm, this::requestNextPage);
-        catalogueGrid.setHasFixedSize(true);
-        catalogueGrid.setAdapter(adapter);
-        catalogueGrid.addOnScrollListener(gridScrollListener);
-
-        LinearLayoutManager llm = new LinearLayoutManager(getActivity());
-        listScrollListener = new EndlessListScrollListener(llm, this::requestNextPage);
-        catalogueList.setHasFixedSize(true);
-        catalogueList.setAdapter(adapter);
-        catalogueList.setLayoutManager(llm);
-        catalogueList.addOnScrollListener(listScrollListener);
-        catalogueList.addItemDecoration(new DividerItemDecoration(
-                ContextCompat.getDrawable(getContext(), R.drawable.line_divider)));
-
-        if (getPresenter().isListMode()) {
-            switcher.showNext();
-        }
-
-        Animation inAnim = AnimationUtils.loadAnimation(getActivity(), android.R.anim.fade_in);
-        Animation outAnim = AnimationUtils.loadAnimation(getActivity(), android.R.anim.fade_out);
-        switcher.setInAnimation(inAnim);
-        switcher.setOutAnimation(outAnim);
-
-        // Create toolbar spinner
-        Context themedContext = getBaseActivity().getSupportActionBar() != null ?
-                getBaseActivity().getSupportActionBar().getThemedContext() : getActivity();
-        spinner = new Spinner(themedContext);
-        ArrayAdapter<Source> spinnerAdapter = new ArrayAdapter<>(themedContext,
-                android.R.layout.simple_spinner_item, getPresenter().getEnabledSources());
-        spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
-
-        if (savedState == null) {
-            selectedIndex = getPresenter().getLastUsedSourceIndex();
-        }
-        spinner.setAdapter(spinnerAdapter);
-        spinner.setSelection(selectedIndex);
-        spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
-            @Override
-            public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
-                Source source = spinnerAdapter.getItem(position);
-                if (selectedIndex != position || adapter.isEmpty()) {
-                    // Set previous selection if it's not a valid source and notify the user
-                    if (!getPresenter().isValidSource(source)) {
-                        spinner.setSelection(getPresenter().findFirstValidSource());
-                        ToastUtil.showShort(getActivity(), R.string.source_requires_login);
-                    } else {
-                        selectedIndex = position;
-                        getPresenter().setEnabledSource(selectedIndex);
-                        showProgressBar();
-                        glm.scrollToPositionWithOffset(0, 0);
-                        llm.scrollToPositionWithOffset(0, 0);
-                        getPresenter().startRequesting(source);
-                    }
-                }
-            }
-
-            @Override
-            public void onNothingSelected(AdapterView<?> parent) {}
-        });
-
-        setToolbarTitle("");
-        toolbar = ((MainActivity)getActivity()).getToolbar();
-        toolbar.addView(spinner);
-
-        return view;
-    }
-
-    @Override
-    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
-        inflater.inflate(R.menu.catalogue_list, menu);
-
-        // Initialize search menu
-        searchItem = menu.findItem(R.id.action_search);
-        final SearchView searchView = (SearchView) searchItem.getActionView();
-
-        if (!TextUtils.isEmpty(query)) {
-            searchItem.expandActionView();
-            searchView.setQuery(query, true);
-            searchView.clearFocus();
-        }
-        searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
-            @Override
-            public boolean onQueryTextSubmit(String query) {
-                onSearchEvent(query, true);
-                return true;
-            }
-
-            @Override
-            public boolean onQueryTextChange(String newText) {
-                onSearchEvent(newText, false);
-                return true;
-            }
-        });
-
-        // Show next display mode
-        displayMode = menu.findItem(R.id.action_display_mode);
-        int icon = getPresenter().isListMode() ?
-                R.drawable.ic_view_module_white_24dp : R.drawable.ic_view_list_white_24dp;
-        displayMode.setIcon(icon);
-    }
-
-    @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-        switch (item.getItemId()) {
-            case R.id.action_display_mode:
-                swapDisplayMode();
-                break;
-        }
-        return super.onOptionsItemSelected(item);
-    }
-
-    @Override
-    public void onStart() {
-        super.onStart();
-        initializeSearchSubscription();
-    }
-
-    @Override
-    public void onStop() {
-        destroySearchSubscription();
-        super.onStop();
-    }
-
-    @Override
-    public void onDestroyView() {
-        if (searchItem != null && searchItem.isActionViewExpanded()) {
-            searchItem.collapseActionView();
-        }
-        toolbar.removeView(spinner);
-        super.onDestroyView();
-    }
-
-    private void initializeSearchSubscription() {
-        queryDebouncerSubject = PublishSubject.create();
-        queryDebouncerSubscription = queryDebouncerSubject
-                .debounce(SEARCH_TIMEOUT, TimeUnit.MILLISECONDS)
-                .observeOn(AndroidSchedulers.mainThread())
-                .subscribe(this::restartRequest);
-    }
-
-    private void destroySearchSubscription() {
-        queryDebouncerSubscription.unsubscribe();
-    }
-
-    private void onSearchEvent(String query, boolean now) {
-        // If the query is not debounced, resolve it instantly
-        if (now)
-            restartRequest(query);
-        else if (queryDebouncerSubject != null)
-            queryDebouncerSubject.onNext(query);
-    }
-
-    private void restartRequest(String newQuery) {
-        // If text didn't change, do nothing
-        if (query.equals(newQuery) || getPresenter().getSource() == null)
-            return;
-
-        query = newQuery;
-        showProgressBar();
-        catalogueGrid.getLayoutManager().scrollToPosition(0);
-        catalogueList.getLayoutManager().scrollToPosition(0);
-
-        getPresenter().restartRequest(query);
-    }
-
-    private void requestNextPage() {
-        if (getPresenter().hasNextPage()) {
-            showGridProgressBar();
-            getPresenter().requestNext();
-        }
-    }
-
-    public void onAddPage(int page, List<Manga> mangas) {
-        hideProgressBar();
-        if (page == 0) {
-            adapter.clear();
-            gridScrollListener.resetScroll();
-            listScrollListener.resetScroll();
-        }
-        adapter.addItems(mangas);
-    }
-
-    public void onAddPageError(Throwable error) {
-        hideProgressBar();
-        ToastUtil.showShort(getContext(), error.getMessage());
-        Timber.e(error, error.getMessage());
-    }
-
-    public void updateImage(Manga manga) {
-        CatalogueGridHolder holder = getHolder(manga);
-        if (holder != null) {
-            holder.setImage(manga, getPresenter());
-        }
-    }
-
-    public void swapDisplayMode() {
-        getPresenter().swapDisplayMode();
-        boolean isListMode = getPresenter().isListMode();
-        int icon = isListMode ?
-                R.drawable.ic_view_module_white_24dp : R.drawable.ic_view_list_white_24dp;
-        displayMode.setIcon(icon);
-        switcher.showNext();
-        if (!isListMode) {
-            // Initialize mangas if going to grid view
-            getPresenter().initializeMangas(adapter.getItems());
-        }
-    }
-
-    @Nullable
-    private CatalogueGridHolder getHolder(Manga manga) {
-        return (CatalogueGridHolder) catalogueGrid.findViewHolderForItemId(manga.id);
-    }
-
-    private void showProgressBar() {
-        progress.setVisibility(ProgressBar.VISIBLE);
-    }
-
-    private void showGridProgressBar() {
-        progressGrid.setVisibility(ProgressBar.VISIBLE);
-    }
-
-    private void hideProgressBar() {
-        progress.setVisibility(ProgressBar.GONE);
-        progressGrid.setVisibility(ProgressBar.GONE);
-    }
-
-    @Override
-    public boolean onListItemClick(int position) {
-        final Manga selectedManga = adapter.getItem(position);
-
-        Intent intent = MangaActivity.newIntent(getActivity(), selectedManga);
-        intent.putExtra(MangaActivity.MANGA_ONLINE, true);
-        startActivity(intent);
-        return false;
-    }
-
-    @Override
-    public void onListItemLongClick(int position) {
-        final Manga selectedManga = adapter.getItem(position);
-
-        int textRes = selectedManga.favorite ? R.string.remove_from_library : R.string.add_to_library;
-
-        new MaterialDialog.Builder(getActivity())
-                .items(getString(textRes))
-                .itemsCallback((dialog, itemView, which, text) -> {
-                    switch (which) {
-                        case 0:
-                            getPresenter().changeMangaFavorite(selectedManga);
-                            adapter.notifyItemChanged(position);
-                            break;
-                    }
-                })
-                .show();
-    }
-}

+ 456 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueFragment.kt

@@ -0,0 +1,456 @@
+package eu.kanade.tachiyomi.ui.catalogue
+
+import android.os.Bundle
+import android.support.v4.content.ContextCompat
+import android.support.v7.widget.GridLayoutManager
+import android.support.v7.widget.LinearLayoutManager
+import android.support.v7.widget.SearchView
+import android.support.v7.widget.Toolbar
+import android.view.*
+import android.view.animation.AnimationUtils
+import android.widget.AdapterView
+import android.widget.ArrayAdapter
+import android.widget.ProgressBar
+import android.widget.Spinner
+import com.afollestad.materialdialogs.MaterialDialog
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
+import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
+import eu.kanade.tachiyomi.ui.decoration.DividerItemDecoration
+import eu.kanade.tachiyomi.ui.main.MainActivity
+import eu.kanade.tachiyomi.ui.manga.MangaActivity
+import eu.kanade.tachiyomi.util.ToastUtil
+import eu.kanade.tachiyomi.widget.EndlessGridScrollListener
+import eu.kanade.tachiyomi.widget.EndlessListScrollListener
+import kotlinx.android.synthetic.main.fragment_catalogue.*
+import nucleus.factory.RequiresPresenter
+import rx.Subscription
+import rx.android.schedulers.AndroidSchedulers
+import rx.subjects.PublishSubject
+import timber.log.Timber
+import java.util.concurrent.TimeUnit
+
+/**
+ * Fragment that shows the manga from the catalogue.
+ * Uses R.layout.fragment_catalogue.
+ */
+@RequiresPresenter(CataloguePresenter::class)
+class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHolder.OnListItemClickListener {
+
+    /**
+     * Spinner shown in the toolbar to change the selected source.
+     */
+    private lateinit var spinner: Spinner
+
+    /**
+     * Adapter containing the list of manga from the catalogue.
+     */
+    private lateinit var adapter: CatalogueAdapter
+
+    /**
+     * Scroll listener for grid mode. It loads next pages when the end of the list is reached.
+     */
+    private lateinit var gridScrollListener: EndlessGridScrollListener
+
+    /**
+     * Scroll listener for list mode. It loads next pages when the end of the list is reached.
+     */
+    private lateinit var listScrollListener: EndlessListScrollListener
+
+    /**
+     * Query of the search box.
+     */
+    private var query = ""
+
+    /**
+     * Selected index of the spinner (selected source).
+     */
+    private var selectedIndex: Int = 0
+
+    /**
+     * Time in milliseconds to wait for input events in the search query before doing network calls.
+     */
+    private val SEARCH_TIMEOUT = 1000L
+
+    /**
+     * Subject to debounce the query.
+     */
+    private val queryDebouncerSubject = PublishSubject.create<String>()
+
+    /**
+     * Subscription of the debouncer subject.
+     */
+    private var queryDebouncerSubscription: Subscription? = null
+
+    /**
+     * Display mode of the catalogue (list or grid mode).
+     */
+    private var displayMode: MenuItem? = null
+
+    /**
+     * Search item.
+     */
+    private var searchItem: MenuItem? = null
+
+    /**
+     * Property to get the toolbar from the containing activity.
+     */
+    private val toolbar: Toolbar
+        get() = (activity as MainActivity).toolbar
+
+    companion object {
+
+        /**
+         * Key to save and restore [query] from a [Bundle].
+         */
+        const val QUERY_KEY = "query_key"
+
+        /**
+         * Key to save and restore [selectedIndex] from a [Bundle].
+         */
+        const val SELECTED_INDEX_KEY = "selected_index_key"
+
+        /**
+         * Creates a new instance of this fragment.
+         *
+         * @return a new instance of [CatalogueFragment].
+         */
+        @JvmStatic
+        fun newInstance(): CatalogueFragment {
+            return CatalogueFragment()
+        }
+    }
+
+    override fun onCreate(savedState: Bundle?) {
+        super.onCreate(savedState)
+        setHasOptionsMenu(true)
+
+        if (savedState != null) {
+            selectedIndex = savedState.getInt(SELECTED_INDEX_KEY)
+            query = savedState.getString(QUERY_KEY)
+        } else {
+            selectedIndex = presenter.getLastUsedSourceIndex()
+        }
+    }
+
+    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
+        return inflater.inflate(R.layout.fragment_catalogue, container, false)
+    }
+
+    override fun onViewCreated(view: View, savedState: Bundle?) {
+        // Initialize adapter, scroll listener and recycler views
+        adapter = CatalogueAdapter(this)
+
+        val glm = catalogue_grid.layoutManager as GridLayoutManager
+        gridScrollListener = EndlessGridScrollListener(glm, { requestNextPage() })
+        catalogue_grid.setHasFixedSize(true)
+        catalogue_grid.adapter = adapter
+        catalogue_grid.addOnScrollListener(gridScrollListener)
+
+        val llm = LinearLayoutManager(activity)
+        listScrollListener = EndlessListScrollListener(llm, { requestNextPage() })
+        catalogue_list.setHasFixedSize(true)
+        catalogue_list.adapter = adapter
+        catalogue_list.layoutManager = llm
+        catalogue_list.addOnScrollListener(listScrollListener)
+        catalogue_list.addItemDecoration(DividerItemDecoration(
+                ContextCompat.getDrawable(context, R.drawable.line_divider)))
+
+        if (presenter.isListMode) {
+            switcher.showNext()
+        }
+
+        switcher.inAnimation = AnimationUtils.loadAnimation(activity, android.R.anim.fade_in)
+        switcher.outAnimation = AnimationUtils.loadAnimation(activity, android.R.anim.fade_out)
+
+        // Create toolbar spinner
+        val themedContext = baseActivity.supportActionBar?.themedContext ?: activity
+
+        val spinnerAdapter = ArrayAdapter(themedContext,
+                android.R.layout.simple_spinner_item, presenter.getEnabledSources())
+        spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
+
+        val onItemSelected = object : AdapterView.OnItemSelectedListener {
+            override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) {
+                val source = spinnerAdapter.getItem(position)
+                if (selectedIndex != position || adapter.isEmpty) {
+                    // Set previous selection if it's not a valid source and notify the user
+                    if (!presenter.isValidSource(source)) {
+                        spinner.setSelection(presenter.findFirstValidSource())
+                        ToastUtil.showShort(activity, R.string.source_requires_login)
+                    } else {
+                        selectedIndex = position
+                        presenter.setEnabledSource(selectedIndex)
+                        showProgressBar()
+                        glm.scrollToPositionWithOffset(0, 0)
+                        llm.scrollToPositionWithOffset(0, 0)
+                        presenter.startRequesting(source)
+                    }
+                }
+            }
+
+            override fun onNothingSelected(parent: AdapterView<*>) {
+            }
+        }
+
+        spinner = Spinner(themedContext).apply {
+            adapter = spinnerAdapter
+            setSelection(selectedIndex)
+            onItemSelectedListener = onItemSelected
+        }
+
+        setToolbarTitle("")
+        toolbar.addView(spinner)
+    }
+
+    override fun onSaveInstanceState(bundle: Bundle) {
+        bundle.putInt(SELECTED_INDEX_KEY, selectedIndex)
+        bundle.putString(QUERY_KEY, query)
+        super.onSaveInstanceState(bundle)
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+        inflater.inflate(R.menu.catalogue_list, menu)
+
+        // Initialize search menu
+        searchItem = menu.findItem(R.id.action_search).apply {
+            val searchView = actionView as SearchView
+
+            if (!query.isNullOrEmpty()) {
+                expandActionView()
+                searchView.setQuery(query, true)
+                searchView.clearFocus()
+            }
+            searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
+                override fun onQueryTextSubmit(query: String): Boolean {
+                    onSearchEvent(query, true)
+                    return true
+                }
+
+                override fun onQueryTextChange(newText: String): Boolean {
+                    onSearchEvent(newText, false)
+                    return true
+                }
+            })
+        }
+
+        // Show next display mode
+        displayMode = menu.findItem(R.id.action_display_mode).apply {
+            val icon = if (presenter.isListMode)
+                R.drawable.ic_view_module_white_24dp
+            else
+                R.drawable.ic_view_list_white_24dp
+            setIcon(icon)
+        }
+
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        when (item.itemId) {
+            R.id.action_display_mode -> swapDisplayMode()
+            else -> return super.onOptionsItemSelected(item)
+        }
+        return true
+    }
+
+    override fun onStart() {
+        super.onStart()
+        initializeSearchSubscription()
+    }
+
+    override fun onStop() {
+        destroySearchSubscription()
+        super.onStop()
+    }
+
+    override fun onDestroyView() {
+        searchItem?.let {
+            if (it.isActionViewExpanded) it.collapseActionView()
+        }
+        toolbar.removeView(spinner)
+        super.onDestroyView()
+    }
+
+    /**
+     * Listen for query events on the debouncer.
+     */
+    private fun initializeSearchSubscription() {
+        queryDebouncerSubscription = queryDebouncerSubject.debounce(SEARCH_TIMEOUT, TimeUnit.MILLISECONDS)
+                .observeOn(AndroidSchedulers.mainThread())
+                .subscribe { restartRequest(it) }
+    }
+
+    /**
+     * Unsubscribe from the query debouncer.
+     */
+    private fun destroySearchSubscription() {
+        queryDebouncerSubscription?.unsubscribe()
+    }
+
+    /**
+     * Called when the input text changes or is submitted
+     *
+     * @param query the new query.
+     * @param now whether to send the network call now or debounce it by [SEARCH_TIMEOUT].
+     */
+    private fun onSearchEvent(query: String, now: Boolean) {
+        if (now) {
+            restartRequest(query)
+        } else {
+            queryDebouncerSubject.onNext(query)
+        }
+    }
+
+    /**
+     * Restarts the request.
+     *
+     * @param newQuery the new query.
+     */
+    private fun restartRequest(newQuery: String) {
+        // If text didn't change, do nothing
+        if (query == newQuery || presenter.source == null)
+            return
+
+        query = newQuery
+        showProgressBar()
+        catalogue_grid.layoutManager.scrollToPosition(0)
+        catalogue_list.layoutManager.scrollToPosition(0)
+
+        presenter.restartRequest(query)
+    }
+
+    /**
+     * Requests the next page (if available). Called from scroll listeners when they reach the end.
+     */
+    private fun requestNextPage() {
+        if (presenter.hasNextPage()) {
+            showGridProgressBar()
+            presenter.requestNext()
+        }
+    }
+
+    /**
+     * Called from the presenter when the network request is received.
+     *
+     * @param page the current page.
+     * @param mangas the list of manga of the page.
+     */
+    fun onAddPage(page: Int, mangas: List<Manga>) {
+        hideProgressBar()
+        if (page == 0) {
+            adapter.clear()
+            gridScrollListener.resetScroll()
+            listScrollListener.resetScroll()
+        }
+        adapter.addItems(mangas)
+    }
+
+    /**
+     * Called from the presenter when the network request fails.
+     *
+     * @param error the error received.
+     */
+    fun onAddPageError(error: Throwable) {
+        hideProgressBar()
+        ToastUtil.showShort(context, error.message)
+        Timber.e(error, error.message)
+    }
+
+    /**
+     * Called from the presenter when a manga is initialized.
+     *
+     * @param manga the manga initialized
+     */
+    fun onMangaInitialized(manga: Manga) {
+        getHolder(manga)?.setImage(manga, presenter)
+    }
+
+    /**
+     * Swaps the current display mode.
+     */
+    fun swapDisplayMode() {
+        presenter.swapDisplayMode()
+        val isListMode = presenter.isListMode
+        val icon = if (isListMode)
+            R.drawable.ic_view_module_white_24dp
+        else
+            R.drawable.ic_view_list_white_24dp
+        displayMode?.setIcon(icon)
+        switcher.showNext()
+        if (!isListMode) {
+            // Initialize mangas if going to grid view
+            presenter.initializeMangas(adapter.items)
+        }
+    }
+
+    /**
+     * Returns the view holder for the given manga.
+     *
+     * @param manga the manga to find.
+     * @return the holder of the manga or null if it's not bound.
+     */
+    private fun getHolder(manga: Manga): CatalogueGridHolder? {
+        return catalogue_grid.findViewHolderForItemId(manga.id) as? CatalogueGridHolder
+    }
+
+    /**
+     * Shows the progress bar.
+     */
+    private fun showProgressBar() {
+        progress.visibility = ProgressBar.VISIBLE
+    }
+
+    /**
+     * Shows the progress bar at the end of the screen.
+     */
+    private fun showGridProgressBar() {
+        progress_grid.visibility = ProgressBar.VISIBLE
+    }
+
+    /**
+     * Hides active progress bars.
+     */
+    private fun hideProgressBar() {
+        progress.visibility = ProgressBar.GONE
+        progress_grid.visibility = ProgressBar.GONE
+    }
+
+    /**
+     * 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 {
+        val selectedManga = adapter.getItem(position)
+
+        val intent = MangaActivity.newIntent(activity, selectedManga)
+        intent.putExtra(MangaActivity.MANGA_ONLINE, true)
+        startActivity(intent)
+        return false
+    }
+
+    /**
+     * Called when a manga is long clicked.
+     *
+     * @param position the position of the element clicked.
+     */
+    override fun onListItemLongClick(position: Int) {
+        val selectedManga = adapter.getItem(position)
+
+        val textRes = if (selectedManga.favorite) R.string.remove_from_library else R.string.add_to_library
+
+        MaterialDialog.Builder(activity)
+                .items(getString(textRes))
+                .itemsCallback { dialog, itemView, which, text ->
+                    when (which) {
+                        0 -> {
+                            presenter.changeMangaFavorite(selectedManga)
+                            adapter.notifyItemChanged(position)
+                        }
+                    }
+                }.show()
+    }
+
+}

+ 0 - 43
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueGridHolder.java

@@ -1,43 +0,0 @@
-package eu.kanade.tachiyomi.ui.catalogue;
-
-import android.view.View;
-import android.widget.ImageView;
-import android.widget.TextView;
-
-import com.mikepenz.iconics.view.IconicsImageView;
-
-import butterknife.Bind;
-import butterknife.ButterKnife;
-import eu.kanade.tachiyomi.R;
-import eu.kanade.tachiyomi.data.database.models.Manga;
-
-public class CatalogueGridHolder extends CatalogueHolder {
-
-    @Bind(R.id.title) TextView title;
-    @Bind(R.id.thumbnail) ImageView thumbnail;
-    @Bind(R.id.favorite_sticker) IconicsImageView favoriteSticker;
-
-    public CatalogueGridHolder(View view, CatalogueAdapter adapter, OnListItemClickListener listener) {
-        super(view, adapter, listener);
-        ButterKnife.bind(this, view);
-    }
-
-    @Override
-    public void onSetValues(Manga manga, CataloguePresenter presenter) {
-        title.setText(manga.title);
-        // Set visibility of in library icon.
-        favoriteSticker.setVisibility(manga.favorite ? View.VISIBLE : View.GONE);
-        // Set alpha of thumbnail.
-        thumbnail.setAlpha(manga.favorite ? 0.3f : 1.0f);
-        setImage(manga, presenter);
-    }
-
-    public void setImage(Manga manga, CataloguePresenter presenter) {
-        if (manga.thumbnail_url != null) {
-            presenter.coverCache.loadFromNetwork(thumbnail, manga.thumbnail_url,
-                    presenter.getSource().getGlideHeaders());
-        } else {
-            thumbnail.setImageResource(android.R.color.transparent);
-        }
-    }
-}

+ 54 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueGridHolder.kt

@@ -0,0 +1,54 @@
+package eu.kanade.tachiyomi.ui.catalogue
+
+import android.view.View
+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 catalogue, 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 catalogue holder.
+ */
+class CatalogueGridHolder(private val view: View, adapter: CatalogueAdapter, listener: OnListItemClickListener) :
+        CatalogueHolder(view, adapter, listener) {
+
+    /**
+     * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
+     * holder with the given manga.
+     *
+     * @param manga the manga to bind.
+     * @param presenter the catalogue presenter.
+     */
+    override fun onSetValues(manga: Manga, presenter: CataloguePresenter) {
+        // Set manga title
+        view.title.text = manga.title
+
+        // Set visibility of in library icon.
+        view.favorite_sticker.visibility = if (manga.favorite) View.VISIBLE else View.GONE
+
+        // Set alpha of thumbnail.
+        view.thumbnail.alpha = if (manga.favorite) 0.3f else 1.0f
+
+        setImage(manga, presenter)
+    }
+
+    /**
+     * Updates the image for this holder. Useful to update the image when the manga is initialized
+     * and the url is now known.
+     *
+     * @param manga the manga to bind.
+     * @param presenter the catalogue presenter.
+     */
+    fun setImage(manga: Manga, presenter: CataloguePresenter) {
+        if (manga.thumbnail_url != null) {
+            presenter.coverCache.loadFromNetwork(view.thumbnail, manga.thumbnail_url,
+                    presenter.source.glideHeaders)
+        } else {
+            view.thumbnail.setImageResource(android.R.color.transparent)
+        }
+    }
+}

+ 0 - 15
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueHolder.java

@@ -1,15 +0,0 @@
-package eu.kanade.tachiyomi.ui.catalogue;
-
-import android.view.View;
-
-import eu.kanade.tachiyomi.data.database.models.Manga;
-import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder;
-
-public abstract class CatalogueHolder extends FlexibleViewHolder {
-
-    public CatalogueHolder(View view, CatalogueAdapter adapter, OnListItemClickListener listener) {
-        super(view, adapter, listener);
-    }
-
-    abstract void onSetValues(Manga manga, CataloguePresenter presenter);
-}

+ 25 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueHolder.kt

@@ -0,0 +1,25 @@
+package eu.kanade.tachiyomi.ui.catalogue
+
+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 catalogue.
+ *
+ * @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.
+ */
+abstract class CatalogueHolder(view: View, adapter: CatalogueAdapter, listener: OnListItemClickListener) :
+        FlexibleViewHolder(view, adapter, listener) {
+
+    /**
+     * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
+     * holder with the given manga.
+     *
+     * @param manga the manga to bind.
+     * @param presenter the catalogue presenter.
+     */
+    abstract fun onSetValues(manga: Manga, presenter: CataloguePresenter)
+}

+ 0 - 32
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueListHolder.java

@@ -1,32 +0,0 @@
-package eu.kanade.tachiyomi.ui.catalogue;
-
-import android.support.v4.content.ContextCompat;
-import android.view.View;
-import android.widget.TextView;
-
-import butterknife.Bind;
-import butterknife.ButterKnife;
-import eu.kanade.tachiyomi.R;
-import eu.kanade.tachiyomi.data.database.models.Manga;
-
-public class CatalogueListHolder extends CatalogueHolder {
-
-    @Bind(R.id.title) TextView title;
-
-    private final int favoriteColor;
-    private final int unfavoriteColor;
-
-    public CatalogueListHolder(View view, CatalogueAdapter adapter, OnListItemClickListener listener) {
-        super(view, adapter, listener);
-        ButterKnife.bind(this, view);
-
-        favoriteColor = ContextCompat.getColor(view.getContext(), R.color.hint_text);
-        unfavoriteColor = ContextCompat.getColor(view.getContext(), R.color.primary_text);
-    }
-
-    @Override
-    public void onSetValues(Manga manga, CataloguePresenter presenter) {
-        title.setText(manga.title);
-        title.setTextColor(manga.favorite ? favoriteColor : unfavoriteColor);
-    }
-}

+ 35 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueListHolder.kt

@@ -0,0 +1,35 @@
+package eu.kanade.tachiyomi.ui.catalogue
+
+import android.support.v4.content.ContextCompat
+import android.view.View
+import eu.kanade.tachiyomi.R
+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 catalogue, like the cover or the title.
+ * All the elements from the layout file "item_catalogue_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 catalogue holder.
+ */
+class CatalogueListHolder(private val view: View, adapter: CatalogueAdapter, listener: OnListItemClickListener) :
+        CatalogueHolder(view, adapter, listener) {
+
+    private val favoriteColor = ContextCompat.getColor(view.context, R.color.hint_text)
+    private val unfavoriteColor = ContextCompat.getColor(view.context, R.color.primary_text)
+
+    /**
+     * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this
+     * holder with the given manga.
+     *
+     * @param manga the manga to bind.
+     * @param presenter the catalogue presenter.
+     */
+    override fun onSetValues(manga: Manga, presenter: CataloguePresenter) {
+        view.title.text = manga.title
+        view.title.setTextColor(if (manga.favorite) favoriteColor else unfavoriteColor)
+    }
+}

+ 0 - 221
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.java

@@ -1,221 +0,0 @@
-package eu.kanade.tachiyomi.ui.catalogue;
-
-import android.os.Bundle;
-import android.text.TextUtils;
-
-import com.pushtorefresh.storio.sqlite.operations.put.PutResult;
-
-import java.util.List;
-
-import javax.inject.Inject;
-
-import eu.kanade.tachiyomi.data.cache.CoverCache;
-import eu.kanade.tachiyomi.data.database.DatabaseHelper;
-import eu.kanade.tachiyomi.data.database.models.Manga;
-import eu.kanade.tachiyomi.data.preference.PreferencesHelper;
-import eu.kanade.tachiyomi.data.source.SourceManager;
-import eu.kanade.tachiyomi.data.source.base.Source;
-import eu.kanade.tachiyomi.data.source.model.MangasPage;
-import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter;
-import eu.kanade.tachiyomi.util.RxPager;
-import icepick.State;
-import rx.Observable;
-import rx.android.schedulers.AndroidSchedulers;
-import rx.schedulers.Schedulers;
-import rx.subjects.PublishSubject;
-import timber.log.Timber;
-
-public class CataloguePresenter extends BasePresenter<CatalogueFragment> {
-
-    @Inject SourceManager sourceManager;
-    @Inject DatabaseHelper db;
-    @Inject CoverCache coverCache;
-    @Inject PreferencesHelper prefs;
-
-    private List<Source> sources;
-    private Source source;
-    @State int sourceId;
-
-    private String query;
-
-    private RxPager<Manga> pager;
-    private MangasPage lastMangasPage;
-
-    private PublishSubject<List<Manga>> mangaDetailSubject;
-
-    private boolean isListMode;
-
-    private static final int GET_MANGA_LIST = 1;
-    private static final int GET_MANGA_DETAIL = 2;
-    private static final int GET_MANGA_PAGE = 3;
-
-    @Override
-    protected void onCreate(Bundle savedState) {
-        super.onCreate(savedState);
-
-        if (savedState != null) {
-            source = sourceManager.get(sourceId);
-        }
-
-        sources = sourceManager.getSources();
-
-        mangaDetailSubject = PublishSubject.create();
-
-        pager = new RxPager<>();
-
-        startableReplay(GET_MANGA_LIST,
-                pager::results,
-                (view, pair) -> view.onAddPage(pair.first, pair.second));
-
-        startableFirst(GET_MANGA_PAGE,
-                () -> pager.request(page -> getMangasPageObservable(page + 1)),
-                (view, next) -> {},
-                (view, error) -> view.onAddPageError(error));
-
-        startableLatestCache(GET_MANGA_DETAIL,
-                () -> mangaDetailSubject
-                        .observeOn(Schedulers.io())
-                        .flatMap(Observable::from)
-                        .filter(manga -> !manga.initialized)
-                        .concatMap(this::getMangaDetails)
-                        .onBackpressureBuffer()
-                        .observeOn(AndroidSchedulers.mainThread()),
-                CatalogueFragment::updateImage,
-                (view, error) -> Timber.e(error.getMessage()));
-
-        add(prefs.catalogueAsList().asObservable()
-                .subscribe(this::setDisplayMode));
-    }
-
-    private void setDisplayMode(boolean asList) {
-        this.isListMode = asList;
-        if (asList) {
-            stop(GET_MANGA_DETAIL);
-        } else {
-            start(GET_MANGA_DETAIL);
-        }
-    }
-
-    public void startRequesting(Source source) {
-        this.source = source;
-        sourceId = source.getId();
-        restartRequest(null);
-    }
-
-    public void restartRequest(String query) {
-        this.query = query;
-        stop(GET_MANGA_PAGE);
-        lastMangasPage = null;
-
-        if (!isListMode) {
-            start(GET_MANGA_DETAIL);
-        }
-        start(GET_MANGA_LIST);
-        start(GET_MANGA_PAGE);
-    }
-
-    public void requestNext() {
-        if (hasNextPage()) {
-            start(GET_MANGA_PAGE);
-        }
-    }
-
-    private Observable<List<Manga>> getMangasPageObservable(int page) {
-        MangasPage nextMangasPage = new MangasPage(page);
-        if (page != 1) {
-            nextMangasPage.url = lastMangasPage.nextPageUrl;
-        }
-
-        Observable<MangasPage> obs = !TextUtils.isEmpty(query) ?
-            source.searchMangasFromNetwork(nextMangasPage, query) :
-            source.pullPopularMangasFromNetwork(nextMangasPage);
-
-        return obs.subscribeOn(Schedulers.io())
-                .doOnNext(mangasPage -> lastMangasPage = mangasPage)
-                .flatMap(mangasPage -> Observable.from(mangasPage.mangas))
-                .map(this::networkToLocalManga)
-                .toList()
-                .doOnNext(this::initializeMangas)
-                .observeOn(AndroidSchedulers.mainThread());
-    }
-
-    private Manga networkToLocalManga(Manga networkManga) {
-        Manga localManga = db.getManga(networkManga.url, source.getId()).executeAsBlocking();
-        if (localManga == null) {
-            PutResult result = db.insertManga(networkManga).executeAsBlocking();
-            networkManga.id = result.insertedId();
-            localManga = networkManga;
-        }
-        return localManga;
-    }
-
-    public void initializeMangas(List<Manga> mangas) {
-        mangaDetailSubject.onNext(mangas);
-    }
-
-    private Observable<Manga> getMangaDetails(final Manga manga) {
-        return source.pullMangaFromNetwork(manga.url)
-                .flatMap(networkManga -> {
-                    manga.copyFrom(networkManga);
-                    db.insertManga(manga).executeAsBlocking();
-                    return Observable.just(manga);
-                })
-                .onErrorResumeNext(error -> Observable.just(manga));
-    }
-
-    public Source getSource() {
-        return source;
-    }
-
-    public boolean hasNextPage() {
-        return lastMangasPage != null && lastMangasPage.nextPageUrl != null;
-    }
-
-    public int getLastUsedSourceIndex() {
-        int index = prefs.lastUsedCatalogueSource().get();
-        if (index < 0 || index >= sources.size() || !isValidSource(sources.get(index))) {
-            return findFirstValidSource();
-        }
-        return index;
-    }
-
-    public boolean isValidSource(Source source) {
-        if (!source.isLoginRequired() || source.isLogged())
-            return true;
-
-        return !(prefs.getSourceUsername(source).equals("")
-                || prefs.getSourcePassword(source).equals(""));
-    }
-
-    public int findFirstValidSource() {
-        for (int i = 0; i < sources.size(); i++) {
-            if (isValidSource(sources.get(i))) {
-                return i;
-            }
-        }
-        return 0;
-    }
-
-    public void setEnabledSource(int index) {
-        prefs.lastUsedCatalogueSource().set(index);
-    }
-
-    public List<Source> getEnabledSources() {
-        // TODO filter by enabled source
-        return sourceManager.getSources();
-    }
-
-    public void changeMangaFavorite(Manga manga) {
-        manga.favorite = !manga.favorite;
-        db.insertManga(manga).executeAsBlocking();
-    }
-
-    public boolean isListMode() {
-        return isListMode;
-    }
-
-    public void swapDisplayMode() {
-        prefs.catalogueAsList().set(!isListMode);
-    }
-
-}

+ 336 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt

@@ -0,0 +1,336 @@
+package eu.kanade.tachiyomi.ui.catalogue
+
+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.Manga
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.data.source.SourceManager
+import eu.kanade.tachiyomi.data.source.base.Source
+import eu.kanade.tachiyomi.data.source.model.MangasPage
+import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
+import eu.kanade.tachiyomi.util.RxPager
+import rx.Observable
+import rx.android.schedulers.AndroidSchedulers
+import rx.schedulers.Schedulers
+import rx.subjects.PublishSubject
+import timber.log.Timber
+import javax.inject.Inject
+
+/**
+ * Presenter of [CatalogueFragment].
+ */
+class CataloguePresenter : BasePresenter<CatalogueFragment>() {
+
+    /**
+     * Source manager.
+     */
+    @Inject lateinit var sourceManager: SourceManager
+
+    /**
+     * Database.
+     */
+    @Inject lateinit var db: DatabaseHelper
+
+    /**
+     * Cover cache.
+     */
+    @Inject lateinit var coverCache: CoverCache
+
+    /**
+     * Preferences.
+     */
+    @Inject lateinit var prefs: PreferencesHelper
+
+    /**
+     * Enabled sources.
+     */
+    private val sources by lazy { sourceManager.sources }
+
+    /**
+     * Active source.
+     */
+    lateinit var source: Source
+        private set
+
+    /**
+     * Query from the view.
+     */
+    private var query: String? = null
+
+    /**
+     * Pager containing a list of manga results.
+     */
+    private lateinit var pager: RxPager<Manga>
+
+    /**
+     * Last fetched page from network.
+     */
+    private var lastMangasPage: MangasPage? = null
+
+    /**
+     * Subject that initializes a list of manga.
+     */
+    private val mangaDetailSubject = PublishSubject.create<List<Manga>>()
+
+    /**
+     * Whether the view is in list mode or not.
+     */
+    var isListMode: Boolean = false
+        private set
+
+    companion object {
+        /**
+         * Id of the restartable that delivers a list of manga from network.
+         */
+        const val GET_MANGA_LIST = 1
+
+        /**
+         * Id of the restartable that requests the list of manga from network.
+         */
+        const val GET_MANGA_PAGE = 2
+
+        /**
+         * Id of the restartable that initializes the details of a manga.
+         */
+        const val GET_MANGA_DETAIL = 3
+
+        /**
+         * Key to save and restore [source] from a [Bundle].
+         */
+        const val ACTIVE_SOURCE_KEY = "active_source"
+    }
+
+    override fun onCreate(savedState: Bundle?) {
+        super.onCreate(savedState)
+
+        if (savedState != null) {
+            source = sourceManager.get(savedState.getInt(ACTIVE_SOURCE_KEY))!!
+        }
+
+        pager = RxPager()
+
+        startableReplay(GET_MANGA_LIST,
+                { pager.results() },
+                { view, pair -> view.onAddPage(pair.first, pair.second) })
+
+        startableFirst(GET_MANGA_PAGE,
+                { pager.request { page -> getMangasPageObservable(page + 1) } },
+                { view, next -> },
+                { view, error -> view.onAddPageError(error) })
+
+        startableLatestCache(GET_MANGA_DETAIL,
+                { mangaDetailSubject.observeOn(Schedulers.io())
+                        .flatMap { Observable.from(it) }
+                        .filter { !it.initialized }
+                        .concatMap { getMangaDetailsObservable(it) }
+                        .onBackpressureBuffer()
+                        .observeOn(AndroidSchedulers.mainThread()) },
+                { view, manga -> view.onMangaInitialized(manga) },
+                { view, error -> Timber.e(error.message) })
+
+        add(prefs.catalogueAsList().asObservable()
+                .subscribe { setDisplayMode(it) })
+    }
+
+    override fun onSave(state: Bundle) {
+        state.putInt(ACTIVE_SOURCE_KEY, source.id)
+        super.onSave(state)
+    }
+
+    /**
+     * Sets the display mode.
+     *
+     * @param asList whether the current mode is in list or not.
+     */
+    private fun setDisplayMode(asList: Boolean) {
+        isListMode = asList
+        if (asList) {
+            stop(GET_MANGA_DETAIL)
+        } else {
+            start(GET_MANGA_DETAIL)
+        }
+    }
+
+    /**
+     * Starts the request with the given source.
+     *
+     * @param source the active source.
+     */
+    fun startRequesting(source: Source) {
+        this.source = source
+        restartRequest(null)
+    }
+
+    /**
+     * Restarts the request for the active source with a query.
+     *
+     * @param query a query, or null if searching popular manga.
+     */
+    fun restartRequest(query: String?) {
+        this.query = query
+        stop(GET_MANGA_PAGE)
+        lastMangasPage = null
+
+        if (!isListMode) {
+            start(GET_MANGA_DETAIL)
+        }
+        start(GET_MANGA_LIST)
+        start(GET_MANGA_PAGE)
+    }
+
+    /**
+     * Requests the next page for the active pager.
+     */
+    fun requestNext() {
+        if (hasNextPage()) {
+            start(GET_MANGA_PAGE)
+        }
+    }
+
+    /**
+     * Returns the observable of the network request for a page.
+     *
+     * @param page the page number to request.
+     * @return an observable of the network request.
+     */
+    private fun getMangasPageObservable(page: Int): Observable<List<Manga>> {
+        val nextMangasPage = MangasPage(page)
+        if (page != 1) {
+            nextMangasPage.url = lastMangasPage!!.nextPageUrl
+        }
+
+        val obs = if (query.isNullOrEmpty())
+            source.pullPopularMangasFromNetwork(nextMangasPage)
+        else
+            source.searchMangasFromNetwork(nextMangasPage, query)
+
+        return obs.subscribeOn(Schedulers.io())
+                .doOnNext { lastMangasPage = it }
+                .flatMap { Observable.from(it.mangas) }
+                .map { networkToLocalManga(it) }
+                .toList()
+                .doOnNext { initializeMangas(it) }
+                .observeOn(AndroidSchedulers.mainThread())
+    }
+
+    /**
+     * Returns a manga from the database for the given manga from network. It creates a new entry
+     * if the manga is not yet in the database.
+     *
+     * @param networkManga the manga from network.
+     * @return a manga from the database.
+     */
+    private fun networkToLocalManga(networkManga: Manga): Manga {
+        var localManga = db.getManga(networkManga.url, source.id).executeAsBlocking()
+        if (localManga == null) {
+            val result = db.insertManga(networkManga).executeAsBlocking()
+            networkManga.id = result.insertedId()
+            localManga = networkManga
+        }
+        return localManga
+    }
+
+    /**
+     * Initialize a list of manga.
+     *
+     * @param mangas the list of manga to initialize.
+     */
+    fun initializeMangas(mangas: List<Manga>) {
+        mangaDetailSubject.onNext(mangas)
+    }
+
+    /**
+     * Returns an observable of manga that initializes the given manga.
+     *
+     * @param manga the manga to initialize.
+     * @return an observable of the manga to initialize
+     */
+    private fun getMangaDetailsObservable(manga: Manga): Observable<Manga> {
+        return source.pullMangaFromNetwork(manga.url)
+                .flatMap { networkManga ->
+                    manga.copyFrom(networkManga)
+                    db.insertManga(manga).executeAsBlocking()
+                    Observable.just(manga)
+                }
+                .onErrorResumeNext { Observable.just(manga) }
+    }
+
+    /**
+     * Returns true if the last fetched page has a next page.
+     */
+    fun hasNextPage(): Boolean {
+        return lastMangasPage?.nextPageUrl != null
+    }
+
+    /**
+     * Gets the last used source from preferences, or the first valid source.
+     *
+     * @return the index of the last used source.
+     */
+    fun getLastUsedSourceIndex(): Int {
+        val index = prefs.lastUsedCatalogueSource().get() ?: -1
+        if (index < 0 || index >= sources.size || !isValidSource(sources[index])) {
+            return findFirstValidSource()
+        }
+        return index
+    }
+
+    /**
+     * Checks if the given source is valid.
+     *
+     * @param source the source to check.
+     * @return true if the source is valid, false otherwise.
+     */
+    fun isValidSource(source: Source): Boolean = with(source) {
+        if (!isLoginRequired || isLogged)
+            return true
+
+        prefs.getSourceUsername(this) != "" && prefs.getSourcePassword(this) != ""
+    }
+
+    /**
+     * Finds the first valid source.
+     *
+     * @return the index of the first valid source.
+     */
+    fun findFirstValidSource(): Int {
+        return sources.indexOfFirst { isValidSource(it) }
+    }
+
+    /**
+     * Sets the enabled source.
+     *
+     * @param index the index of the source in [sources].
+     */
+    fun setEnabledSource(index: Int) {
+        prefs.lastUsedCatalogueSource().set(index)
+    }
+
+    /**
+     * Returns a list of enabled sources.
+     *
+     * TODO filter by enabled sources.
+     */
+    fun getEnabledSources(): List<Source> {
+        return sourceManager.sources
+    }
+
+    /**
+     * Adds or removes a manga from the library.
+     *
+     * @param manga the manga to update.
+     */
+    fun changeMangaFavorite(manga: Manga) {
+        manga.favorite = !manga.favorite
+        db.insertManga(manga).executeAsBlocking()
+    }
+
+    /**
+     * Changes the active display mode.
+     */
+    fun swapDisplayMode() {
+        prefs.catalogueAsList().set(!isListMode)
+    }
+
+}

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryFragment.kt

@@ -225,7 +225,7 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
      */
     private fun setCategories(categories: List<Category>) {
         adapter.categories = categories
-        tabs.setTabsFromPagerAdapter(adapter)
+        tabs.setupWithViewPager(view_pager)
         tabs.visibility = if (categories.size <= 1) View.GONE else View.VISIBLE
     }
 

+ 40 - 8
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt

@@ -3,9 +3,11 @@ package eu.kanade.tachiyomi.ui.setting
 import android.app.Activity
 import android.content.Intent
 import android.os.Bundle
+import android.os.Environment
 import android.support.v7.widget.RecyclerView
 import android.view.View
 import android.view.ViewGroup
+import com.afollestad.materialdialogs.MaterialDialog
 import com.nononsenseapps.filepicker.AbstractFilePickerFragment
 import com.nononsenseapps.filepicker.FilePickerActivity
 import com.nononsenseapps.filepicker.FilePickerFragment
@@ -29,24 +31,54 @@ class SettingsDownloadsFragment : SettingsNestedFragment() {
         }
     }
 
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        downloadDirPref.setOnPreferenceClickListener { preference ->
-            val i = Intent(activity, CustomLayoutPickerActivity::class.java)
-            i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
-            i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
-            i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
-            i.putExtra(FilePickerActivity.EXTRA_START_PATH, preferences.downloadsDirectory)
+    override fun onViewCreated(view: View, savedState: Bundle?) {
+        downloadDirPref.setOnPreferenceClickListener {
+
+            val externalDirs = getExternalFilesDirs()
+            val selectedIndex = externalDirs.indexOf(File(preferences.downloadsDirectory))
+
+            MaterialDialog.Builder(activity)
+                    .items(externalDirs + getString(R.string.custom_dir))
+                    .itemsCallbackSingleChoice(selectedIndex, { dialog, view, which, text ->
+                        if (which == externalDirs.size) {
+                            // Custom dir selected, open directory selector
+                            val i = Intent(activity, CustomLayoutPickerActivity::class.java)
+                            i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
+                            i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
+                            i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
+                            i.putExtra(FilePickerActivity.EXTRA_START_PATH, preferences.downloadsDirectory)
+
+                            startActivityForResult(i, DOWNLOAD_DIR_CODE)
+                        } else {
+                            // One of the predefined folders was selected
+                            preferences.downloadsDirectory = text.toString()
+                            updateDownloadsDir()
+                        }
+                        true
+                    })
+                    .show()
 
-            startActivityForResult(i, DOWNLOAD_DIR_CODE)
             true
         }
     }
 
     override fun onResume() {
         super.onResume()
+        updateDownloadsDir()
+    }
+
+    fun updateDownloadsDir() {
         downloadDirPref.summary = preferences.downloadsDirectory
     }
 
+    fun getExternalFilesDirs(): List<File> {
+        val defaultDir = Environment.getExternalStorageDirectory().absolutePath +
+                File.separator + getString(R.string.app_name) +
+                File.separator + "downloads"
+
+        return mutableListOf(File(defaultDir)) + context.getExternalFilesDirs("")
+    }
+
     override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
         if (data != null && requestCode == DOWNLOAD_DIR_CODE && resultCode == Activity.RESULT_OK) {
             preferences.downloadsDirectory = data.data.path

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -124,6 +124,7 @@
     <string name="pref_download_directory">Downloads directory</string>
     <string name="pref_download_slots">Simultaneous downloads</string>
     <string name="pref_download_only_over_wifi">Only download over Wi-Fi</string>
+    <string name="custom_dir">Custom directory</string>
 
       <!-- Advanced section -->
     <string name="pref_clear_chapter_cache">Clear chapter cache</string>

+ 1 - 1
settings.gradle

@@ -1,2 +1,2 @@
-include ':app', ':SubsamplingScaleImageView', ':ReactiveNetwork'
+include ':app', ':SubsamplingScaleImageView'
 project(':SubsamplingScaleImageView').projectDir = new File('libs/SubsamplingScaleImageView')