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.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import java.io.File
|
||||
@ -32,6 +31,7 @@ interface CustomAttributesRepository {
|
||||
suspend fun import(fromDir: File)
|
||||
|
||||
suspend fun getAllTags(startsWith: String? = null): List<String>
|
||||
fun getItemsForTag(tag: String): Flow<List<Searchable>>
|
||||
}
|
||||
|
||||
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>> {
|
||||
if (query.isBlank()) {
|
||||
return flow {
|
||||
|
||||
@ -38,4 +38,7 @@ interface CustomAttrsDao {
|
||||
|
||||
@Query("SELECT DISTINCT value FROM CustomAttributes WHERE type = 'tag' ORDER BY value")
|
||||
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?
|
||||
|
||||
@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)
|
||||
fun insertReplaceExisting(toDatabaseEntity: FavoritesItemEntity)
|
||||
|
||||
@ -42,6 +42,7 @@ dependencies {
|
||||
|
||||
implementation(libs.koin.android)
|
||||
|
||||
implementation(project(":base"))
|
||||
implementation(project(":search"))
|
||||
implementation(project(":calendar"))
|
||||
implementation(project(":database"))
|
||||
|
||||
@ -81,7 +81,7 @@ interface FavoritesRepository {
|
||||
* Get items with the given keys from the favorites database.
|
||||
* 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 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()
|
||||
return dao.getFromKeys(keys)
|
||||
.mapNotNull { fromDatabaseEntity(it).searchable }
|
||||
|
||||
@ -58,6 +58,9 @@ internal fun getSerializer(searchable: Searchable?): SearchableSerializer {
|
||||
if (searchable is Website) {
|
||||
return WebsiteSerializer()
|
||||
}
|
||||
if (searchable is Tag) {
|
||||
return TagSerializer()
|
||||
}
|
||||
return NullSerializer()
|
||||
}
|
||||
|
||||
@ -99,5 +102,8 @@ internal fun getDeserializer(context: Context, serialized: String): SearchableDe
|
||||
if (type == "website") {
|
||||
return WebsiteDeserializer()
|
||||
}
|
||||
if (type == "tag") {
|
||||
return TagDeserializer()
|
||||
}
|
||||
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 -->
|
||||
<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_tags">Tags</string>
|
||||
<string name="edit_favorites_dialog_tag_section_empty">Pinned tags will appear here</string>
|
||||
<!-- Nextcloud login flow, URL-->
|
||||
<string name="nextcloud_server_url">Nextcloud server URL</string>
|
||||
<!-- 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.preferences.LauncherDataStore
|
||||
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.widgets.WidgetRepository
|
||||
import kotlinx.coroutines.flow.*
|
||||
@ -21,6 +22,14 @@ open class FavoritesVM : ViewModel(), KoinComponent {
|
||||
|
||||
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 ->
|
||||
if (tag == null) {
|
||||
val columns = dataStore.data.map { it.grid.columnCount }
|
||||
@ -43,7 +52,7 @@ open class FavoritesVM : ViewModel(), KoinComponent {
|
||||
val frequentlyUsedRows = it[3] as Int
|
||||
|
||||
val pinned = favoritesRepository.getFavorites(
|
||||
excludeTypes = if (excludeCalendar) listOf("calendar") else null,
|
||||
excludeTypes = if (excludeCalendar) listOf("calendar", "tag") else listOf("tag"),
|
||||
manuallySorted = true,
|
||||
automaticallySorted = true,
|
||||
limit = 10 * columns,
|
||||
@ -51,7 +60,7 @@ open class FavoritesVM : ViewModel(), KoinComponent {
|
||||
if (includeFrequentlyUsed) {
|
||||
emitAll(pinned.flatMapLatest { pinned ->
|
||||
favoritesRepository.getFavorites(
|
||||
excludeTypes = if (excludeCalendar) listOf("calendar") else null,
|
||||
excludeTypes = if (excludeCalendar) listOf("calendar", "tag") else listOf("tag"),
|
||||
frequentlyUsed = true,
|
||||
limit = frequentlyUsedRows * columns - pinned.size % columns,
|
||||
).map {
|
||||
@ -66,7 +75,7 @@ open class FavoritesVM : ViewModel(), KoinComponent {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
emptyFlow<List<Searchable>>()
|
||||
customAttributesRepository.getItemsForTag(tag)
|
||||
}
|
||||
}.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.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material.icons.rounded.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.ShapedLauncherIcon
|
||||
import de.mm20.launcher2.ui.ktx.toPixels
|
||||
import de.mm20.launcher2.ui.launcher.helper.DraggableItem
|
||||
import de.mm20.launcher2.ui.launcher.helper.LazyVerticalDragAndDropGrid
|
||||
import de.mm20.launcher2.ui.launcher.helper.rememberLazyDragAndDropGridState
|
||||
import de.mm20.launcher2.ui.launcher.helper.*
|
||||
import de.mm20.launcher2.ui.locals.LocalGridColumns
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@ -115,6 +112,9 @@ fun ReorderFavoritesGrid(viewModel: EditFavoritesSheetVM) {
|
||||
val items by viewModel.gridItems.observeAsState(emptyList())
|
||||
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) }
|
||||
|
||||
val contextMenuCloseDistance = 8.dp.toPixels()
|
||||
@ -379,7 +379,104 @@ fun ReorderFavoritesGrid(viewModel: EditFavoritesSheetVM) {
|
||||
.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 Spacer(val span: Int = 1) : FavoritesSheetGridItem
|
||||
object EmptySection : FavoritesSheetGridItem
|
||||
class Tags() : FavoritesSheetGridItem
|
||||
object Tags : FavoritesSheetGridItem
|
||||
}
|
||||
|
||||
enum class FavoritesSheetSection {
|
||||
|
||||
@ -3,6 +3,7 @@ package de.mm20.launcher2.ui.launcher.modals
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
@ -11,14 +12,18 @@ import androidx.lifecycle.viewModelScope
|
||||
import de.mm20.launcher2.appshortcuts.AppShortcutRepository
|
||||
import de.mm20.launcher2.badges.Badge
|
||||
import de.mm20.launcher2.badges.BadgeRepository
|
||||
import de.mm20.launcher2.customattrs.CustomAttributesRepository
|
||||
import de.mm20.launcher2.favorites.FavoritesRepository
|
||||
import de.mm20.launcher2.icons.IconRepository
|
||||
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.PermissionsManager
|
||||
import de.mm20.launcher2.preferences.LauncherDataStore
|
||||
import de.mm20.launcher2.search.data.AppShortcut
|
||||
import de.mm20.launcher2.search.data.Searchable
|
||||
import de.mm20.launcher2.search.data.Tag
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
@ -33,6 +38,7 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
|
||||
private val shortcutRepository: AppShortcutRepository by inject()
|
||||
private val iconRepository: IconRepository by inject()
|
||||
private val badgeRepository: BadgeRepository by inject()
|
||||
private val customAttributesRepository: CustomAttributesRepository by inject()
|
||||
private val permissionsManager: PermissionsManager by inject()
|
||||
private val dataStore: LauncherDataStore by inject()
|
||||
|
||||
@ -46,18 +52,37 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
|
||||
private var automaticallySorted: MutableList<Searchable> = mutableListOf()
|
||||
private var frequentlyUsed: MutableList<Searchable> = mutableListOf()
|
||||
|
||||
val pinnedTags = MutableLiveData<List<Tag>>(emptyList())
|
||||
val availableTags = MutableLiveData<List<Tag>>(emptyList())
|
||||
|
||||
suspend fun reload() {
|
||||
loading.value = true
|
||||
manuallySorted = mutableListOf()
|
||||
manuallySorted = repository.getFavorites(
|
||||
manuallySorted = true
|
||||
manuallySorted = true,
|
||||
excludeTypes = listOf("tag"),
|
||||
).first().toMutableList()
|
||||
automaticallySorted = repository.getFavorites(
|
||||
automaticallySorted = true
|
||||
automaticallySorted = true,
|
||||
excludeTypes = listOf("tag"),
|
||||
).first().toMutableList()
|
||||
frequentlyUsed = repository.getFavorites(
|
||||
frequentlyUsed = true
|
||||
frequentlyUsed = true,
|
||||
excludeTypes = listOf("tag"),
|
||||
).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()
|
||||
loading.value = false
|
||||
}
|
||||
@ -65,7 +90,7 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
|
||||
private fun buildItemList() {
|
||||
val items = mutableListOf<FavoritesSheetGridItem>()
|
||||
|
||||
items.add(FavoritesSheetGridItem.Tags())
|
||||
items.add(FavoritesSheetGridItem.Tags)
|
||||
|
||||
items.add(FavoritesSheetGridItem.Divider(FavoritesSheetSection.ManuallySorted))
|
||||
if (manuallySorted.isEmpty()) {
|
||||
@ -144,10 +169,11 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent {
|
||||
|
||||
private fun save() {
|
||||
repository.updateFavorites(
|
||||
buildList {
|
||||
manuallySorted = buildList {
|
||||
pinnedTags.value?.let { addAll(it) }
|
||||
addAll(manuallySorted)
|
||||
},
|
||||
buildList {
|
||||
automaticallySorted = buildList {
|
||||
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.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material.icons.rounded.Work
|
||||
import androidx.compose.material.icons.rounded.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@ -53,7 +50,7 @@ fun SearchColumn(
|
||||
val viewModel: SearchVM = 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)
|
||||
|
||||
@ -73,6 +70,9 @@ fun SearchColumn(
|
||||
|
||||
var showEditFavoritesDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val pinnedTags by favoritesVM.pinnedTags.collectAsState(emptyList())
|
||||
val selectedTag by favoritesVM.selectedTag.collectAsState(null)
|
||||
val tagsScrollState = rememberScrollState()
|
||||
|
||||
LazyColumn(
|
||||
state = state,
|
||||
@ -101,12 +101,12 @@ fun SearchColumn(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
.horizontalScroll(tagsScrollState).padding(end = 12.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
selected = true,
|
||||
onClick = { /*TODO*/ },
|
||||
selected = selectedTag == null,
|
||||
onClick = { favoritesVM.selectTag(null) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Star,
|
||||
@ -115,6 +115,20 @@ fun SearchColumn(
|
||||
},
|
||||
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(
|
||||
elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(),
|
||||
@ -223,11 +237,11 @@ fun LazyListScope.GridResults(
|
||||
before: (@Composable () -> Unit)? = null,
|
||||
after: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
if (items.isEmpty() && before == null && after == null) return
|
||||
|
||||
if (before != null) {
|
||||
item(contentType = "ListItemsBefore") {
|
||||
PartialCardRow(isFirst = true, isLast = false, reverse = reverse) {
|
||||
PartialCardRow(isFirst = true, isLast = items.isEmpty() && after == null, reverse = reverse) {
|
||||
before()
|
||||
}
|
||||
}
|
||||
@ -257,7 +271,7 @@ fun LazyListScope.GridResults(
|
||||
|
||||
if (after != null) {
|
||||
item(contentType = "ListItemsAfter") {
|
||||
PartialCardRow(isFirst = false, isLast = true, reverse = reverse) {
|
||||
PartialCardRow(isFirst = items.isEmpty() && before == null, isLast = true, reverse = reverse) {
|
||||
after()
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material.icons.rounded.Tag
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -21,6 +22,8 @@ import de.mm20.launcher2.ui.launcher.search.common.grid.SearchResultGrid
|
||||
fun FavoritesWidget() {
|
||||
val viewModel: FavoritesWidgetVM = viewModel()
|
||||
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) }
|
||||
|
||||
Column {
|
||||
@ -43,8 +46,8 @@ fun FavoritesWidget() {
|
||||
) {
|
||||
FilterChip(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
selected = true,
|
||||
onClick = { /*TODO*/ },
|
||||
selected = selectedTag == null,
|
||||
onClick = { viewModel.selectTag(null) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Star,
|
||||
@ -53,6 +56,20 @@ fun FavoritesWidget() {
|
||||
},
|
||||
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(
|
||||
elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user