diff --git a/app/build.gradle b/app/build.gradle index ade5c71d99..71c18fce9d 100644 --- a/app/build.gradle +++ b/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' diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.java b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.java index a7124b0346..713c055036 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.java +++ b/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 pending = new ArrayList<>(); diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueAdapter.java b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueAdapter.java deleted file mode 100644 index 9d7cd6f704..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueAdapter.java +++ /dev/null @@ -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 { - - private CatalogueFragment fragment; - - public CatalogueAdapter(CatalogueFragment fragment) { - this.fragment = fragment; - mItems = new ArrayList<>(); - setHasStableIds(true); - } - - public void addItems(List list) { - mItems.addAll(list); - notifyDataSetChanged(); - } - - public void clear() { - mItems.clear(); - notifyDataSetChanged(); - } - - public List 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)); - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueAdapter.kt new file mode 100644 index 0000000000..d67f28d953 --- /dev/null +++ b/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() { + + /** + * Property to get the list of manga in the adapter. + */ + val items: List + get() = mItems + + init { + mItems = ArrayList() + setHasStableIds(true) + } + + /** + * Adds a list of manga to the adapter. + * + * @param list the list to add. + */ + fun addItems(list: List) { + 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) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueFragment.java b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueFragment.java deleted file mode 100644 index de60edf469..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueFragment.java +++ /dev/null @@ -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 - 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 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 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 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(); - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueFragment.kt new file mode 100644 index 0000000000..1ed4a6b1ba --- /dev/null +++ b/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(), 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() + + /** + * 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) { + 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() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueGridHolder.java b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueGridHolder.java deleted file mode 100644 index 5690061a07..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueGridHolder.java +++ /dev/null @@ -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); - } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueGridHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueGridHolder.kt new file mode 100644 index 0000000000..8503603dbe --- /dev/null +++ b/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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueHolder.java b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueHolder.java deleted file mode 100644 index 8de83acee1..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueHolder.java +++ /dev/null @@ -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); -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueHolder.kt new file mode 100644 index 0000000000..3cc9a8bb9e --- /dev/null +++ b/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) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueListHolder.java b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueListHolder.java deleted file mode 100644 index 94042c4494..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueListHolder.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueListHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CatalogueListHolder.kt new file mode 100644 index 0000000000..8eb52283f0 --- /dev/null +++ b/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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.java b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.java deleted file mode 100644 index 8baba8a381..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.java +++ /dev/null @@ -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 { - - @Inject SourceManager sourceManager; - @Inject DatabaseHelper db; - @Inject CoverCache coverCache; - @Inject PreferencesHelper prefs; - - private List sources; - private Source source; - @State int sourceId; - - private String query; - - private RxPager pager; - private MangasPage lastMangasPage; - - private PublishSubject> 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> getMangasPageObservable(int page) { - MangasPage nextMangasPage = new MangasPage(page); - if (page != 1) { - nextMangasPage.url = lastMangasPage.nextPageUrl; - } - - Observable 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 mangas) { - mangaDetailSubject.onNext(mangas); - } - - private Observable 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 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); - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/CataloguePresenter.kt new file mode 100644 index 0000000000..7ad91ea812 --- /dev/null +++ b/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() { + + /** + * 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 + + /** + * Last fetched page from network. + */ + private var lastMangasPage: MangasPage? = null + + /** + * Subject that initializes a list of manga. + */ + private val mangaDetailSubject = PublishSubject.create>() + + /** + * 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> { + 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) { + 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 { + 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 { + 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) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryFragment.kt index 5102ffae04..50950803b1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryFragment.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryFragment.kt @@ -225,7 +225,7 @@ class LibraryFragment : BaseRxFragment(), ActionMode.Callback */ private fun setCategories(categories: List) { adapter.categories = categories - tabs.setTabsFromPagerAdapter(adapter) + tabs.setupWithViewPager(view_pager) tabs.visibility = if (categories.size <= 1) View.GONE else View.VISIBLE } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt index a5ea0138ab..4af3787393 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadsFragment.kt +++ b/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 { + 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d84d3286c0..572f266149 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -124,6 +124,7 @@ Downloads directory Simultaneous downloads Only download over Wi-Fi + Custom directory Clear chapter cache diff --git a/settings.gradle b/settings.gradle index 0b17c98678..195296d200 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,2 @@ -include ':app', ':SubsamplingScaleImageView', ':ReactiveNetwork' +include ':app', ':SubsamplingScaleImageView' project(':SubsamplingScaleImageView').projectDir = new File('libs/SubsamplingScaleImageView')