Tracking sheet and search adjustments (#5427)

* Tracking sheet and search visual adjustments

* Remove track item divider

* Add start margin to "add tracking" button

* Fix track search dialog crash when no item chosen

* Show "remove" action only when track item is previously set

* Remove placeholder for total chapters

* Cleanups

* Add track search error/empty result message

* Make track search dialog fullscreen

* Use AutofitRecyclerView for track search dialog

* Fix text input overlapping

* Run track search from IME action instead

* Remove deprecated method

* Reformat

* Set track search error message on the placeholder

* Use payload to notify track search item change

* Fix track search action icon tint color
This commit is contained in:
Ivan Iskandar
2021-06-28 22:33:26 +07:00
committed by GitHub
parent 7e3ea9074c
commit cb71d44024
18 changed files with 662 additions and 434 deletions

View File

@ -1118,8 +1118,7 @@ class MangaController :
fun onTrackingSearchResultsError(error: Throwable) {
Timber.e(error)
activity?.toast(error.message)
getTrackingSearchDialog()?.onSearchResultsError()
getTrackingSearchDialog()?.onSearchResultsError(error.message)
}
private fun getTrackingSearchDialog(): TrackSearchDialog? {

View File

@ -4,6 +4,8 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.databinding.TrackItemBinding
import eu.kanade.tachiyomi.util.view.applyElevationOverlay
import uy.kohesive.injekt.api.get
class TrackAdapter(listener: OnClickListener) : RecyclerView.Adapter<TrackHolder>() {
@ -29,6 +31,7 @@ class TrackAdapter(listener: OnClickListener) : RecyclerView.Adapter<TrackHolder
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackHolder {
binding = TrackItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.card.applyElevationOverlay()
return TrackHolder(binding, this)
}

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.manga.track
import android.annotation.SuppressLint
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.TrackItemBinding
import uy.kohesive.injekt.injectLazy
@ -37,38 +38,64 @@ class TrackHolder(private val binding: TrackItemBinding, adapter: TrackAdapter)
fun bind(item: TrackItem) {
val track = item.track
binding.trackLogo.setImageResource(item.service.getLogo())
binding.logoContainer.setBackgroundColor(item.service.getLogoColor())
binding.logoContainer.setCardBackgroundColor(item.service.getLogoColor())
binding.trackSet.isVisible = track == null
binding.trackTitle.isVisible = track != null
binding.topDivider.isVisible = track != null
binding.middleRow.isVisible = track != null
binding.bottomDivider.isVisible = track != null
binding.bottomRow.isVisible = track != null
binding.card.isVisible = track != null
if (track != null) {
val ctx = binding.trackTitle.context
binding.trackTitle.text = track.title
binding.trackChapters.text = "${track.last_chapter_read}/" +
if (track.total_chapters > 0) track.total_chapters else "-"
binding.trackChapters.text = track.last_chapter_read.toString()
if (track.total_chapters > 0) {
binding.trackChapters.text = "${binding.trackChapters.text} / ${track.total_chapters}"
}
binding.trackStatus.text = item.service.getStatus(track.status)
if (item.service.getScoreList().isEmpty()) {
binding.trackScore.isVisible = false
binding.vertDivider2.isVisible = false
} else {
binding.trackScore.text = if (track.score == 0f) "-" else item.service.displayScore(track)
val supportsScoring = item.service.getScoreList().isNotEmpty()
if (supportsScoring) {
if (track.score != 0F) {
item.service.getScoreList()
binding.trackScore.text = item.service.displayScore(track)
binding.trackScore.alpha = SET_STATUS_TEXT_ALPHA
} else {
binding.trackScore.text = ctx.getString(R.string.score)
binding.trackScore.alpha = UNSET_STATUS_TEXT_ALPHA
}
}
binding.trackScore.isVisible = supportsScoring
binding.vertDivider2.isVisible = supportsScoring
if (item.service.supportsReadingDates) {
binding.trackStartDate.text =
if (track.started_reading_date != 0L) dateFormat.format(track.started_reading_date) else "-"
binding.trackFinishDate.text =
if (track.finished_reading_date != 0L) dateFormat.format(track.finished_reading_date) else "-"
} else {
binding.bottomDivider.isVisible = false
binding.bottomRow.isVisible = false
val supportsReadingDates = item.service.supportsReadingDates
if (supportsReadingDates) {
if (track.started_reading_date != 0L) {
binding.trackStartDate.text = dateFormat.format(track.started_reading_date)
binding.trackStartDate.alpha = SET_STATUS_TEXT_ALPHA
} else {
binding.trackStartDate.text = ctx.getString(R.string.track_started_reading_date)
binding.trackStartDate.alpha = UNSET_STATUS_TEXT_ALPHA
}
if (track.finished_reading_date != 0L) {
binding.trackFinishDate.text = dateFormat.format(track.finished_reading_date)
binding.trackFinishDate.alpha = SET_STATUS_TEXT_ALPHA
} else {
binding.trackFinishDate.text = ctx.getString(R.string.track_finished_reading_date)
binding.trackFinishDate.alpha = UNSET_STATUS_TEXT_ALPHA
}
}
binding.bottomDivider.isVisible = supportsReadingDates
binding.bottomRow.isVisible = supportsReadingDates
}
}
companion object {
private const val SET_STATUS_TEXT_ALPHA = 1F
private const val UNSET_STATUS_TEXT_ALPHA = 0.5F
}
}

View File

@ -1,76 +1,57 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.content.Context
import android.view.View
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ArrayAdapter
import androidx.core.view.isVisible
import coil.clear
import coil.load
import eu.kanade.tachiyomi.R
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.databinding.TrackSearchItemBinding
import eu.kanade.tachiyomi.util.view.inflate
import eu.kanade.tachiyomi.util.view.applyElevationOverlay
class TrackSearchAdapter(context: Context) :
ArrayAdapter<TrackSearch>(context, R.layout.track_search_item, mutableListOf<TrackSearch>()) {
class TrackSearchAdapter(
private val currentTrackUrl: String?,
private val onSelectionChanged: (TrackSearch?) -> Unit
) : RecyclerView.Adapter<TrackSearchHolder>() {
var selectedItemPosition = -1
set(value) {
if (field != value) {
val previousPosition = field
field = value
// Just notify the now-unselected item
notifyItemChanged(previousPosition, UncheckPayload)
onSelectionChanged(items.getOrNull(value))
}
}
override fun getView(position: Int, view: View?, parent: ViewGroup): View {
var v = view
// Get the data item for this position
val track = getItem(position)!!
// Check if an existing view is being reused, otherwise inflate the view
val holder: TrackSearchHolder // view lookup cache stored in tag
if (v == null) {
v = parent.inflate(R.layout.track_search_item)
holder = TrackSearchHolder(v)
v.tag = holder
var items = emptyList<TrackSearch>()
set(value) {
if (field != value) {
field = value
selectedItemPosition = value.indexOfFirst { it.tracking_url == currentTrackUrl }
notifyDataSetChanged()
}
}
override fun getItemCount(): Int = items.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackSearchHolder {
val binding = TrackSearchItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.container.applyElevationOverlay()
return TrackSearchHolder(binding, this)
}
override fun onBindViewHolder(holder: TrackSearchHolder, position: Int) {
holder.bind(items[position], position)
}
override fun onBindViewHolder(holder: TrackSearchHolder, position: Int, payloads: MutableList<Any>) {
if (payloads.getOrNull(0) == UncheckPayload) {
holder.setUnchecked()
} else {
holder = v.tag as TrackSearchHolder
super.onBindViewHolder(holder, position, payloads)
}
holder.onSetValues(track)
return v
}
fun setItems(syncs: List<TrackSearch>) {
setNotifyOnChange(false)
clear()
addAll(syncs)
notifyDataSetChanged()
}
class TrackSearchHolder(private val view: View) {
private val binding = TrackSearchItemBinding.bind(view)
fun onSetValues(track: TrackSearch) {
binding.trackSearchTitle.text = track.title
binding.trackSearchSummary.text = track.summary
binding.trackSearchCover.clear()
if (track.cover_url.isNotEmpty()) {
binding.trackSearchCover.load(track.cover_url)
}
val hasStatus = track.publishing_status.isNotBlank()
binding.trackSearchStatus.isVisible = hasStatus
binding.trackSearchStatusResult.isVisible = hasStatus
if (hasStatus) {
binding.trackSearchStatusResult.text = track.publishing_status.capitalize()
}
val hasType = track.publishing_type.isNotBlank()
binding.trackSearchType.isVisible = hasType
binding.trackSearchTypeResult.isVisible = hasType
if (hasType) {
binding.trackSearchTypeResult.text = track.publishing_type.capitalize()
}
val hasStartDate = track.start_date.isNotBlank()
binding.trackSearchStart.isVisible = hasStartDate
binding.trackSearchStartResult.isVisible = hasStartDate
if (hasStartDate) {
binding.trackSearchStartResult.text = track.start_date
}
}
companion object {
private object UncheckPayload
}
}

View File

@ -2,29 +2,31 @@ package eu.kanade.tachiyomi.ui.manga.track
import android.app.Dialog
import android.os.Bundle
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatDialog
import androidx.core.content.getSystemService
import androidx.core.os.bundleOf
import androidx.core.view.WindowCompat
import androidx.core.view.isVisible
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.customview.customView
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.databinding.TrackSearchDialogBinding
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.manga.MangaController
import kotlinx.coroutines.flow.debounce
import eu.kanade.tachiyomi.util.view.setNavigationBarTransparentCompat
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import reactivecircus.flowbinding.android.widget.itemClicks
import reactivecircus.flowbinding.android.widget.textChanges
import reactivecircus.flowbinding.android.widget.editorActionEvents
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit
class TrackSearchDialog : DialogController {
@ -32,59 +34,130 @@ class TrackSearchDialog : DialogController {
private var adapter: TrackSearchAdapter? = null
private var selectedItem: Track? = null
private val service: TrackService
private val currentTrackUrl: String?
private val trackController
get() = targetController as MangaController
constructor(target: MangaController, service: TrackService) : super(
bundleOf(KEY_SERVICE to service.id)
) {
private lateinit var currentlySearched: String
constructor(
target: MangaController,
_service: TrackService,
_currentTrackUrl: String?
) : super(bundleOf(KEY_SERVICE to _service.id, KEY_CURRENT_URL to _currentTrackUrl)) {
targetController = target
this.service = service
service = _service
currentTrackUrl = _currentTrackUrl
}
@Suppress("unused")
constructor(bundle: Bundle) : super(bundle) {
service = Injekt.get<TrackManager>().getService(bundle.getInt(KEY_SERVICE))!!
currentTrackUrl = bundle.getString(KEY_CURRENT_URL)
}
@Suppress("DEPRECATION")
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
binding = TrackSearchDialogBinding.inflate(LayoutInflater.from(activity!!))
val dialog = MaterialDialog(activity!!)
.customView(view = binding!!.root)
.positiveButton(android.R.string.ok) { onPositiveButtonClick() }
.negativeButton(android.R.string.cancel)
.neutralButton(R.string.action_remove) { onRemoveButtonClick() }
onViewCreated(dialog.view, savedViewState)
return dialog
}
fun onViewCreated(view: View, savedState: Bundle?) {
// Create adapter
val adapter = TrackSearchAdapter(view.context)
this.adapter = adapter
binding!!.trackSearchList.adapter = adapter
// Set listeners
selectedItem = null
binding!!.trackSearchList.itemClicks()
.onEach { position ->
selectedItem = adapter.getItem(position)
// Toolbar stuff
binding!!.toolbar.setNavigationOnClickListener { dialog?.dismiss() }
binding!!.toolbar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.done -> {
val adapter = adapter ?: return@setOnMenuItemClickListener true
val item = adapter.items.getOrNull(adapter.selectedItemPosition)
if (item != null) {
trackController.presenter.registerTracking(item, service)
dialog?.dismiss()
}
}
R.id.remove -> {
trackController.presenter.unregisterTracking(service)
dialog?.dismiss()
}
}
.launchIn(trackController.viewScope)
true
}
binding!!.toolbar.menu.findItem(R.id.remove).isVisible = currentTrackUrl != null
// Create adapter
adapter = TrackSearchAdapter(currentTrackUrl) { which ->
binding!!.toolbar.menu.findItem(R.id.done).isEnabled = which != null
}
binding!!.trackSearchRecyclerview.adapter = adapter
// Do an initial search based on the manga's title
if (savedState == null) {
val title = trackController.presenter.manga.title
binding!!.trackSearch.append(title)
search(title)
if (savedViewState == null) {
currentlySearched = trackController.presenter.manga.title
binding!!.titleInput.editText?.append(currentlySearched)
}
search(currentlySearched)
// Input listener
binding?.titleInput?.editText
?.editorActionEvents {
when (it.actionId) {
EditorInfo.IME_ACTION_SEARCH -> {
true
}
else -> {
it.keyEvent?.action == KeyEvent.ACTION_DOWN && it.keyEvent?.keyCode == KeyEvent.KEYCODE_ENTER
}
}
}
?.filter { it.view.text.isNotBlank() }
?.onEach {
val query = it.view.text.toString()
if (query != currentlySearched) {
currentlySearched = query
search(it.view.text.toString())
it.view.context.getSystemService<InputMethodManager>()?.hideSoftInputFromWindow(it.view.windowToken, 0)
it.view.clearFocus()
}
}
?.launchIn(trackController.viewScope)
// Edge to edge
binding!!.appbar.applyInsetter {
type(navigationBars = true, statusBars = true) {
padding(left = true, top = true, right = true)
}
}
binding!!.titleInput.applyInsetter {
type(navigationBars = true) {
margin(horizontal = true)
}
}
binding!!.progress.applyInsetter {
type(navigationBars = true) {
margin()
}
}
binding!!.message.applyInsetter {
type(navigationBars = true) {
margin()
}
}
binding!!.trackSearchRecyclerview.applyInsetter {
type(navigationBars = true) {
padding(vertical = true)
margin(horizontal = true)
}
}
return AppCompatDialog(activity!!, R.style.ThemeOverlay_Tachiyomi_Dialog_Fullscreen).apply {
setContentView(binding!!.root)
}
}
override fun onAttach(view: View) {
super.onAttach(view)
dialog?.window?.let { window ->
window.setNavigationBarTransparentCompat(window.context)
WindowCompat.setDecorFitsSystemWindows(window, false)
}
}
@ -94,46 +167,39 @@ class TrackSearchDialog : DialogController {
adapter = null
}
override fun onAttach(view: View) {
super.onAttach(view)
binding!!.trackSearch.textChanges()
.debounce(TimeUnit.SECONDS.toMillis(1))
.filter { it.isNotBlank() }
.onEach { search(it.toString()) }
.launchIn(trackController.viewScope)
}
private fun search(query: String) {
val binding = binding ?: return
binding.progress.isVisible = true
binding.trackSearchList.isVisible = false
binding.trackSearchRecyclerview.isVisible = false
binding.message.isVisible = false
trackController.presenter.trackingSearch(query, service)
}
fun onSearchResults(results: List<TrackSearch>) {
selectedItem = null
val binding = binding ?: return
binding.progress.isVisible = false
binding.trackSearchList.isVisible = true
adapter?.setItems(results)
val emptyResult = results.isEmpty()
adapter?.items = results
binding.trackSearchRecyclerview.isVisible = !emptyResult
binding.trackSearchRecyclerview.scrollToPosition(0)
binding.message.isVisible = emptyResult
if (emptyResult) {
binding.message.text = binding.message.context.getString(R.string.no_results_found)
}
}
fun onSearchResultsError() {
fun onSearchResultsError(message: String?) {
val binding = binding ?: return
binding.progress.isVisible = false
binding.trackSearchList.isVisible = false
adapter?.setItems(emptyList())
}
private fun onPositiveButtonClick() {
trackController.presenter.registerTracking(selectedItem, service)
}
private fun onRemoveButtonClick() {
trackController.presenter.unregisterTracking(service)
binding.trackSearchRecyclerview.isVisible = false
binding.message.isVisible = true
binding.message.text = message ?: binding.message.context.getString(R.string.unknown_error)
adapter?.items = emptyList()
}
private companion object {
const val KEY_SERVICE = "service_id"
const val KEY_CURRENT_URL = "current_url"
}
}

View File

@ -0,0 +1,61 @@
package eu.kanade.tachiyomi.ui.manga.track
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import coil.clear
import coil.load
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.databinding.TrackSearchItemBinding
import eu.kanade.tachiyomi.util.view.setMaxLinesAndEllipsize
import java.util.Locale
class TrackSearchHolder(
private val binding: TrackSearchItemBinding,
private val adapter: TrackSearchAdapter
) : RecyclerView.ViewHolder(binding.root) {
fun bind(track: TrackSearch, position: Int) {
binding.container.isChecked = position == adapter.selectedItemPosition
binding.container.setOnClickListener {
adapter.selectedItemPosition = position
binding.container.isChecked = true
}
binding.trackSearchTitle.text = track.title
binding.trackSearchCover.clear()
if (track.cover_url.isNotEmpty()) {
binding.trackSearchCover.load(track.cover_url)
}
val hasStatus = track.publishing_status.isNotBlank()
binding.trackSearchStatus.isVisible = hasStatus
binding.trackSearchStatusResult.isVisible = hasStatus
if (hasStatus) {
binding.trackSearchStatusResult.text = track.publishing_status.lowercase().replaceFirstChar {
it.titlecase(Locale.getDefault())
}
}
val hasType = track.publishing_type.isNotBlank()
binding.trackSearchType.isVisible = hasType
binding.trackSearchTypeResult.isVisible = hasType
if (hasType) {
binding.trackSearchTypeResult.text = track.publishing_type.lowercase().replaceFirstChar {
it.titlecase(Locale.getDefault())
}
}
val hasStartDate = track.start_date.isNotBlank()
binding.trackSearchStart.isVisible = hasStartDate
binding.trackSearchStartResult.isVisible = hasStartDate
if (hasStartDate) {
binding.trackSearchStartResult.text = track.start_date
}
binding.trackSearchSummary.setMaxLinesAndEllipsize()
binding.trackSearchSummary.text = track.summary
}
fun setUnchecked() {
binding.container.isChecked = false
}
}

View File

@ -96,7 +96,8 @@ class TrackSheet(
}
}
} else {
TrackSearchDialog(controller, item.service).showDialog(controller.router, TAG_SEARCH_CONTROLLER)
TrackSearchDialog(controller, item.service, item.track?.tracking_url)
.showDialog(controller.router, TAG_SEARCH_CONTROLLER)
}
}

View File

@ -38,6 +38,7 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.lang.truncateCenter
import timber.log.Timber
import uy.kohesive.injekt.api.get
import java.io.File
import kotlin.math.roundToInt

View File

@ -4,10 +4,12 @@ package eu.kanade.tachiyomi.util.view
import android.annotation.SuppressLint
import android.graphics.Point
import android.text.TextUtils
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.TextView
import androidx.annotation.MenuRes
import androidx.annotation.StringRes
import androidx.appcompat.view.menu.MenuBuilder
@ -16,12 +18,17 @@ import androidx.appcompat.widget.TooltipCompat
import androidx.core.content.ContextCompat
import androidx.core.view.forEach
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.card.MaterialCardView
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.google.android.material.elevation.ElevationOverlayProvider
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import com.google.android.material.snackbar.Snackbar
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.system.getResourceColor
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
/**
* Returns coordinates of view.
@ -174,3 +181,21 @@ inline fun ChipGroup.setChips(
addView(chip)
}
}
/**
* Applies elevation overlay to a MaterialCardView
*/
inline fun MaterialCardView.applyElevationOverlay() {
if (Injekt.get<PreferencesHelper>().isDarkMode()) {
val provider = ElevationOverlayProvider(context)
setCardBackgroundColor(provider.compositeOverlay(cardBackgroundColor.defaultColor, cardElevation))
}
}
/**
* Sets TextView max lines dynamically. Can only be called when the view is already laid out.
*/
inline fun TextView.setMaxLinesAndEllipsize(_ellipsize: TextUtils.TruncateAt = TextUtils.TruncateAt.END) = post {
maxLines = (measuredHeight - paddingTop - paddingBottom) / lineHeight
ellipsize = _ellipsize
}