Fix filter
parent
d5d19c37d8
commit
755f1e5747
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +1,36 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<TextView
|
<LinearLayout
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center_vertical|start"
|
android:gravity="center_vertical|start"
|
||||||
android:minHeight="@dimen/header_height"
|
android:minHeight="@dimen/header_height"
|
||||||
|
android:orientation="horizontal"
|
||||||
android:paddingStart="?android:listPreferredItemPaddingStart"
|
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||||
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
android:paddingEnd="?android:listPreferredItemPaddingEnd">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||||
tools:text="@tools:sample/lorem[2]" />
|
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>
|
||||||
Loading…
Reference in New Issue