From f16e9c2b4018c1e0787c4a56ec9a05cd907c44fb Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Fri, 23 Sep 2022 22:38:44 +0200 Subject: [PATCH] Pin tags to favorites --- .../customattrs/CustomAttributesRepository.kt | 9 +- .../mm20/launcher2/database/CustomAttrsDao.kt | 3 + .../de/mm20/launcher2/database/SearchDao.kt | 2 +- favorites/build.gradle.kts | 1 + .../favorites/FavoritesRepository.kt | 4 +- .../mm20/launcher2/favorites/Serialization.kt | 6 + .../launcher2/favorites/TagSerialization.kt | 29 ++ .../java/de/mm20/launcher2/search/data/Tag.kt | 20 + i18n/src/main/res/values/strings.xml | 2 + .../mm20/launcher2/ui/common/FavoritesVM.kt | 15 +- .../ui/launcher/helper/DragAndDropList.kt | 341 ++++++++++++++++++ .../ui/launcher/modals/EditFavoritesSheet.kt | 113 +++++- .../launcher/modals/EditFavoritesSheetVM.kt | 62 +++- .../ui/launcher/search/SearchColumn.kt | 36 +- .../widgets/favorites/FavoritesWidget.kt | 21 +- 15 files changed, 630 insertions(+), 34 deletions(-) create mode 100644 favorites/src/main/java/de/mm20/launcher2/favorites/TagSerialization.kt create mode 100644 favorites/src/main/java/de/mm20/launcher2/search/data/Tag.kt create mode 100644 ui/src/main/java/de/mm20/launcher2/ui/launcher/helper/DragAndDropList.kt diff --git a/customattrs/src/main/java/de/mm20/launcher2/customattrs/CustomAttributesRepository.kt b/customattrs/src/main/java/de/mm20/launcher2/customattrs/CustomAttributesRepository.kt index e408a336..d8aaea76 100644 --- a/customattrs/src/main/java/de/mm20/launcher2/customattrs/CustomAttributesRepository.kt +++ b/customattrs/src/main/java/de/mm20/launcher2/customattrs/CustomAttributesRepository.kt @@ -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 + fun getItemsForTag(tag: String): Flow> } internal class CustomAttributesRepositoryImpl( @@ -115,6 +115,13 @@ internal class CustomAttributesRepositoryImpl( } } + override fun getItemsForTag(tag: String): Flow> { + val dao = appDatabase.customAttrsDao() + return dao.getItemsWithTag(tag).map { + favoritesRepository.getFromKeys(it) + } + } + override suspend fun search(query: String): Flow> { if (query.isBlank()) { return flow { diff --git a/database/src/main/java/de/mm20/launcher2/database/CustomAttrsDao.kt b/database/src/main/java/de/mm20/launcher2/database/CustomAttrsDao.kt index 54ada72d..d592af59 100644 --- a/database/src/main/java/de/mm20/launcher2/database/CustomAttrsDao.kt +++ b/database/src/main/java/de/mm20/launcher2/database/CustomAttrsDao.kt @@ -38,4 +38,7 @@ interface CustomAttrsDao { @Query("SELECT DISTINCT value FROM CustomAttributes WHERE type = 'tag' ORDER BY value") suspend fun getAllTags(): List + + @Query("SELECT key FROM CustomAttributes WHERE type = 'tag' AND value = :tag") + fun getItemsWithTag(tag: String): Flow> } \ No newline at end of file diff --git a/database/src/main/java/de/mm20/launcher2/database/SearchDao.kt b/database/src/main/java/de/mm20/launcher2/database/SearchDao.kt index 2962bfb7..7797cca4 100644 --- a/database/src/main/java/de/mm20/launcher2/database/SearchDao.kt +++ b/database/src/main/java/de/mm20/launcher2/database/SearchDao.kt @@ -144,7 +144,7 @@ interface SearchDao { fun getFavorite(key: String): FavoritesItemEntity? @Query("SELECT * FROM Searchable WHERE `key` IN (:keys)") - fun getFromKeys(keys: List): List + suspend fun getFromKeys(keys: List): List @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertReplaceExisting(toDatabaseEntity: FavoritesItemEntity) diff --git a/favorites/build.gradle.kts b/favorites/build.gradle.kts index efd42e99..b5030a44 100644 --- a/favorites/build.gradle.kts +++ b/favorites/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { implementation(libs.koin.android) + implementation(project(":base")) implementation(project(":search")) implementation(project(":calendar")) implementation(project(":database")) diff --git a/favorites/src/main/java/de/mm20/launcher2/favorites/FavoritesRepository.kt b/favorites/src/main/java/de/mm20/launcher2/favorites/FavoritesRepository.kt index 3141cbc4..3a70fd24 100644 --- a/favorites/src/main/java/de/mm20/launcher2/favorites/FavoritesRepository.kt +++ b/favorites/src/main/java/de/mm20/launcher2/favorites/FavoritesRepository.kt @@ -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): List + suspend fun getFromKeys(keys: List): List suspend fun export(toDir: File) suspend fun import(fromDir: File) @@ -379,7 +379,7 @@ internal class FavoritesRepositoryImpl( } } - override fun getFromKeys(keys: List): List { + override suspend fun getFromKeys(keys: List): List { val dao = database.searchDao() return dao.getFromKeys(keys) .mapNotNull { fromDatabaseEntity(it).searchable } diff --git a/favorites/src/main/java/de/mm20/launcher2/favorites/Serialization.kt b/favorites/src/main/java/de/mm20/launcher2/favorites/Serialization.kt index bf7c26b1..3fcc628f 100644 --- a/favorites/src/main/java/de/mm20/launcher2/favorites/Serialization.kt +++ b/favorites/src/main/java/de/mm20/launcher2/favorites/Serialization.kt @@ -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() } \ No newline at end of file diff --git a/favorites/src/main/java/de/mm20/launcher2/favorites/TagSerialization.kt b/favorites/src/main/java/de/mm20/launcher2/favorites/TagSerialization.kt new file mode 100644 index 00000000..6ef74007 --- /dev/null +++ b/favorites/src/main/java/de/mm20/launcher2/favorites/TagSerialization.kt @@ -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")) + } + +} \ No newline at end of file diff --git a/favorites/src/main/java/de/mm20/launcher2/search/data/Tag.kt b/favorites/src/main/java/de/mm20/launcher2/search/data/Tag.kt new file mode 100644 index 00000000..25765382 --- /dev/null +++ b/favorites/src/main/java/de/mm20/launcher2/search/data/Tag.kt @@ -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() + ) + } +} \ No newline at end of file diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 440d7e39..00129c3c 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -244,6 +244,8 @@ Pinned – manually sorted Drag items here + Tags + Pinned tags will appear here Nextcloud server URL diff --git a/ui/src/main/java/de/mm20/launcher2/ui/common/FavoritesVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/common/FavoritesVM.kt index 78806840..ac1bd282 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/common/FavoritesVM.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/common/FavoritesVM.kt @@ -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(null) + val pinnedTags = favoritesRepository.getFavorites( + includeTypes = listOf("tag"), + manuallySorted = true, + automaticallySorted = true, + ).map { + it.filterIsInstance() + } + val favorites: Flow> = 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>() + customAttributesRepository.getItemsForTag(tag) } }.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1) diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/helper/DragAndDropList.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/helper/DragAndDropList.kt new file mode 100644 index 00000000..47095be9 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/helper/DragAndDropList.kt @@ -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(null) + var draggedItemAbsolutePosition by mutableStateOf(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(null) + val droppedItemOffset = mutableStateOf(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) }, + ) +} diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheet.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheet.kt index d170772f..cdbd12bc 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheet.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheet.kt @@ -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(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 { diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheetVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheetVM.kt index addad655..6d3fd01f 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheetVM.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheetVM.kt @@ -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 = mutableListOf() private var frequentlyUsed: MutableList = mutableListOf() + val pinnedTags = MutableLiveData>(emptyList()) + val availableTags = MutableLiveData>(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().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() - 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() + } } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt index c1dd4f68..7f1bec86 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt @@ -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() } } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/favorites/FavoritesWidget.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/favorites/FavoritesWidget.kt index af624617..adbb01ef 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/favorites/FavoritesWidget.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/favorites/FavoritesWidget.kt @@ -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(),