Pin tags to favorites

This commit is contained in:
MM20 2022-09-23 22:38:44 +02:00
parent 60f36795ab
commit f16e9c2b40
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
15 changed files with 630 additions and 34 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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()
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(),