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

View File

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

View File

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

View File

@ -42,6 +42,7 @@ dependencies {
implementation(libs.koin.android)
implementation(project(":base"))
implementation(project(":search"))
implementation(project(":calendar"))
implementation(project(":database"))

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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