From b27d5607ac3458c0449491270e24c6bd694a4df8 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 3 Apr 2024 07:40:01 +0300 Subject: [PATCH] New details activity --- app/src/main/AndroidManifest.xml | 28 + .../kotatsu/bookmarks/ui/BookmarksFragment.kt | 12 +- .../koitharu/kotatsu/core/ui/BaseActivity.kt | 4 +- .../core/ui/list/ListSelectionController.kt | 7 +- .../core/ui/sheet/BaseAdaptiveSheet.kt | 96 ++- .../kotatsu/core/ui/widgets/ChipsView.kt | 2 +- .../kotatsu/core/ui/widgets/ProgressButton.kt | 144 ++++ .../koitharu/kotatsu/core/util/ext/Android.kt | 12 + .../details/domain/DetailsInteractor.kt | 5 + .../kotatsu/details/ui/DetailsActivity.kt | 15 +- .../kotatsu/details/ui/DetailsActivity2.kt | 728 ++++++++++++++++++ .../kotatsu/details/ui/DetailsMenuProvider.kt | 3 +- .../kotatsu/details/ui/DetailsViewModel.kt | 4 +- .../details/ui/pager/ChaptersPagesSheet.kt | 56 ++ .../details/ui/pager/DetailsPagerAdapter2.kt | 37 + .../ui/pager/chapters/ChaptersFragment.kt | 3 +- .../download/ui/list/DownloadsActivity.kt | 2 +- .../kotatsu/favourites/data/FavouritesDao.kt | 5 +- .../favourites/domain/FavouritesRepository.kt | 6 + .../categories/FavouriteCategoriesActivity.kt | 2 +- .../kotatsu/list/ui/MangaListFragment.kt | 3 +- .../remotelist/ui/RemoteListViewModel.kt | 20 +- .../kotatsu/search/ui/SearchViewModel.kt | 17 +- .../search/ui/multi/MultiSearchActivity.kt | 2 +- app/src/main/res/drawable/bg_chip.xml | 10 + app/src/main/res/drawable/ic_language.xml | 11 + app/src/main/res/drawable/ic_list_sheet.xml | 12 + app/src/main/res/drawable/ic_user.xml | 11 + .../main/res/layout/activity_details_new.xml | 428 ++++++++++ app/src/main/res/layout/activity_search.xml | 2 + .../main/res/layout/layout_details_chips.xml | 66 ++ .../main/res/layout/sheet_chapters_pages.xml | 28 + app/src/main/res/values/attrs.xml | 12 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/pref_appearance.xml | 5 + 35 files changed, 1757 insertions(+), 42 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ProgressButton.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity2.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesSheet.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/DetailsPagerAdapter2.kt create mode 100644 app/src/main/res/drawable/bg_chip.xml create mode 100644 app/src/main/res/drawable/ic_language.xml create mode 100644 app/src/main/res/drawable/ic_list_sheet.xml create mode 100644 app/src/main/res/drawable/ic_user.xml create mode 100644 app/src/main/res/layout/activity_details_new.xml create mode 100644 app/src/main/res/layout/layout_details_chips.xml create mode 100644 app/src/main/res/layout/sheet_chapters_pages.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 044602cf4..f3ad19ec4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -94,6 +94,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt index 51cf7e009..aa0166a15 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt @@ -25,6 +25,7 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver +import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding @@ -71,7 +72,7 @@ class BookmarksFragment : ) { super.onViewBindingCreated(binding, savedInstanceState) selectionController = ListSelectionController( - activity = requireActivity(), + appCompatDelegate = checkNotNull(findAppCompatDelegate()), decoration = BookmarksSelectionDecoration(binding.root.context), registryOwner = this, callback = this, @@ -100,7 +101,7 @@ class BookmarksFragment : } viewModel.onError.observeEvent( viewLifecycleOwner, - SnackbarErrorObserver(binding.recyclerView, this) + SnackbarErrorObserver(binding.recyclerView, this), ) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) } @@ -206,10 +207,11 @@ class BookmarksFragment : companion object { @Deprecated( - "", ReplaceWith( + "", + ReplaceWith( "BookmarksFragment()", - "org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment" - ) + "org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment", + ), ) fun newInstance() = BookmarksFragment() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt index 9342e30cf..0f0914798 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt @@ -132,6 +132,8 @@ abstract class BaseActivity : } else { ContextCompat.getColor(this, R.color.kotatsu_m3_background) } + defaultStatusBarColor = window.statusBarColor + window.statusBarColor = actionModeColor val insets = ViewCompat.getRootWindowInsets(viewBinding.root) ?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return findViewById(androidx.appcompat.R.id.action_mode_bar).apply { @@ -140,8 +142,6 @@ abstract class BaseActivity : topMargin = insets.top } } - defaultStatusBarColor = window.statusBarColor - window.statusBarColor = actionModeColor } @CallSuper diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ListSelectionController.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ListSelectionController.kt index e552e1098..e66857c99 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ListSelectionController.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/ListSelectionController.kt @@ -1,10 +1,9 @@ package org.koitharu.kotatsu.core.ui.list -import android.app.Activity import android.os.Bundle import android.view.Menu import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.view.ActionMode import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -20,7 +19,7 @@ private const val KEY_SELECTION = "selection" private const val PROVIDER_NAME = "selection_decoration" class ListSelectionController( - private val activity: Activity, + private val appCompatDelegate: AppCompatDelegate, private val decoration: AbstractSelectionItemDecoration, private val registryOwner: SavedStateRegistryOwner, private val callback: Callback2, @@ -108,7 +107,7 @@ class ListSelectionController( private fun startActionMode() { if (actionMode == null) { - actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this) + actionMode = appCompatDelegate.startSupportActionMode(this) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt index d9941765d..7e15d2344 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt @@ -1,25 +1,39 @@ package org.koitharu.kotatsu.core.ui.sheet import android.app.Dialog +import android.content.Context +import android.graphics.Color +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams import androidx.activity.OnBackPressedDispatcher +import androidx.annotation.CallSuper +import androidx.appcompat.app.AppCompatDialog import androidx.appcompat.app.AppCompatDialogFragment +import androidx.appcompat.view.ActionMode +import androidx.appcompat.widget.ActionBarContextView +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.viewbinding.ViewBinding import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.sidesheet.SideSheetDialog import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate +import org.koitharu.kotatsu.core.util.ext.getThemeColor import com.google.android.material.R as materialR abstract class BaseAdaptiveSheet : AppCompatDialogFragment() { private var waitingForDismissAllowingStateLoss = false private var isFitToContentsDisabled = false + private var defaultStatusBarColor = Color.TRANSPARENT var viewBinding: B? = null private set @@ -31,6 +45,9 @@ abstract class BaseAdaptiveSheet : AppCompatDialogFragment() { protected val behavior: AdaptiveSheetBehavior? get() = AdaptiveSheetBehavior.from(dialog) + @JvmField + val actionModeDelegate = ActionModeDelegate() + val isExpanded: Boolean get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED @@ -60,11 +77,45 @@ abstract class BaseAdaptiveSheet : AppCompatDialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val context = requireContext() - return if (context.resources.getBoolean(R.bool.is_tablet)) { - SideSheetDialog(context, theme) + val dialog = if (context.resources.getBoolean(R.bool.is_tablet)) { + SideSheetDialogImpl(context, theme) } else { - BottomSheetDialog(context, theme) + BottomSheetDialogImpl(context, theme) } + dialog.onBackPressedDispatcher.addCallback(actionModeDelegate) + return dialog + } + + @CallSuper + protected open fun dispatchSupportActionModeStarted(mode: ActionMode) { + actionModeDelegate.onSupportActionModeStarted(mode) + val ctx = requireContext() + val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + ColorUtils.compositeColors( + ContextCompat.getColor(ctx, com.google.android.material.R.color.m3_appbar_overlay_color), + ctx.getThemeColor(R.attr.m3ColorBackground), + ) + } else { + ContextCompat.getColor(ctx, R.color.kotatsu_m3_background) + } + dialog?.window?.let { + defaultStatusBarColor = it.statusBarColor + it.statusBarColor = actionModeColor + } + val insets = ViewCompat.getRootWindowInsets(requireView()) + ?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return + dialog?.window?.decorView?.findViewById(androidx.appcompat.R.id.action_mode_bar)?.apply { + setBackgroundColor(actionModeColor) + updateLayoutParams { + topMargin = insets.top + } + } + } + + @CallSuper + protected open fun dispatchSupportActionModeFinished(mode: ActionMode) { + actionModeDelegate.onSupportActionModeFinished(mode) + dialog?.window?.statusBarColor = defaultStatusBarColor } fun addSheetCallback(callback: AdaptiveSheetCallback) { @@ -81,6 +132,11 @@ abstract class BaseAdaptiveSheet : AppCompatDialogFragment() { protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit + fun startSupportActionMode(callback: ActionMode.Callback): ActionMode? { + val appCompatDialog = dialog as? AppCompatDialog ?: return null + return appCompatDialog.delegate.startSupportActionMode(callback) + } + protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) { val b = behavior ?: return if (isExpanded) { @@ -171,4 +227,38 @@ abstract class BaseAdaptiveSheet : AppCompatDialogFragment() { override fun onSlide(sheet: View, slideOffset: Float) {} } + + private inner class SideSheetDialogImpl(context: Context, theme: Int) : SideSheetDialog(context, theme) { + + override fun onSupportActionModeStarted(mode: ActionMode?) { + super.onSupportActionModeStarted(mode) + if (mode != null) { + dispatchSupportActionModeStarted(mode) + } + } + + override fun onSupportActionModeFinished(mode: ActionMode?) { + super.onSupportActionModeFinished(mode) + if (mode != null) { + dispatchSupportActionModeFinished(mode) + } + } + } + + private inner class BottomSheetDialogImpl(context: Context, theme: Int) : BottomSheetDialog(context, theme) { + + override fun onSupportActionModeStarted(mode: ActionMode?) { + super.onSupportActionModeStarted(mode) + if (mode != null) { + dispatchSupportActionModeStarted(mode) + } + } + + override fun onSupportActionModeFinished(mode: ActionMode?) { + super.onSupportActionModeFinished(mode) + if (mode != null) { + dispatchSupportActionModeFinished(mode) + } + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt index f2dc11325..812e56285 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt @@ -110,7 +110,7 @@ class ChipsView @JvmOverloads constructor( chip.isChipIconVisible = false chip.isCloseIconVisible = onChipCloseClickListener != null chip.setOnCloseIconClickListener(chipOnCloseListener) - chip.setEnsureMinTouchTargetSize(false) // TODO remove + chip.setEnsureMinTouchTargetSize(false) chip.setOnClickListener(chipOnClickListener) addView(chip) return chip diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ProgressButton.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ProgressButton.kt new file mode 100644 index 000000000..4561fdfdb --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ProgressButton.kt @@ -0,0 +1,144 @@ +package org.koitharu.kotatsu.core.ui.widgets + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Outline +import android.graphics.Paint +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.view.ViewOutlineProvider +import android.view.animation.AccelerateDecelerateInterpolator +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.appcompat.widget.LinearLayoutCompat +import androidx.core.content.withStyledAttributes +import androidx.core.widget.TextViewCompat +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.getAnimationDuration +import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.resolveDp +import org.koitharu.kotatsu.core.util.ext.setTextAndVisible +import org.koitharu.kotatsu.core.util.ext.textAndVisible +import com.google.android.material.R as materialR + +class ProgressButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayoutCompat(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener { + + private val textViewTitle = TextView(context) + private val textViewSubtitle = TextView(context) + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + private var progress = 0f + private var colorBase = context.getThemeColor(materialR.attr.colorSecondaryContainer) + private var colorProgress = context.getThemeColor(materialR.attr.colorPrimary) + private var progressAnimator: ValueAnimator? = null + + var title: CharSequence? + get() = textViewTitle.textAndVisible + set(value) { + textViewTitle.textAndVisible = value + } + + var subtitle: CharSequence? + get() = textViewSubtitle.textAndVisible + set(value) { + textViewSubtitle.textAndVisible = value + } + + init { + orientation = VERTICAL + outlineProvider = OutlineProvider() + clipToOutline = true + + context.withStyledAttributes(attrs, R.styleable.ProgressButton, defStyleAttr) { + val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat + TextViewCompat.setTextAppearance( + textViewTitle, + getResourceId(R.styleable.ProgressButton_titleTextAppearance, textAppearanceFallback), + ) + TextViewCompat.setTextAppearance( + textViewSubtitle, + getResourceId(R.styleable.ProgressButton_subtitleTextAppearance, textAppearanceFallback), + ) + textViewTitle.text = getText(R.styleable.ProgressButton_title) + textViewSubtitle.text = getText(R.styleable.ProgressButton_subtitle) + colorBase = getColor(R.styleable.ProgressButton_baseColor, colorBase) + colorProgress = getColor(R.styleable.ProgressButton_progressColor, colorProgress) + progress = getInt(R.styleable.ProgressButton_android_progress, 0).toFloat() / + getInt(R.styleable.ProgressButton_android_max, 100).toFloat() + } + + addView(textViewTitle, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) + addView( + textViewSubtitle, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).also { lp -> + lp.topMargin = context.resources.resolveDp(2) + }, + ) + + paint.style = Paint.Style.FILL + paint.color = colorProgress + applyGravity() + setWillNotDraw(false) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawColor(colorBase) + canvas.drawRect(0f, 0f, width * progress, height.toFloat(), paint) + } + + override fun setGravity(gravity: Int) { + super.setGravity(gravity) + if (childCount != 0) { + applyGravity() + } + } + + override fun onAnimationUpdate(animation: ValueAnimator) { + progress = animation.animatedValue as Float + invalidate() + } + + fun setTitle(@StringRes titleResId: Int) { + textViewTitle.setTextAndVisible(titleResId) + } + + fun setSubtitle(@StringRes titleResId: Int) { + textViewSubtitle.setTextAndVisible(titleResId) + } + + fun setProgress(value: Float, animate: Boolean) { + progressAnimator?.cancel() + if (animate) { + progressAnimator = ValueAnimator.ofFloat(progress, value).apply { + duration = context.getAnimationDuration(android.R.integer.config_shortAnimTime) + interpolator = AccelerateDecelerateInterpolator() + addUpdateListener(this@ProgressButton) + start() + } + } else { + progressAnimator = null + progress = value + invalidate() + } + } + + private fun applyGravity() { + val value = (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) or Gravity.CENTER_VERTICAL + textViewTitle.gravity = value + textViewSubtitle.gravity = value + } + + private class OutlineProvider : ViewOutlineProvider() { + + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, view.width, view.height, view.height / 2f) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt index 2e4a59062..863e51032 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt @@ -31,10 +31,15 @@ import android.webkit.WebView import androidx.activity.result.ActivityResultLauncher import androidx.annotation.IntegerRes import androidx.annotation.WorkerThread +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.appcompat.app.AppCompatDialog import androidx.core.app.ActivityOptionsCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.os.LocaleListCompat +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import androidx.work.CoroutineWorker @@ -216,6 +221,13 @@ fun Context.findActivity(): Activity? = when (this) { else -> null } +fun Fragment.findAppCompatDelegate(): AppCompatDelegate? { + ((this as? DialogFragment)?.dialog as? AppCompatDialog)?.run { + return delegate + } + return parentFragment?.findAppCompatDelegate() ?: (activity as? AppCompatActivity)?.delegate +} + fun Context.checkNotificationPermission(channelId: String?): Boolean { val hasPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PERMISSION_GRANTED diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsInteractor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsInteractor.kt index 76df746e0..68e0b99af 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsInteractor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsInteractor.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.details.data.MangaDetails @@ -35,6 +36,10 @@ class DetailsInteractor @Inject constructor( .map { it.isNotEmpty() } } + fun observeFavourite(mangaId: Long): Flow> { + return favouritesRepository.observeCategories(mangaId) + } + fun observeNewChapters(mangaId: Long): Flow { return settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled } .flatMapLatest { isEnabled -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 204377b43..62a58933d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -29,6 +29,7 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding +import androidx.preference.PreferenceManager import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayoutMediator @@ -422,15 +423,25 @@ class DetailsActivity : companion object { const val TIP_BUTTON = "btn_read" + private const val KEY_NEW_ACTIVITY = "new_details_screen" fun newIntent(context: Context, manga: Manga): Intent { - return Intent(context, DetailsActivity::class.java) + return getActivityIntent(context) .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga)) } fun newIntent(context: Context, mangaId: Long): Intent { - return Intent(context, DetailsActivity::class.java) + return getActivityIntent(context) .putExtra(MangaIntent.KEY_ID, mangaId) } + + private fun getActivityIntent(context: Context): Intent { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val useNewActivity = prefs.getBoolean(KEY_NEW_ACTIVITY, false) + return Intent( + context, + if (useNewActivity) DetailsActivity2::class.java else DetailsActivity::class.java, + ) + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity2.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity2.kt new file mode 100644 index 000000000..768af6d91 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity2.kt @@ -0,0 +1,728 @@ +package org.koitharu.kotatsu.details.ui + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.text.style.DynamicDrawableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.ImageSpan +import android.text.style.RelativeSizeSpan +import android.transition.TransitionManager +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewTreeObserver +import android.widget.Toast +import androidx.activity.viewModels +import androidx.appcompat.widget.PopupMenu +import androidx.core.content.ContextCompat +import androidx.core.graphics.Insets +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import androidx.core.text.method.LinkMovementMethodCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.swiperefreshlayout.widget.CircularProgressDrawable +import coil.ImageLoader +import coil.request.ImageRequest +import coil.request.SuccessResult +import coil.transform.CircleCropTransformation +import coil.util.CoilUtils +import com.google.android.material.chip.Chip +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.filterNotNull +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter +import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksSheet +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.model.iconResId +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.model.titleResId +import org.koitharu.kotatsu.core.os.AppShortcutManager +import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.parser.favicon.faviconUri +import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.ui.BaseListAdapter +import org.koitharu.kotatsu.core.ui.image.ChipIconTarget +import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver +import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration +import org.koitharu.kotatsu.core.ui.util.MenuInvalidator +import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver +import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.ViewBadge +import org.koitharu.kotatsu.core.util.ext.crossfade +import org.koitharu.kotatsu.core.util.ext.drawableStart +import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty +import org.koitharu.kotatsu.core.util.ext.isTextTruncated +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.parentView +import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf +import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat +import org.koitharu.kotatsu.core.util.ext.showDistinct +import org.koitharu.kotatsu.core.util.ext.source +import org.koitharu.kotatsu.core.util.ext.textAndVisible +import org.koitharu.kotatsu.databinding.ActivityDetailsNewBinding +import org.koitharu.kotatsu.details.data.MangaDetails +import org.koitharu.kotatsu.details.data.ReadingTime +import org.koitharu.kotatsu.details.service.MangaPrefetchService +import org.koitharu.kotatsu.details.ui.model.ChapterListItem +import org.koitharu.kotatsu.details.ui.model.HistoryInfo +import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet +import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity +import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration +import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter +import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver +import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet +import org.koitharu.kotatsu.image.ui.ImageActivity +import org.koitharu.kotatsu.list.domain.ListExtraProvider +import org.koitharu.kotatsu.list.ui.adapter.ListItemType +import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.MangaItemModel +import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver +import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.util.ellipsize +import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder +import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet +import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo +import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet +import org.koitharu.kotatsu.search.ui.MangaListActivity +import org.koitharu.kotatsu.search.ui.SearchActivity +import javax.inject.Inject +import com.google.android.material.R as materialR + +@AndroidEntryPoint +class DetailsActivity2 : + BaseActivity(), + View.OnClickListener, + View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, View.OnLayoutChangeListener, + ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener { + + @Inject + lateinit var shortcutManager: AppShortcutManager + + @Inject + lateinit var coil: ImageLoader + + @Inject + lateinit var tagHighlighter: ListExtraProvider + + private val viewModel: DetailsViewModel by viewModels() + + var bottomSheetMediator: ChaptersBottomSheetMediator? = null + private set + + private lateinit var chaptersBadge: ViewBadge + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityDetailsNewBinding.inflate(layoutInflater)) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setDisplayShowTitleEnabled(false) + } + viewBinding.buttonRead.setOnClickListener(this) + viewBinding.buttonRead.setOnLongClickListener(this) + viewBinding.buttonRead.setOnContextClickListenerCompat(this) + viewBinding.buttonChapters.setOnClickListener(this) + viewBinding.infoLayout.chipBranch.setOnClickListener(this) + viewBinding.infoLayout.chipSize.setOnClickListener(this) + viewBinding.infoLayout.chipSource.setOnClickListener(this) + viewBinding.infoLayout.chipFavorite.setOnClickListener(this) + viewBinding.infoLayout.chipAuthor.setOnClickListener(this) + viewBinding.imageViewCover.setOnClickListener(this) + viewBinding.buttonDescriptionMore.setOnClickListener(this) + viewBinding.buttonBookmarksMore.setOnClickListener(this) + viewBinding.buttonScrobblingMore.setOnClickListener(this) + viewBinding.buttonRelatedMore.setOnClickListener(this) + viewBinding.infoLayout.chipSource.setOnClickListener(this) + viewBinding.infoLayout.chipSize.setOnClickListener(this) + viewBinding.textViewDescription.addOnLayoutChangeListener(this) + viewBinding.textViewDescription.viewTreeObserver.addOnDrawListener(this) + viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance() + viewBinding.chipsTags.onChipClickListener = this + viewBinding.recyclerViewRelated.addItemDecoration( + SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)), + ) + TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView) + + chaptersBadge = ViewBadge(viewBinding.buttonChapters, this) + + viewModel.details.filterNotNull().observe(this, ::onMangaUpdated) + viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) + viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged) + viewModel.onError.observeEvent( + this, + SnackbarErrorObserver(viewBinding.scrollView, null, exceptionResolver) { + if (it) viewModel.reload() + }, + ) + viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.scrollView, null)) + viewModel.historyInfo.observe(this, ::onHistoryChanged) + viewModel.isLoading.observe(this, ::onLoadingStateChanged) + viewModel.bookmarks.observe(this, ::onBookmarksChanged) + viewModel.scrobblingInfo.observe(this, ::onScrobblingInfoChanged) + viewModel.localSize.observe(this, ::onLocalSizeChanged) + viewModel.relatedManga.observe(this, ::onRelatedMangaChanged) + // viewModel.chapters.observe(this, ::onChaptersChanged) + // viewModel.readingTime.observe(this, ::onReadingTimeChanged) + viewModel.selectedBranch.observe(this) { + viewBinding.infoLayout.chipBranch.text = it.ifNullOrEmpty { getString(R.string.system_default) } + } + viewModel.favouriteCategories.observe(this, ::onFavoritesChanged) + val menuInvalidator = MenuInvalidator(this) + viewModel.isStatsAvailable.observe(this, menuInvalidator) + viewModel.remoteManga.observe(this, menuInvalidator) + viewModel.branches.observe(this) { + viewBinding.infoLayout.chipBranch.isVisible = it.size > 1 + } + viewModel.chapters.observe(this, PrefetchObserver(this)) + viewModel.onDownloadStarted.observeEvent( + this, + DownloadStartedObserver(viewBinding.scrollView), + ) + + addMenuProvider( + DetailsMenuProvider( + activity = this, + viewModel = viewModel, + snackbarHost = viewBinding.scrollView, + appShortcutManager = shortcutManager, + ), + ) + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_read -> openReader(isIncognitoMode = false) + R.id.chip_branch -> showBranchPopupMenu(v) + R.id.button_chapters -> { + ChaptersPagesSheet().showDistinct(supportFragmentManager, "ChaptersPagesSheet") + } + + R.id.chip_author -> { + val manga = viewModel.manga.value ?: return + startActivity( + SearchActivity.newIntent( + context = v.context, + source = manga.source, + query = manga.author ?: return, + ), + ) + } + + R.id.chip_source -> { + val manga = viewModel.manga.value ?: return + startActivity( + MangaListActivity.newIntent( + context = v.context, + source = manga.source, + ), + ) + } + + R.id.chip_size -> { + val manga = viewModel.manga.value ?: return + LocalInfoDialog.show(supportFragmentManager, manga) + } + + R.id.chip_favorite -> { + val manga = viewModel.manga.value ?: return + FavoriteSheet.show(supportFragmentManager, manga) + } + + R.id.imageView_cover -> { + val manga = viewModel.manga.value ?: return + startActivity( + ImageActivity.newIntent( + v.context, + manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }, + manga.source, + ), + scaleUpActivityOptionsOf(v), + ) + } + + R.id.button_description_more -> { + val tv = viewBinding.textViewDescription + TransitionManager.beginDelayedTransition(tv.parentView) + if (tv.maxLines in 1 until Integer.MAX_VALUE) { + tv.maxLines = Integer.MAX_VALUE + } else { + tv.maxLines = resources.getInteger(R.integer.details_description_lines) + } + } + + R.id.button_scrobbling_more -> { + val manga = viewModel.manga.value ?: return + ScrobblingSelectorSheet.show(supportFragmentManager, manga, null) + } + + R.id.button_bookmarks_more -> { + val manga = viewModel.manga.value ?: return + BookmarksSheet.show(supportFragmentManager, manga) + } + + R.id.button_related_more -> { + val manga = viewModel.manga.value ?: return + startActivity(RelatedMangaActivity.newIntent(v.context, manga)) + } + } + } + + override fun onChipClick(chip: Chip, data: Any?) { + val tag = data as? MangaTag ?: return + startActivity(MangaListActivity.newIntent(this, setOf(tag))) + } + + override fun onLongClick(v: View): Boolean = when (v.id) { + R.id.button_read -> { + val menu = PopupMenu(v.context, v) + menu.inflate(R.menu.popup_read) + menu.menu.findItem(R.id.action_forget)?.isVisible = viewModel.historyInfo.value.run { + !isIncognitoMode && history != null + } + menu.setOnMenuItemClickListener(this) + menu.setForceShowIcon(true) + menu.show() + true + } + + else -> false + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_incognito -> { + openReader(isIncognitoMode = true) + true + } + + R.id.action_forget -> { + viewModel.removeFromHistory() + true + } + + R.id.action_pages_thumbs -> { + val history = viewModel.historyInfo.value.history + PagesThumbnailsSheet.show( + fm = supportFragmentManager, + manga = viewModel.manga.value ?: return false, + chapterId = history?.chapterId + ?: viewModel.chapters.value.firstOrNull()?.chapter?.id + ?: return false, + currentPage = history?.page ?: 0, + ) + true + } + + else -> false + } + } + + override fun onItemClick(item: Bookmark, view: View) { + startActivity( + IntentBuilder(view.context).bookmark(item).incognito(true).build(), + ) + Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show() + } + + override fun onDraw() { + viewBinding.run { + buttonDescriptionMore.isVisible = textViewDescription.maxLines == Int.MAX_VALUE || + textViewDescription.isTextTruncated + } + } + + override fun onLayoutChange( + v: View?, + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int + ) { + with(viewBinding) { + buttonDescriptionMore.isVisible = textViewDescription.isTextTruncated + } + } + + private fun onChaptersChanged(chapters: List?) { + // TODO + } + + private fun onFavoritesChanged(categories: Set) { + val chip = viewBinding.infoLayout.chipFavorite + chip.setChipIconResource(if (categories.isEmpty()) R.drawable.ic_heart_outline else R.drawable.ic_heart) + chip.text = if (categories.isEmpty()) { + getString(R.string.add_to_favourites) + } else { + if (categories.size == 1) { + categories.first().title.ellipsize(FAV_LABEL_LIMIT) + } + buildString(FAV_LABEL_LIMIT + 6) { + for ((i, cat) in categories.withIndex()) { + if (i == 0) { + append(cat.title.ellipsize(FAV_LABEL_LIMIT - 4)) + } else if (length + cat.title.length > FAV_LABEL_LIMIT) { + append(", ") + append(getString(R.string.list_ellipsize_pattern, categories.size - i)) + break + } else { + append(", ") + append(cat.title) + } + } + } + } + } + + private fun onReadingTimeChanged(time: ReadingTime?) { + // TODO + } + + private fun onDescriptionChanged(description: CharSequence?) { + val tv = viewBinding.textViewDescription + if (description.isNullOrBlank()) { + tv.setText(R.string.no_description) + } else { + tv.text = description + } + } + + private fun onLocalSizeChanged(size: Long) { + val chip = viewBinding.infoLayout.chipSize + if (size == 0L) { + chip.isVisible = false + } else { + chip.text = FileSize.BYTES.format(chip.context, size) + chip.isVisible = true + } + } + + private fun onRelatedMangaChanged(related: List) { + if (related.isEmpty()) { + viewBinding.groupRelated.isVisible = false + return + } + val rv = viewBinding.recyclerViewRelated + + @Suppress("UNCHECKED_CAST") + val adapter = (rv.adapter as? BaseListAdapter) ?: BaseListAdapter() + .addDelegate( + ListItemType.MANGA_GRID, + mangaGridItemAD( + coil, this, + StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)), + ) { item, view -> + startActivity(DetailsActivity.newIntent(view.context, item)) + }, + ).also { rv.adapter = it } + adapter.items = related + viewBinding.groupRelated.isVisible = true + } + + private fun onLoadingStateChanged(isLoading: Boolean) { + val button = viewBinding.buttonChapters + if (isLoading) { + button.setImageDrawable( + CircularProgressDrawable(this).also { + it.setStyle(CircularProgressDrawable.LARGE) + it.setColorSchemeColors(getThemeColor(materialR.attr.colorControlNormal)) + it.start() + }, + ) + } else { + button.setImageResource(R.drawable.ic_list_sheet) + } + } + + private fun onBookmarksChanged(bookmarks: List) { + var adapter = viewBinding.recyclerViewBookmarks.adapter as? BookmarksAdapter + viewBinding.groupBookmarks.isGone = bookmarks.isEmpty() + if (adapter != null) { + adapter.items = bookmarks + } else { + adapter = BookmarksAdapter(coil, this, this) + adapter.items = bookmarks + viewBinding.recyclerViewBookmarks.adapter = adapter + val spacing = resources.getDimensionPixelOffset(R.dimen.bookmark_list_spacing) + viewBinding.recyclerViewBookmarks.addItemDecoration(SpacingItemDecoration(spacing)) + } + } + + private fun onScrobblingInfoChanged(scrobblings: List) { + var adapter = viewBinding.recyclerViewScrobbling.adapter as? ScrollingInfoAdapter + viewBinding.groupScrobbling.isGone = scrobblings.isEmpty() + if (adapter != null) { + adapter.items = scrobblings + } else { + adapter = ScrollingInfoAdapter(this, coil, supportFragmentManager) + adapter.items = scrobblings + viewBinding.recyclerViewScrobbling.adapter = adapter + viewBinding.recyclerViewScrobbling.addItemDecoration(ScrobblingItemDecoration()) + } + } + + private fun onMangaUpdated(details: MangaDetails) { + with(viewBinding) { + val manga = details.toManga() + val hasChapters = !manga.chapters.isNullOrEmpty() + // Main + loadCover(manga) + textViewTitle.text = manga.title + textViewSubtitle.textAndVisible = manga.altTitle + infoLayout.chipAuthor.textAndVisible = manga.author + if (manga.hasRating) { + ratingBar.rating = manga.rating * ratingBar.numStars + ratingBar.isVisible = true + } else { + ratingBar.isVisible = false + } + + textViewState.apply { + manga.state?.let { state -> + textAndVisible = resources.getString(state.titleResId) + drawableStart = ContextCompat.getDrawable(context, state.iconResId) + } ?: run { + isVisible = false + } + } + if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.DUMMY) { + infoLayout.chipSource.isVisible = false + } else { + infoLayout.chipSource.text = manga.source.title + infoLayout.chipSource.isVisible = true + } + + textViewNsfw.isVisible = manga.isNsfw + + // Chips + bindTags(manga) + + textViewDescription.text = details.description.ifNullOrEmpty { getString(R.string.no_description) } + + viewBinding.infoLayout.chipSource.also { chip -> + ImageRequest.Builder(this@DetailsActivity2) + .data(manga.source.faviconUri()) + .lifecycle(this@DetailsActivity2) + .crossfade(false) + .size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size)) + .target(ChipIconTarget(chip)) + .placeholder(R.drawable.ic_web) + .fallback(R.drawable.ic_web) + .error(R.drawable.ic_web) + .source(manga.source) + .transformations(CircleCropTransformation()) + .allowRgb565(true) + .enqueueWith(coil) + } + + buttonChapters.isEnabled = hasChapters + title = manga.title + buttonRead.isEnabled = hasChapters + invalidateOptionsMenu() + } + } + + private fun onMangaRemoved(manga: Manga) { + Toast.makeText( + this, + getString(R.string._s_deleted_from_local_storage, manga.title), + Toast.LENGTH_SHORT, + ).show() + finishAfterTransition() + } + + override fun onWindowInsetsChanged(insets: Insets) { + viewBinding.root.updatePadding( + left = insets.left, + right = insets.right, + ) + } + + private fun onHistoryChanged(info: HistoryInfo) { + with(viewBinding.buttonRead) { + if (info.history != null) { + setTitle(R.string._continue) + } else { + setTitle(R.string.read) + } + } + viewBinding.buttonRead.subtitle = when { + !info.isValid -> getString(R.string.loading_) + info.currentChapter >= 0 -> getString( + R.string.chapter_d_of_d, + info.currentChapter + 1, + info.totalChapters, + ) + + info.totalChapters == 0 -> getString(R.string.no_chapters) + else -> resources.getQuantityString( + R.plurals.chapters, + info.totalChapters, + info.totalChapters, + ) + } + viewBinding.buttonRead.setProgress(info.history?.percent?.coerceIn(0f, 1f) ?: 0f, true) + } + + private fun onNewChaptersChanged(count: Int) { + chaptersBadge.counter = count + } + + private fun showBranchPopupMenu(v: View) { + val menu = PopupMenu(v.context, v) + val branches = viewModel.branches.value + for ((i, branch) in branches.withIndex()) { + val title = buildSpannedString { + if (branch.isCurrent) { + inSpans( + ImageSpan( + this@DetailsActivity2, + R.drawable.ic_current_chapter, + DynamicDrawableSpan.ALIGN_BASELINE, + ), + ) { + append(' ') + } + append(' ') + } + append(branch.name ?: getString(R.string.system_default)) + append(' ') + append(' ') + inSpans( + ForegroundColorSpan( + v.context.getThemeColor( + android.R.attr.textColorSecondary, + Color.LTGRAY, + ), + ), + RelativeSizeSpan(0.74f), + ) { + append(branch.count.toString()) + } + } + menu.menu.add(Menu.NONE, Menu.NONE, i, title) + } + menu.setOnMenuItemClickListener { + viewModel.setSelectedBranch(branches.getOrNull(it.order)?.name) + true + } + menu.show() + } + + private fun openReader(isIncognitoMode: Boolean) { + val manga = viewModel.manga.value ?: return + val chapterId = viewModel.historyInfo.value.history?.chapterId + if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) { + Snackbar.make(viewBinding.scrollView, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT) + .show() + } else { + startActivity( + IntentBuilder(this) + .manga(manga) + .branch(viewModel.selectedBranchValue) + .incognito(isIncognitoMode) + .build(), + ) + if (isIncognitoMode) { + Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show() + } + } + } + + private fun bindTags(manga: Manga) { + viewBinding.chipsTags.isVisible = manga.tags.isNotEmpty() + viewBinding.chipsTags.setChips( + manga.tags.map { tag -> + ChipsView.ChipModel( + title = tag.title, + tint = tagHighlighter.getTagTint(tag), + icon = 0, + data = tag, + isCheckable = false, + isChecked = false, + ) + }, + ) + } + + private fun loadCover(manga: Manga) { + val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl } + val lastResult = CoilUtils.result(viewBinding.imageViewCover) + if (lastResult is SuccessResult && lastResult.request.data == imageUrl) { + return + } + val request = ImageRequest.Builder(this) + .target(viewBinding.imageViewCover) + .size(CoverSizeResolver(viewBinding.imageViewCover)) + .data(imageUrl) + .tag(manga.source) + .crossfade(this) + .lifecycle(this) + .placeholderMemoryCacheKey(manga.coverUrl) + val previousDrawable = lastResult?.drawable + if (previousDrawable != null) { + request.fallback(previousDrawable) + .placeholder(previousDrawable) + .error(previousDrawable) + } else { + request.fallback(R.drawable.ic_placeholder) + .placeholder(R.drawable.ic_placeholder) + .error(R.drawable.ic_error_placeholder) + } + request.enqueueWith(coil) + } + + private class PrefetchObserver( + private val context: Context, + ) : FlowCollector?> { + + private var isCalled = false + + override suspend fun emit(value: List?) { + if (value.isNullOrEmpty()) { + return + } + if (!isCalled) { + isCalled = true + val item = value.find { it.isCurrent } ?: value.first() + MangaPrefetchService.prefetchPages(context, item.chapter) + } + } + } + + companion object { + + private const val FAV_LABEL_LIMIT = 10 + + fun newIntent(context: Context, manga: Manga): Intent { + return Intent(context, DetailsActivity2::class.java) + .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga)) + } + + fun newIntent(context: Context, mangaId: Long): Intent { + return Intent(context, DetailsActivity2::class.java) + .putExtra(MangaIntent.KEY_ID, mangaId) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt index 60eba722d..edc13345a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt @@ -35,6 +35,7 @@ class DetailsMenuProvider( override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_details, menu) + menu.findItem(R.id.action_favourite).isVisible = activity is DetailsActivity } override fun onPrepareMenu(menu: Menu) { @@ -48,7 +49,7 @@ class DetailsMenuProvider( menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null menu.findItem(R.id.action_stats).isVisible = viewModel.isStatsAvailable.value menu.findItem(R.id.action_favourite).setIcon( - if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline, + if (viewModel.favouriteCategories.value.isNotEmpty()) R.drawable.ic_heart else R.drawable.ic_heart_outline, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 8e407c091..6f185d14c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -101,8 +101,8 @@ class DetailsViewModel @Inject constructor( val history = historyRepository.observeOne(mangaId) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) - val favouriteCategories = interactor.observeIsFavourite(mangaId) - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) + val favouriteCategories = interactor.observeFavourite(mangaId) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptySet()) val isStatsAvailable = statsRepository.observeHasStats(mangaId) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesSheet.kt new file mode 100644 index 000000000..2c91c9c97 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesSheet.kt @@ -0,0 +1,56 @@ +package org.koitharu.kotatsu.details.ui.pager + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.view.ActionMode +import androidx.core.view.isVisible +import com.google.android.material.tabs.TabLayoutMediator +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet +import org.koitharu.kotatsu.core.ui.util.ActionModeListener +import org.koitharu.kotatsu.core.util.ext.recyclerView +import org.koitharu.kotatsu.core.util.ext.setTabsEnabled +import org.koitharu.kotatsu.databinding.SheetChaptersPagesBinding +import javax.inject.Inject + +@AndroidEntryPoint +class ChaptersPagesSheet : BaseAdaptiveSheet(), ActionModeListener { + + @Inject + lateinit var settings: AppSettings + + override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetChaptersPagesBinding { + return SheetChaptersPagesBinding.inflate(inflater, container, false) + } + + override fun onViewBindingCreated(binding: SheetChaptersPagesBinding, savedInstanceState: Bundle?) { + super.onViewBindingCreated(binding, savedInstanceState) + val adapter = DetailsPagerAdapter2(this, settings) + binding.pager.recyclerView?.isNestedScrollingEnabled = false + binding.pager.offscreenPageLimit = 1 + binding.pager.adapter = adapter + TabLayoutMediator(binding.tabs, binding.pager, adapter).attach() + binding.pager.setCurrentItem(settings.defaultDetailsTab, false) + binding.tabs.isVisible = adapter.itemCount > 1 + + actionModeDelegate.addListener(this, viewLifecycleOwner) + } + + override fun onActionModeStarted(mode: ActionMode) { + setExpanded(true, true) + viewBinding?.run { + pager.isUserInputEnabled = false + tabs.setTabsEnabled(false) + } + } + + override fun onActionModeFinished(mode: ActionMode) { + setExpanded(isExpanded, false) + viewBinding?.run { + pager.isUserInputEnabled = true + tabs.setTabsEnabled(true) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/DetailsPagerAdapter2.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/DetailsPagerAdapter2.kt new file mode 100644 index 000000000..e335d5cf8 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/DetailsPagerAdapter2.kt @@ -0,0 +1,37 @@ +package org.koitharu.kotatsu.details.ui.pager + +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.details.ui.pager.chapters.ChaptersFragment +import org.koitharu.kotatsu.details.ui.pager.pages.PagesFragment + +class DetailsPagerAdapter2( + fragment: Fragment, + settings: AppSettings, +) : FragmentStateAdapter(fragment), + TabLayoutMediator.TabConfigurationStrategy { + + val isPagesTabEnabled = settings.isPagesTabEnabled + + override fun getItemCount(): Int = if (isPagesTabEnabled) 2 else 1 + + override fun createFragment(position: Int): Fragment = when (position) { + 0 -> ChaptersFragment() + 1 -> PagesFragment() + else -> throw IllegalArgumentException("Invalid position $position") + } + + override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { + tab.setText( + when (position) { + 0 -> R.string.chapters + 1 -> R.string.pages + else -> 0 + }, + ) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt index 641ebbd60..262763986 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt @@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback +import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.FragmentChaptersBinding @@ -59,7 +60,7 @@ class ChaptersFragment : super.onViewBindingCreated(binding, savedInstanceState) chaptersAdapter = ChaptersAdapter(this) selectionController = ListSelectionController( - activity = requireActivity(), + appCompatDelegate = checkNotNull(findAppCompatDelegate()), decoration = ChaptersSelectionDecoration(binding.root.context), registryOwner = this, callback = this, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt index f0b51a621..91021b970 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt @@ -44,7 +44,7 @@ class DownloadsActivity : BaseActivity(), val downloadsAdapter = DownloadsAdapter(this, coil, this) val decoration = TypedListSpacingDecoration(this, false) selectionController = ListSelectionController( - activity = this, + appCompatDelegate = delegate, decoration = DownloadsSelectionDecoration(this), registryOwner = this, callback = this, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index 53f5f5bb1..9b4cdba3a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -129,7 +129,10 @@ abstract class FavouritesDao { @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id AND deleted_at = 0") abstract fun observeIds(id: Long): Flow> - @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0") + @Query("SELECT favourite_categories.* FROM favourites LEFT JOIN favourite_categories ON favourite_categories.category_id = favourites.category_id WHERE favourites.manga_id = :mangaId AND favourites.deleted_at = 0") + abstract fun observeCategories(mangaId: Long): Flow> + + @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0 ORDER BY favourites.created_at ASC") abstract suspend fun findCategoriesIds(mangaIds: Collection): List /** INSERT **/ diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index 7c6bd3c5b..2d38a3160 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -107,6 +107,12 @@ class FavouritesRepository @Inject constructor( return db.getFavouritesDao().observeIds(mangaId).map { it.toSet() } } + fun observeCategories(mangaId: Long): Flow> { + return db.getFavouritesDao().observeCategories(mangaId).map { + it.mapTo(LinkedHashSet(it.size)) { x -> x.toFavouriteCategory() } + } + } + suspend fun getCategory(id: Long): FavouriteCategory { return db.getFavouriteCategoriesDao().find(id.toInt()).toFavouriteCategory() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt index fc5a482c4..d8a2daa6a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt @@ -51,7 +51,7 @@ class FavouriteCategoriesActivity : supportActionBar?.setDisplayHomeAsUpEnabled(true) adapter = CategoriesAdapter(coil, this, this, this) selectionController = ListSelectionController( - activity = this, + appCompatDelegate = delegate, decoration = CategoriesSelectionDecoration(this), registryOwner = this, callback = CategoriesSelectionCallback(viewBinding.recyclerView, viewModel), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 41240eb32..79ac3ced3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -33,6 +33,7 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.addMenuProvider +import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate import org.koitharu.kotatsu.core.util.ext.measureHeight import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent @@ -98,7 +99,7 @@ abstract class MangaListFragment : listAdapter = onCreateAdapter() spanResolver = MangaListSpanResolver(binding.root.resources) selectionController = ListSelectionController( - activity = requireActivity(), + appCompatDelegate = checkNotNull(findAppCompatDelegate()), decoration = MangaSelectionDecoration(binding.root.context), registryOwner = this, callback = this, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 7b4769704..c73cc2716 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -72,7 +71,7 @@ open class RemoteListViewModel @Inject constructor( get() = repository.isSearchSupported override val content = combine( - mangaList.map { it?.distinctById()?.skipNsfwIfNeeded() }, + mangaList.map { it?.skipNsfwIfNeeded() }, listMode, listError, hasNextPage, @@ -136,17 +135,16 @@ open class RemoteListViewModel @Inject constructor( offset = if (append) mangaList.value?.size ?: 0 else 0, filter = filterState, ) - val oldList = mangaList.getAndUpdate { oldList -> - if (!append || oldList.isNullOrEmpty()) { - list - } else { - oldList + list - } - }.orEmpty() + val prevList = mangaList.value.orEmpty() + if (!append) { + mangaList.value = list.distinctById() + } else if (list.isNotEmpty()) { + mangaList.value = (prevList + list).distinctById() + } hasNextPage.value = if (append) { - list.isNotEmpty() + prevList != mangaList.value } else { - list.size > oldList.size || hasNextPage.value + list.size > prevList.size || hasNextPage.value } } catch (e: CancellationException) { throw e diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt index 2b6f4a2a7..a1e5688e3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/SearchViewModel.kt @@ -9,9 +9,11 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.distinctById import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.require @@ -46,7 +48,7 @@ class SearchViewModel @Inject constructor( private var loadingJob: Job? = null override val content = combine( - mangaList, + mangaList.map { it?.skipNsfwIfNeeded() }, listMode, listError, hasNextPage, @@ -102,14 +104,19 @@ class SearchViewModel @Inject constructor( listError.value = null val list = repository.getList( offset = if (append) mangaList.value?.size ?: 0 else 0, - filter = MangaListFilter.Search(query) + filter = MangaListFilter.Search(query), ) + val prevList = mangaList.value.orEmpty() if (!append) { - mangaList.value = list + mangaList.value = list.distinctById() } else if (list.isNotEmpty()) { - mangaList.value = mangaList.value?.plus(list) ?: list + mangaList.value = (prevList + list).distinctById() + } + hasNextPage.value = if (append) { + prevList != mangaList.value + } else { + list.isNotEmpty() } - hasNextPage.value = list.isNotEmpty() } catch (e: CancellationException) { throw e } catch (e: Throwable) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt index 48f88d3b5..afcef1511 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt @@ -66,7 +66,7 @@ class MultiSearchActivity : val sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = true) val selectionDecoration = MangaSelectionDecoration(this) selectionController = ListSelectionController( - activity = this, + appCompatDelegate = delegate, decoration = selectionDecoration, registryOwner = this, callback = this, diff --git a/app/src/main/res/drawable/bg_chip.xml b/app/src/main/res/drawable/bg_chip.xml new file mode 100644 index 000000000..43fff8fa6 --- /dev/null +++ b/app/src/main/res/drawable/bg_chip.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_language.xml b/app/src/main/res/drawable/ic_language.xml new file mode 100644 index 000000000..f19319392 --- /dev/null +++ b/app/src/main/res/drawable/ic_language.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_list_sheet.xml b/app/src/main/res/drawable/ic_list_sheet.xml new file mode 100644 index 000000000..2a7ccb3a9 --- /dev/null +++ b/app/src/main/res/drawable/ic_list_sheet.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_user.xml b/app/src/main/res/drawable/ic_user.xml new file mode 100644 index 000000000..7b01ef4b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_user.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/activity_details_new.xml b/app/src/main/res/layout/activity_details_new.xml new file mode 100644 index 000000000..43dec3f63 --- /dev/null +++ b/app/src/main/res/layout/activity_details_new.xml @@ -0,0 +1,428 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +