Refactor quick filter implementation
This commit is contained in:
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -4,6 +4,7 @@
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
|
||||
@@ -51,9 +52,19 @@ abstract class HistoryDao {
|
||||
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
|
||||
"WHERE history.deleted_at = 0",
|
||||
)
|
||||
for (option in filterOptions) {
|
||||
val groupedOptions = filterOptions.groupBy { it.groupKey }
|
||||
for ((_, group) in groupedOptions) {
|
||||
if (group.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
append(" AND ")
|
||||
append(option.getCondition())
|
||||
if (group.size > 1) {
|
||||
group.joinTo(this, separator = " OR ", prefix = "(", postfix = ")") {
|
||||
it.getCondition()
|
||||
}
|
||||
} else {
|
||||
append(group.single().getCondition())
|
||||
}
|
||||
}
|
||||
append(" GROUP BY history.manga_id ORDER BY ")
|
||||
append(orderBy)
|
||||
@@ -159,9 +170,11 @@ abstract class HistoryDao {
|
||||
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<HistoryWithManga>>
|
||||
|
||||
private fun ListFilterOption.getCondition(): String = when (this) {
|
||||
ListFilterOption.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = history.manga_id) > 0"
|
||||
ListFilterOption.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)"
|
||||
ListFilterOption.COMPLETED -> "percent >= 0.9999"
|
||||
ListFilterOption.DOWNLOADED -> throw IllegalArgumentException("Unsupported option $this")
|
||||
ListFilterOption.Downloaded -> throw IllegalArgumentException("Unsupported option $this")
|
||||
is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id AND category_id = ${category.id})"
|
||||
ListFilterOption.Macro.COMPLETED -> "percent >= 0.9999"
|
||||
ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = history.manga_id) > 0"
|
||||
ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)"
|
||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE history.manga_id = manga_tags.manga_id AND tag_id = ${tag.toEntity().id})"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.koitharu.kotatsu.history.domain
|
||||
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.MangaListQuickFilter
|
||||
import javax.inject.Inject
|
||||
|
||||
class HistoryListQuickFilter @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
private val repository: HistoryRepository,
|
||||
) : MangaListQuickFilter() {
|
||||
|
||||
override suspend fun getAvailableFilterOptions(): List<ListFilterOption> = buildList {
|
||||
add(ListFilterOption.Downloaded)
|
||||
if (settings.isTrackerEnabled) {
|
||||
add(ListFilterOption.Macro.NEW_CHAPTERS)
|
||||
}
|
||||
add(ListFilterOption.Macro.COMPLETED)
|
||||
add(ListFilterOption.Macro.FAVORITE)
|
||||
repository.getPopularTags(3).mapTo(this) {
|
||||
ListFilterOption.Tag(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
|
||||
|
||||
@@ -35,10 +34,6 @@ class HistoryListFragment : MangaListFragment() {
|
||||
|
||||
override fun onScrolledToEnd() = viewModel.requestMoreItems()
|
||||
|
||||
override fun onFilterOptionClick(option: ListFilterOption) {
|
||||
viewModel.onFilterOptionClick(option)
|
||||
}
|
||||
|
||||
override fun onEmptyActionClick() {
|
||||
startActivity(NetworkManageIntent())
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package org.koitharu.kotatsu.history.ui
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -21,31 +24,30 @@ import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.combine
|
||||
import org.koitharu.kotatsu.core.util.ext.onFirst
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.history.domain.HistoryListQuickFilter
|
||||
import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase
|
||||
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
import org.koitharu.kotatsu.list.domain.MangaListMapper
|
||||
import org.koitharu.kotatsu.list.domain.QuickFilterListener
|
||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyHint
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.list.ui.model.QuickFilter
|
||||
import org.koitharu.kotatsu.list.ui.model.TipModel
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.time.Instant
|
||||
import java.util.EnumSet
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -58,9 +60,10 @@ class HistoryListViewModel @Inject constructor(
|
||||
private val mangaListMapper: MangaListMapper,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val markAsReadUseCase: MarkAsReadUseCase,
|
||||
private val quickFilter: HistoryListQuickFilter,
|
||||
networkState: NetworkState,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
) : MangaListViewModel(settings, downloadScheduler) {
|
||||
) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener by quickFilter {
|
||||
|
||||
private val sortOrder: StateFlow<ListSortOrder> = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.IO,
|
||||
@@ -68,8 +71,6 @@ class HistoryListViewModel @Inject constructor(
|
||||
valueProducer = { historySortOrder },
|
||||
)
|
||||
|
||||
private val filterOptions = MutableStateFlow<Set<ListFilterOption>>(EnumSet.noneOf(ListFilterOption::class.java))
|
||||
|
||||
override val listMode = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_LIST_MODE_HISTORY,
|
||||
@@ -93,7 +94,7 @@ class HistoryListViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
override val content = combine(
|
||||
filterOptions,
|
||||
quickFilter.appliedOptions,
|
||||
observeHistory(),
|
||||
isGroupingEnabled,
|
||||
observeListModeWithTriggers(),
|
||||
@@ -105,7 +106,7 @@ class HistoryListViewModel @Inject constructor(
|
||||
if (filters.isEmpty()) {
|
||||
listOf(getEmptyState(hasFilters = false))
|
||||
} else {
|
||||
listOf(filterItem(filters), getEmptyState(hasFilters = true))
|
||||
listOf(quickFilter.filterItem(filters), getEmptyState(hasFilters = true))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,8 +119,8 @@ class HistoryListViewModel @Inject constructor(
|
||||
loadingCounter.increment()
|
||||
}.onFirst {
|
||||
loadingCounter.decrement()
|
||||
}.catch {
|
||||
emit(listOf(it.toErrorState(canRetry = false)))
|
||||
}.catch { e ->
|
||||
emit(listOf(e.toErrorState(canRetry = false)))
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||
|
||||
override fun onRefresh() = Unit
|
||||
@@ -161,29 +162,24 @@ class HistoryListViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun onFilterOptionClick(option: ListFilterOption) {
|
||||
filterOptions.value = EnumSet.copyOf(filterOptions.value).also {
|
||||
if (option in it) {
|
||||
it.remove(option)
|
||||
} else {
|
||||
it.add(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeHistory() = combine(sortOrder, filterOptions, limit, ::Triple)
|
||||
.flatMapLatest { repository.observeAllWithHistory(it.first, it.second - ListFilterOption.DOWNLOADED, it.third) }
|
||||
private fun observeHistory() = combine(sortOrder, quickFilter.appliedOptions, limit, ::Triple)
|
||||
.flatMapLatest { repository.observeAllWithHistory(it.first, it.second - ListFilterOption.Downloaded, it.third) }
|
||||
|
||||
private suspend fun mapList(
|
||||
filters: Set<ListFilterOption>,
|
||||
list: List<MangaWithHistory>,
|
||||
historyList: List<MangaWithHistory>,
|
||||
grouped: Boolean,
|
||||
mode: ListMode,
|
||||
isOnline: Boolean,
|
||||
isIncognito: Boolean,
|
||||
): List<ListModel> {
|
||||
val list = if (!isOnline || ListFilterOption.Downloaded in filters) {
|
||||
historyList.mapToLocal()
|
||||
} else {
|
||||
historyList
|
||||
}
|
||||
val result = ArrayList<ListModel>((if (grouped) (list.size * 1.4).toInt() else list.size) + 3)
|
||||
result += filterItem(filters)
|
||||
result += quickFilter.filterItem(filters)
|
||||
if (isIncognito) {
|
||||
result += TipModel(
|
||||
key = AppSettings.KEY_INCOGNITO_MODE,
|
||||
@@ -205,12 +201,7 @@ class HistoryListViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
var isEmpty = true
|
||||
for ((m, history) in list) {
|
||||
val manga = if ((!isOnline && !m.isLocal) || ListFilterOption.DOWNLOADED in filters) {
|
||||
localMangaRepository.findSavedManga(m)?.manga ?: continue
|
||||
} else {
|
||||
m
|
||||
}
|
||||
for ((manga, history) in list) {
|
||||
isEmpty = false
|
||||
if (grouped) {
|
||||
val header = history.header(order)
|
||||
@@ -229,6 +220,20 @@ class HistoryListViewModel @Inject constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun List<MangaWithHistory>.mapToLocal() = coroutineScope {
|
||||
map {
|
||||
async {
|
||||
if (it.manga.isLocal) {
|
||||
it
|
||||
} else {
|
||||
localMangaRepository.findSavedManga(it.manga)?.let { localManga ->
|
||||
MangaWithHistory(localManga.manga, it.history)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
||||
private fun MangaHistory.header(order: ListSortOrder): ListHeader? = when (order) {
|
||||
ListSortOrder.LAST_READ,
|
||||
ListSortOrder.LONG_AGO_READ -> ListHeader(calculateTimeAgo(updatedAt))
|
||||
@@ -254,18 +259,6 @@ class HistoryListViewModel @Inject constructor(
|
||||
ListSortOrder.RATING -> null
|
||||
}
|
||||
|
||||
private fun filterItem(selected: Set<ListFilterOption>) = QuickFilter(
|
||||
items = ListFilterOption.HISTORY.map { option ->
|
||||
ChipsView.ChipModel(
|
||||
titleResId = option.titleResId,
|
||||
icon = option.iconResId,
|
||||
isCheckable = true,
|
||||
isChecked = option in selected,
|
||||
data = option,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private fun getEmptyState(hasFilters: Boolean) = if (hasFilters) {
|
||||
EmptyState(
|
||||
icon = R.drawable.ic_empty_history,
|
||||
|
||||
@@ -3,26 +3,84 @@ package org.koitharu.kotatsu.list.domain
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.R
|
||||
import java.util.EnumSet
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
enum class ListFilterOption(
|
||||
@StringRes val titleResId: Int,
|
||||
@DrawableRes val iconResId: Int,
|
||||
) {
|
||||
sealed interface ListFilterOption {
|
||||
|
||||
DOWNLOADED(R.string.on_device, R.drawable.ic_storage),
|
||||
COMPLETED(R.string.status_completed, R.drawable.ic_state_finished),
|
||||
NEW_CHAPTERS(R.string.new_chapters, R.drawable.ic_updated),
|
||||
FAVORITE(R.string.favourites, R.drawable.ic_heart_outline),
|
||||
;
|
||||
@get:StringRes
|
||||
val titleResId: Int
|
||||
|
||||
companion object {
|
||||
@get:DrawableRes
|
||||
val iconResId: Int
|
||||
|
||||
val HISTORY: Set<ListFilterOption> = EnumSet.of(
|
||||
DOWNLOADED,
|
||||
NEW_CHAPTERS,
|
||||
FAVORITE,
|
||||
COMPLETED,
|
||||
)
|
||||
val titleText: CharSequence?
|
||||
|
||||
val groupKey: String
|
||||
|
||||
data object Downloaded : ListFilterOption {
|
||||
|
||||
override val titleResId: Int
|
||||
get() = R.string.on_device
|
||||
|
||||
override val iconResId: Int
|
||||
get() = R.drawable.ic_storage
|
||||
|
||||
override val titleText: CharSequence?
|
||||
get() = null
|
||||
|
||||
override val groupKey: String
|
||||
get() = "_downloaded"
|
||||
}
|
||||
|
||||
enum class Macro(
|
||||
@StringRes override val titleResId: Int,
|
||||
@DrawableRes override val iconResId: Int,
|
||||
) : ListFilterOption {
|
||||
|
||||
COMPLETED(R.string.status_completed, R.drawable.ic_state_finished),
|
||||
NEW_CHAPTERS(R.string.new_chapters, R.drawable.ic_updated),
|
||||
FAVORITE(R.string.favourites, R.drawable.ic_heart_outline),
|
||||
;
|
||||
|
||||
override val titleText: CharSequence?
|
||||
get() = null
|
||||
|
||||
override val groupKey: String
|
||||
get() = name
|
||||
}
|
||||
|
||||
data class Tag(
|
||||
val tag: MangaTag
|
||||
) : ListFilterOption {
|
||||
|
||||
override val titleResId: Int
|
||||
get() = 0
|
||||
|
||||
override val iconResId: Int
|
||||
get() = R.drawable.ic_tag
|
||||
|
||||
override val titleText: String
|
||||
get() = tag.title
|
||||
|
||||
override val groupKey: String
|
||||
get() = "_tag"
|
||||
}
|
||||
|
||||
data class Favorite(
|
||||
val category: FavouriteCategory
|
||||
) : ListFilterOption {
|
||||
|
||||
override val titleResId: Int
|
||||
get() = 0
|
||||
|
||||
override val iconResId: Int
|
||||
get() = R.drawable.ic_heart_outline
|
||||
|
||||
override val titleText: String
|
||||
get() = category.title
|
||||
|
||||
override val groupKey: String
|
||||
get() = "_favcat"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.koitharu.kotatsu.list.domain
|
||||
|
||||
import androidx.collection.ArraySet
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.list.ui.model.QuickFilter
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
|
||||
abstract class MangaListQuickFilter : QuickFilterListener {
|
||||
|
||||
private val appliedFilter = MutableStateFlow<Set<ListFilterOption>>(emptySet())
|
||||
private val availableFilterOptions = SuspendLazy {
|
||||
getAvailableFilterOptions()
|
||||
}
|
||||
|
||||
val appliedOptions
|
||||
get() = appliedFilter.asStateFlow()
|
||||
|
||||
override fun toggleFilterOption(option: ListFilterOption) {
|
||||
appliedFilter.value = ArraySet(appliedFilter.value).also {
|
||||
if (option in it) {
|
||||
it.remove(option)
|
||||
} else {
|
||||
it.add(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearFilter() {
|
||||
appliedFilter.value = emptySet()
|
||||
}
|
||||
|
||||
suspend fun filterItem(
|
||||
selectedOptions: Set<ListFilterOption>,
|
||||
) = QuickFilter(
|
||||
items = availableFilterOptions.tryGet().getOrNull()?.map { option ->
|
||||
ChipsView.ChipModel(
|
||||
title = option.titleText,
|
||||
titleResId = option.titleResId,
|
||||
icon = option.iconResId,
|
||||
isCheckable = true,
|
||||
isChecked = option in selectedOptions,
|
||||
data = option,
|
||||
)
|
||||
}.orEmpty(),
|
||||
)
|
||||
|
||||
protected abstract suspend fun getAvailableFilterOptions(): List<ListFilterOption>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.list.domain
|
||||
|
||||
interface QuickFilterListener {
|
||||
|
||||
fun toggleFilterOption(option: ListFilterOption)
|
||||
|
||||
fun clearFilter()
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.QuickFilterListener
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
|
||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
|
||||
@@ -227,7 +228,9 @@ abstract class MangaListFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFilterOptionClick(option: ListFilterOption) = Unit
|
||||
override fun onFilterOptionClick(option: ListFilterOption) {
|
||||
(viewModel as? QuickFilterListener)?.toggleFilterOption(option)
|
||||
}
|
||||
|
||||
override fun onFilterClick(view: View?) = Unit
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ abstract class MangaListViewModel(
|
||||
listMode,
|
||||
settings.observe().filter { key ->
|
||||
key == AppSettings.KEY_PROGRESS_INDICATORS || key == AppSettings.KEY_TRACKER_ENABLED
|
||||
}.onStart { emit("") }
|
||||
}.onStart { emit("") },
|
||||
) { mode, _ ->
|
||||
mode
|
||||
}
|
||||
|
||||
12
app/src/main/res/drawable/ic_tag.xml
Normal file
12
app/src/main/res/drawable/ic_tag.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M21.41 11.58L12.41 2.58A2 2 0 0 0 11 2H4A2 2 0 0 0 2 4V11A2 2 0 0 0 2.59 12.42L11.59 21.42A2 2 0 0 0 13 22A2 2 0 0 0 14.41 21.41L21.41 14.41A2 2 0 0 0 22 13A2 2 0 0 0 21.41 11.58M13 20L4 11V4H11L20 13M6.5 5A1.5 1.5 0 1 1 5 6.5A1.5 1.5 0 0 1 6.5 5Z" />
|
||||
</vector>
|
||||
Reference in New Issue
Block a user