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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml
index fdfa0bd41..494f0935a 100644
--- a/app/src/main/res/layout/activity_search.xml
+++ b/app/src/main/res/layout/activity_search.xml
@@ -21,6 +21,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
+ android:layout_marginTop="8dp"
+ android:gravity="center_vertical"
app:iconifiedByDefault="false"
app:queryBackground="@null"
app:searchHintIcon="@null"
diff --git a/app/src/main/res/layout/layout_details_chips.xml b/app/src/main/res/layout/layout_details_chips.xml
new file mode 100644
index 000000000..914aa7cb9
--- /dev/null
+++ b/app/src/main/res/layout/layout_details_chips.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/sheet_chapters_pages.xml b/app/src/main/res/layout/sheet_chapters_pages.xml
new file mode 100644
index 000000000..fd5840604
--- /dev/null
+++ b/app/src/main/res/layout/sheet_chapters_pages.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
index ef95edffb..26977cbac 100644
--- a/app/src/main/res/values/attrs.xml
+++ b/app/src/main/res/values/attrs.xml
@@ -8,6 +8,7 @@
+
@@ -147,4 +148,15 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d914c5561..032ecda13 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -642,4 +642,5 @@
Enable the \"Pages\" tab on the details screen
No data was received from server
Please select a proper Kotatsu backup file
+ (+%d)
diff --git a/app/src/main/res/xml/pref_appearance.xml b/app/src/main/res/xml/pref_appearance.xml
index ad0723445..e5b247b45 100644
--- a/app/src/main/res/xml/pref_appearance.xml
+++ b/app/src/main/res/xml/pref_appearance.xml
@@ -48,6 +48,11 @@
+
+