diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt index 88c6ca809..54cae9713 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt @@ -7,6 +7,7 @@ import android.view.KeyEvent import android.view.MenuItem import android.view.View import android.view.ViewGroup +import androidx.annotation.CallSuper import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.ActionBarContextView @@ -20,6 +21,7 @@ import androidx.viewbinding.ViewBinding import org.koin.android.ext.android.get import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.prefs.AppSettings @@ -36,6 +38,8 @@ abstract class BaseActivity : AppCompatActivity(), @Suppress("LeakingThis") protected val insetsDelegate = WindowInsetsDelegate(this) + val actionModeDelegate = ActionModeDelegate() + override fun onCreate(savedInstanceState: Bundle?) { val settings = get() when { @@ -90,8 +94,10 @@ abstract class BaseActivity : AppCompatActivity(), return isNight && get().isAmoledTheme } + @CallSuper override fun onSupportActionModeStarted(mode: ActionMode) { super.onSupportActionModeStarted(mode) + actionModeDelegate.onSupportActionModeStarted(mode) val insets = ViewCompat.getRootWindowInsets(binding.root) ?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return val view = findViewById(androidx.appcompat.R.id.action_mode_bar) @@ -100,6 +106,12 @@ abstract class BaseActivity : AppCompatActivity(), } } + @CallSuper + override fun onSupportActionModeFinished(mode: ActionMode) { + super.onSupportActionModeFinished(mode) + actionModeDelegate.onSupportActionModeFinished(mode) + } + override fun onBackPressed() { if ( // https://issuetracker.google.com/issues/139738913 Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt index 50eeb61ff..17c8931b1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt @@ -6,10 +6,12 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.viewbinding.ViewBinding +import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -abstract class BaseFragment : Fragment(), +abstract class BaseFragment : + Fragment(), WindowInsetsDelegate.WindowInsetsListener { private var viewBinding: B? = null @@ -23,6 +25,9 @@ abstract class BaseFragment : Fragment(), @Suppress("LeakingThis") protected val insetsDelegate = WindowInsetsDelegate(this) + protected val actionModeDelegate: ActionModeDelegate + get() = (requireActivity() as BaseActivity<*>).actionModeDelegate + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -47,4 +52,4 @@ abstract class BaseFragment : Fragment(), protected fun bindingOrNull() = viewBinding protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B -} +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt new file mode 100644 index 000000000..ac624d3c6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt @@ -0,0 +1,111 @@ +package org.koitharu.kotatsu.base.ui.list.decor + +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.RectF +import android.view.View +import androidx.core.view.children +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.NO_ID + +abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() { + + private val bounds = Rect() + private val boundsF = RectF() + private val selection = HashSet() + + protected var hasBackground: Boolean = true + protected var hasForeground: Boolean = false + protected var isIncludeDecorAndMargins: Boolean = true + + val checkedItemsCount: Int + get() = selection.size + + val checkedItemsIds: Set + get() = selection + + fun toggleItemChecked(id: Long) { + if (!selection.remove(id)) { + selection.add(id) + } + } + + fun setItemIsChecked(id: Long, isChecked: Boolean) { + if (isChecked) { + selection.add(id) + } else { + selection.remove(id) + } + } + + fun checkAll(ids: Collection) { + selection.addAll(ids) + } + + fun clearSelection() { + selection.clear() + } + + override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + if (hasBackground) { + doDraw(canvas, parent, state, false) + } else { + super.onDraw(canvas, parent, state) + } + } + + override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + if (hasForeground) { + doDraw(canvas, parent, state, true) + } else { + super.onDrawOver(canvas, parent, state) + } + } + + private fun doDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State, isOver: Boolean) { + val checkpoint = canvas.save() + if (parent.clipToPadding) { + canvas.clipRect( + parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight, + parent.height - parent.paddingBottom + ) + } + + for (child in parent.children) { + val itemId = getItemId(parent, child) + if (itemId != NO_ID && itemId in selection) { + if (isIncludeDecorAndMargins) { + parent.getDecoratedBoundsWithMargins(child, bounds) + } else { + bounds.set(child.left, child.top, child.right, child.bottom) + } + boundsF.set(bounds) + boundsF.offset(child.translationX, child.translationY) + if (isOver) { + onDrawForeground(canvas, parent, child, boundsF, state) + } else { + onDrawBackground(canvas, parent, child, boundsF, state) + } + } + } + canvas.restoreToCount(checkpoint) + } + + protected open fun getItemId(parent: RecyclerView, child: View) = parent.getChildItemId(child) + + protected open fun onDrawBackground( + canvas: Canvas, + parent: RecyclerView, + child: View, + bounds: RectF, + state: RecyclerView.State, + ) = Unit + + protected open fun onDrawForeground( + canvas: Canvas, + parent: RecyclerView, + child: View, + bounds: RectF, + state: RecyclerView.State, + ) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeDelegate.kt new file mode 100644 index 000000000..68a65e638 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeDelegate.kt @@ -0,0 +1,50 @@ +package org.koitharu.kotatsu.base.ui.util + +import androidx.appcompat.view.ActionMode +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner + +class ActionModeDelegate { + + private var activeActionMode: ActionMode? = null + private var listeners: MutableList? = null + + val isActionModeStarted: Boolean + get() = activeActionMode != null + + fun onSupportActionModeStarted(mode: ActionMode) { + activeActionMode = mode + listeners?.forEach { it.onActionModeStarted(mode) } + } + + fun onSupportActionModeFinished(mode: ActionMode) { + activeActionMode = null + listeners?.forEach { it.onActionModeFinished(mode) } + } + + fun addListener(listener: ActionModeListener) { + if (listeners == null) { + listeners = ArrayList() + } + checkNotNull(listeners).add(listener) + } + + fun removeListener(listener: ActionModeListener) { + listeners?.remove(listener) + } + + fun addListener(listener: ActionModeListener, owner: LifecycleOwner) { + addListener(listener) + owner.lifecycle.addObserver(ListenerLifecycleObserver(listener)) + } + + private inner class ListenerLifecycleObserver( + private val listener: ActionModeListener, + ) : DefaultLifecycleObserver { + + override fun onDestroy(owner: LifecycleOwner) { + super.onDestroy(owner) + removeListener(listener) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeListener.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeListener.kt new file mode 100644 index 000000000..0c87ff612 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActionModeListener.kt @@ -0,0 +1,10 @@ +package org.koitharu.kotatsu.base.ui.util + +import androidx.appcompat.view.ActionMode + +interface ActionModeListener { + + fun onActionModeStarted(mode: ActionMode) + + fun onActionModeFinished(mode: ActionMode) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt index 70955b232..3079b6c76 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.core.model import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.mapToSet fun Manga.withoutChapters() = if (chapters.isNullOrEmpty()) { this @@ -22,4 +23,6 @@ fun Manga.withoutChapters() = if (chapters.isNullOrEmpty()) { chapters = null, source = source, ) -} \ No newline at end of file +} + +fun Collection.ids() = mapToSet { it.id } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt index a54304c28..98ebe3a6a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt @@ -24,7 +24,7 @@ class ShortcutsRepository( private val context: Context, private val coil: ImageLoader, private val historyRepository: HistoryRepository, - private val mangaRepository: MangaDataRepository + private val mangaRepository: MangaDataRepository, ) { private val iconSize by lazy { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt index 4cee51d52..96f369c56 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt @@ -178,9 +178,7 @@ class ChaptersFragment : override fun onNothingSelected(parent: AdapterView<*>?) = Unit override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - val manga = viewModel.manga.value mode.menuInflater.inflate(R.menu.mode_chapters, menu) - mode.title = manga?.title return true } @@ -190,12 +188,7 @@ class ChaptersFragment : menu.findItem(R.id.action_save).isVisible = items.none { x -> x.chapter.source == MangaSource.LOCAL } - mode.subtitle = resources.getQuantityString( - R.plurals.chapters_from_x, - items.size, - items.size, - chaptersAdapter?.itemCount ?: 0 - ) + mode.title = items.size.toString() return true } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index b06ea2511..ef427f091 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -4,7 +4,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.net.Uri import android.os.Bundle import android.view.Menu import android.view.MenuItem @@ -17,6 +16,7 @@ import androidx.appcompat.view.ActionMode import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.Insets import androidx.core.net.toFile +import androidx.core.net.toUri import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding @@ -163,7 +163,7 @@ class DetailsActivity : BaseActivity(), TabLayoutMediato R.id.action_share -> { viewModel.manga.value?.let { if (it.source == MangaSource.LOCAL) { - ShareHelper(this).shareCbz(Uri.parse(it.url).toFile()) + ShareHelper(this).shareCbz(listOf(it.url.toUri().toFile())) } else { ShareHelper(this).shareMangaLink(it) } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt index 5cc67a7d0..0ff2fbc94 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersSelectionDecoration.kt @@ -2,69 +2,32 @@ package org.koitharu.kotatsu.details.ui.adapter import android.content.Context import android.graphics.Canvas +import android.graphics.Color import android.graphics.Paint -import android.graphics.Rect -import androidx.core.content.ContextCompat -import androidx.core.view.children +import android.graphics.RectF +import android.view.View import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration +import org.koitharu.kotatsu.utils.ext.getThemeColor +import com.google.android.material.R as materialR -class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoration() { +class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { - private val bounds = Rect() - private val selection = HashSet() private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material) init { - paint.color = ContextCompat.getColor(context, R.color.selector_foreground) + paint.color = context.getThemeColor(materialR.attr.colorSecondaryContainer, Color.LTGRAY) paint.style = Paint.Style.FILL } - val checkedItemsCount: Int - get() = selection.size - - val checkedItemsIds: Set - get() = selection - - fun toggleItemChecked(id: Long) { - if (!selection.remove(id)) { - selection.add(id) - } - } - - fun setItemIsChecked(id: Long, isChecked: Boolean) { - if (isChecked) { - selection.add(id) - } else { - selection.remove(id) - } - } - - fun checkAll(ids: Collection) { - selection.addAll(ids) - } - - fun clearSelection() { - selection.clear() - } - - override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { - canvas.save() - if (parent.clipToPadding) { - canvas.clipRect( - parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight, - parent.height - parent.paddingBottom - ) - } - - for (child in parent.children) { - val itemId = parent.getChildItemId(child) - if (itemId in selection) { - parent.getDecoratedBoundsWithMargins(child, bounds) - bounds.offset(child.translationX.toInt(), child.translationY.toInt()) - canvas.drawRect(bounds, paint) - } - } - canvas.restore() + override fun onDrawBackground( + canvas: Canvas, + parent: RecyclerView, + child: View, + bounds: RectF, + state: RecyclerView.State, + ) { + canvas.drawRoundRect(bounds, radius, radius, paint) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index 8dc7a738f..27a993a91 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -10,6 +10,7 @@ import android.os.PowerManager import android.widget.Toast import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.mapLatest @@ -187,6 +188,29 @@ class DownloadService : BaseService() { } } + fun start(context: Context, manga: Collection) { + if (manga.isEmpty()) { + return + } + confirmDataTransfer(context) { + for (item in manga) { + val intent = Intent(context, DownloadService::class.java) + intent.putExtra(EXTRA_MANGA, ParcelableManga(item)) + ContextCompat.startForegroundService(context, intent) + } + } + } + + fun confirmAndStart(context: Context, items: Set) { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.save_manga) + .setMessage(R.string.batch_manga_save_confirm) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.save) { _, _ -> + start(context, items) + }.show() + } + fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL) .putExtra(EXTRA_CANCEL_ID, startId) diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index 868e2495c..db334b38b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -43,21 +43,6 @@ class FavouritesRepository(private val db: MangaDatabase) { .flatMapLatest { order -> observeAll(categoryId, order) } } - suspend fun getManga(categoryId: Long, offset: Int): List { - val entities = db.favouritesDao.findAll(categoryId, offset, 20) - return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } - } - - suspend fun getAllCategories(): List { - val entities = db.favouriteCategoriesDao.findAll() - return entities.map { it.toFavouriteCategory() } - } - - suspend fun getCategories(mangaId: Long): List { - val entities = db.favouritesDao.find(mangaId)?.categories - return entities?.map { it.toFavouriteCategory() }.orEmpty() - } - fun observeCategories(): Flow> { return db.favouriteCategoriesDao.observeAll().mapItems { it.toFavouriteCategory() @@ -70,8 +55,8 @@ class FavouritesRepository(private val db: MangaDatabase) { }.distinctUntilChanged() } - fun observeCategoriesIds(mangaId: Long): Flow> { - return db.favouritesDao.observeIds(mangaId) + fun observeCategoriesIds(mangaId: Long): Flow> { + return db.favouritesDao.observeIds(mangaId).map { it.toSet() } } suspend fun addCategory(title: String): FavouriteCategory { @@ -107,22 +92,32 @@ class FavouritesRepository(private val db: MangaDatabase) { } } - suspend fun addToCategory(manga: Manga, categoryId: Long) { - val tags = manga.tags.map(TagEntity.Companion::fromMangaTag) + suspend fun addToCategory(categoryId: Long, mangas: Collection) { db.withTransaction { - db.tagsDao.upsert(tags) - db.mangaDao.upsert(MangaEntity.from(manga), tags) - val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis()) - db.favouritesDao.insert(entity) + for (manga in mangas) { + val tags = manga.tags.map(TagEntity.Companion::fromMangaTag) + db.tagsDao.upsert(tags) + db.mangaDao.upsert(MangaEntity.from(manga), tags) + val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis()) + db.favouritesDao.insert(entity) + } } } - suspend fun removeFromCategory(manga: Manga, categoryId: Long) { - db.favouritesDao.delete(categoryId, manga.id) + suspend fun removeFromFavourites(ids: Collection) { + db.withTransaction { + for (id in ids) { + db.favouritesDao.delete(id) + } + } } - suspend fun removeFromFavourites(manga: Manga) { - db.favouritesDao.delete(manga.id) + suspend fun removeFromCategory(categoryId: Long, ids: Collection) { + db.withTransaction { + for (id in ids) { + db.favouritesDao.delete(categoryId, id) + } + } } private fun observeOrder(categoryId: Long): Flow { diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt index 949a4a852..9fc69a228 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt @@ -2,14 +2,19 @@ package org.koitharu.kotatsu.favourites.ui import android.os.Bundle import android.view.* +import androidx.appcompat.view.ActionMode import androidx.core.graphics.Insets +import androidx.core.view.children import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import com.google.android.material.snackbar.Snackbar +import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator +import java.util.* import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment +import org.koitharu.kotatsu.base.ui.util.ActionModeListener import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.ui.titleRes import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding @@ -21,10 +26,12 @@ import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.showPopupMenu -import java.util.* -class FavouritesContainerFragment : BaseFragment(), - FavouritesTabLongClickListener, CategoriesEditDelegate.CategoriesEditCallback { +class FavouritesContainerFragment : + BaseFragment(), + FavouritesTabLongClickListener, + CategoriesEditDelegate.CategoriesEditCallback, + ActionModeListener { private val viewModel by viewModel() private val editDelegate by lazy(LazyThreadSafetyMode.NONE) { @@ -51,6 +58,7 @@ class FavouritesContainerFragment : BaseFragment(), binding.pager.adapter = adapter pagerAdapter = adapter TabLayoutMediator(binding.tabs, binding.pager, adapter).attach() + actionModeDelegate.addListener(this, viewLifecycleOwner) viewModel.categories.observe(viewLifecycleOwner, ::onCategoriesChanged) viewModel.onError.observe(viewLifecycleOwner, ::onError) @@ -61,6 +69,16 @@ class FavouritesContainerFragment : BaseFragment(), super.onDestroyView() } + override fun onActionModeStarted(mode: ActionMode) { + binding.pager.isUserInputEnabled = false + binding.tabs.setTabsEnabled(false) + } + + override fun onActionModeFinished(mode: ActionMode) { + binding.pager.isUserInputEnabled = true + binding.tabs.setTabsEnabled(true) + } + override fun onWindowInsetsChanged(insets: Insets) { val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top binding.root.updatePadding( @@ -146,18 +164,20 @@ class FavouritesContainerFragment : BaseFragment(), private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) { val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) { - val menuItem = submenu.add( - R.id.group_order, - Menu.NONE, - i, - item.titleRes - ) + val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes) menuItem.isCheckable = true menuItem.isChecked = item == category.order } submenu.setGroupCheckable(R.id.group_order, true, true) } + private fun TabLayout.setTabsEnabled(enabled: Boolean) { + val tabStrip = getChildAt(0) as? ViewGroup ?: return + for (tab in tabStrip.children) { + tab.isEnabled = enabled + } + } + companion object { fun newInstance() = FavouritesContainerFragment() diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt index 3ee1f8c17..184c6cfe6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt @@ -2,18 +2,20 @@ package org.koitharu.kotatsu.favourites.ui.categories.select import android.os.Bundle import android.view.LayoutInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.appcompat.widget.Toolbar import androidx.fragment.app.FragmentManager import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.model.withoutChapters import org.koitharu.kotatsu.databinding.DialogFavoriteCategoriesBinding import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter @@ -26,10 +28,10 @@ class FavouriteCategoriesDialog : BaseBottomSheet(), OnListItemClickListener, CategoriesEditDelegate.CategoriesEditCallback, - View.OnClickListener { + Toolbar.OnMenuItemClickListener { private val viewModel by viewModel { - parametersOf(requireNotNull(arguments?.getParcelable(MangaIntent.KEY_MANGA)).manga) + parametersOf(requireNotNull(arguments?.getParcelableArrayList(KEY_MANGA_LIST)).map { it.manga }) } private var adapter: MangaCategoriesAdapter? = null @@ -46,7 +48,7 @@ class FavouriteCategoriesDialog : super.onViewCreated(view, savedInstanceState) adapter = MangaCategoriesAdapter(this) binding.recyclerViewCategories.adapter = adapter - binding.textViewAdd.setOnClickListener(this) + binding.toolbar.setOnMenuItemClickListener(this) viewModel.content.observe(viewLifecycleOwner, this::onContentChanged) viewModel.onError.observe(viewLifecycleOwner, ::onError) @@ -57,9 +59,13 @@ class FavouriteCategoriesDialog : super.onDestroyView() } - override fun onClick(v: View) { - when (v.id) { - R.id.textView_add -> editDelegate.createCategory() + override fun onMenuItemClick(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_create -> { + editDelegate.createCategory() + true + } + else -> false } } @@ -86,10 +92,15 @@ class FavouriteCategoriesDialog : companion object { private const val TAG = "FavouriteCategoriesDialog" + private const val KEY_MANGA_LIST = "manga_list" - fun show(fm: FragmentManager, manga: Manga) = FavouriteCategoriesDialog() - .withArgs(1) { - putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga)) - }.show(fm, TAG) + fun show(fm: FragmentManager, manga: Manga) = Companion.show(fm, listOf(manga)) + + fun show(fm: FragmentManager, manga: Collection) = FavouriteCategoriesDialog().withArgs(1) { + putParcelableArrayList( + KEY_MANGA_LIST, + manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it.withoutChapters()) } + ) + }.show(fm, TAG) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt index 511ad6b0b..84f9cf8f9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt @@ -4,19 +4,20 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct class MangaCategoriesViewModel( - private val manga: Manga, + private val manga: List, private val favouritesRepository: FavouritesRepository ) : BaseViewModel() { val content = combine( favouritesRepository.observeCategories(), - favouritesRepository.observeCategoriesIds(manga.id) + observeCategoriesIds(), ) { all, checked -> all.map { MangaCategoryItem( @@ -30,9 +31,9 @@ class MangaCategoriesViewModel( fun setChecked(categoryId: Long, isChecked: Boolean) { launchJob(Dispatchers.Default) { if (isChecked) { - favouritesRepository.addToCategory(manga, categoryId) + favouritesRepository.addToCategory(categoryId, manga) } else { - favouritesRepository.removeFromCategory(manga, categoryId) + favouritesRepository.removeFromCategory(categoryId, manga.ids()) } } } @@ -42,4 +43,25 @@ class MangaCategoriesViewModel( favouritesRepository.addCategory(name) } } + + private fun observeCategoriesIds() = if (manga.size == 1) { + // Fast path + favouritesRepository.observeCategoriesIds(manga[0].id) + } else { + combine( + manga.map { favouritesRepository.observeCategoriesIds(it.id) } + ) { array -> + val result = HashSet() + var isFirst = true + for (ids in array) { + if (isFirst) { + result.addAll(ids) + isFirst = false + } else { + result.retainAll(ids.toSet()) + } + } + result + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt index 949d13659..1bfd8766a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt @@ -1,13 +1,12 @@ package org.koitharu.kotatsu.favourites.ui.list import android.view.Menu -import android.view.MenuInflater import android.view.MenuItem +import androidx.appcompat.view.ActionMode import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.koitharu.kotatsu.R import org.koitharu.kotatsu.list.ui.MangaListFragment -import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.utils.ext.withArgs class FavouritesListFragment : MangaListFragment() { @@ -23,17 +22,20 @@ class FavouritesListFragment : MangaListFragment() { override fun onScrolledToEnd() = Unit - override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) { - super.onCreatePopupMenu(inflater, menu, data) - inflater.inflate(R.menu.popup_favourites, menu) + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.mode_favourites, menu) + return super.onCreateActionMode(mode, menu) } - override fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = when (item.itemId) { - R.id.action_remove -> { - viewModel.removeFromFavourites(data) - true + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_remove -> { + viewModel.removeFromFavourites(selectedItemsIds) + mode.finish() + true + } + else -> super.onActionItemClicked(mode, item) } - else -> super.onPopupMenuItemSelected(item, data) } companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt index 34a848730..476fa6fb1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt @@ -13,7 +13,6 @@ import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toUi -import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct @@ -56,12 +55,15 @@ class FavouritesListViewModel( override fun onRetry() = Unit - fun removeFromFavourites(manga: Manga) { + fun removeFromFavourites(ids: Set) { + if (ids.isEmpty()) { + return + } launchJob { if (categoryId == 0L) { - repository.removeFromFavourites(manga) + repository.removeFromFavourites(ids) } else { - repository.removeFromCategory(manga, categoryId) + repository.removeFromCategory(categoryId, ids) } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt index 4235765fd..cd1ad25bd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt @@ -81,6 +81,14 @@ class HistoryRepository( db.historyDao.delete(manga.id) } + suspend fun delete(ids: Collection) { + db.withTransaction { + for (id in ids) { + db.historyDao.delete(id) + } + } + } + /** * Try to replace one manga with another one * Useful for replacing saved manga on deleting it with remove source diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt index 3e2772c62..bdc5cc5c2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt @@ -5,13 +5,11 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View +import androidx.appcompat.view.ActionMode import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.list.ui.MangaListFragment -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.ellipsize class HistoryListFragment : MangaListFragment() { @@ -20,7 +18,6 @@ class HistoryListFragment : MangaListFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.onItemRemoved.observe(viewLifecycleOwner, ::onItemRemoved) viewModel.isGroupingEnabled.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() } @@ -59,30 +56,22 @@ class HistoryListFragment : MangaListFragment() { } } - override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) { - super.onCreatePopupMenu(inflater, menu, data) - inflater.inflate(R.menu.popup_history, menu) + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.mode_history, menu) + return super.onCreateActionMode(mode, menu) } - override fun onPopupMenuItemSelected(item: MenuItem, data: Manga): Boolean { + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { return when (item.itemId) { R.id.action_remove -> { - viewModel.removeFromHistory(data) + viewModel.removeFromHistory(selectedItemsIds) + mode.finish() true } - else -> super.onPopupMenuItemSelected(item, data) + else -> super.onActionItemClicked(mode, item) } } - private fun onItemRemoved(item: Manga) { - Snackbar.make( - binding.recyclerView, getString( - R.string._s_removed_from_history, - item.title.ellipsize(16) - ), Snackbar.LENGTH_SHORT - ).show() - } - companion object { fun newInstance() = HistoryListFragment() diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index f52241708..42dd81e95 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -2,6 +2,8 @@ package org.koitharu.kotatsu.history.ui import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import java.util.* +import java.util.concurrent.TimeUnit import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* import org.koitharu.kotatsu.R @@ -13,14 +15,10 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.MangaWithHistory import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.* -import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.onFirst -import java.util.* -import java.util.concurrent.TimeUnit class HistoryListViewModel( private val repository: HistoryRepository, @@ -29,7 +27,6 @@ class HistoryListViewModel( private val trackingRepository: TrackingRepository, ) : MangaListViewModel(settings) { - val onItemRemoved = SingleLiveEvent() val isGroupingEnabled = MutableLiveData() private val historyGrouping = settings.observe() @@ -72,10 +69,12 @@ class HistoryListViewModel( } } - fun removeFromHistory(manga: Manga) { + fun removeFromHistory(ids: Set) { + if (ids.isEmpty()) { + return + } launchJob { - repository.delete(manga) - onItemRemoved.call(manga) + repository.delete(ids) shortcutsRepository.updateShortcuts() } } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index cc6b8a2e0..d0f5f9d78 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -3,9 +3,11 @@ package org.koitharu.kotatsu.list.ui import android.os.Bundle import android.view.* import androidx.annotation.CallSuper -import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.collection.ArraySet import androidx.core.graphics.Insets -import androidx.core.view.GravityCompat +import androidx.core.view.isNotEmpty import androidx.core.view.updatePadding import androidx.recyclerview.widget.GridLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout @@ -24,24 +26,31 @@ import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.MangaItemModel import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ext.* abstract class MangaListFragment : BaseFragment(), PaginationScrollListener.Callback, MangaListListener, - SwipeRefreshLayout.OnRefreshListener { + SwipeRefreshLayout.OnRefreshListener, + ActionMode.Callback { private var listAdapter: MangaListAdapter? = null private var paginationListener: PaginationScrollListener? = null + private var selectionDecoration: MangaSelectionDecoration? = null + private var actionMode: ActionMode? = null private val spanResolver = MangaListSpanResolver() private val spanSizeLookup = SpanSizeLookup() private val listCommitCallback = Runnable { @@ -51,6 +60,12 @@ abstract class MangaListFragment : protected abstract val viewModel: MangaListViewModel + protected val selectedItemsIds: Set + get() = selectionDecoration?.checkedItemsIds?.toSet().orEmpty() + + protected val selectedItems: Set + get() = collectSelectedItems() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setHasOptionsMenu(true) @@ -68,10 +83,12 @@ abstract class MangaListFragment : lifecycleOwner = viewLifecycleOwner, listener = this, ) + selectionDecoration = MangaSelectionDecoration(view.context) paginationListener = PaginationScrollListener(4, this) with(binding.recyclerView) { setHasFixedSize(true) adapter = listAdapter + addItemDecoration(selectionDecoration!!) addOnScrollListener(paginationListener!!) } with(binding.swipeRefreshLayout) { @@ -91,6 +108,7 @@ abstract class MangaListFragment : override fun onDestroyView() { listAdapter = null paginationListener = null + selectionDecoration = null spanSizeLookup.invalidateCache() super.onDestroyView() } @@ -109,22 +127,28 @@ abstract class MangaListFragment : } override fun onItemClick(item: Manga, view: View) { + if (selectionDecoration?.checkedItemsCount != 0) { + selectionDecoration?.toggleItemChecked(item.id) + if (selectionDecoration?.checkedItemsCount == 0) { + actionMode?.finish() + } else { + actionMode?.invalidate() + binding.recyclerView.invalidateItemDecorations() + } + return + } startActivity(DetailsActivity.newIntent(context ?: return, item)) } override fun onItemLongClick(item: Manga, view: View): Boolean { - val menu = PopupMenu(context ?: return false, view) - onCreatePopupMenu(menu.menuInflater, menu.menu, item) - return if (menu.menu.hasVisibleItems()) { - menu.setOnMenuItemClickListener { - onPopupMenuItemSelected(it, item) - } - menu.gravity = GravityCompat.END or Gravity.TOP - menu.show() - true - } else { - false + if (actionMode == null) { + actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this) } + return actionMode?.also { + selectionDecoration?.setItemIsChecked(item.id, true) + binding.recyclerView.invalidateItemDecorations() + it.invalidate() + } != null } @CallSuper @@ -238,12 +262,67 @@ abstract class MangaListFragment : addOnLayoutChangeListener(spanResolver) } } + selectionDecoration?.let { addItemDecoration(it) } } } - protected open fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) = Unit + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + return menu.isNotEmpty() + } - protected open fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = false + @CallSuper + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.title = selectionDecoration?.checkedItemsCount?.toString() + return true + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_select_all -> { + val ids = listAdapter?.items?.mapNotNull { + (it as? MangaItemModel)?.id + } ?: return false + selectionDecoration?.checkAll(ids) + binding.recyclerView.invalidateItemDecorations() + mode.invalidate() + true + } + R.id.action_share -> { + ShareHelper(requireContext()).shareMangaLinks(selectedItems) + mode.finish() + true + } + R.id.action_favourite -> { + FavouriteCategoriesDialog.show(childFragmentManager, selectedItems) + mode.finish() + true + } + R.id.action_save -> { + DownloadService.confirmAndStart(requireContext(), selectedItems) + mode.finish() + true + } + else -> false + } + } + + override fun onDestroyActionMode(mode: ActionMode) { + selectionDecoration?.clearSelection() + binding.recyclerView.invalidateItemDecorations() + actionMode = null + } + + private fun collectSelectedItems(): Set { + val checkedIds = selectionDecoration?.checkedItemsIds ?: return emptySet() + val items = listAdapter?.items ?: return emptySet() + val result = ArraySet(checkedIds.size) + for (item in items) { + if (item is MangaItemModel && item.id in checkedIds) { + result.add(item.manga) + } + } + return result + } private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() { diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt new file mode 100644 index 000000000..939d5b256 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt @@ -0,0 +1,71 @@ +package org.koitharu.kotatsu.list.ui + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.view.View +import androidx.cardview.widget.CardView +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.NO_ID +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration +import org.koitharu.kotatsu.list.ui.model.MangaItemModel +import org.koitharu.kotatsu.utils.ext.getItem +import org.koitharu.kotatsu.utils.ext.getThemeColor +import com.google.android.material.R as materialR + +class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle) + private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer) + private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED) + private val fillColor = ColorUtils.setAlphaComponent( + ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), + 0x74 + ) + + init { + hasBackground = false + hasForeground = true + isIncludeDecorAndMargins = false + + paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width) + checkIcon?.setTint(strokeColor) + } + + override fun getItemId(parent: RecyclerView, child: View): Long { + val holder = parent.getChildViewHolder(child) ?: return NO_ID + val item = holder.getItem() ?: return NO_ID + return item.id + } + + override fun onDrawForeground( + canvas: Canvas, + parent: RecyclerView, + child: View, + bounds: RectF, + state: RecyclerView.State, + ) { + val radius = (child as? CardView)?.radius ?: 0f + paint.color = fillColor + paint.style = Paint.Style.FILL + canvas.drawRoundRect(bounds, radius, radius, paint) + paint.color = strokeColor + paint.style = Paint.Style.STROKE + canvas.drawRoundRect(bounds, radius, radius, paint) + checkIcon?.run { + setBounds( + (bounds.left + iconOffset).toInt(), + (bounds.top + iconOffset).toInt(), + (bounds.left + iconOffset + intrinsicWidth).toInt(), + (bounds.top + iconOffset + intrinsicHeight).toInt(), + ) + draw(canvas) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt index 159316c28..9575d82fd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt @@ -3,9 +3,9 @@ package org.koitharu.kotatsu.list.ui.model import org.koitharu.kotatsu.parsers.model.Manga data class MangaGridModel( - val id: Long, + override val id: Long, val title: String, val coverUrl: String, - val manga: Manga, + override val manga: Manga, val counter: Int, -) : ListModel \ No newline at end of file +) : MangaItemModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaItemModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaItemModel.kt new file mode 100644 index 000000000..64a4f66ae --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaItemModel.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.list.ui.model + +import org.koitharu.kotatsu.parsers.model.Manga + +sealed interface MangaItemModel : ListModel { + + val id: Long + val manga: Manga +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt index 46df8f7bb..c1cedeb24 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt @@ -3,12 +3,12 @@ package org.koitharu.kotatsu.list.ui.model import org.koitharu.kotatsu.parsers.model.Manga data class MangaListDetailedModel( - val id: Long, + override val id: Long, val title: String, val subtitle: String?, val tags: String, val coverUrl: String, val rating: String?, - val manga: Manga, + override val manga: Manga, val counter: Int, -) : ListModel \ No newline at end of file +) : MangaItemModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt index 7f9c9fd90..7533858eb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt @@ -3,10 +3,10 @@ package org.koitharu.kotatsu.list.ui.model import org.koitharu.kotatsu.parsers.model.Manga data class MangaListModel( - val id: Long, + override val id: Long, val title: String, val subtitle: String, val coverUrl: String, - val manga: Manga, + override val manga: Manga, val counter: Int, -) : ListModel \ No newline at end of file +) : MangaItemModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index 0bb8d1588..4e5115ac8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -9,6 +9,9 @@ import android.view.MenuItem import android.view.View import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.view.ActionMode +import androidx.core.net.toFile +import androidx.core.net.toUri import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import org.koin.androidx.viewmodel.ext.android.viewModel @@ -16,8 +19,7 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.list.ui.MangaListFragment -import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.ellipsize +import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.progress.Progress class LocalListFragment : MangaListFragment(), ActivityResultCallback> { @@ -46,7 +48,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback { - MaterialAlertDialogBuilder(context ?: return false) - .setTitle(R.string.delete_manga) - .setMessage(getString(R.string.text_delete_local_manga, data.title)) - .setPositiveButton(R.string.delete) { _, _ -> - viewModel.delete(data) - } - .setNegativeButton(android.R.string.cancel, null) - .show() + R.id.action_remove -> { + showDeletionConfirm(selectedItemsIds, mode) true } - else -> super.onPopupMenuItemSelected(item, data) + R.id.action_share -> { + val files = selectedItems.map { it.url.toUri().toFile() } + ShareHelper(requireContext()).shareCbz(files) + mode.finish() + true + } + else -> super.onActionItemClicked(mode, item) } } - private fun onItemRemoved(item: Manga) { - Snackbar.make( - binding.recyclerView, getString( - R.string._s_deleted_from_local_storage, - item.title.ellipsize(16) - ), Snackbar.LENGTH_SHORT - ).show() + private fun showDeletionConfirm(ids: Set, mode: ActionMode) { + MaterialAlertDialogBuilder(context ?: return) + .setTitle(R.string.delete_manga) + .setMessage(getString(R.string.text_delete_local_manga_batch)) + .setPositiveButton(R.string.delete) { _, _ -> + viewModel.delete(ids) + mode.finish() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun onItemRemoved() { + Snackbar.make(binding.recyclerView, R.string.removal_completed, Snackbar.LENGTH_SHORT).show() } private fun onImportProgressChanged(progress: Progress?) { diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index a828d0ed5..f4568b23f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -3,10 +3,12 @@ package org.koitharu.kotatsu.local.ui import android.net.Uri import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.os.ShortcutsRepository @@ -19,7 +21,6 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.progress.Progress -import java.io.IOException class LocalListViewModel( private val repository: LocalMangaRepository, @@ -28,7 +29,7 @@ class LocalListViewModel( private val shortcutsRepository: ShortcutsRepository, ) : MangaListViewModel(settings) { - val onMangaRemoved = SingleLiveEvent() + val onMangaRemoved = SingleLiveEvent() val importProgress = MutableLiveData(null) private val listError = MutableStateFlow(null) private val mangaList = MutableStateFlow?>(null) @@ -87,18 +88,23 @@ class LocalListViewModel( } } - fun delete(manga: Manga) { - launchJob { + fun delete(ids: Set) { + launchLoadingJob { withContext(Dispatchers.Default) { - val original = repository.getRemoteManga(manga) - repository.delete(manga) || throw IOException("Unable to delete file") - runCatching { - historyRepository.deleteOrSwap(manga, original) + val itemsToRemove = checkNotNull(mangaList.value).filter { it.id in ids } + for (manga in itemsToRemove) { + val original = repository.getRemoteManga(manga) + repository.delete(manga) || throw IOException("Unable to delete file") + runCatching { + historyRepository.deleteOrSwap(manga, original) + } + mangaList.update { list -> + list?.filterNot { it.id == manga.id } + } } - mangaList.value = mangaList.value?.filterNot { it.id == manga.id } } shortcutsRepository.updateShortcuts() - onMangaRemoved.call(manga) + onMangaRemoved.call(Unit) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index 091e6ae05..967589c65 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -8,6 +8,7 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup.MarginLayoutParams import androidx.appcompat.app.ActionBarDrawerToggle +import androidx.appcompat.view.ActionMode import androidx.core.content.ContextCompat import androidx.core.graphics.Insets import androidx.core.view.GravityCompat @@ -288,6 +289,16 @@ class MainActivity : }.show() } + override fun onSupportActionModeStarted(mode: ActionMode) { + super.onSupportActionModeStarted(mode) + adjustDrawerLock() + } + + override fun onSupportActionModeFinished(mode: ActionMode) { + super.onSupportActionModeFinished(mode) + adjustDrawerLock() + } + private fun onOpenReader(manga: Manga) { val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { ActivityOptions.makeClipRevealAnimation( @@ -372,14 +383,14 @@ class MainActivity : } private fun onSearchOpened() { - drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) drawerToggle?.isDrawerIndicatorEnabled = false + adjustDrawerLock() adjustFabVisibility(isSearchOpened = true) } private fun onSearchClosed() { - drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED) drawerToggle?.isDrawerIndicatorEnabled = true + adjustDrawerLock() adjustFabVisibility(isSearchOpened = false) } @@ -402,4 +413,12 @@ class MainActivity : ) { if (!isSearchOpened && topFragment is HistoryListFragment) binding.fab.show() else binding.fab.hide() } + + private fun adjustDrawerLock() { + val drawer = drawer ?: return + val isLocked = actionModeDelegate.isActionModeStarted || (drawerToggle?.isDrawerIndicatorEnabled == false) + drawer.setDrawerLockMode( + if (isLocked) DrawerLayout.LOCK_MODE_LOCKED_CLOSED else DrawerLayout.LOCK_MODE_UNLOCKED + ) + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index 2df3aa5db..12402fcbc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.remotelist.ui import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import androidx.appcompat.view.ActionMode import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.koitharu.kotatsu.R @@ -49,6 +50,11 @@ class RemoteListFragment : MangaListFragment() { } } + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.mode_remote, menu) + return super.onCreateActionMode(mode, menu) + } + override fun onFilterClick() { FilterBottomSheet.show(childFragmentManager) } diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt index 0b25b1648..6aec88710 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt @@ -1,7 +1,10 @@ package org.koitharu.kotatsu.search.ui +import android.view.Menu +import androidx.appcompat.view.ActionMode import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.utils.ext.serializableArgument @@ -21,6 +24,11 @@ class SearchFragment : MangaListFragment() { viewModel.loadNextPage() } + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.mode_remote, menu) + return super.onCreateActionMode(mode, menu) + } + companion object { private const val ARG_QUERY = "query" diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt index 81fc4491a..185de3d25 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt @@ -1,12 +1,14 @@ package org.koitharu.kotatsu.search.ui.global +import android.view.Menu +import androidx.appcompat.view.ActionMode import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.utils.ext.stringArgument import org.koitharu.kotatsu.utils.ext.withArgs - class GlobalSearchFragment : MangaListFragment() { override val viewModel by viewModel { @@ -17,6 +19,11 @@ class GlobalSearchFragment : MangaListFragment() { override fun onScrolledToEnd() = Unit + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.mode_remote, menu) + return super.onCreateActionMode(mode, menu) + } + companion object { private const val ARG_QUERY = "query" diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt index 507c2a142..46f36bef0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import androidx.appcompat.view.ActionMode import com.google.android.material.snackbar.Snackbar import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R @@ -46,6 +47,11 @@ class SuggestionsFragment : MangaListFragment() { override fun onScrolledToEnd() = Unit + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.mode_remote, menu) + return super.onCreateActionMode(mode, menu) + } + companion object { fun newInstance() = SuggestionsFragment() diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt index 62af95924..ae8d0ed5f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt @@ -25,7 +25,9 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.progress.Progress -class FeedFragment : BaseFragment(), PaginationScrollListener.Callback, +class FeedFragment : + BaseFragment(), + PaginationScrollListener.Callback, MangaListListener { private val viewModel by viewModel() diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt index ef533cdc1..898db478e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.tracker.ui -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -12,13 +11,13 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.core.model.TrackingLogItem import org.koitharu.kotatsu.list.ui.model.EmptyState +import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.ui.model.toFeedItem import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct -import org.koitharu.kotatsu.utils.ext.mapItems class FeedViewModel( private val repository: TrackingRepository @@ -27,30 +26,34 @@ class FeedViewModel( private val logList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) private var loadingJob: Job? = null + private val header = ListHeader(null, R.string.updates, null) - val isEmptyState = MutableLiveData(false) val onFeedCleared = SingleLiveEvent() val content = combine( - logList.filterNotNull().mapItems { - it.toFeedItem() - }, + logList.filterNotNull(), hasNextPage ) { list, isHasNextPage -> - when { - list.isEmpty() -> listOf( - EmptyState( - icon = R.drawable.ic_feed, - textPrimary = R.string.text_empty_holder_primary, - textSecondary = R.string.text_feed_holder, - actionStringRes = 0, + buildList(list.size + 2) { + add(header) + if (list.isEmpty()) { + add( + EmptyState( + icon = R.drawable.ic_feed, + textPrimary = R.string.text_empty_holder_primary, + textSecondary = R.string.text_feed_holder, + actionStringRes = 0, + ) ) - ) - isHasNextPage -> list + LoadingFooter - else -> list + } else { + list.mapTo(this) { it.toFeedItem() } + if (isHasNextPage) { + add(LoadingFooter) + } + } } }.asLiveDataDistinct( viewModelScope.coroutineContext + Dispatchers.Default, - listOf(LoadingState) + listOf(header, LoadingState) ) init { @@ -66,7 +69,6 @@ class FeedViewModel( val list = repository.getTrackingLog(offset, 20) if (!append) { logList.value = list - isEmptyState.postValue(list.isEmpty()) } else if (list.isNotEmpty()) { logList.value = logList.value?.plus(list) ?: list } @@ -80,7 +82,6 @@ class FeedViewModel( lastJob?.cancelAndJoin() repository.clearLogs() logList.value = emptyList() - isEmptyState.postValue(true) onFeedCleared.postCall(Unit) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt index 68c66bb3f..1c2d5f7fd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt @@ -4,11 +4,10 @@ import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.DiffUtil import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import kotlin.jvm.internal.Intrinsics import org.koitharu.kotatsu.list.ui.adapter.* import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.tracker.ui.model.FeedItem -import kotlin.jvm.internal.Intrinsics class FeedAdapter( coil: ImageLoader, @@ -24,6 +23,7 @@ class FeedAdapter( .addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener)) .addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener)) .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(listener)) + .addDelegate(ITEM_TYPE_HEADER, listHeaderAD()) } private class DiffCallback : DiffUtil.ItemCallback() { @@ -32,10 +32,7 @@ class FeedAdapter( oldItem is FeedItem && newItem is FeedItem -> { oldItem.id == newItem.id } - oldItem == LoadingFooter && newItem == LoadingFooter -> { - true - } - else -> false + else -> oldItem.javaClass == newItem.javaClass } override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { @@ -51,5 +48,6 @@ class FeedAdapter( const val ITEM_TYPE_ERROR_STATE = 3 const val ITEM_TYPE_ERROR_FOOTER = 4 const val ITEM_TYPE_EMPTY = 5 + const val ITEM_TYPE_HEADER = 6 } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt index 9b5e33454..cdd15ab86 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt @@ -1,62 +1,82 @@ package org.koitharu.kotatsu.utils import android.content.Context -import android.content.Intent import android.net.Uri +import androidx.core.app.ShareCompat import androidx.core.content.FileProvider +import java.io.File import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.model.Manga -import java.io.File + +private const val TYPE_TEXT = "text/plain" +private const val TYPE_IMAGE = "image/*" +private const val TYPE_CBZ = "application/x-cbz" class ShareHelper(private val context: Context) { fun shareMangaLink(manga: Manga) { - val intent = Intent(Intent.ACTION_SEND) - intent.type = "text/plain" - intent.putExtra(Intent.EXTRA_TEXT, buildString { + val text = buildString { append(manga.title) append("\n \n") append(manga.publicUrl) - }) - val shareIntent = - Intent.createChooser(intent, context.getString(R.string.share_s, manga.title)) - context.startActivity(shareIntent) + } + ShareCompat.IntentBuilder(context) + .setText(text) + .setType(TYPE_TEXT) + .setChooserTitle(context.getString(R.string.share_s, manga.title)) + .startChooser() } - fun shareCbz(file: File) { - val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file) - val intent = Intent(Intent.ACTION_SEND) - intent.setDataAndType(uri, context.contentResolver.getType(uri)) - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - val shareIntent = - Intent.createChooser(intent, context.getString(R.string.share_s, file.name)) - context.startActivity(shareIntent) + fun shareMangaLinks(manga: Collection) { + if (manga.isEmpty()) { + return + } + if (manga.size == 1) { + shareMangaLink(manga.first()) + return + } + val text = manga.joinToString("\n \n") { + "${it.title} - ${it.publicUrl}" + } + ShareCompat.IntentBuilder(context) + .setText(text) + .setType(TYPE_TEXT) + .setChooserTitle(R.string.share) + .startChooser() } - fun shareBackup(file: File) { - val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file) - val intent = Intent(Intent.ACTION_SEND) - intent.setDataAndType(uri, context.contentResolver.getType(uri)) - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - val shareIntent = - Intent.createChooser(intent, context.getString(R.string.share_s, file.name)) - context.startActivity(shareIntent) + fun shareCbz(files: Collection) { + if (files.isEmpty()) { + return + } + val intentBuilder = ShareCompat.IntentBuilder(context) + .setType(TYPE_CBZ) + for (file in files) { + val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file) + intentBuilder.addStream(uri) + } + files.singleOrNull()?.let { + intentBuilder.setChooserTitle(context.getString(R.string.share_s, it.name)) + } ?: run { + intentBuilder.setChooserTitle(R.string.share) + } + intentBuilder.startChooser() } fun shareImage(uri: Uri) { - val intent = Intent(Intent.ACTION_SEND) - intent.setDataAndType(uri, context.contentResolver.getType(uri) ?: "image/*") - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - val shareIntent = Intent.createChooser(intent, context.getString(R.string.share_image)) - context.startActivity(shareIntent) + ShareCompat.IntentBuilder(context) + .setStream(uri) + .setType(context.contentResolver.getType(uri) ?: TYPE_IMAGE) + .setChooserTitle(R.string.share_image) + .startChooser() } fun shareText(text: String) { - val intent = Intent(Intent.ACTION_SEND) - intent.type = "text/plain" - intent.putExtra(Intent.EXTRA_TEXT, text) - val shareIntent = Intent.createChooser(intent, context.getString(R.string.share)) - context.startActivity(shareIntent) + ShareCompat.IntentBuilder(context) + .setText(text) + .setType(TYPE_TEXT) + .setChooserTitle(R.string.share) + .startChooser() } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 000000000..51a177f52 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_favorite_categories.xml b/app/src/main/res/layout/dialog_favorite_categories.xml index 00001e5fb..2085c72f7 100644 --- a/app/src/main/res/layout/dialog_favorite_categories.xml +++ b/app/src/main/res/layout/dialog_favorite_categories.xml @@ -7,12 +7,12 @@ android:layout_height="wrap_content" android:orientation="vertical"> - + android:layout_height="match_parent" + app:menu="@menu/opt_favourites_bs" + app:title="@string/add_to_favourites" /> - - + tools:listitem="@layout/item_checkable_new" /> diff --git a/app/src/main/res/menu/mode_chapters.xml b/app/src/main/res/menu/mode_chapters.xml index 2ea959a28..2e9a6aff0 100644 --- a/app/src/main/res/menu/mode_chapters.xml +++ b/app/src/main/res/menu/mode_chapters.xml @@ -3,16 +3,16 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> - - + + \ No newline at end of file diff --git a/app/src/main/res/menu/mode_favourites.xml b/app/src/main/res/menu/mode_favourites.xml new file mode 100644 index 000000000..8718fee23 --- /dev/null +++ b/app/src/main/res/menu/mode_favourites.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/mode_history.xml b/app/src/main/res/menu/mode_history.xml new file mode 100644 index 000000000..90459c9ac --- /dev/null +++ b/app/src/main/res/menu/mode_history.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/mode_local.xml b/app/src/main/res/menu/mode_local.xml new file mode 100644 index 000000000..024fc87cb --- /dev/null +++ b/app/src/main/res/menu/mode_local.xml @@ -0,0 +1,23 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/mode_remote.xml b/app/src/main/res/menu/mode_remote.xml new file mode 100644 index 000000000..aa96ccf72 --- /dev/null +++ b/app/src/main/res/menu/mode_remote.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/opt_favourites_bs.xml b/app/src/main/res/menu/opt_favourites_bs.xml new file mode 100644 index 000000000..56e7d5723 --- /dev/null +++ b/app/src/main/res/menu/opt_favourites_bs.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/popup_favourites.xml b/app/src/main/res/menu/popup_favourites.xml deleted file mode 100644 index 42eebe5fb..000000000 --- a/app/src/main/res/menu/popup_favourites.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/popup_history.xml b/app/src/main/res/menu/popup_history.xml deleted file mode 100644 index 42eebe5fb..000000000 --- a/app/src/main/res/menu/popup_history.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/popup_local.xml b/app/src/main/res/menu/popup_local.xml deleted file mode 100644 index 4880a25a5..000000000 --- a/app/src/main/res/menu/popup_local.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 6dd32371b..c2d158769 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -19,6 +19,7 @@ 36dp 48dp 16dp + 2dp 124dp 4dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b2dc561b1..a825c48a6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -270,4 +270,7 @@ Find chapter No chapters in this manga %1$s%% + Delete selected items from device permanently? + Removal completed + Are you sure you want to download all selected manga with all its chapters? This action can consume a lot of traffic and storage \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 212be7451..2acf59e35 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -6,6 +6,11 @@ ?colorControlNormal + + + + + + + + + +