Refactor quick filter implementation

This commit is contained in:
Koitharu
2024-08-04 10:22:49 +03:00
parent d00822a6c3
commit 8b71f99666
11 changed files with 230 additions and 72 deletions

1
.idea/gradle.xml generated
View File

@@ -4,6 +4,7 @@
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" /> <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">

View File

@@ -10,6 +10,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.TagEntity 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.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder 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 " + "SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
"WHERE history.deleted_at = 0", "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(" 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(" GROUP BY history.manga_id ORDER BY ")
append(orderBy) append(orderBy)
@@ -159,9 +170,11 @@ abstract class HistoryDao {
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<HistoryWithManga>> protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<HistoryWithManga>>
private fun ListFilterOption.getCondition(): String = when (this) { private fun ListFilterOption.getCondition(): String = when (this) {
ListFilterOption.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = history.manga_id) > 0" ListFilterOption.Downloaded -> throw IllegalArgumentException("Unsupported option $this")
ListFilterOption.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)" is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id AND category_id = ${category.id})"
ListFilterOption.COMPLETED -> "percent >= 0.9999" ListFilterOption.Macro.COMPLETED -> "percent >= 0.9999"
ListFilterOption.DOWNLOADED -> throw IllegalArgumentException("Unsupported option $this") 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})"
} }
} }

View File

@@ -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)
}
}
}

View File

