diff --git a/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt b/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt index 4ef8d385..3c4a15ac 100644 --- a/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt +++ b/base/src/main/java/de/mm20/launcher2/licenses/OpenSourceLicenses.kt @@ -239,11 +239,4 @@ val OpenSourceLicenses = arrayOf( licenseText = R.raw.license_apache_2, url = "https://bigbadaboom.github.io/androidsvg/" ), - OpenSourceLibrary( - name = "Compose LazyList/Grid reorder", - description = "A Jetpack Compose (Android + Desktop) modifier enabling reordering by drag and drop in a LazyList and LazyGrid.", - licenseName = R.string.apache_license_name, - licenseText = R.raw.license_apache_2, - url = "https://github.com/aclassen/ComposeReorderable" - ) ) \ 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 c5443b8c..f209add6 100644 --- a/database/src/main/java/de/mm20/launcher2/database/SearchDao.kt +++ b/database/src/main/java/de/mm20/launcher2/database/SearchDao.kt @@ -17,6 +17,9 @@ interface SearchDao { @Insert(onConflict = OnConflictStrategy.IGNORE) fun insertSkipExisting(items: FavoritesItemEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAllReplaceExisting(items: List) + @Query("SELECT * FROM Searchable " + "WHERE ((:manuallySorted AND pinned > 1) OR " + @@ -155,4 +158,6 @@ interface SearchDao { @Query("DELETE FROM Searchable WHERE hidden = 0") fun deleteAllFavorites() + @Query("UPDATE Searchable SET `pinned` = 0") + fun unpinAll() } 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 3ed79100..810d33c5 100644 --- a/favorites/src/main/java/de/mm20/launcher2/favorites/FavoritesRepository.kt +++ b/favorites/src/main/java/de/mm20/launcher2/favorites/FavoritesRepository.kt @@ -1,6 +1,7 @@ package de.mm20.launcher2.favorites import android.content.Context +import android.util.Log import de.mm20.launcher2.crashreporter.CrashReporter import de.mm20.launcher2.database.AppDatabase import de.mm20.launcher2.database.entities.FavoritesItemEntity @@ -17,12 +18,32 @@ import org.koin.core.component.KoinComponent import java.io.File interface FavoritesRepository { + @Deprecated("Use getFavorites(java.util.List, java.util.List, boolean, boolean, boolean, java.lang.Integer) instead.") fun getFavorites( columns: Int, maxRows: Int? = null, excludeCalendarEvents: Boolean = false ): Flow> + /** + * Get favorites + * @param includeTypes Include only items of these types. Cannot be used together with excludeTypes. + * @param excludeTypes Exclude only items of these types. Cannot be used together with includeTypes. + * @param manuallySorted Include items that have been sorted manually + * @param automaticallySorted Include items that are pinned but not sorted + * @param frequentlyUsed Include items that are not pinned but most frequently used + * @param limit Maximum number of items returned. + */ + fun getFavorites( + includeTypes: List? = null, + excludeTypes: List? = null, + manuallySorted: Boolean = false, + automaticallySorted: Boolean = false, + frequentlyUsed: Boolean = false, + limit: Int = 100 + ): Flow> + + fun getPinnedCalendarEvents(): Flow> fun getHiddenCalendarEventKeys(): Flow> fun isPinned(searchable: Searchable): Flow @@ -32,7 +53,11 @@ interface FavoritesRepository { fun hideItem(searchable: Searchable) fun unhideItem(searchable: Searchable) fun incrementLaunchCounter(searchable: Searchable) - fun saveFavorites(favorites: List) + fun updateFavorites( + manuallySorted: List, + automaticallySorted: List, + ) + fun getHiddenItems(): Flow> fun getHiddenItemKeys(): Flow> fun remove(searchable: Searchable) @@ -51,6 +76,13 @@ interface FavoritesRepository { suspend fun export(toDir: File) suspend fun import(fromDir: File) + + /** + * Remove database entries that are invalid. This includes + * - entries that cannot be deserialized anymore + * - entries that are inconsistent (the key column is not equal to the key of the searchable) + */ + suspend fun cleanupDatabase(): Int } internal class FavoritesRepositoryImpl( @@ -74,21 +106,19 @@ internal class FavoritesRepositoryImpl( excludeTypes = listOf("calendar"), manuallySorted = true, automaticallySorted = true, - frequentlyUsed = false, + frequentlyUsed = false, limit = columns * (maxRows ?: 20) ) } else { dao.getFavorites( manuallySorted = true, automaticallySorted = true, - frequentlyUsed = false, - limit = columns * (maxRows ?: 20)) + frequentlyUsed = false, + limit = columns * (maxRows ?: 20) + ) }.map { it.mapNotNull { val item = fromDatabaseEntity(it).searchable - if (item == null) { - dao.deleteByKey(it.key) - } return@mapNotNull item } } @@ -99,7 +129,7 @@ internal class FavoritesRepositoryImpl( val autoFavs = dao.getFavorites( manuallySorted = false, automaticallySorted = false, - frequentlyUsed = true, + frequentlyUsed = true, limit = favCount.coerceAtMost((maxRows ?: 20) * columns) - pinned.size ).first().mapNotNull { val item = fromDatabaseEntity(it).searchable @@ -113,6 +143,47 @@ internal class FavoritesRepositoryImpl( } } + override fun getFavorites( + includeTypes: List?, + excludeTypes: List?, + manuallySorted: Boolean, + automaticallySorted: Boolean, + frequentlyUsed: Boolean, + limit: Int + ): Flow> { + val dao = database.searchDao() + val entities = when { + includeTypes == null && excludeTypes == null -> dao.getFavorites( + manuallySorted = manuallySorted, + automaticallySorted = automaticallySorted, + frequentlyUsed = frequentlyUsed, + limit = limit + ) + includeTypes != null && excludeTypes == null -> { + dao.getFavoritesWithTypes( + includeTypes = includeTypes, + manuallySorted = manuallySorted, + automaticallySorted = automaticallySorted, + frequentlyUsed = frequentlyUsed, + limit = limit + ) + } + excludeTypes != null && includeTypes == null -> { + dao.getFavoritesWithoutTypes( + excludeTypes = excludeTypes, + manuallySorted = manuallySorted, + automaticallySorted = automaticallySorted, + frequentlyUsed = frequentlyUsed, + limit = limit + ) + } + else -> throw IllegalArgumentException("You can either use includeTypes or excludeTypes, not both") + } + return entities.map { + it.mapNotNull { fromDatabaseEntity(it).searchable } + } + } + override fun getPinnedCalendarEvents(): Flow> { return database.searchDao().getFavoritesWithTypes( includeTypes = listOf("calendar"), @@ -198,15 +269,6 @@ internal class FavoritesRepositoryImpl( } } - override fun saveFavorites(favorites: List) { - scope.launch { - withContext(Dispatchers.IO) { - AppDatabase.getInstance(context).searchDao() - .saveFavorites(favorites.mapNotNull { it.toDatabaseEntity() }) - } - } - } - override fun getHiddenItems(): Flow> { return database.searchDao().getHiddenItems().map { it.mapNotNull { fromDatabaseEntity(it).searchable } @@ -240,22 +302,71 @@ internal class FavoritesRepositoryImpl( } } + override fun updateFavorites( + manuallySorted: List, + automaticallySorted: List + ) { + val dao = database.searchDao() + scope.launch { + withContext(Dispatchers.IO) { + val keys = manuallySorted.map { it.key } + automaticallySorted.map { it.key } + val entities = dao.getFromKeys(keys) + val updatedManuallySorted = manuallySorted.mapIndexedNotNull { index, searchable -> + val entity = entities.find { searchable.key == it.key } ?: FavoritesItem( + key = searchable.key, + searchable = searchable, + launchCount = 0, + pinPosition = 0, + hidden = false, + ).toDatabaseEntity() ?: return@mapIndexedNotNull null + entity.pinPosition = manuallySorted.size - index + 1 + entity + } + val updatedAutomaticallySorted = automaticallySorted.mapIndexedNotNull { index, searchable -> + val entity = entities.find { searchable.key == it.key } ?: FavoritesItem( + key = searchable.key, + searchable = searchable, + launchCount = 0, + pinPosition = 0, + hidden = false, + ).toDatabaseEntity() ?: return@mapIndexedNotNull null + entity.pinPosition = 1 + entity + } + database.runInTransaction { + dao.unpinAll() + dao.insertAllReplaceExisting(updatedManuallySorted) + dao.insertAllReplaceExisting(updatedAutomaticallySorted) + } + } + } + } + private fun fromDatabaseEntity(entity: FavoritesItemEntity): FavoritesItem { val deserializer: SearchableDeserializer = getDeserializer(context, entity.serializedSearchable) + val searchable = deserializer.deserialize(entity.serializedSearchable.substringAfter("#")) + if (searchable == null) removeInvalidItem(entity.key) return FavoritesItem( key = entity.key, - searchable = deserializer.deserialize(entity.serializedSearchable.substringAfter("#")), + searchable = searchable, launchCount = entity.launchCount, pinPosition = entity.pinPosition, hidden = entity.hidden ) } + private fun removeInvalidItem(key: String) { + scope.launch { + database.searchDao().deleteByKey(key) + } + } + override fun getFromKeys(keys: List): List { val dao = database.searchDao() - return dao.getFromKeys(keys).mapNotNull { fromDatabaseEntity(it).searchable } + return dao.getFromKeys(keys) + .mapNotNull { fromDatabaseEntity(it).searchable } } override suspend fun export(toDir: File) = withContext(Dispatchers.IO) { @@ -315,4 +426,26 @@ internal class FavoritesRepositoryImpl( } } } + + override suspend fun cleanupDatabase(): Int { + var removed = 0 + val job = scope.launch { + val dao = database.backupDao() + var page = 0 + do { + val favorites = dao.exportFavorites(limit = 100, offset = page * 100) + for (fav in favorites) { + val item = fromDatabaseEntity(fav) + if (item.searchable == null || item.searchable.key != item.key) { + removeInvalidItem(item.key) + removed++ + Log.i("MM20", "SearchableDatabase cleanup: removed invalid item ${item.key}") + } + } + page++ + } while (favorites.size == 100) + } + job.join() + return removed + } } \ 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 0003cd74..e3e59cac 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -434,6 +434,13 @@ Use degrees Fahrenheit and miles per hour Imperial units Debug + Tools + Clean up database + Remove broken and unused entries from the launcher database + + %1$d entry has been removed. + %1$d entries have been removed. + Icons Cards Customize card appearance diff --git a/settings.gradle.kts b/settings.gradle.kts index a8121b84..fc374c39 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -382,10 +382,6 @@ dependencyResolutionManagement { alias("lottie") .to("com.airbnb.android", "lottie-compose") .version("5.2.0") - - alias("composereorderable") - .to("org.burnoutcrew.composereorderable", "reorderable") - .version("0.9.2") } } } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 35775e76..27ad79f6 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -112,7 +112,6 @@ dependencies { implementation(libs.coil.compose) implementation(libs.lottie) - implementation(libs.composereorderable) implementation(project(":material-color-utilities")) diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/ShapedLauncherIcon.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/ShapedLauncherIcon.kt index e169376f..6ca39158 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/component/ShapedLauncherIcon.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/ShapedLauncherIcon.kt @@ -8,7 +8,9 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.* +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -93,12 +95,16 @@ fun ShapedLauncherIcon( clip = currentIcon?.backgroundLayer !is TransparentLayer this.shape = shape } - .pointerInput(onClick, onLongClick) { - detectTapGestures( - onLongPress = { onLongClick?.invoke() }, - onTap = { onClick?.invoke() }, - ) - }, + .then( + if (onClick != null || onLongClick != null) { + Modifier.pointerInput(onClick, onLongClick) { + detectTapGestures( + onLongPress = { onLongClick?.invoke() }, + onTap = { onClick?.invoke() }, + ) + } + } else Modifier + ), contentAlignment = Alignment.Center ) { currentIcon?.let { @@ -124,12 +130,16 @@ fun ShapedLauncherIcon( modifier = Modifier .size(size * 0.33f) .align(Alignment.BottomEnd) - .pointerInput(onClick, onLongClick) { - detectTapGestures( - onLongPress = { onLongClick?.invoke() }, - onTap = { onClick?.invoke() }, - ) - }, + .then( + if (onClick != null || onLongClick != null) { + Modifier.pointerInput(onClick, onLongClick) { + detectTapGestures( + onLongPress = { onLongClick?.invoke() }, + onTap = { onClick?.invoke() }, + ) + } + } else Modifier + ), color = MaterialTheme.colorScheme.secondary, shape = CircleShape ) { @@ -468,34 +478,34 @@ private val PentagonShape: Shape } private val TeardropShape: Shape -get() = GenericShape { size, _ -> - moveTo(0.5f * size.width, 0f) - cubicTo( - 0.776f * size.width, 0f, - size.width, 0.224f * size.height, - size.width, 0.5f * size.height, - ) - lineTo( - size.width, 0.88f * size.height, - ) - cubicTo( - size.width, 0.946f * size.height, - 0.946f * size.width, size.height, - 0.88f * size.width, size.height, - ) - lineTo(0.5f * size.width, size.height) - cubicTo( - 0.224f * size.width, size.height, - 0f, 0.776f * size.height, - 0f, 0.5f * size.height, - ) - cubicTo( - 0f, 0.224f * size.height, - 0.224f * size.width, 0f, - 0.5f * size.width, 0f, - ) - close() -} + get() = GenericShape { size, _ -> + moveTo(0.5f * size.width, 0f) + cubicTo( + 0.776f * size.width, 0f, + size.width, 0.224f * size.height, + size.width, 0.5f * size.height, + ) + lineTo( + size.width, 0.88f * size.height, + ) + cubicTo( + size.width, 0.946f * size.height, + 0.946f * size.width, size.height, + 0.88f * size.width, size.height, + ) + lineTo(0.5f * size.width, size.height) + cubicTo( + 0.224f * size.width, size.height, + 0f, 0.776f * size.height, + 0f, 0.5f * size.height, + ) + cubicTo( + 0f, 0.224f * size.height, + 0.224f * size.width, 0f, + 0.5f * size.width, 0f, + ) + close() + } private val EasterEggShape: Shape get() = GenericShape { size, _ -> diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivityVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivityVM.kt index 3be824c1..e361e605 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivityVM.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivityVM.kt @@ -13,8 +13,6 @@ import org.koin.core.component.inject class LauncherActivityVM : ViewModel(), KoinComponent { private val dataStore: LauncherDataStore by inject() - val isEditFavoritesShown = MutableLiveData(false) - private var isDarkInMode = MutableStateFlow(false) private val dimBackgroundState = combine( @@ -46,13 +44,5 @@ class LauncherActivityVM : ViewModel(), KoinComponent { isDarkInMode.value = darkMode } - fun showEditFavorites() { - isEditFavoritesShown.value = true - } - - fun hideEditFavorites() { - isEditFavoritesShown.value = false - } - val layout = dataStore.data.map { it.appearance.layout }.asLiveData() } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/SharedLauncherActivity.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/SharedLauncherActivity.kt index c69a4124..65636fb8 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/SharedLauncherActivity.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/SharedLauncherActivity.kt @@ -42,7 +42,6 @@ import de.mm20.launcher2.ui.base.ProvideCurrentTime import de.mm20.launcher2.ui.base.ProvideSettings import de.mm20.launcher2.ui.component.NavBarEffects import de.mm20.launcher2.ui.ktx.animateTo -import de.mm20.launcher2.ui.launcher.modals.EditFavoritesView import de.mm20.launcher2.ui.launcher.transitions.HomeTransitionManager import de.mm20.launcher2.ui.launcher.transitions.LocalHomeTransitionManager import de.mm20.launcher2.ui.locals.LocalSnackbarHostState @@ -180,29 +179,6 @@ abstract class SharedLauncherActivity( } } } - - var editFavoritesDialog: MaterialDialog? = null - viewModel.isEditFavoritesShown.observe(this) { - if (it) { - val view = EditFavoritesView(this@SharedLauncherActivity) - editFavoritesDialog = - MaterialDialog(this, BottomSheet(LayoutMode.MATCH_PARENT)).show { - customView(view = view) - title(res = R.string.menu_item_edit_favs) - positiveButton(res = R.string.close) { - viewModel.hideEditFavorites() - it.dismiss() - } - onDismiss { - view.save() - viewModel.hideEditFavorites() - } - } - } else { - editFavoritesDialog?.dismiss() - editFavoritesDialog = null - } - } } override fun onAttachedToWindow() { diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/helper/DragAndDropGrid.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/helper/DragAndDropGrid.kt new file mode 100644 index 00000000..7aa793ff --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/helper/DragAndDropGrid.kt @@ -0,0 +1,317 @@ +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.grid.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +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 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 rememberLazyDragAndDropGridState( + gridState: LazyGridState = rememberLazyGridState(), + onDragStart: (item: LazyGridItemInfo) -> Boolean = { true }, + onDragEnd: (item: LazyGridItemInfo) -> Unit = {}, + onDragCancel: (item: LazyGridItemInfo) -> Unit = {}, + onItemMove: (from: LazyGridItemInfo, to: LazyGridItemInfo) -> Unit +): LazyDragAndDropGridState { + return remember { + LazyDragAndDropGridState( + gridState, + onDragStart, + onDragEnd, + onDragCancel, + onItemMove + ) + } +} + +data class LazyDragAndDropGridState( + val gridState: LazyGridState, + val onDragStart: (item: LazyGridItemInfo) -> Boolean = { true }, + val onDragEnd: (item: LazyGridItemInfo) -> Unit = {}, + val onDragCancel: (item: LazyGridItemInfo) -> Unit = {}, + val onItemMove: (from: LazyGridItemInfo, to: LazyGridItemInfo) -> Unit +) { + 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 = gridState.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(draggedItem: LazyGridItemInfo): Boolean { + if (!onDragStart(draggedItem)) return false + this.draggedItem = draggedItem + draggedItemAbsolutePosition = draggedItem.offset.toOffset() + return true + } + + /** + * 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. + */ + suspend fun attemptMove(dropTarget: LazyGridItemInfo) { + 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 = + gridState.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 + gridState.scrollBy(delta * timeDelta / 1000_000_000f) + lastFrame = frame + } + } + } + } + + /** + * Cancel scrolling + */ + fun endScrolling() { + currentScrollDelta = 0f + scrollJob?.cancel() + scrollJob = null + } +} + +@Composable +fun LazyVerticalDragAndDropGrid( + state: LazyDragAndDropGridState, + columns: GridCells, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + content: LazyGridScope.() -> Unit +) { + LazyVerticalGrid( + columns, + modifier.dragAndDrop( + state, + LocalLayoutDirection.current == LayoutDirection.Rtl, + LocalHapticFeedback.current + ), + state.gridState, + contentPadding, + false, + verticalArrangement, + horizontalArrangement, + flingBehavior, + userScrollEnabled, + content, + ) +} + +@Composable +fun LazyHorizontalDragAndDropGrid( + state: LazyDragAndDropGridState, + rows: GridCells, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + content: LazyGridScope.() -> Unit +) { + LazyHorizontalGrid( + rows, + modifier.dragAndDrop( + state, + LocalLayoutDirection.current == LayoutDirection.Rtl, + LocalHapticFeedback.current + ), + state.gridState, + contentPadding, + false, + horizontalArrangement, + verticalArrangement, + flingBehavior, + userScrollEnabled, + content, + ) +} + +fun Modifier.dragAndDrop( + state: LazyDragAndDropGridState, + isRtl: Boolean, + hapticFeedback: HapticFeedback +) = + this then pointerInput(null) { + val scope = CoroutineScope(coroutineContext) + val scrollEdgeSize = 32.dp.toPx() + val scrollDelta = 128.dp.toPx() + detectDragGesturesAfterLongPress( + onDragStart = { offset -> + val draggedItem = state.gridState.layoutInfo.visibleItemsInfo.find { + Rect( + it.offset.toOffset().let {off -> + if (isRtl) off.copy(x = state.gridState.layoutInfo.viewportSize.width - off.x - it.size.width) + else off + }, + it.size.toSize() + ).contains(offset) + } + if (draggedItem != null && state.startDrag(draggedItem)) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } + }, + onDrag = { change, dragAmount -> + val absPosition = state.draggedItemAbsolutePosition + val draggedItem = state.draggedItem + if (absPosition != null && draggedItem != null) { + state.draggedItemAbsolutePosition = absPosition + dragAmount.let { + if (isRtl) it.copy(x = -it.x) + else it + } + val draggedCenter = Rect(absPosition, draggedItem.size.toSize()).center + val dragOver = state.gridState.layoutInfo.visibleItemsInfo.find { + Rect( + it.offset.toOffset(), + it.size.toSize() + ).contains(draggedCenter) + } + + if (dragOver != null && dragOver.key != state.draggedItem?.key) { + scope.launch { + state.attemptMove(dragOver) + } + } + + val toStart = + if (state.gridState.layoutInfo.orientation == Orientation.Horizontal) draggedCenter.x else draggedCenter.y + + + if (toStart - state.gridState.layoutInfo.viewportStartOffset < scrollEdgeSize) { + scope.launch { + state.enableScrolling(-scrollDelta) + } + } else if (state.gridState.layoutInfo.viewportEndOffset - toStart < scrollEdgeSize) { + scope.launch { + state.enableScrolling(scrollDelta) + } + } else { + state.endScrolling() + } + } + }, + onDragCancel = { + scope.launch { state.cancelDrag() } + }, + onDragEnd = { + scope.launch { state.endDrag() } + }, + ) + } + +@Composable +fun LazyGridItemScope.DraggableItem( + modifier: Modifier = Modifier, + state: LazyDragAndDropGridState, + 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 new file mode 100644 index 00000000..7b948a08 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheet.kt @@ -0,0 +1,212 @@ +package de.mm20.launcher2.ui.launcher.modals + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.badges.Badge +import de.mm20.launcher2.icons.LauncherIcon +import de.mm20.launcher2.search.data.Searchable +import de.mm20.launcher2.ui.component.BottomSheetDialog +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 kotlin.math.roundToInt + +@Composable +fun EditFavoritesSheet( + onDismiss: () -> Unit, +) { + val viewModel: EditFavoritesSheetVM = viewModel() + + LaunchedEffect(null) { + viewModel.reload() + } + + val items by viewModel.gridItems.observeAsState(emptyList()) + val loading by viewModel.loading.observeAsState(true) + + val columns = 5 + + val state = rememberLazyDragAndDropGridState( + onDragStart = { + items.getOrNull(it.index) is FavoritesSheetGridItem.Favorite + } + ) { from, to -> + viewModel.moveItem(from, to) + } + + val iconSize = 48.dp.toPixels() + + BottomSheetDialog(onDismissRequest = onDismiss, title = { /*TODO*/ }) { + if (loading) { + Box(modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f)) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp).align(Alignment.Center) + ) + } + } else { + LazyVerticalDragAndDropGrid( + state = state, + columns = GridCells.Fixed(columns), + + ) { + items( + items.size, + key = { i -> + val it = items[i] + if (it is FavoritesSheetGridItem.Favorite) it.item.key else i + }, + span = { i -> + val it = items[i] + when (it) { + is FavoritesSheetGridItem.Favorite -> GridItemSpan(1) + is FavoritesSheetGridItem.Divider -> GridItemSpan(columns) + is FavoritesSheetGridItem.EmptySection -> GridItemSpan(columns) + is FavoritesSheetGridItem.Spacer -> GridItemSpan(it.span) + is FavoritesSheetGridItem.Tags -> GridItemSpan(columns) + } + } + ) { i -> + when (val it = items[i]) { + is FavoritesSheetGridItem.Favorite -> { + val icon by remember(it.item.key) { + viewModel.getIcon( + it.item, + iconSize.roundToInt() + ) + }.collectAsState(null) + val badge by remember(it.item.key) { + viewModel.getBadge( + it.item, + ) + }.collectAsState(null) + DraggableItem(state = state, key = it.item.key) { dragged -> + GridItem( + label = it.item.labelOverride ?: it.item.label, + icon = icon, + badge = badge + ) + } + } + is FavoritesSheetGridItem.Divider -> { + Text( + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), + text = stringResource(id = it.titleRes), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + is FavoritesSheetGridItem.EmptySection -> { + val shape = MaterialTheme.shapes.medium + val color = MaterialTheme.colorScheme.outline + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .drawBehind { + drawOutline( + outline = shape.createOutline( + size, + layoutDirection, + Density(density, fontScale) + ), + color = color, + style = Stroke( + 2.dp.toPx(), + pathEffect = PathEffect.dashPathEffect( + intervals = floatArrayOf( + 4.dp.toPx(), + 4.dp.toPx(), + ) + ) + ) + ) + } + ) { + Text( + modifier = Modifier + .align(Alignment.Center) + .padding( + horizontal = 16.dp, + vertical = 24.dp, + ), + text = "Drag items here", + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.outline + ) + } + } + is FavoritesSheetGridItem.Spacer -> { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) + } + is FavoritesSheetGridItem.Tags -> {} + } + } + } + + } + } +} + +@Composable +fun GridItem( + modifier: Modifier = Modifier, + label: String, + icon: LauncherIcon?, + badge: Badge? +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + ShapedLauncherIcon( + size = 48.dp, + icon = { icon }, + badge = { badge }) + Text( + label, + modifier = Modifier.padding(top = 8.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall + ) + } +} + +sealed interface FavoritesSheetGridItem { + class Favorite(val item: Searchable) : FavoritesSheetGridItem + class Divider(val titleRes: Int) : FavoritesSheetGridItem + class Spacer(val span: Int = 1) : FavoritesSheetGridItem + class EmptySection() : FavoritesSheetGridItem + class Tags() : FavoritesSheetGridItem +} \ No newline at end of file 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 new file mode 100644 index 00000000..7e3b30b5 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesSheetVM.kt @@ -0,0 +1,145 @@ +package de.mm20.launcher2.ui.launcher.modals + +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.mm20.launcher2.badges.Badge +import de.mm20.launcher2.badges.BadgeRepository +import de.mm20.launcher2.favorites.FavoritesRepository +import de.mm20.launcher2.icons.IconRepository +import de.mm20.launcher2.icons.LauncherIcon +import de.mm20.launcher2.search.data.Searchable +import de.mm20.launcher2.ui.R +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class EditFavoritesSheetVM : ViewModel(), KoinComponent { + + private val repository: FavoritesRepository by inject() + private val iconRepository: IconRepository by inject() + private val badgeRepository: BadgeRepository by inject() + + val gridItems = MutableLiveData>(emptyList()) + + val loading = MutableLiveData(false) + + private var manuallySorted: MutableList = mutableListOf() + private var automaticallySorted: MutableList = mutableListOf() + private var frequentlyUsed: MutableList = mutableListOf() + + suspend fun reload() { + loading.value = true + manuallySorted = mutableListOf() + manuallySorted = repository.getFavorites( + manuallySorted = true + ).first().toMutableList() + automaticallySorted = repository.getFavorites( + automaticallySorted = true + ).first().toMutableList() + frequentlyUsed = repository.getFavorites( + frequentlyUsed = true + ).first().toMutableList() + buildItemList() + loading.value = false + } + + private fun buildItemList() { + val items = mutableListOf() + + items.add(FavoritesSheetGridItem.Tags()) + + items.add(FavoritesSheetGridItem.Divider(R.string.edit_favorites_dialog_pinned_sorted)) + if (manuallySorted.isEmpty()) { + items.add(FavoritesSheetGridItem.EmptySection()) + } else { + items.addAll(manuallySorted.map { FavoritesSheetGridItem.Favorite(it) }) + items.add(FavoritesSheetGridItem.Spacer()) + } + + items.add(FavoritesSheetGridItem.Divider(R.string.edit_favorites_dialog_pinned_unsorted)) + if (automaticallySorted.isEmpty()) { + items.add(FavoritesSheetGridItem.EmptySection()) + } else { + items.addAll(automaticallySorted.map { FavoritesSheetGridItem.Favorite(it) }) + items.add(FavoritesSheetGridItem.Spacer()) + } + + items.add(FavoritesSheetGridItem.Divider(R.string.edit_favorites_dialog_unpinned)) + if (frequentlyUsed.isEmpty()) { + items.add(FavoritesSheetGridItem.EmptySection()) + } else { + items.addAll(frequentlyUsed.map { FavoritesSheetGridItem.Favorite(it) }) + items.add(FavoritesSheetGridItem.Spacer()) + } + + gridItems.value = items + } + + fun moveItem(from: LazyGridItemInfo, to: LazyGridItemInfo) { + gridItems.value?.getOrNull(from.index)?.takeIf { it is FavoritesSheetGridItem.Favorite } + ?: return + gridItems.value?.getOrNull(to.index) + ?.takeIf { + it is FavoritesSheetGridItem.Favorite || + it is FavoritesSheetGridItem.EmptySection || + it is FavoritesSheetGridItem.Spacer + } + ?: return + val manuallySortedSize = manuallySorted.size + 1 + val automaticallySortedSize = automaticallySorted.size + 1 + val item = when { + from.index < manuallySortedSize + 2 -> { + manuallySorted.removeAt(from.index - 2) + } + from.index < manuallySortedSize + automaticallySortedSize + 3 -> { + automaticallySorted.removeAt(from.index - 3 - manuallySortedSize) + } + else -> { + frequentlyUsed.removeAt(from.index - 4 - manuallySortedSize - automaticallySortedSize) + } + } + + when { + to.index < manuallySortedSize + 2 -> { + manuallySorted.add((to.index - 2).coerceAtMost(manuallySorted.size), item) + } + to.index < manuallySortedSize + automaticallySortedSize + 3 -> { + automaticallySorted.add( + (to.index - 3 - manuallySortedSize).coerceAtMost( + automaticallySorted.size + ), item + ) + } + else -> { + frequentlyUsed.add( + (to.index - 4 - manuallySortedSize - automaticallySortedSize).coerceAtMost( + frequentlyUsed.size + ), + item + ) + } + } + repository.updateFavorites( + buildList { + addAll(manuallySorted) + }, + buildList { + addAll(automaticallySorted) + }, + ) + buildItemList() + } + + fun getIcon(searchable: Searchable, size: Int): Flow { + return iconRepository.getIcon(searchable, size) + } + + fun getBadge(searchable: Searchable): Flow { + return badgeRepository.getBadge(searchable) + } + +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesVM.kt deleted file mode 100644 index fa76452a..00000000 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesVM.kt +++ /dev/null @@ -1,20 +0,0 @@ -package de.mm20.launcher2.ui.launcher.modals - -import androidx.lifecycle.ViewModel -import de.mm20.launcher2.favorites.FavoritesItem -import de.mm20.launcher2.favorites.FavoritesRepository -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -class EditFavoritesVM : ViewModel(), KoinComponent { - - private val repository: FavoritesRepository by inject() - - suspend fun getFavorites(): List { - return repository.getAllFavoriteItems() - } - - fun saveFavorites(favorites: List) { - repository.saveFavorites(favorites) - } -} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesView.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesView.kt deleted file mode 100644 index 54bdb257..00000000 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/EditFavoritesView.kt +++ /dev/null @@ -1,128 +0,0 @@ -package de.mm20.launcher2.ui.launcher.modals - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.widget.FrameLayout -import androidx.activity.viewModels -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -import de.mm20.launcher2.favorites.FavoritesItem -import de.mm20.launcher2.ktx.lifecycleScope -import de.mm20.launcher2.ui.R -import de.mm20.launcher2.ui.databinding.DialogEditFavoritesBinding -import de.mm20.launcher2.ui.databinding.EditFavoritesTitleBinding -import de.mm20.launcher2.ui.legacy.component.EditFavoritesRow -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class EditFavoritesView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : FrameLayout(context, attrs) { - - val viewModel: EditFavoritesVM by (context as AppCompatActivity).viewModels() - - private val binding = DialogEditFavoritesBinding.inflate(LayoutInflater.from(context), this) - - init { - lifecycleScope.launch { - initView() - } - } - - private lateinit var favorites: MutableList - - suspend fun initView() { - favorites = withContext(Dispatchers.IO) { - viewModel.getFavorites().toMutableList() - } - binding.progressBar.visibility = View.GONE - binding.itemList.addView(getLabel(R.string.edit_favorites_dialog_pinned_sorted)) - - binding.itemList.setContainerScrollView(binding.scrollView) - - var stage = 0 - for (favorite in favorites) { - if (favorite.pinPosition <= 1 && stage == 0) { - getLabel(R.string.edit_favorites_dialog_pinned_unsorted).let { - it.tag = "stage1" - binding.itemList.addDragView(it, it.getChildAt(1)) - } - stage++ - } - if (favorite.pinPosition == 0 && stage == 1) { - getLabel(R.string.edit_favorites_dialog_unpinned).let { - it.tag = "stage2" - binding.itemList.addDragView(it, it.getChildAt(1)) - } - stage++ - } - val view = EditFavoritesRow(context, favoritesItem = favorite) - binding.itemList.addDragView(view, view.getDragHandle()) - } - if (stage == 0) { - getLabel(R.string.edit_favorites_dialog_pinned_unsorted).let { - it.tag = "stage1" - binding.itemList.addDragView(it, it.getChildAt(1)) - } - stage++ - } - if (stage == 1) { - getLabel(R.string.edit_favorites_dialog_unpinned).let { - it.tag = "stage2" - binding.itemList.addDragView(it, it.getChildAt(1)) - } - } - - binding.itemList.setOnViewSwapListener { firstView, firstPosition, secondView, secondPosition -> - if (firstView is EditFavoritesRow && secondView is EditFavoritesRow) { - val firstItem = firstView.favoritesItem - val secondItem = secondView.favoritesItem - val i = firstItem.pinPosition - firstItem.pinPosition = secondItem.pinPosition - secondItem.pinPosition = i - return@setOnViewSwapListener - } - val fw = if (firstPosition > secondPosition) secondView else firstView - val sw = if (firstPosition > secondPosition) firstView else secondView - if (fw.tag == "stage1" && sw is EditFavoritesRow) { - favorites.forEach { - if (it.pinPosition > 1) { - it.pinPosition++ - } - } - sw.favoritesItem.pinPosition = 2 - return@setOnViewSwapListener - } - if (sw.tag == "stage1" && fw is EditFavoritesRow) { - favorites.forEach { - if (it.pinPosition > 1) { - it.pinPosition-- - } - } - return@setOnViewSwapListener - } - if (fw.tag == "stage2" && sw is EditFavoritesRow) { - sw.favoritesItem.pinPosition = 1 - return@setOnViewSwapListener - } - if (sw.tag == "stage2" && fw is EditFavoritesRow) { - fw.favoritesItem.pinPosition = 0 - return@setOnViewSwapListener - } - } - } - - fun save() { - viewModel.saveFavorites(favorites) - } - - private fun getLabel(@StringRes label: Int): FrameLayout { - return EditFavoritesTitleBinding.inflate(LayoutInflater.from(context)).also { - it.text.setText(label) - }.root - - } -} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBar.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBar.kt index 93996734..bfe34e64 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBar.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBar.kt @@ -95,15 +95,6 @@ fun SearchBar( style = style, overflowMenu = { show, onDismissRequest -> DropdownMenu(expanded = show, onDismissRequest = onDismissRequest) { - DropdownMenuItem( - onClick = { - activityViewModel.showEditFavorites() - onDismissRequest() - }, - text = { - Text(stringResource(R.string.menu_item_edit_favs)) - } - ) DropdownMenuItem( onClick = { context.startActivity( 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 b862ee50..c75f0320 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 @@ -1,18 +1,21 @@ package de.mm20.launcher2.ui.launcher.search +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope 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.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.res.stringResource @@ -23,6 +26,7 @@ import de.mm20.launcher2.search.data.Searchable import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.component.LauncherCard import de.mm20.launcher2.ui.component.PartialLauncherCard +import de.mm20.launcher2.ui.launcher.modals.EditFavoritesSheet import de.mm20.launcher2.ui.launcher.search.calculator.CalculatorItem import de.mm20.launcher2.ui.launcher.search.common.grid.GridItem import de.mm20.launcher2.ui.launcher.search.common.list.ListItem @@ -64,6 +68,8 @@ fun SearchColumn( val wikipedia by viewModel.wikipediaResult.observeAsState(null) val website by viewModel.websiteResult.observeAsState(null) + var showEditFavoritesDialog by remember { mutableStateOf(false) } + LazyColumn( state = state, @@ -77,6 +83,40 @@ fun SearchColumn( columns = columns, showLabels = showLabels, reverse = reverse, + after = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp, end = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()), + ) { + FilterChip( + modifier = Modifier.padding(start = 16.dp), + selected = true, + onClick = { /*TODO*/ }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = null + ) + }, + label = { Text("Favorites") } + ) + } + SmallFloatingActionButton( + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + onClick = { showEditFavoritesDialog = true } + ) { + Icon(imageVector = Icons.Rounded.Edit, contentDescription = null) + } + } + } ) } GridResults( @@ -90,7 +130,10 @@ fun SearchColumn( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp) - .padding(top = if (reverse) 4.dp else 8.dp, bottom = if (reverse) 8.dp else 4.dp), + .padding( + top = if (reverse) 4.dp else 8.dp, + bottom = if (reverse) 8.dp else 4.dp + ), ) { FilterChip( modifier = Modifier.padding(horizontal = 8.dp), @@ -100,7 +143,11 @@ fun SearchColumn( Icon(imageVector = Icons.Rounded.Person, contentDescription = null) }, label = { - Text(stringResource(R.string.apps_profile_main), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + stringResource(R.string.apps_profile_main), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } ) FilterChip( @@ -110,7 +157,11 @@ fun SearchColumn( Icon(imageVector = Icons.Rounded.Work, contentDescription = null) }, label = { - Text(stringResource(R.string.apps_profile_work), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + stringResource(R.string.apps_profile_work), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } ) } @@ -149,6 +200,12 @@ fun SearchColumn( HiddenResults() } } + + if (showEditFavoritesDialog) { + EditFavoritesSheet( + onDismiss = { showEditFavoritesDialog = false } + ) + } } fun LazyListScope.GridResults( diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreen.kt index 65da4443..0bee3dbd 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreen.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreen.kt @@ -1,16 +1,18 @@ package de.mm20.launcher2.ui.settings.debug import android.content.Intent +import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.core.content.FileProvider -import de.mm20.launcher2.crashreporter.CrashReporter +import androidx.lifecycle.viewmodel.compose.viewModel import de.mm20.launcher2.debug.DebugInformationDumper import de.mm20.launcher2.ktx.tryStartActivity import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.component.preferences.Preference +import de.mm20.launcher2.ui.component.preferences.PreferenceCategory import de.mm20.launcher2.ui.component.preferences.PreferenceScreen import de.mm20.launcher2.ui.locals.LocalNavController import kotlinx.coroutines.launch @@ -20,39 +22,61 @@ import java.io.File fun DebugSettingsScreen() { val context = LocalContext.current val scope = rememberCoroutineScope() + val viewModel: DebugSettingsScreenVM = viewModel() val navController = LocalNavController.current PreferenceScreen( stringResource(R.string.preference_screen_debug) ) { item { - Preference( - title = stringResource(R.string.preference_crash_reporter), - summary = stringResource(R.string.preference_crash_reporter_summary), - onClick = { - navController?.navigate("settings/debug/crashreporter") - }) + PreferenceCategory { + Preference( + title = stringResource(R.string.preference_crash_reporter), + summary = stringResource(R.string.preference_crash_reporter_summary), + onClick = { + navController?.navigate("settings/debug/crashreporter") + }) - Preference( - title = stringResource(R.string.preference_export_log), - onClick = { - scope.launch { - val path = DebugInformationDumper().dump(context) - context.tryStartActivity( - Intent.createChooser( - Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra( - Intent.EXTRA_STREAM, FileProvider.getUriForFile( - context, - context.applicationContext.packageName + ".fileprovider", - File(path) + Preference( + title = stringResource(R.string.preference_export_log), + onClick = { + scope.launch { + val path = DebugInformationDumper().dump(context) + context.tryStartActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra( + Intent.EXTRA_STREAM, FileProvider.getUriForFile( + context, + context.applicationContext.packageName + ".fileprovider", + File(path) + ) ) - ) - }, null + }, null + ) ) - ) - } - }) + } + }) + } + PreferenceCategory(stringResource(R.string.preference_category_debug_tools)) { + Preference( + title = stringResource(R.string.preference_debug_cleanup_database), + summary = stringResource(R.string.preference_debug_cleanup_database_summary), + onClick = { + scope.launch { + val removedCount = viewModel.cleanUpDatabase() + Toast.makeText( + context, + context.resources.getQuantityString( + R.plurals.debug_cleanup_database_result, + removedCount, + removedCount + ), + Toast.LENGTH_SHORT + ).show() + } + }) + } } } } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreenVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreenVM.kt index 8691412c..9c6b1a70 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreenVM.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreenVM.kt @@ -1,10 +1,16 @@ package de.mm20.launcher2.ui.settings.debug import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.mm20.launcher2.favorites.FavoritesRepository +import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.koin.core.component.inject class DebugSettingsScreenVM: ViewModel(), KoinComponent { - fun exportLog() { + val favoritesRepository: FavoritesRepository by inject() + suspend fun cleanUpDatabase(): Int { + return favoritesRepository.cleanupDatabase() } } \ No newline at end of file