Fix filter
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
package org.koitharu.kotatsu.base.ui.list
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class FitHeightGridLayoutManager : GridLayoutManager {
|
||||
|
||||
constructor(context: Context?, spanCount: Int) : super(context, spanCount)
|
||||
|
||||
constructor(
|
||||
context: Context?,
|
||||
attrs: AttributeSet?,
|
||||
defStyleAttr: Int,
|
||||
defStyleRes: Int,
|
||||
) : super(context, attrs, defStyleAttr, defStyleRes)
|
||||
|
||||
constructor(
|
||||
context: Context?,
|
||||
spanCount: Int,
|
||||
orientation: Int,
|
||||
reverseLayout: Boolean,
|
||||
) : super(context, spanCount, orientation, reverseLayout)
|
||||
|
||||
|
||||
override fun layoutDecoratedWithMargins(child: View, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
if (orientation == RecyclerView.VERTICAL && child.layoutParams.height == LayoutParams.MATCH_PARENT) {
|
||||
val parentBottom = height - paddingBottom
|
||||
val offset = parentBottom - bottom
|
||||
super.layoutDecoratedWithMargins(child, left, top + offset, right, bottom + offset)
|
||||
} else {
|
||||
super.layoutDecoratedWithMargins(child, left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.koitharu.kotatsu.base.ui.list
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.LayoutParams
|
||||
|
||||
class FitHeightLinearLayoutManager : LinearLayoutManager {
|
||||
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(
|
||||
context: Context,
|
||||
@RecyclerView.Orientation orientation: Int,
|
||||
reverseLayout: Boolean,
|
||||
) : super(context, orientation, reverseLayout)
|
||||
|
||||
constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet?,
|
||||
@AttrRes defStyleAttr: Int,
|
||||
@StyleRes defStyleRes: Int,
|
||||
) : super(context, attrs, defStyleAttr, defStyleRes)
|
||||
|
||||
override fun layoutDecoratedWithMargins(child: View, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
if (orientation == RecyclerView.VERTICAL && child.layoutParams.height == LayoutParams.MATCH_PARENT) {
|
||||
val parentBottom = height - paddingBottom
|
||||
val offset = parentBottom - bottom
|
||||
super.layoutDecoratedWithMargins(child, left, top + offset, right, bottom + offset)
|
||||
} else {
|
||||
super.layoutDecoratedWithMargins(child, left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,14 @@ import androidx.core.graphics.Insets
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager
|
||||
import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager
|
||||
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
|
||||
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
|
||||
@@ -214,19 +215,19 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
||||
removeOnLayoutChangeListener(spanResolver)
|
||||
when (mode) {
|
||||
ListMode.LIST -> {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
layoutManager = FitHeightLinearLayoutManager(context)
|
||||
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
|
||||
addItemDecoration(SpacingItemDecoration(spacing))
|
||||
updatePadding(left = spacing, right = spacing)
|
||||
}
|
||||
ListMode.DETAILED_LIST -> {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
layoutManager = FitHeightLinearLayoutManager(context)
|
||||
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
|
||||
updatePadding(left = spacing, right = spacing)
|
||||
addItemDecoration(SpacingItemDecoration(spacing))
|
||||
}
|
||||
ListMode.GRID -> {
|
||||
layoutManager = GridLayoutManager(context, spanResolver.spanCount).also {
|
||||
layoutManager = FitHeightGridLayoutManager(context, spanResolver.spanCount).also {
|
||||
it.spanSizeLookup = spanSizeLookup
|
||||
}
|
||||
val spacing = resources.getDimensionPixelOffset(R.dimen.grid_spacing)
|
||||
|
||||
@@ -5,10 +5,7 @@ import androidx.recyclerview.widget.DiffUtil
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.core.ui.DateTimeAgo
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.*
|
||||
import kotlin.jvm.internal.Intrinsics
|
||||
|
||||
class MangaListAdapter(
|
||||
@@ -60,6 +57,16 @@ class MangaListAdapter(
|
||||
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
|
||||
return Intrinsics.areEqual(oldItem, newItem)
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
|
||||
return when (newItem) {
|
||||
is MangaListModel,
|
||||
is MangaGridModel,
|
||||
is MangaListDetailedModel,
|
||||
is CurrentFilterModel -> Unit
|
||||
else -> super.getChangePayload(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -45,7 +46,13 @@ fun filterHeaderDelegate() = adapterDelegateViewBinding<FilterItem.Header, Filte
|
||||
) {
|
||||
|
||||
bind {
|
||||
binding.root.setText(item.titleResId)
|
||||
binding.textViewTitle.setText(item.titleResId)
|
||||
binding.badge.isVisible = if (item.counter == 0) {
|
||||
false
|
||||
} else {
|
||||
binding.badge.text = item.counter.toString()
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,34 +5,21 @@ import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.koin.androidx.viewmodel.ViewModelOwner.Companion.from
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.databinding.SheetFilterBinding
|
||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
|
||||
import org.koitharu.kotatsu.utils.BottomSheetToolbarController
|
||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||
|
||||
class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>(), MenuItem.OnActionExpandListener,
|
||||
SearchView.OnQueryTextListener, DialogInterface.OnKeyListener {
|
||||
|
||||
private val viewModel by sharedViewModel<FilterViewModel>(
|
||||
private val viewModel by sharedViewModel<RemoteListViewModel>(
|
||||
owner = { from(requireParentFragment(), requireParentFragment()) }
|
||||
) {
|
||||
parametersOf(
|
||||
requireArguments().getParcelable<MangaSource>(ARG_SOURCE),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val state = requireArguments().getParcelable<FilterState>(ARG_STATE)
|
||||
viewModel.updateState(state)
|
||||
}
|
||||
)
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return super.onCreateDialog(savedInstanceState).also {
|
||||
@@ -53,10 +40,7 @@ class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>(), MenuItem.OnActi
|
||||
}
|
||||
val adapter = FilterAdapter(viewModel)
|
||||
binding.recyclerView.adapter = adapter
|
||||
viewModel.filter.observe(viewLifecycleOwner, adapter::setItems)
|
||||
viewModel.result.observe(viewLifecycleOwner) {
|
||||
parentFragmentManager.setFragmentResult(REQUEST_KEY, bundleOf(ARG_STATE to it))
|
||||
}
|
||||
viewModel.filterItems.observe(viewLifecycleOwner, adapter::setItems)
|
||||
initOptionsMenu()
|
||||
}
|
||||
|
||||
@@ -75,7 +59,7 @@ class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>(), MenuItem.OnActi
|
||||
override fun onQueryTextSubmit(query: String?): Boolean = false
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
viewModel.performSearch(newText?.trim().orEmpty())
|
||||
viewModel.filterSearch(newText?.trim().orEmpty())
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -104,19 +88,8 @@ class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>(), MenuItem.OnActi
|
||||
|
||||
companion object {
|
||||
|
||||
const val REQUEST_KEY = "filter"
|
||||
|
||||
const val ARG_STATE = "state"
|
||||
private const val TAG = "FilterBottomSheet"
|
||||
private const val ARG_SOURCE = "source"
|
||||
|
||||
fun show(
|
||||
fm: FragmentManager,
|
||||
source: MangaSource,
|
||||
state: FilterState,
|
||||
) = FilterBottomSheet().withArgs(2) {
|
||||
putParcelable(ARG_SOURCE, source)
|
||||
putParcelable(ARG_STATE, state)
|
||||
}.show(fm, TAG)
|
||||
fun show(fm: FragmentManager) = FilterBottomSheet().show(fm, TAG)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import java.util.*
|
||||
|
||||
class FilterCoordinator(
|
||||
private val repository: RemoteMangaRepository,
|
||||
dataRepository: MangaDataRepository,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) : OnFilterChangedListener {
|
||||
|
||||
private val currentState = MutableStateFlow(FilterState(repository.sortOrders.firstOrNull(), emptySet()))
|
||||
private var searchQuery = MutableStateFlow("")
|
||||
private val localTagsDeferred = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
|
||||
dataRepository.findTags(repository.source)
|
||||
}
|
||||
private var availableTagsDeferred = loadTagsAsync()
|
||||
|
||||
val items = getItemsFlow()
|
||||
.asLiveDataDistinct(coroutineScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
init {
|
||||
observeState()
|
||||
}
|
||||
|
||||
override fun onSortItemClick(item: FilterItem.Sort) {
|
||||
currentState.update { oldValue ->
|
||||
FilterState(item.order, oldValue.tags)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTagItemClick(item: FilterItem.Tag) {
|
||||
currentState.update { oldValue ->
|
||||
val newTags = if (item.isChecked) {
|
||||
oldValue.tags - item.tag
|
||||
} else {
|
||||
oldValue.tags + item.tag
|
||||
}
|
||||
FilterState(oldValue.sortOrder, newTags)
|
||||
}
|
||||
}
|
||||
|
||||
fun observeState() = currentState.asStateFlow()
|
||||
|
||||
fun removeTag(tag: MangaTag) {
|
||||
currentState.update { oldValue ->
|
||||
FilterState(oldValue.sortOrder, oldValue.tags - tag)
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
currentState.update { oldValue ->
|
||||
FilterState(oldValue.sortOrder, emptySet())
|
||||
}
|
||||
}
|
||||
|
||||
fun snapshot() = currentState.value
|
||||
|
||||
fun performSearch(query: String) {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
private fun getItemsFlow() = combine(
|
||||
getTagsAsFlow(),
|
||||
currentState,
|
||||
searchQuery,
|
||||
) { tags, state, query ->
|
||||
buildFilterList(tags, state, query)
|
||||
}
|
||||
|
||||
private fun getTagsAsFlow() = flow {
|
||||
val localTags = localTagsDeferred.await()
|
||||
emit(TagsWrapper(localTags, isLoading = true, isError = false))
|
||||
val remoteTags = tryLoadTags()
|
||||
if (remoteTags == null) {
|
||||
emit(TagsWrapper(localTags, isLoading = false, isError = true))
|
||||
} else {
|
||||
emit(TagsWrapper(mergeTags(remoteTags, localTags), isLoading = false, isError = false))
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun buildFilterList(
|
||||
allTags: TagsWrapper,
|
||||
state: FilterState,
|
||||
query: String,
|
||||
): List<FilterItem> {
|
||||
val sortOrders = repository.sortOrders.sortedBy { it.ordinal }
|
||||
val tags = mergeTags(state.tags, allTags.tags).sortedBy { it.title }
|
||||
val list = ArrayList<FilterItem>(tags.size + sortOrders.size + 3)
|
||||
if (query.isEmpty()) {
|
||||
if (sortOrders.isNotEmpty()) {
|
||||
list.add(FilterItem.Header(R.string.sort_order, 0))
|
||||
sortOrders.mapTo(list) {
|
||||
FilterItem.Sort(it, isSelected = it == state.sortOrder)
|
||||
}
|
||||
}
|
||||
if(allTags.isLoading || allTags.isError || tags.isNotEmpty()) {
|
||||
list.add(FilterItem.Header(R.string.genres, state.tags.size))
|
||||
tags.mapTo(list) {
|
||||
FilterItem.Tag(it, isChecked = it in state.tags)
|
||||
}
|
||||
}
|
||||
if (allTags.isError) {
|
||||
list.add(FilterItem.Error(R.string.filter_load_error))
|
||||
} else if (allTags.isLoading) {
|
||||
list.add(FilterItem.Loading)
|
||||
}
|
||||
} else {
|
||||
tags.mapNotNullTo(list) {
|
||||
if (it.title.contains(query, ignoreCase = true)) {
|
||||
FilterItem.Tag(it, isChecked = it in state.tags)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (list.isEmpty()) {
|
||||
list.add(FilterItem.Error(R.string.nothing_found))
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private suspend fun tryLoadTags(): Set<MangaTag>? {
|
||||
val shouldRetryOnError = availableTagsDeferred.isCompleted
|
||||
val result = availableTagsDeferred.await()
|
||||
if (result == null && shouldRetryOnError) {
|
||||
availableTagsDeferred = loadTagsAsync()
|
||||
return availableTagsDeferred.await()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
|
||||
runCatching {
|
||||
repository.getTags()
|
||||
}.onFailure { error ->
|
||||
if (BuildConfig.DEBUG) {
|
||||
error.printStackTrace()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> {
|
||||
val result = TreeSet(TagTitleComparator())
|
||||
result.addAll(secondary)
|
||||
result.addAll(primary)
|
||||
return result
|
||||
}
|
||||
|
||||
private class TagsWrapper(
|
||||
val tags: Set<MangaTag>,
|
||||
val isLoading: Boolean,
|
||||
val isError: Boolean,
|
||||
) {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as TagsWrapper
|
||||
|
||||
if (tags != other.tags) return false
|
||||
if (isLoading != other.isLoading) return false
|
||||
if (isError != other.isError) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = tags.hashCode()
|
||||
result = 31 * result + isLoading.hashCode()
|
||||
result = 31 * result + isError.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private class TagTitleComparator : Comparator<MangaTag> {
|
||||
|
||||
override fun compare(o1: MangaTag, o2: MangaTag) = compareValues(
|
||||
o1.title.lowercase(),
|
||||
o2.title.lowercase(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,9 @@ class FilterDiffCallback : DiffUtil.ItemCallback<FilterItem>() {
|
||||
override fun areContentsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
|
||||
return when {
|
||||
oldItem == FilterItem.Loading && newItem == FilterItem.Loading -> true
|
||||
oldItem is FilterItem.Header && newItem is FilterItem.Header -> true
|
||||
oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
|
||||
oldItem.counter == newItem.counter
|
||||
}
|
||||
oldItem is FilterItem.Error && newItem is FilterItem.Error -> true
|
||||
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
|
||||
oldItem.isChecked == newItem.isChecked
|
||||
@@ -40,15 +42,18 @@ class FilterDiffCallback : DiffUtil.ItemCallback<FilterItem>() {
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: FilterItem, newItem: FilterItem): Any? {
|
||||
val isCheckedChanged = when {
|
||||
val hasPayload = when {
|
||||
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
|
||||
oldItem.isChecked != newItem.isChecked
|
||||
}
|
||||
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
|
||||
oldItem.isSelected != newItem.isSelected
|
||||
}
|
||||
oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
|
||||
oldItem.counter != newItem.counter
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
return if (isCheckedChanged) Unit else super.getChangePayload(oldItem, newItem)
|
||||
return if (hasPayload) Unit else super.getChangePayload(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ sealed interface FilterItem {
|
||||
|
||||
class Header(
|
||||
@StringRes val titleResId: Int,
|
||||
val counter: Int,
|
||||
) : FilterItem
|
||||
|
||||
class Sort(
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
package org.koitharu.kotatsu.list.ui.filter
|
||||
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.*
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.replaceWith
|
||||
import java.util.*
|
||||
|
||||
class FilterViewModel(
|
||||
private val repository: RemoteMangaRepository,
|
||||
dataRepository: MangaDataRepository,
|
||||
) : BaseViewModel(), OnFilterChangedListener {
|
||||
|
||||
val filter = MutableLiveData<List<FilterItem>>()
|
||||
val result = MutableLiveData<FilterState>()
|
||||
private var job: Job? = null
|
||||
private var selectedSortOrder: SortOrder? = repository.sortOrders.firstOrNull()
|
||||
private val selectedTags = HashSet<MangaTag>()
|
||||
private var searchQuery: String = ""
|
||||
private val localTagsDeferred = viewModelScope.async(Dispatchers.Default) {
|
||||
dataRepository.findTags(repository.source)
|
||||
}
|
||||
private var availableTagsDeferred = loadTagsAsync()
|
||||
|
||||
override fun onSortItemClick(item: FilterItem.Sort) {
|
||||
selectedSortOrder = item.order
|
||||
updateFilters(updateResults = true)
|
||||
}
|
||||
|
||||
override fun onTagItemClick(item: FilterItem.Tag) {
|
||||
val isModified = if (item.isChecked) {
|
||||
selectedTags.remove(item.tag)
|
||||
} else {
|
||||
selectedTags.add(item.tag)
|
||||
}
|
||||
if (isModified) {
|
||||
updateFilters(updateResults = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateState(state: FilterState?) {
|
||||
if (state != null) {
|
||||
selectedSortOrder = state.sortOrder
|
||||
selectedTags.replaceWith(state.tags)
|
||||
}
|
||||
if (job == null) {
|
||||
showFilter()
|
||||
} else {
|
||||
updateFilters(updateResults = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun performSearch(query: String) {
|
||||
if (searchQuery != query) {
|
||||
searchQuery = query
|
||||
updateFilters(updateResults = false)
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
private fun updateFilters(updateResults: Boolean) {
|
||||
val previousJob = job
|
||||
val query = searchQuery
|
||||
job = launchJob(Dispatchers.Default) {
|
||||
previousJob?.cancelAndJoin()
|
||||
if (query.isNotEmpty()) {
|
||||
showFilteredTags(query)
|
||||
return@launchJob
|
||||
}
|
||||
val tags = tryLoadTags()
|
||||
val localTags = localTagsDeferred.await()
|
||||
val sortOrders = repository.sortOrders
|
||||
val list = ArrayList<FilterItem>(sortOrders.size + (tags?.size ?: 1) + 2)
|
||||
list.add(FilterItem.Header(R.string.sort_order))
|
||||
sortOrders.sortedBy { it.ordinal }.mapTo(list) {
|
||||
FilterItem.Sort(it, isSelected = it == selectedSortOrder)
|
||||
}
|
||||
if (tags == null || tags.isNotEmpty() || selectedTags.isNotEmpty()) {
|
||||
list.add(FilterItem.Header(R.string.genres))
|
||||
val mappedTags = TreeSet<FilterItem.Tag>(compareBy({ !it.isChecked }, { it.tag.title }))
|
||||
localTags.mapTo(mappedTags) { FilterItem.Tag(it, isChecked = it in selectedTags) }
|
||||
tags?.mapTo(mappedTags) { FilterItem.Tag(it, isChecked = it in selectedTags) }
|
||||
selectedTags.mapTo(mappedTags) { FilterItem.Tag(it, isChecked = true) }
|
||||
list.addAll(mappedTags)
|
||||
if (tags == null) {
|
||||
list.add(FilterItem.Error(R.string.filter_load_error))
|
||||
}
|
||||
}
|
||||
ensureActive()
|
||||
filter.postValue(list)
|
||||
}
|
||||
if (updateResults) {
|
||||
result.postValue(FilterState(selectedSortOrder, selectedTags))
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFilter() {
|
||||
job = launchJob(Dispatchers.Default) {
|
||||
val sortOrders = repository.sortOrders
|
||||
val list = ArrayList<FilterItem>(sortOrders.size + selectedTags.size + 3)
|
||||
list.add(FilterItem.Header(R.string.sort_order))
|
||||
sortOrders.sortedBy { it.ordinal }.mapTo(list) {
|
||||
FilterItem.Sort(it, isSelected = it == selectedSortOrder)
|
||||
}
|
||||
if (selectedTags.isNotEmpty()) {
|
||||
list.add(FilterItem.Header(R.string.genres))
|
||||
selectedTags.sortedBy { it.title }.mapTo(list) {
|
||||
FilterItem.Tag(it, isChecked = it in selectedTags)
|
||||
}
|
||||
}
|
||||
list.add(FilterItem.Loading)
|
||||
filter.postValue(list)
|
||||
updateFilters(updateResults = false)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private suspend fun showFilteredTags(query: String) {
|
||||
val tags = tryLoadTags()
|
||||
val localTags = localTagsDeferred.await()
|
||||
val list = ArrayList<FilterItem>()
|
||||
val mappedTags = TreeSet<FilterItem.Tag>(compareBy({ !it.isChecked }, { it.tag.title }))
|
||||
localTags.mapNotNullTo(mappedTags) {
|
||||
if (it.title.contains(query, ignoreCase = true)) {
|
||||
FilterItem.Tag(it, isChecked = it in selectedTags)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
tags?.mapNotNullTo(mappedTags) {
|
||||
if (it.title.contains(query, ignoreCase = true)) {
|
||||
FilterItem.Tag(it, isChecked = it in selectedTags)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
selectedTags.mapNotNullTo(mappedTags) {
|
||||
if (it.title.contains(query, ignoreCase = true)) {
|
||||
FilterItem.Tag(it, isChecked = true)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
list.addAll(mappedTags)
|
||||
if (tags == null) {
|
||||
list.add(FilterItem.Error(R.string.filter_load_error))
|
||||
}
|
||||
if (list.isEmpty()) {
|
||||
list.add(FilterItem.Error(R.string.nothing_found))
|
||||
}
|
||||
currentCoroutineContext().ensureActive()
|
||||
filter.postValue(list)
|
||||
}
|
||||
|
||||
private suspend fun tryLoadTags(): Set<MangaTag>? {
|
||||
val shouldRetryOnError = availableTagsDeferred.isCompleted
|
||||
val result = availableTagsDeferred.await()
|
||||
if (result == null && shouldRetryOnError) {
|
||||
availableTagsDeferred = loadTagsAsync()
|
||||
return availableTagsDeferred.await()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun loadTagsAsync() = viewModelScope.async(Dispatchers.Default) {
|
||||
runCatching {
|
||||
repository.getTags()
|
||||
}.onFailure { error ->
|
||||
if (BuildConfig.DEBUG) {
|
||||
error.printStackTrace()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.list.ui.filter.FilterViewModel
|
||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
|
||||
|
||||
val remoteListModule
|
||||
@@ -16,12 +15,6 @@ val remoteListModule
|
||||
RemoteListViewModel(
|
||||
repository = get<MangaRepository>(named(params.get<MangaSource>())) as RemoteMangaRepository,
|
||||
settings = get(),
|
||||
)
|
||||
}
|
||||
|
||||
viewModel { params ->
|
||||
FilterViewModel(
|
||||
repository = get<MangaRepository>(named(params.get<MangaSource>())) as RemoteMangaRepository,
|
||||
dataRepository = get(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
package org.koitharu.kotatsu.remotelist.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.fragment.app.FragmentResultListener
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.list.ui.filter.FilterBottomSheet
|
||||
import org.koitharu.kotatsu.list.ui.filter.FilterState
|
||||
import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity
|
||||
import org.koitharu.kotatsu.utils.ext.parcelableArgument
|
||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||
|
||||
class RemoteListFragment : MangaListFragment(), FragmentResultListener {
|
||||
class RemoteListFragment : MangaListFragment() {
|
||||
|
||||
override val viewModel by viewModel<RemoteListViewModel> {
|
||||
parametersOf(source)
|
||||
@@ -25,11 +21,6 @@ class RemoteListFragment : MangaListFragment(), FragmentResultListener {
|
||||
|
||||
private val source by parcelableArgument<MangaSource>(ARG_SOURCE)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
childFragmentManager.setFragmentResultListener(FilterBottomSheet.REQUEST_KEY, viewLifecycleOwner, this)
|
||||
}
|
||||
|
||||
override fun onScrolledToEnd() {
|
||||
viewModel.loadNextPage()
|
||||
}
|
||||
@@ -63,19 +54,11 @@ class RemoteListFragment : MangaListFragment(), FragmentResultListener {
|
||||
}
|
||||
|
||||
override fun onFilterClick() {
|
||||
FilterBottomSheet.show(childFragmentManager, source, viewModel.filter)
|
||||
FilterBottomSheet.show(childFragmentManager)
|
||||
}
|
||||
|
||||
override fun onEmptyActionClick() {
|
||||
viewModel.applyFilter(FilterState(viewModel.filter.sortOrder, emptySet()))
|
||||
}
|
||||
|
||||
override fun onFragmentResult(requestKey: String, result: Bundle) {
|
||||
when (requestKey) {
|
||||
FilterBottomSheet.REQUEST_KEY -> viewModel.applyFilter(
|
||||
result.getParcelable(FilterBottomSheet.ARG_STATE) ?: return
|
||||
)
|
||||
}
|
||||
viewModel.resetFilter()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,100 +1,116 @@
|
||||
package org.koitharu.kotatsu.remotelist.ui
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
||||
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.filter.FilterCoordinator
|
||||
import org.koitharu.kotatsu.list.ui.filter.FilterItem
|
||||
import org.koitharu.kotatsu.list.ui.filter.FilterState
|
||||
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
|
||||
import org.koitharu.kotatsu.list.ui.model.*
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
|
||||
private const val FILTER_MIN_INTERVAL = 750L
|
||||
|
||||
class RemoteListViewModel(
|
||||
private val repository: RemoteMangaRepository,
|
||||
settings: AppSettings
|
||||
) : MangaListViewModel(settings) {
|
||||
settings: AppSettings,
|
||||
dataRepository: MangaDataRepository,
|
||||
) : MangaListViewModel(settings), OnFilterChangedListener {
|
||||
|
||||
var filter = FilterState(repository.sortOrders.firstOrNull(), emptySet())
|
||||
private set
|
||||
private val filter = FilterCoordinator(repository, dataRepository, viewModelScope)
|
||||
private val mangaList = MutableStateFlow<List<Manga>?>(null)
|
||||
private val hasNextPage = MutableStateFlow(false)
|
||||
private val listError = MutableStateFlow<Throwable?>(null)
|
||||
private var loadingJob: Job? = null
|
||||
private val headerModel = MutableStateFlow(
|
||||
ListHeader(repository.title, 0, filter.sortOrder)
|
||||
)
|
||||
|
||||
val filterItems: LiveData<List<FilterItem>>
|
||||
get() = filter.items
|
||||
|
||||
override val content = combine(
|
||||
mangaList,
|
||||
createListModeFlow(),
|
||||
headerModel,
|
||||
filter.observeState(),
|
||||
listError,
|
||||
hasNextPage
|
||||
) { list, mode, header, error, hasNext ->
|
||||
when {
|
||||
list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true))
|
||||
list == null -> listOf(LoadingState)
|
||||
list.isEmpty() -> createEmptyState()
|
||||
else -> {
|
||||
val result = ArrayList<ListModel>(list.size + 3)
|
||||
result += header
|
||||
createFilterModel()?.let { result.add(it) }
|
||||
list.toUi(result, mode)
|
||||
when {
|
||||
error != null -> result += error.toErrorFooter()
|
||||
hasNext -> result += LoadingFooter
|
||||
hasNextPage,
|
||||
) { list, mode, filterState, error, hasNext ->
|
||||
buildList(list?.size?.plus(3) ?: 3) {
|
||||
add(ListHeader(repository.title, 0, filterState.sortOrder))
|
||||
createFilterModel(filterState)?.let { add(it) }
|
||||
when {
|
||||
list.isNullOrEmpty() && error != null -> add(error.toErrorState(canRetry = true))
|
||||
list == null -> add(LoadingState)
|
||||
list.isEmpty() -> add(createEmptyState(filterState))
|
||||
else -> {
|
||||
list.toUi(this, mode)
|
||||
when {
|
||||
error != null -> add(error.toErrorFooter())
|
||||
hasNext -> add(LoadingFooter)
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
|
||||
}.asLiveDataDistinct(
|
||||
viewModelScope.coroutineContext + Dispatchers.Default,
|
||||
listOf(ListHeader(repository.title, 0, null), LoadingState),
|
||||
)
|
||||
|
||||
init {
|
||||
loadList(false)
|
||||
filter.observeState()
|
||||
.debounce(FILTER_MIN_INTERVAL)
|
||||
.onEach { filterState ->
|
||||
loadingJob?.cancelAndJoin()
|
||||
mangaList.value = null
|
||||
hasNextPage.value = false
|
||||
loadList(filterState, false)
|
||||
}.catch { error ->
|
||||
listError.value = error
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
loadList(append = false)
|
||||
loadList(filter.snapshot(), append = false)
|
||||
}
|
||||
|
||||
override fun onRetry() {
|
||||
loadList(append = !mangaList.value.isNullOrEmpty())
|
||||
loadList(filter.snapshot(), append = !mangaList.value.isNullOrEmpty())
|
||||
}
|
||||
|
||||
override fun onRemoveFilterTag(tag: MangaTag) {
|
||||
val tags = filter.tags
|
||||
if (tag !in tags) {
|
||||
return
|
||||
}
|
||||
applyFilter(FilterState(filter.sortOrder, tags - tag))
|
||||
filter.removeTag(tag)
|
||||
}
|
||||
|
||||
override fun onSortItemClick(item: FilterItem.Sort) {
|
||||
filter.onSortItemClick(item)
|
||||
}
|
||||
|
||||
override fun onTagItemClick(item: FilterItem.Tag) {
|
||||
filter.onTagItemClick(item)
|
||||
}
|
||||
|
||||
fun loadNextPage() {
|
||||
if (hasNextPage.value && listError.value == null) {
|
||||
loadList(append = true)
|
||||
loadList(filter.snapshot(), append = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun applyFilter(newFilter: FilterState) {
|
||||
if (filter == newFilter) {
|
||||
return
|
||||
}
|
||||
filter = newFilter
|
||||
headerModel.value = ListHeader(repository.title, 0, newFilter.sortOrder)
|
||||
mangaList.value = null
|
||||
hasNextPage.value = false
|
||||
loadList(false)
|
||||
}
|
||||
fun filterSearch(query: String) = filter.performSearch(query)
|
||||
|
||||
private fun loadList(append: Boolean) {
|
||||
fun resetFilter() = filter.reset()
|
||||
|
||||
private fun loadList(filterState: FilterState, append: Boolean) {
|
||||
if (loadingJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
@@ -103,8 +119,8 @@ class RemoteListViewModel(
|
||||
listError.value = null
|
||||
val list = repository.getList2(
|
||||
offset = if (append) mangaList.value?.size ?: 0 else 0,
|
||||
sortOrder = filter.sortOrder,
|
||||
tags = filter.tags,
|
||||
sortOrder = filterState.sortOrder,
|
||||
tags = filterState.tags,
|
||||
)
|
||||
if (!append) {
|
||||
mangaList.value = list
|
||||
@@ -121,21 +137,18 @@ class RemoteListViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFilterModel(): CurrentFilterModel? {
|
||||
val tags = filter.tags
|
||||
return if (tags.isEmpty()) {
|
||||
private fun createFilterModel(filterState: FilterState): CurrentFilterModel? {
|
||||
return if (filterState.tags.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
CurrentFilterModel(tags.map { ChipsView.ChipModel(0, it.title, it) })
|
||||
CurrentFilterModel(filterState.tags.map { ChipsView.ChipModel(0, it.title, it) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun createEmptyState() = listOf(
|
||||
EmptyState(
|
||||
icon = R.drawable.ic_book_cross,
|
||||
textPrimary = R.string.nothing_found,
|
||||
textSecondary = 0,
|
||||
actionStringRes = if (filter.tags.isEmpty()) 0 else R.string.reset_filter,
|
||||
)
|
||||
private fun createEmptyState(filterState: FilterState) = EmptyState(
|
||||
icon = R.drawable.ic_book_cross,
|
||||
textPrimary = R.string.nothing_found,
|
||||
textSecondary = 0,
|
||||
actionStringRes = if (filterState.tags.isEmpty()) 0 else R.string.reset_filter,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ fun sourceConfigHeaderDelegate() = adapterDelegateViewBinding<SourceConfigItem.H
|
||||
) {
|
||||
|
||||
bind {
|
||||
binding.root.setText(item.titleResId)
|
||||
binding.textViewTitle.setText(item.titleResId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ var TextView.drawableStart: Drawable?
|
||||
setCompoundDrawablesRelativeWithIntrinsicBounds(value, dr[1], dr[2], dr[3])
|
||||
}
|
||||
|
||||
var TextView.drawableEnd: Drawable?
|
||||
inline get() = compoundDrawablesRelative[2]
|
||||
set(value) {
|
||||
val dr = compoundDrawablesRelative
|
||||
setCompoundDrawablesRelativeWithIntrinsicBounds(dr[0], dr[1], value, dr[3])
|
||||
}
|
||||
|
||||
fun TextView.setTextAndVisible(@StringRes textResId: Int) {
|
||||
if (textResId == 0) {
|
||||
text = null
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="3"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
@@ -67,6 +68,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="3"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
app:layout_constraintEnd_toEndOf="@id/textView_title"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="5"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
@@ -68,11 +69,12 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="4"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
app:layout_constraintEnd_toEndOf="@id/textView_title"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/textView_title"
|
||||
tools:text="@tools:sample/lorem[2]" />
|
||||
tools:text="@tools:sample/lorem[30]" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_author"
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="3"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
@@ -67,6 +68,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="3"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
app:layout_constraintEnd_toEndOf="@id/textView_title"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/grid_spacing_outer"
|
||||
app:fastScrollEnabled="true"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layoutManager="org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager"
|
||||
tools:listitem="@layout/item_manga_list" />
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
@@ -1,13 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical|start"
|
||||
android:minHeight="@dimen/header_height"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||
tools:text="@tools:sample/lorem[2]" />
|
||||
android:paddingEnd="?android:listPreferredItemPaddingEnd">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||
tools:text="@tools:sample/lorem[2]" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/badge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:background="@drawable/badge"
|
||||
android:paddingHorizontal="6dp"
|
||||
android:paddingVertical="2dp"
|
||||
android:textColor="?attr/colorOnTertiary"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone"
|
||||
tools:text="54"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</LinearLayout>
|
||||
Reference in New Issue
Block a user