@@ -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.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentListBinding 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.MangaListFragment
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
@@ -35,10 +34,6 @@ class HistoryListFragment : MangaListFragment() {
override fun onScrolledToEnd() = viewModel.requestMoreItems() override fun onScrolledToEnd() = viewModel.requestMoreItems()
override fun onFilterOptionClick(option: ListFilterOption) {
viewModel.onFilterOptionClick(option)
}
override fun onEmptyActionClick() { override fun onEmptyActionClick() {
startActivity(NetworkManageIntent()) startActivity(NetworkManageIntent())
} }

View File

@@ -3,6 +3,9 @@ package org.koitharu.kotatsu.history.ui
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers 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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow 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.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleAction 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.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.combine import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository 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.MarkAsReadUseCase
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.domain.MangaListMapper 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.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyHint import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState 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.TipModel
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant import java.time.Instant
import java.util.EnumSet
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
@@ -58,9 +60,10 @@ class HistoryListViewModel @Inject constructor(
private val mangaListMapper: MangaListMapper, private val mangaListMapper: MangaListMapper,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val markAsReadUseCase: MarkAsReadUseCase, private val markAsReadUseCase: MarkAsReadUseCase,
private val quickFilter: HistoryListQuickFilter,
networkState: NetworkState, networkState: NetworkState,
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) { ) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener by quickFilter {
private val sortOrder: StateFlow<ListSortOrder> = settings.observeAsStateFlow( private val sortOrder: StateFlow<ListSortOrder> = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.IO, scope = viewModelScope + Dispatchers.IO,
@@ -68,8 +71,6 @@ class HistoryListViewModel @Inject constructor(
valueProducer = { historySortOrder }, valueProducer = { historySortOrder },
) )
private val filterOptions = MutableStateFlow<Set<ListFilterOption>>(EnumSet.noneOf(ListFilterOption::class.java))
override val listMode = settings.observeAsStateFlow( override val listMode = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default, scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_LIST_MODE_HISTORY, key = AppSettings.KEY_LIST_MODE_HISTORY,
@@ -93,7 +94,7 @@ class HistoryListViewModel @Inject constructor(
) )
override val content = combine( override val content = combine(
filterOptions, quickFilter.appliedOptions,
observeHistory(), observeHistory(),
isGroupingEnabled, isGroupingEnabled,
observeListModeWithTriggers(), observeListModeWithTriggers(),
@@ -105,7 +106,7 @@ class HistoryListViewModel @Inject constructor(
if (filters.isEmpty()) { if (filters.isEmpty()) {
listOf(getEmptyState(hasFilters = false)) listOf(getEmptyState(hasFilters = false))
} else { } else {
listOf(filterItem(filters), getEmptyState(hasFilters = true)) listOf(quickFilter.filterItem(filters), getEmptyState(hasFilters = true))
} }
} }
@@ -118,8 +119,8 @@ class HistoryListViewModel @Inject constructor(
loadingCounter.increment() loadingCounter.increment()
}.onFirst { }.onFirst {
loadingCounter.decrement() loadingCounter.decrement()
}.catch { }.catch { e ->
emit(listOf(it.toErrorState(canRetry = false))) emit(listOf(e.toErrorState(canRetry = false)))
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
override fun onRefresh() = Unit override fun onRefresh() = Unit
@@ -161,29 +162,24 @@ class HistoryListViewModel @Inject constructor(
} }
} }
fun onFilterOptionClick(option: ListFilterOption) { private fun observeHistory() = combine(sortOrder, quickFilter.appliedOptions, limit, ::Triple)
filterOptions.value = EnumSet.copyOf(filterOptions.value).also { .flatMapLatest { repository.observeAllWithHistory(it.first, it.second - ListFilterOption.Downloaded, it.third) }
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 suspend fun mapList( private suspend fun mapList(
filters: Set<ListFilterOption>, filters: Set<ListFilterOption>,
list: List<MangaWithHistory>, historyList: List<MangaWithHistory>,
grouped: Boolean, grouped: Boolean,
mode: ListMode, mode: ListMode,
isOnline: Boolean, isOnline: Boolean,
isIncognito: Boolean, isIncognito: Boolean,
): List<ListModel> { ): 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) val result = ArrayList<ListModel>((if (grouped) (list.size * 1.4).toInt() else list.size) + 3)
result += filterItem(filters) result += quickFilter.filterItem(filters)
if (isIncognito) { if (isIncognito) {
result += TipModel( result += TipModel(
key = AppSettings.KEY_INCOGNITO_MODE, key = AppSettings.KEY_INCOGNITO_MODE,
@@ -205,12 +201,7 @@ class HistoryListViewModel @Inject constructor(
) )
} }
var isEmpty = true var isEmpty = true
for ((m, history) in list) { for ((manga, history) in list) {
val manga = if ((!isOnline && !m.isLocal) || ListFilterOption.DOWNLOADED in filters) {
localMangaRepository.findSavedManga(m)?.manga ?: continue
} else {
m
}
isEmpty = false isEmpty = false
if (grouped) { if (grouped) {
val header = history.header(order) val header = history.header(order)
@@ -229,6 +220,20 @@ class HistoryListViewModel @Inject constructor(
return result 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) { private fun MangaHistory.header(order: ListSortOrder): ListHeader? = when (order) {
ListSortOrder.LAST_READ, ListSortOrder.LAST_READ,
ListSortOrder.LONG_AGO_READ -> ListHeader(calculateTimeAgo(updatedAt)) ListSortOrder.LONG_AGO_READ -> ListHeader(calculateTimeAgo(updatedAt))
@@ -254,18 +259,6 @@ class HistoryListViewModel @Inject constructor(
ListSortOrder.RATING -> null 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) { private fun getEmptyState(hasFilters: Boolean) = if (hasFilters) {
EmptyState( EmptyState(
icon = R.drawable.ic_empty_history, icon = R.drawable.ic_empty_history,

View File

@@ -3,26 +3,84 @@ package org.koitharu.kotatsu.list.domain
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.koitharu.kotatsu.R 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( sealed interface ListFilterOption {
@StringRes val titleResId: Int,
@DrawableRes val iconResId: Int,
) {
DOWNLOADED(R.string.on_device, R.drawable.ic_storage), @get:StringRes
COMPLETED(R.string.status_completed, R.drawable.ic_state_finished), val titleResId: Int
NEW_CHAPTERS(R.string.new_chapters, R.drawable.ic_updated),
FAVORITE(R.string.favourites, R.drawable.ic_heart_outline),
;
companion object { @get:DrawableRes
val iconResId: Int
val HISTORY: Set<ListFilterOption> = EnumSet.of( val titleText: CharSequence?
DOWNLOADED,
NEW_CHAPTERS, val groupKey: String
FAVORITE,
COMPLETED, 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"
} }
} }

View File

@@ -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>
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.list.domain
interface QuickFilterListener {
fun toggleFilterOption(option: ListFilterOption)
fun clearFilter()
}

View File

@@ -45,6 +45,7 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.list.domain.ListFilterOption 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.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener 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 override fun onFilterClick(view: View?) = Unit

View File

@@ -65,7 +65,7 @@ abstract class MangaListViewModel(
listMode, listMode,
settings.observe().filter { key -> settings.observe().filter { key ->
key == AppSettings.KEY_PROGRESS_INDICATORS || key == AppSettings.KEY_TRACKER_ENABLED key == AppSettings.KEY_PROGRESS_INDICATORS || key == AppSettings.KEY_TRACKER_ENABLED
}.onStart { emit("") } }.onStart { emit("") },
) { mode, _ -> ) { mode, _ ->
mode mode
} }

View 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>