Pin tags to favorites
This commit is contained in:
parent
60f36795ab
commit
f16e9c2b40
@ -10,7 +10,6 @@ import kotlinx.coroutines.*
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@ -32,6 +31,7 @@ interface CustomAttributesRepository {
|
|||||||
suspend fun import(fromDir: File)
|
suspend fun import(fromDir: File)
|
||||||
|
|
||||||
suspend fun getAllTags(startsWith: String? = null): List<String>
|
suspend fun getAllTags(startsWith: String? = null): List<String>
|
||||||
|
fun getItemsForTag(tag: String): Flow<List<Searchable>>
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class CustomAttributesRepositoryImpl(
|
internal class CustomAttributesRepositoryImpl(
|
||||||
@ -115,6 +115,13 @@ internal class CustomAttributesRepositoryImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getItemsForTag(tag: String): Flow<List<Searchable>> {
|
||||||
|
val dao = appDatabase.customAttrsDao()
|
||||||
|
return dao.getItemsWithTag(tag).map {
|
||||||
|
favoritesRepository.getFromKeys(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun search(query: String): Flow<List<Searchable>> {
|
override suspend fun search(query: String): Flow<List<Searchable>> {
|
||||||
if (query.isBlank()) {
|
if (query.isBlank()) {
|
||||||
return flow {
|
return flow {
|
||||||
|
|||||||
@ -38,4 +38,7 @@ interface CustomAttrsDao {
|
|||||||
|
|
||||||
@Query("SELECT DISTINCT value FROM CustomAttributes WHERE type = 'tag' ORDER BY value")
|
@Query("SELECT DISTINCT value FROM CustomAttributes WHERE type = 'tag' ORDER BY value")
|
||||||
suspend fun getAllTags(): List<String>
|
suspend fun getAllTags(): List<String>
|
||||||
|
|
||||||
|
@Query("SELECT key FROM CustomAttributes WHERE type = 'tag' AND value = :tag")
|
||||||
|
fun getItemsWithTag(tag: String): Flow<List<String>>
|
||||||
}
|
}
|
||||||
@ -144,7 +144,7 @@ interface SearchDao {
|
|||||||
fun getFavorite(key: String): FavoritesItemEntity?
|
fun getFavorite(key: String): FavoritesItemEntity?
|
||||||
|
|
||||||
@Query("SELECT * FROM Searchable WHERE `key` IN (:keys)")
|
@Query("SELECT * FROM Searchable WHERE `key` IN (:keys)")
|
||||||
fun getFromKeys(keys: List<String>): List<FavoritesItemEntity>
|
suspend fun getFromKeys(keys: List<String>): List<FavoritesItemEntity>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
fun insertReplaceExisting(toDatabaseEntity: FavoritesItemEntity)
|
fun insertReplaceExisting(toDatabaseEntity: FavoritesItemEntity)
|
||||||
|
|||||||
@ -42,6 +42,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.koin.android)
|
implementation(libs.koin.android)
|
||||||
|
|
||||||
|
implementation(project(":base"))
|
||||||
implementation(project(":search"))
|
implementation(project(":search"))
|
||||||
implementation(project(":calendar"))
|
implementation(project(":calendar"))
|
||||||
implementation(project(":database"))
|
implementation(project(":database"))
|
||||||
|
|||||||
@ -81,7 +81,7 @@ interface FavoritesRepository {
|
|||||||
* Get items with the given keys from the favorites database.
|
* Get items with the given keys from the favorites database.
|
||||||
* Items that don't exist in the database will not be returned.
|
* Items that don't exist in the database will not be returned.
|
||||||
*/
|
*/
|
||||||
fun getFromKeys(keys: List<String>): List<Searchable>
|
suspend fun getFromKeys(keys: List<String>): List<Searchable>
|
||||||
|
|
||||||
suspend fun export(toDir: File)
|
suspend fun export(toDir: File)
|
||||||
suspend fun import(fromDir: File)
|
suspend fun import(fromDir: File)
|
||||||
@ -379,7 +379,7 @@ internal class FavoritesRepositoryImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getFromKeys(keys: List<String>): List<Searchable> {
|
override suspend fun getFromKeys(keys: List<String>): List<Searchable> {
|
||||||
val dao = database.searchDao()
|
val dao = database.searchDao()
|
||||||
return dao.getFromKeys(keys)
|
return dao.getFromKeys(keys)
|
||||||
.mapNotNull { fromDatabaseEntity(it).searchable }
|
.mapNotNull { fromDatabaseEntity(it).searchable }
|
||||||
|
|||||||
@ -58,6 +58,9 @@ internal fun getSerializer(searchable: Searchable?): SearchableSerializer {
|
|||||||
if (searchable is Website) {
|
if (searchable is Website) {
|
||||||
return WebsiteSerializer()
|
return WebsiteSerializer()
|
||||||
}
|
}
|
||||||
|
if (searchable is Tag) {
|
||||||
|
return TagSerializer()
|
||||||
|
}
|
||||||
return NullSerializer()
|
return NullSerializer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,5 +102,8 @@ internal fun getDeserializer(context: Context, serialized: String): SearchableDe
|
|||||||
if (type == "website") {
|
if (type == "website") {
|
||||||
return WebsiteDeserializer()
|
return WebsiteDeserializer()
|
||||||
}
|
}
|
||||||
|
if (type == "tag") {
|
||||||
|
return TagDeserializer()
|
||||||
|
}
|
||||||
return NullDeserializer()
|
return NullDeserializer()
|
||||||
}
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package de.mm20.launcher2.favorites
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import de.mm20.launcher2.search.SearchableDeserializer
|
||||||
|
import de.mm20.launcher2.search.SearchableSerializer
|
||||||
|
import de.mm20.launcher2.search.data.Searchable
|
||||||
|
import de.mm20.launcher2.search.data.Tag
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class TagSerializer: SearchableSerializer {
|
||||||
|
override fun serialize(searchable: Searchable): String {
|
||||||
|
searchable as Tag
|
||||||
|
val json = JSONObject()
|
||||||
|
json.put("tag", searchable.tag)
|
||||||
|
return json.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override val typePrefix: String
|
||||||
|
get() = "tag"
|
||||||
|
}
|
||||||
|
|
||||||
|
class TagDeserializer: SearchableDeserializer {
|
||||||
|
override fun deserialize(serialized: String): Searchable {
|
||||||
|
val json = JSONObject(serialized)
|
||||||
|
|
||||||
|
return Tag(json.getString("tag"))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
20
favorites/src/main/java/de/mm20/launcher2/search/data/Tag.kt
Normal file
20
favorites/src/main/java/de/mm20/launcher2/search/data/Tag.kt
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package de.mm20.launcher2.search.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import de.mm20.launcher2.icons.ColorLayer
|
||||||
|
import de.mm20.launcher2.icons.StaticLauncherIcon
|
||||||
|
import de.mm20.launcher2.icons.TextLayer
|
||||||
|
|
||||||
|
class Tag(
|
||||||
|
val tag: String,
|
||||||
|
): Searchable() {
|
||||||
|
override val key: String = "tag://$tag"
|
||||||
|
override val label: String = tag
|
||||||
|
|
||||||
|
override fun getPlaceholderIcon(context: Context): StaticLauncherIcon {
|
||||||
|
return StaticLauncherIcon(
|
||||||
|
foregroundLayer = TextLayer("#"),
|
||||||
|
backgroundLayer = ColorLayer()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -244,6 +244,8 @@
|
|||||||
<!-- Edit favorites, title for items that are pinned and have been sorted by the user -->
|
<!-- Edit favorites, title for items that are pinned and have been sorted by the user -->
|
||||||
<string name="edit_favorites_dialog_pinned_sorted">Pinned – manually sorted</string>
|
<string name="edit_favorites_dialog_pinned_sorted">Pinned – manually sorted</string>
|
||||||
<string name="edit_favorites_dialog_empty_section">Drag items here</string>
|
<string name="edit_favorites_dialog_empty_section">Drag items here</string>
|
||||||
|
<string name="edit_favorites_dialog_tags">Tags</string>
|
||||||
|
<string name="edit_favorites_dialog_tag_section_empty">Pinned tags will appear here</string>
|
||||||
<!-- Nextcloud login flow, URL-->
|
<!-- Nextcloud login flow, URL-->
|
||||||
<string name="nextcloud_server_url">Nextcloud server URL</string>
|
<string name="nextcloud_server_url">Nextcloud server URL</string>
|
||||||
<!-- Nextcloud/Owncloud login flow, empty URL-->
|
<!-- Nextcloud/Owncloud login flow, empty URL-->
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import de.mm20.launcher2.customattrs.CustomAttributesRepository
|
|||||||
import de.mm20.launcher2.favorites.FavoritesRepository
|
import de.mm20.launcher2.favorites.FavoritesRepository
|
||||||
import de.mm20.launcher2.preferences.LauncherDataStore
|
import de.mm20.launcher2.preferences.LauncherDataStore
|
||||||
import de.mm20.launcher2.search.data.Searchable
|
import de.mm20.launcher2.search.data.Searchable
|
||||||
|
import de.mm20.launcher2.search.data.Tag
|
||||||
import de.mm20.launcher2.ui.utils.withCustomLabels
|
import de.mm20.launcher2.ui.utils.withCustomLabels
|
||||||
import de.mm20.launcher2.widgets.WidgetRepository
|
import de.mm20.launcher2.widgets.WidgetRepository
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
@ -21,6 +22,14 @@ open class FavoritesVM : ViewModel(), KoinComponent {
|
|||||||
|
|
||||||
val selectedTag = MutableStateFlow<String?>(null)
|
val selectedTag = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
|
val pinnedTags = favoritesRepository.getFavorites(
|
||||||
|
includeTypes = listOf("tag"),
|
||||||
|
manuallySorted = true,
|
||||||
|
automaticallySorted = true,
|
||||||
|
).map {
|
||||||
|
it.filterIsInstance<Tag>()
|
||||||
|
}
|
||||||
|
|
||||||
val favorites: Flow<List<Searchable>> = selectedTag.flatMapLatest { tag ->
|
val favorites: Flow<List<Searchable>> = selectedTag.flatMapLatest { tag ->
|
||||||
if (tag == null) {
|
if (tag == null) {
|
||||||
val columns = dataStore.data.map { it.grid.columnCount }
|
val columns = dataStore.data.map { it.grid.columnCount }
|
||||||
@ -43,7 +52,7 @@ open class FavoritesVM : ViewModel(), KoinComponent {
|
|||||||
val frequentlyUsedRows = it[3] as Int
|
val frequentlyUsedRows = it[3] as Int
|
||||||
|
|
||||||
val pinned = favoritesRepository.getFavorites(
|
val pinned = favoritesRepository.getFavorites(
|
||||||
excludeTypes = if (excludeCalendar) listOf("calendar") else null,
|
excludeTypes = if (excludeCalendar) listOf("calendar", "tag") else listOf("tag"),
|
||||||
manuallySorted = true,
|
manuallySorted = true,
|
||||||
automaticallySorted = true,
|
automaticallySorted = true,
|
||||||
limit = 10 * columns,
|
limit = 10 * columns,
|
||||||
@ -51,7 +60,7 @@ open class FavoritesVM : ViewModel(), KoinComponent {
|
|||||||
if (includeFrequentlyUsed) {
|
if (includeFrequentlyUsed) {
|
||||||
emitAll(pinned.flatMapLatest { pinned ->
|
emitAll(pinned.flatMapLatest { pinned ->
|
||||||
favoritesRepository.getFavorites(
|
favoritesRepository.getFavorites(
|
||||||
excludeTypes = if (excludeCalendar) listOf("calendar") else null,
|
excludeTypes = if (excludeCalendar) listOf("calendar", "tag") else listOf("tag"),
|
||||||
frequentlyUsed = true,
|
frequentlyUsed = true,
|
||||||
limit = frequentlyUsedRows * columns - pinned.size % columns,
|
limit = frequentlyUsedRows * columns - pinned.size % columns,
|
||||||
).map {
|
).map {
|
||||||
@ -66,7 +75,7 @@ open class FavoritesVM : ViewModel(), KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
emptyFlow<List<Searchable>>()
|
customAttributesRepository.getItemsForTag(tag)
|
||||||
}
|
}
|
||||||
}.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
|
}.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,341 @@
|
|||||||
|
package de.mm20.launcher2.ui.launcher.helper
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.VectorConverter
|
||||||
|
import androidx.compose.foundation.gestures.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.*
|
||||||
|
import androidx.compose.foundation.lazy.grid.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.unit.*
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import de.mm20.launcher2.ui.ktx.animateTo
|
||||||
|
import de.mm20.launcher2.ui.ktx.toIntOffset
|
||||||
|
import de.mm20.launcher2.ui.ktx.toPixels
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.android.awaitFrame
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and remember a [LazyDragAndDropGridState]
|
||||||
|
* @param gridState the [LazyGridState] to use with the LazyGrid
|
||||||
|
* @param onDragStart callback that will be called when an item is picked up. If the return value
|
||||||
|
* is false, the drag operation will be canceled.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberLazyDragAndDropListState(
|
||||||
|
listState: LazyListState = rememberLazyListState(),
|
||||||
|
scrollEdgeSize: Dp = 32.dp,
|
||||||
|
scrollDelta: Dp = 128.dp,
|
||||||
|
onDragStart: (item: LazyListItemInfo) -> Boolean = { true },
|
||||||
|
onDrag: (item: LazyListItemInfo, offset: Offset) -> Unit = { _, _ -> },
|
||||||
|
onDragEnd: (item: LazyListItemInfo) -> Unit = {},
|
||||||
|
onDragCancel: (item: LazyListItemInfo) -> Unit = {},
|
||||||
|
onItemMove: (from: LazyListItemInfo, to: LazyListItemInfo) -> Unit,
|
||||||
|
): LazyDragAndDropListState {
|
||||||
|
val scrollDeltaPx = scrollDelta.toPixels()
|
||||||
|
val scrollEdgeSizePx = scrollEdgeSize.toPixels()
|
||||||
|
return remember {
|
||||||
|
LazyDragAndDropListState(
|
||||||
|
listState,
|
||||||
|
onDragStart,
|
||||||
|
onDrag,
|
||||||
|
onDragEnd,
|
||||||
|
onDragCancel,
|
||||||
|
onItemMove,
|
||||||
|
scrollDeltaPx,
|
||||||
|
scrollEdgeSizePx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LazyDragAndDropListState(
|
||||||
|
val listState: LazyListState,
|
||||||
|
val onDragStart: (item: LazyListItemInfo) -> Boolean = { true },
|
||||||
|
val onDrag: (item: LazyListItemInfo, offset: Offset) -> Unit = { _, _ -> },
|
||||||
|
val onDragEnd: (item: LazyListItemInfo) -> Unit = {},
|
||||||
|
val onDragCancel: (item: LazyListItemInfo) -> Unit = {},
|
||||||
|
val onItemMove: (from: LazyListItemInfo, to: LazyListItemInfo) -> Unit,
|
||||||
|
private val scrollDelta: Float,
|
||||||
|
private val scrollEdgeSize: Float,
|
||||||
|
) {
|
||||||
|
var draggedItem by mutableStateOf<LazyListItemInfo?>(null)
|
||||||
|
var draggedItemAbsolutePosition by mutableStateOf<Offset?>(null)
|
||||||
|
val draggedItemOffset by derivedStateOf {
|
||||||
|
val absPos = draggedItemAbsolutePosition ?: return@derivedStateOf null
|
||||||
|
val key = draggedItem?.key ?: return@derivedStateOf null
|
||||||
|
val draggedItem = listState.layoutInfo.visibleItemsInfo.find {
|
||||||
|
it.key == key
|
||||||
|
} ?: return@derivedStateOf null
|
||||||
|
return@derivedStateOf absPos - draggedItem.offset.toOffset()
|
||||||
|
}
|
||||||
|
|
||||||
|
var droppedItemKey by mutableStateOf<Any?>(null)
|
||||||
|
val droppedItemOffset = mutableStateOf<IntOffset>(IntOffset.Zero)
|
||||||
|
|
||||||
|
private var currentDropPosition: Int? = null
|
||||||
|
private var dropJob: Job? = null
|
||||||
|
|
||||||
|
fun startDrag(offset: Offset): Boolean {
|
||||||
|
val draggedItem = listState.layoutInfo.visibleItemsInfo.find {
|
||||||
|
Rect(
|
||||||
|
it.offset.toOffset(),
|
||||||
|
it.size.toSize()
|
||||||
|
).contains(offset)
|
||||||
|
} ?: return false
|
||||||
|
|
||||||
|
if (!onDragStart(draggedItem)) return false
|
||||||
|
this.draggedItem = draggedItem
|
||||||
|
draggedItemAbsolutePosition = draggedItem.offset.toOffset()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun drag(dragAmount: Offset) {
|
||||||
|
val absPosition = draggedItemAbsolutePosition
|
||||||
|
val draggedItem = draggedItem
|
||||||
|
if (absPosition != null && draggedItem != null) {
|
||||||
|
draggedItemAbsolutePosition = absPosition + dragAmount
|
||||||
|
val draggedCenter = Rect(absPosition, draggedItem.size.toSize()).center
|
||||||
|
val dragOver = listState.layoutInfo.visibleItemsInfo.find {
|
||||||
|
Rect(
|
||||||
|
it.offset.toOffset(),
|
||||||
|
it.size.toSize()
|
||||||
|
).contains(draggedCenter)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragOver != null && dragOver.key != draggedItem.key) {
|
||||||
|
attemptMove(dragOver)
|
||||||
|
}
|
||||||
|
|
||||||
|
val toStart =
|
||||||
|
if (listState.layoutInfo.orientation == Orientation.Horizontal) draggedCenter.x else draggedCenter.y
|
||||||
|
|
||||||
|
|
||||||
|
if (toStart - listState.layoutInfo.viewportStartOffset < scrollEdgeSize) {
|
||||||
|
enableScrolling(-scrollDelta)
|
||||||
|
} else if (listState.layoutInfo.viewportEndOffset - toStart < scrollEdgeSize) {
|
||||||
|
enableScrolling(scrollDelta)
|
||||||
|
} else {
|
||||||
|
endScrolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
draggedItemOffset?.let { onDrag(draggedItem, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.toOffset(): Offset {
|
||||||
|
return if (listState.layoutInfo.orientation == Orientation.Horizontal) {
|
||||||
|
Offset(this.toFloat(), 0f)
|
||||||
|
} else {
|
||||||
|
Offset(0f, this.toFloat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.toSize(): Size {
|
||||||
|
return if (listState.layoutInfo.orientation == Orientation.Horizontal) {
|
||||||
|
Size(this.toFloat(), listState.layoutInfo.viewportSize.height.toFloat())
|
||||||
|
} else {
|
||||||
|
Size(listState.layoutInfo.viewportSize.width.toFloat(), this.toFloat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the currently dragged item to the specified drop target if the dragged item is held
|
||||||
|
* for at least 300ms over the drop target. The move operation is canceled if the dragged item
|
||||||
|
* is released or moved out of the dropTarget area during the 300ms time frame.
|
||||||
|
*/
|
||||||
|
private suspend fun attemptMove(dropTarget: LazyListItemInfo) {
|
||||||
|
if (currentDropPosition != dropTarget.index) {
|
||||||
|
coroutineScope {
|
||||||
|
dropJob?.cancelAndJoin()
|
||||||
|
dropJob = launch {
|
||||||
|
currentDropPosition = dropTarget.index
|
||||||
|
delay(300)
|
||||||
|
// Get a fresh copy of layout info because index in saved layout info might be outdated
|
||||||
|
val dragged =
|
||||||
|
listState.layoutInfo.visibleItemsInfo.find { it.key == draggedItem?.key }
|
||||||
|
if (dragged != null) {
|
||||||
|
onItemMove(dragged, dropTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun cancelDrag() {
|
||||||
|
draggedItem?.let { onDragCancel(it) }
|
||||||
|
afterDragEnded()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun endDrag() {
|
||||||
|
draggedItem?.let { onDragEnd(it) }
|
||||||
|
afterDragEnded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun afterDragEnded() {
|
||||||
|
endScrolling()
|
||||||
|
val key = draggedItem?.key
|
||||||
|
val startOffset = draggedItemOffset
|
||||||
|
draggedItem = null
|
||||||
|
draggedItemAbsolutePosition = null
|
||||||
|
currentDropPosition = null
|
||||||
|
|
||||||
|
if (key == null || startOffset == null) return
|
||||||
|
droppedItemKey = key
|
||||||
|
droppedItemOffset.value = startOffset.toIntOffset()
|
||||||
|
droppedItemOffset.animateTo(IntOffset.Zero, IntOffset.VectorConverter)
|
||||||
|
droppedItemKey = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scrollJob: Job? = null
|
||||||
|
private var currentScrollDelta = 0.0f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll the lazy grid by `delta` px per second until [endScrolling] is called
|
||||||
|
*/
|
||||||
|
suspend fun enableScrolling(delta: Float) {
|
||||||
|
if (currentScrollDelta == delta) return
|
||||||
|
coroutineScope {
|
||||||
|
scrollJob?.cancelAndJoin()
|
||||||
|
scrollJob = launch {
|
||||||
|
currentScrollDelta = delta
|
||||||
|
delay(500)
|
||||||
|
var lastFrame = awaitFrame()
|
||||||
|
while (isActive) {
|
||||||
|
val frame = awaitFrame()
|
||||||
|
val timeDelta = frame - lastFrame
|
||||||
|
listState.scrollBy(delta * timeDelta / 1000_000_000f)
|
||||||
|
lastFrame = frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel scrolling
|
||||||
|
*/
|
||||||
|
fun endScrolling() {
|
||||||
|
currentScrollDelta = 0f
|
||||||
|
scrollJob?.cancel()
|
||||||
|
scrollJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LazyDragAndDropRow(
|
||||||
|
state: LazyDragAndDropListState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||||
|
//reverseLayout: Boolean = false, //TODO: Fix reverse layout
|
||||||
|
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
|
||||||
|
verticalAlignment: Alignment.Vertical = Alignment.Top,
|
||||||
|
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
|
||||||
|
userScrollEnabled: Boolean = true,
|
||||||
|
content: LazyListScope.() -> Unit
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = modifier.dragAndDrop(
|
||||||
|
state,
|
||||||
|
LocalLayoutDirection.current == LayoutDirection.Rtl,
|
||||||
|
LocalHapticFeedback.current
|
||||||
|
),
|
||||||
|
state = state.listState,
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
reverseLayout = false,
|
||||||
|
verticalAlignment = verticalAlignment,
|
||||||
|
horizontalArrangement = horizontalArrangement,
|
||||||
|
flingBehavior = flingBehavior,
|
||||||
|
userScrollEnabled = userScrollEnabled,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LazyDragAndDropColumn(
|
||||||
|
state: LazyDragAndDropListState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||||
|
//reverseLayout: Boolean = false, //TODO: Fix reverse layout
|
||||||
|
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
|
||||||
|
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||||
|
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
|
||||||
|
userScrollEnabled: Boolean = true,
|
||||||
|
content: LazyListScope.() -> Unit
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.dragAndDrop(
|
||||||
|
state,
|
||||||
|
LocalLayoutDirection.current == LayoutDirection.Rtl,
|
||||||
|
LocalHapticFeedback.current
|
||||||
|
),
|
||||||
|
state = state.listState,
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
reverseLayout = false,
|
||||||
|
verticalArrangement = verticalArrangement,
|
||||||
|
horizontalAlignment = horizontalAlignment,
|
||||||
|
flingBehavior = flingBehavior,
|
||||||
|
userScrollEnabled = userScrollEnabled,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Modifier.dragAndDrop(
|
||||||
|
state: LazyDragAndDropListState,
|
||||||
|
isRtl: Boolean,
|
||||||
|
hapticFeedback: HapticFeedback
|
||||||
|
) =
|
||||||
|
this then pointerInput(null) {
|
||||||
|
val scope = CoroutineScope(coroutineContext)
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDragStart = { offset ->
|
||||||
|
if (state.startDrag(offset)) {
|
||||||
|
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDrag = { _, dragAmount ->
|
||||||
|
scope.launch { state.drag(dragAmount.let {
|
||||||
|
if (isRtl) it.copy(x = -it.x) else it
|
||||||
|
}) }
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
scope.launch { state.cancelDrag() }
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
scope.launch { state.endDrag() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LazyItemScope.DraggableItem(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
state: LazyDragAndDropListState,
|
||||||
|
key: Any?,
|
||||||
|
content: @Composable BoxScope.(isDragged: Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
val isDragged = state.draggedItem?.key == key || state.droppedItemKey == key
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.then(if (isDragged) Modifier else Modifier.animateItemPlacement())
|
||||||
|
.zIndex(if (isDragged) 1f else 0f)
|
||||||
|
.offset {
|
||||||
|
if (state.draggedItem?.key == key) {
|
||||||
|
state.draggedItemOffset?.toIntOffset() ?: IntOffset.Zero
|
||||||
|
} else if (state.droppedItemKey == key) {
|
||||||
|
state.droppedItemOffset.value
|
||||||
|
} else {
|
||||||
|
IntOffset.Zero
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content = { content(isDragged) },
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -8,15 +8,14 @@ import androidx.activity.result.IntentSenderRequest
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.Add
|
import androidx.compose.material.icons.rounded.*
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
|
||||||
import androidx.compose.material.icons.rounded.Settings
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
@ -42,9 +41,7 @@ import de.mm20.launcher2.ui.component.BottomSheetDialog
|
|||||||
import de.mm20.launcher2.ui.component.MissingPermissionBanner
|
import de.mm20.launcher2.ui.component.MissingPermissionBanner
|
||||||
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
|
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
|
||||||
import de.mm20.launcher2.ui.ktx.toPixels
|
import de.mm20.launcher2.ui.ktx.toPixels
|
||||||
import de.mm20.launcher2.ui.launcher.helper.DraggableItem
|
import de.mm20.launcher2.ui.launcher.helper.*
|
||||||
import de.mm20.launcher2.ui.launcher.helper.LazyVerticalDragAndDropGrid
|
|
||||||
import de.mm20.launcher2.ui.launcher.helper.rememberLazyDragAndDropGridState
|
|
||||||
import de.mm20.launcher2.ui.locals.LocalGridColumns
|
import de.mm20.launcher2.ui.locals.LocalGridColumns
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@ -115,6 +112,9 @@ fun ReorderFavoritesGrid(viewModel: EditFavoritesSheetVM) {
|
|||||||
val items by viewModel.gridItems.observeAsState(emptyList())
|
val items by viewModel.gridItems.observeAsState(emptyList())
|
||||||
val columns = LocalGridColumns.current
|
val columns = LocalGridColumns.current
|
||||||
|
|
||||||
|
val availableTags by viewModel.availableTags.observeAsState(emptyList())
|
||||||
|
val pinnedTags by viewModel.pinnedTags.observeAsState(emptyList())
|
||||||
|
|
||||||
var contextMenuItemKey by remember { mutableStateOf<String?>(null) }
|
var contextMenuItemKey by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
val contextMenuCloseDistance = 8.dp.toPixels()
|
val contextMenuCloseDistance = 8.dp.toPixels()
|
||||||
@ -379,7 +379,104 @@ fun ReorderFavoritesGrid(viewModel: EditFavoritesSheetVM) {
|
|||||||
.height(48.dp)
|
.height(48.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is FavoritesSheetGridItem.Tags -> {}
|
is FavoritesSheetGridItem.Tags -> {
|
||||||
|
var showAddMenu by remember { mutableStateOf(false) }
|
||||||
|
Column {
|
||||||
|
if (availableTags.isNotEmpty() || pinnedTags.isNotEmpty()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 16.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
text = stringResource(R.string.edit_favorites_dialog_tags),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
Box() {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
modifier = Modifier.offset(x = 4.dp),
|
||||||
|
enabled = availableTags.isNotEmpty(),
|
||||||
|
onClick = {
|
||||||
|
showAddMenu = true
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Add,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showAddMenu,
|
||||||
|
onDismissRequest = { showAddMenu = false }) {
|
||||||
|
for (tag in availableTags) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Rounded.Tag, null)
|
||||||
|
},
|
||||||
|
text = { Text(tag.label) },
|
||||||
|
onClick = {
|
||||||
|
viewModel.pinTag(tag)
|
||||||
|
showAddMenu = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pinnedTags.isNotEmpty()) {
|
||||||
|
val rowState = rememberLazyDragAndDropListState { from, to ->
|
||||||
|
viewModel.moveTag(from, to)
|
||||||
|
}
|
||||||
|
LazyDragAndDropRow(state = rowState) {
|
||||||
|
items(
|
||||||
|
pinnedTags,
|
||||||
|
key = { it.key }
|
||||||
|
) { tag ->
|
||||||
|
DraggableItem(state = rowState, key = tag.key) { dragged ->
|
||||||
|
|
||||||
|
FilterChip(
|
||||||
|
modifier = Modifier.padding(end = 12.dp),
|
||||||
|
selected = false,
|
||||||
|
onClick = {},
|
||||||
|
label = { Text(tag.label) },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Rounded.Tag, null)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
viewModel.unpinTag(tag)
|
||||||
|
},
|
||||||
|
imageVector = Icons.Rounded.Close,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
elevation = if (dragged) FilterChipDefaults.elevatedFilterChipElevation() else FilterChipDefaults.filterChipElevation(),
|
||||||
|
colors = if (dragged) FilterChipDefaults.elevatedFilterChipColors()
|
||||||
|
else FilterChipDefaults.filterChipColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
text = stringResource(R.string.edit_favorites_dialog_tag_section_empty),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -485,7 +582,7 @@ sealed interface FavoritesSheetGridItem {
|
|||||||
class Divider(val section: FavoritesSheetSection) : FavoritesSheetGridItem
|
class Divider(val section: FavoritesSheetSection) : FavoritesSheetGridItem
|
||||||
class Spacer(val span: Int = 1) : FavoritesSheetGridItem
|
class Spacer(val span: Int = 1) : FavoritesSheetGridItem
|
||||||
object EmptySection : FavoritesSheetGridItem
|
object EmptySection : FavoritesSheetGridItem
|
||||||
class Tags() : FavoritesSheetGridItem
|
object Tags : FavoritesSheetGridItem
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class FavoritesSheetSection {
|
enum class FavoritesSheetSection {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package de.mm20.launcher2.ui.launcher.modals
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||||
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
|
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
@ -11,14 +12,18 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import de.mm20.launcher2.appshortcuts.AppShortcutRepository
|
import de.mm20.launcher2.appshortcuts.AppShortcutRepository
|
||||||
import de.mm20.launcher2.badges.Badge
|
import de.mm20.launcher2.badges.Badge
|
||||||
import de.mm20.launcher2.badges.BadgeRepository
|
import de.mm20.launcher2.badges.BadgeRepository
|
||||||
|
import de.mm20.launcher2.customattrs.CustomAttributesRepository
|
||||||
import de.mm20.launcher2.favorites.FavoritesRepository
|
import de.mm20.launcher2.favorites.FavoritesRepository
|
||||||
import de.mm20.launcher2.icons.IconRepository
|
import de.mm20.launcher2.icons.IconRepository
|
||||||
import de.mm20.launcher2.icons.LauncherIcon
|
import de.mm20.launcher2.icons.LauncherIcon
|
||||||
|
import de.mm20.launcher2.ktx.normalize
|
||||||
|
import de.mm20.launcher2.ktx.romanize
|
||||||
import de.mm20.launcher2.permissions.PermissionGroup
|
import de.mm20.launcher2.permissions.PermissionGroup
|
||||||
import de.mm20.launcher2.permissions.PermissionsManager
|
import de.mm20.launcher2.permissions.PermissionsManager
|
||||||
import de.mm20.launcher2.preferences.LauncherDataStore
|
import de.mm20.launcher2.preferences.LauncherDataStore
|
||||||
import de.mm20.launcher2.search.data.AppShortcut
|
import de.mm20.launcher2.search.data.AppShortcut
|
||||||
import de.mm20.launcher2.search.data.Searchable
|
import de.mm20.launcher2.search.data.Searchable
|
||||||
|
import de.mm20.launcher2.search.data.Tag
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
@ -33,6 +38,7 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
|
|||||||
private val shortcutRepository: AppShortcutRepository by inject()
|
private val shortcutRepository: AppShortcutRepository by inject()
|
||||||
private val iconRepository: IconRepository by inject()
|
private val iconRepository: IconRepository by inject()
|
||||||
private val badgeRepository: BadgeRepository by inject()
|
private val badgeRepository: BadgeRepository by inject()
|
||||||
|
private val customAttributesRepository: CustomAttributesRepository by inject()
|
||||||
private val permissionsManager: PermissionsManager by inject()
|
private val permissionsManager: PermissionsManager by inject()
|
||||||
private val dataStore: LauncherDataStore by inject()
|
private val dataStore: LauncherDataStore by inject()
|
||||||
|
|
||||||
@ -46,18 +52,37 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
|
|||||||
private var automaticallySorted: MutableList<Searchable> = mutableListOf()
|
private var automaticallySorted: MutableList<Searchable> = mutableListOf()
|
||||||
private var frequentlyUsed: MutableList<Searchable> = mutableListOf()
|
private var frequentlyUsed: MutableList<Searchable> = mutableListOf()
|
||||||
|
|
||||||
|
val pinnedTags = MutableLiveData<List<Tag>>(emptyList())
|
||||||
|
val availableTags = MutableLiveData<List<Tag>>(emptyList())
|
||||||
|
|
||||||
suspend fun reload() {
|
suspend fun reload() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
manuallySorted = mutableListOf()
|
manuallySorted = mutableListOf()
|
||||||
manuallySorted = repository.getFavorites(
|
manuallySorted = repository.getFavorites(
|
||||||
manuallySorted = true
|
manuallySorted = true,
|
||||||
|
excludeTypes = listOf("tag"),
|
||||||
).first().toMutableList()
|
).first().toMutableList()
|
||||||
automaticallySorted = repository.getFavorites(
|
automaticallySorted = repository.getFavorites(
|
||||||
automaticallySorted = true
|
automaticallySorted = true,
|
||||||
|
excludeTypes = listOf("tag"),
|
||||||
).first().toMutableList()
|
).first().toMutableList()
|
||||||
frequentlyUsed = repository.getFavorites(
|
frequentlyUsed = repository.getFavorites(
|
||||||
frequentlyUsed = true
|
frequentlyUsed = true,
|
||||||
|
excludeTypes = listOf("tag"),
|
||||||
).first().toMutableList()
|
).first().toMutableList()
|
||||||
|
val pinnedTags = repository.getFavorites(
|
||||||
|
includeTypes = listOf("tag"),
|
||||||
|
manuallySorted = true,
|
||||||
|
automaticallySorted = true,
|
||||||
|
).first().filterIsInstance<Tag>().toMutableList()
|
||||||
|
availableTags.value =
|
||||||
|
customAttributesRepository
|
||||||
|
.getAllTags()
|
||||||
|
.filter {t -> pinnedTags.none { it.tag == t } }
|
||||||
|
.sortedBy { it.normalize() }
|
||||||
|
.map { Tag(it) }
|
||||||
|
this.pinnedTags.value = pinnedTags
|
||||||
|
|
||||||
buildItemList()
|
buildItemList()
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -65,7 +90,7 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
|
|||||||
private fun buildItemList() {
|
private fun buildItemList() {
|
||||||
val items = mutableListOf<FavoritesSheetGridItem>()
|
val items = mutableListOf<FavoritesSheetGridItem>()
|
||||||
|
|
||||||
items.add(FavoritesSheetGridItem.Tags())
|
items.add(FavoritesSheetGridItem.Tags)
|
||||||
|
|
||||||
items.add(FavoritesSheetGridItem.Divider(FavoritesSheetSection.ManuallySorted))
|
items.add(FavoritesSheetGridItem.Divider(FavoritesSheetSection.ManuallySorted))
|
||||||
if (manuallySorted.isEmpty()) {
|
if (manuallySorted.isEmpty()) {
|
||||||
@ -144,10 +169,11 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
|
|||||||
|
|
||||||
private fun save() {
|
private fun save() {
|
||||||
repository.updateFavorites(
|
repository.updateFavorites(
|
||||||
buildList {
|
manuallySorted = buildList {
|
||||||
|
pinnedTags.value?.let { addAll(it) }
|
||||||
addAll(manuallySorted)
|
addAll(manuallySorted)
|
||||||
},
|
},
|
||||||
buildList {
|
automaticallySorted = buildList {
|
||||||
addAll(automaticallySorted)
|
addAll(automaticallySorted)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -245,5 +271,29 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun pinTag(tag: Tag) {
|
||||||
|
val pinned = pinnedTags.value?.toMutableList() ?: mutableListOf()
|
||||||
|
pinned.add(tag)
|
||||||
|
val available = availableTags.value ?: emptyList()
|
||||||
|
availableTags.value = available.filter { it.tag != tag.tag }
|
||||||
|
pinnedTags.value = pinned.distinctBy { it.tag }
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unpinTag(tag: Tag) {
|
||||||
|
val pinned = pinnedTags.value?.toMutableList() ?: mutableListOf()
|
||||||
|
val available = availableTags.value ?: emptyList()
|
||||||
|
availableTags.value = (available + tag).sorted()
|
||||||
|
pinnedTags.value = pinned.filter { it.tag != tag.tag }
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun moveTag(from: LazyListItemInfo, to: LazyListItemInfo) {
|
||||||
|
val pinned = pinnedTags.value?.toMutableList() ?: return
|
||||||
|
val tag = pinned.removeAt(from.index)
|
||||||
|
pinned.add(to.index, tag)
|
||||||
|
pinnedTags.value = pinned
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -8,10 +8,7 @@ import androidx.compose.foundation.lazy.LazyListState
|
|||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.Edit
|
import androidx.compose.material.icons.rounded.*
|
||||||
import androidx.compose.material.icons.rounded.Person
|
|
||||||
import androidx.compose.material.icons.rounded.Star
|
|
||||||
import androidx.compose.material.icons.rounded.Work
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
@ -53,7 +50,7 @@ fun SearchColumn(
|
|||||||
val viewModel: SearchVM = viewModel()
|
val viewModel: SearchVM = viewModel()
|
||||||
|
|
||||||
val favoritesVM: SearchFavoritesVM = viewModel()
|
val favoritesVM: SearchFavoritesVM = viewModel()
|
||||||
val favorites by remember { favoritesVM.favorites }.collectAsState(emptyList())
|
val favorites by favoritesVM.favorites.collectAsState(emptyList())
|
||||||
|
|
||||||
val showLabels by viewModel.showLabels.observeAsState(true)
|
val showLabels by viewModel.showLabels.observeAsState(true)
|
||||||
|
|
||||||
@ -73,6 +70,9 @@ fun SearchColumn(
|
|||||||
|
|
||||||
var showEditFavoritesDialog by remember { mutableStateOf(false) }
|
var showEditFavoritesDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val pinnedTags by favoritesVM.pinnedTags.collectAsState(emptyList())
|
||||||
|
val selectedTag by favoritesVM.selectedTag.collectAsState(null)
|
||||||
|
val tagsScrollState = rememberScrollState()
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = state,
|
state = state,
|
||||||
@ -101,12 +101,12 @@ fun SearchColumn(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.horizontalScroll(rememberScrollState()),
|
.horizontalScroll(tagsScrollState).padding(end = 12.dp),
|
||||||
) {
|
) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
selected = true,
|
selected = selectedTag == null,
|
||||||
onClick = { /*TODO*/ },
|
onClick = { favoritesVM.selectTag(null) },
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Star,
|
imageVector = Icons.Rounded.Star,
|
||||||
@ -115,6 +115,20 @@ fun SearchColumn(
|
|||||||
},
|
},
|
||||||
label = { Text(stringResource(R.string.favorites)) }
|
label = { Text(stringResource(R.string.favorites)) }
|
||||||
)
|
)
|
||||||
|
for (tag in pinnedTags) {
|
||||||
|
FilterChip(
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
selected = selectedTag == tag.tag,
|
||||||
|
onClick = { favoritesVM.selectTag(tag.tag) },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Tag,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text(tag.label) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SmallFloatingActionButton(
|
SmallFloatingActionButton(
|
||||||
elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(),
|
elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(),
|
||||||
@ -223,11 +237,11 @@ fun LazyListScope.GridResults(
|
|||||||
before: (@Composable () -> Unit)? = null,
|
before: (@Composable () -> Unit)? = null,
|
||||||
after: (@Composable () -> Unit)? = null,
|
after: (@Composable () -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
if (items.isEmpty()) return
|
if (items.isEmpty() && before == null && after == null) return
|
||||||
|
|
||||||
if (before != null) {
|
if (before != null) {
|
||||||
item(contentType = "ListItemsBefore") {
|
item(contentType = "ListItemsBefore") {
|
||||||
PartialCardRow(isFirst = true, isLast = false, reverse = reverse) {
|
PartialCardRow(isFirst = true, isLast = items.isEmpty() && after == null, reverse = reverse) {
|
||||||
before()
|
before()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -257,7 +271,7 @@ fun LazyListScope.GridResults(
|
|||||||
|
|
||||||
if (after != null) {
|
if (after != null) {
|
||||||
item(contentType = "ListItemsAfter") {
|
item(contentType = "ListItemsAfter") {
|
||||||
PartialCardRow(isFirst = false, isLast = true, reverse = reverse) {
|
PartialCardRow(isFirst = items.isEmpty() && before == null, isLast = true, reverse = reverse) {
|
||||||
after()
|
after()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.Edit
|
import androidx.compose.material.icons.rounded.Edit
|
||||||
import androidx.compose.material.icons.rounded.Star
|
import androidx.compose.material.icons.rounded.Star
|
||||||
|
import androidx.compose.material.icons.rounded.Tag
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@ -21,6 +22,8 @@ import de.mm20.launcher2.ui.launcher.search.common.grid.SearchResultGrid
|
|||||||
fun FavoritesWidget() {
|
fun FavoritesWidget() {
|
||||||
val viewModel: FavoritesWidgetVM = viewModel()
|
val viewModel: FavoritesWidgetVM = viewModel()
|
||||||
val favorites by remember { viewModel.favorites }.collectAsState(emptyList())
|
val favorites by remember { viewModel.favorites }.collectAsState(emptyList())
|
||||||
|
val pinnedTags by viewModel.pinnedTags.collectAsState(emptyList())
|
||||||
|
val selectedTag by viewModel.selectedTag.collectAsState(null)
|
||||||
var showEditFavoritesDialog by remember { mutableStateOf(false) }
|
var showEditFavoritesDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
@ -43,8 +46,8 @@ fun FavoritesWidget() {
|
|||||||
) {
|
) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
selected = true,
|
selected = selectedTag == null,
|
||||||
onClick = { /*TODO*/ },
|
onClick = { viewModel.selectTag(null) },
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Star,
|
imageVector = Icons.Rounded.Star,
|
||||||
@ -53,6 +56,20 @@ fun FavoritesWidget() {
|
|||||||
},
|
},
|
||||||
label = { Text(stringResource(R.string.favorites)) }
|
label = { Text(stringResource(R.string.favorites)) }
|
||||||
)
|
)
|
||||||
|
for (tag in pinnedTags) {
|
||||||
|
FilterChip(
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
selected = selectedTag == tag.tag,
|
||||||
|
onClick = { viewModel.selectTag(tag.tag) },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Tag,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text(tag.label) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SmallFloatingActionButton(
|
SmallFloatingActionButton(
|
||||||
elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(),
|
elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user