diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 529374ff..4b0117ba 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -34,6 +34,7 @@ + diff --git a/app/ui/build.gradle.kts b/app/ui/build.gradle.kts index 30f66ba3..4a93b4fb 100644 --- a/app/ui/build.gradle.kts +++ b/app/ui/build.gradle.kts @@ -51,6 +51,7 @@ android { "-opt-in=androidx.compose.animation.ExperimentalAnimationApi", "-opt-in=com.google.accompanist.pager.ExperimentalPagerApi", "-opt-in=androidx.compose.animation.ExperimentalSharedTransitionApi", + "-Xwhen-guards", ) } @@ -95,6 +96,8 @@ dependencies { implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.navigationanimation) + implementation(libs.haze) + implementation(libs.androidx.core) implementation(libs.androidx.activitycompose) implementation(libs.bundles.androidx.lifecycle) diff --git a/app/ui/src/main/AndroidManifest.xml b/app/ui/src/main/AndroidManifest.xml index f13dbfb0..fb84288b 100644 --- a/app/ui/src/main/AndroidManifest.xml +++ b/app/ui/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ android:resumeWhilePausing="true" android:stateNotNeeded="true" android:theme="@style/LauncherTheme" + android:enableOnBackInvokedCallback="true" android:windowSoftInputMode="stateHidden|adjustResize"> @@ -37,11 +38,13 @@ android:name=".assistant.AssistantActivity" android:excludeFromRecents="true" android:exported="true" - android:launchMode="singleTask" + android:launchMode="singleTop" + android:taskAffinity="de.mm20.launcher2.assistant" android:resumeWhilePausing="true" android:stateNotNeeded="true" - android:theme="@style/LauncherTheme" - android:windowSoftInputMode="stateHidden|adjustResize"> + android:theme="@style/AssistantTheme" + android:windowSoftInputMode="stateHidden|adjustResize" + android:enableOnBackInvokedCallback="true"> diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/assistant/AssistantScaffold.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/assistant/AssistantScaffold.kt deleted file mode 100644 index c7cfd97a..00000000 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/assistant/AssistantScaffold.kt +++ /dev/null @@ -1,220 +0,0 @@ -package de.mm20.launcher2.ui.assistant - -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.* -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.repeatOnLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.accompanist.systemuicontroller.rememberSystemUiController -import de.mm20.launcher2.preferences.SearchBarColors -import de.mm20.launcher2.searchactions.actions.SearchAction -import de.mm20.launcher2.ui.component.SearchBarLevel -import de.mm20.launcher2.ui.launcher.LauncherScaffoldVM -import de.mm20.launcher2.ui.launcher.gestures.LauncherGestureHandler -import de.mm20.launcher2.ui.launcher.search.SearchColumn -import de.mm20.launcher2.ui.launcher.search.SearchVM -import de.mm20.launcher2.ui.launcher.searchbar.LauncherSearchBar -import de.mm20.launcher2.ui.locals.LocalCardStyle -import de.mm20.launcher2.ui.locals.LocalDarkTheme -import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first - -@Composable -fun AssistantScaffold( - modifier: Modifier = Modifier, - darkStatusBarIcons: Boolean = false, - darkNavBarIcons: Boolean = false, - bottomSearchBar: Boolean = false, - reverseSearchResults: Boolean = false, - fixedSearchBar: Boolean = false, -) { - val viewModel: LauncherScaffoldVM = viewModel() - val searchVM: SearchVM = viewModel() - - val context = LocalContext.current - - var searchBarFocused by remember { mutableStateOf(false) } - - val lifecycleOwner = LocalLifecycleOwner.current - val keyboardController = LocalSoftwareKeyboardController.current - LaunchedEffect(null) { - lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { - searchBarFocused = false - if (!viewModel.autoFocusSearch.first()) return@repeatOnLifecycle - delay(100) - searchBarFocused = true - keyboardController?.show() - } - } - - val searchState = rememberLazyListState() - - val filterBar by searchVM.filterBar.collectAsState(false) - - val keyboardFilterBarPadding by animateDpAsState( - if (WindowInsets.imeAnimationTarget.getBottom(LocalDensity.current) > 0 && !searchVM.showFilters.value && filterBar) 50.dp else 0.dp - ) - - val isSearchAtStart by remember { - derivedStateOf { - searchState.firstVisibleItemIndex == 0 && searchState.firstVisibleItemScrollOffset == 0 - } - } - - val isSearchAtEnd by remember { - derivedStateOf { - val lastItem = - searchState.layoutInfo.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf true - lastItem.offset + lastItem.size <= searchState.layoutInfo.viewportEndOffset - searchState.layoutInfo.afterContentPadding - } - } - - val searchBarLevel by remember { - derivedStateOf { - when { - reverseSearchResults == bottomSearchBar && isSearchAtStart -> SearchBarLevel.Active - reverseSearchResults != bottomSearchBar && isSearchAtEnd -> SearchBarLevel.Active - else -> SearchBarLevel.Raised - } - } - } - - val systemUiController = rememberSystemUiController() - val showStatusBarScrim by remember { - derivedStateOf { - if (reverseSearchResults) { - !isSearchAtEnd - } else { - !isSearchAtStart - } - } - } - - val showNavBarScrim by remember { - derivedStateOf { - if (reverseSearchResults) { - !isSearchAtStart - } else { - !isSearchAtEnd - } - } - } - - - val colorSurface = MaterialTheme.colorScheme.surface - val isDarkTheme = LocalDarkTheme.current - LaunchedEffect(darkStatusBarIcons, colorSurface, showStatusBarScrim) { - if (showStatusBarScrim) { - systemUiController.setStatusBarColor( - colorSurface.copy(0.7f), - ) - } else { - systemUiController.setStatusBarColor( - Color.Transparent, - darkIcons = !isDarkTheme, - ) - } - } - - LaunchedEffect(darkNavBarIcons, showNavBarScrim) { - if (showNavBarScrim) { - systemUiController.setNavigationBarColor( - colorSurface.copy(0.7f), - ) - } else { - systemUiController.setNavigationBarColor( - Color.Transparent, - darkIcons = darkNavBarIcons, - navigationBarContrastEnforced = false - ) - } - } - - val density = LocalDensity.current - val maxSearchBarOffset = with(density) { 128.dp.toPx() } - var searchBarOffset by remember { - mutableFloatStateOf(0f) - } - - val nestedScrollConnection = remember { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val y = available.y * if (reverseSearchResults) -1f else 1f - searchBarOffset = (searchBarOffset + y).coerceIn(-maxSearchBarOffset, 0f) - return super.onPreScroll(available, source) - } - } - } - val actions = searchVM.searchActionResults - val webSearchPadding by animateDpAsState( - if (actions.isEmpty()) 0.dp else 48.dp - ) - val windowInsets = WindowInsets.safeDrawing.asPaddingValues() - val cardStyle = LocalCardStyle.current - Box( - modifier = modifier - .fillMaxSize() - .background( - MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.85f * cardStyle.opacity) - ) - .nestedScroll(nestedScrollConnection) - ) { - SearchColumn( - modifier = Modifier.fillMaxSize(), - paddingValues = PaddingValues( - top = (if (bottomSearchBar) 0.dp else 64.dp + webSearchPadding) + 8.dp + windowInsets.calculateTopPadding(), - bottom = (if (bottomSearchBar) 64.dp + webSearchPadding else 0.dp) + 8.dp + windowInsets.calculateBottomPadding() + keyboardFilterBarPadding - ), - reverse = reverseSearchResults, - state = searchState - ) - - val value by searchVM.searchQuery - - val searchBarColor by viewModel.searchBarColor.collectAsState() - val searchBarStyle by viewModel.searchBarStyle.collectAsState() - - val launchOnEnter by searchVM.launchOnEnter.collectAsState(false) - - LauncherSearchBar( - modifier = Modifier - .fillMaxSize(), - style = searchBarStyle, - level = { searchBarLevel }, - value = { value }, - focused = searchBarFocused, - onFocusChange = { - if (it) viewModel.openSearch() - viewModel.setSearchbarFocus(it) - }, - actions = actions, - highlightedAction = searchVM.bestMatch.value as? SearchAction, - isSearchOpen = true, - darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == SearchBarColors.Auto || searchBarColor == SearchBarColors.Dark, - bottomSearchBar = bottomSearchBar, - searchBarOffset = { - (if (searchBarFocused || fixedSearchBar) 0 - else searchBarOffset.toInt() * if (bottomSearchBar) -1 else 1) - - (if (bottomSearchBar) with(density) { keyboardFilterBarPadding.toPx() }.toInt() else 0) - }, - onKeyboardActionGo = if (launchOnEnter) { - { searchVM.launchBestMatchOrAction(context) } - } else null - ) - } - LauncherGestureHandler() -} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/base/ProvideSettings.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/base/ProvideSettings.kt index da3e46b2..d4b0f9a7 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/base/ProvideSettings.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/base/ProvideSettings.kt @@ -9,6 +9,8 @@ import de.mm20.launcher2.ui.component.ProvideIconShape import de.mm20.launcher2.ui.locals.LocalCardStyle import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled import de.mm20.launcher2.ui.locals.LocalGridSettings +import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme +import de.mm20.launcher2.ui.theme.transparency.TransparencyScheme import de.mm20.launcher2.widgets.FavoritesWidget import de.mm20.launcher2.widgets.WidgetRepository import kotlinx.coroutines.flow.combine @@ -46,6 +48,10 @@ fun ProvideSettings( LocalCardStyle provides cardStyle, LocalFavoritesEnabled provides favoritesEnabled, LocalGridSettings provides gridSettings, + LocalTransparencyScheme provides TransparencyScheme( + background = cardStyle.opacity * 0.85f, + surface = cardStyle.opacity, + ) ) { ProvideIconShape(iconShape) { content() diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/common/FavoritesTagSelector.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/common/FavoritesTagSelector.kt index 004e5d41..d55c55dd 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/common/FavoritesTagSelector.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/common/FavoritesTagSelector.kt @@ -73,7 +73,6 @@ fun FavoritesTagSelector( Row( modifier = Modifier .weight(1f) - .consumeAllScrolling() .horizontalScroll(scrollState) .padding(end = 12.dp), ) { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/FakeSplashScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/FakeSplashScreen.kt index 2e44342f..f06f9bfb 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/FakeSplashScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/FakeSplashScreen.kt @@ -51,7 +51,6 @@ fun FakeSplashScreen( Surface( modifier = modifier .fillMaxSize(), - shadowElevation = 4.dp, color = animatedBackgroundColor, ) { Box( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/LauncherCard.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/LauncherCard.kt index 465fcebe..2391a478 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/LauncherCard.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/LauncherCard.kt @@ -25,12 +25,13 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import de.mm20.launcher2.ui.locals.LocalCardStyle +import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme @Composable fun LauncherCard( modifier: Modifier = Modifier, elevation: Dp = 2.dp, - backgroundOpacity: Float = LocalCardStyle.current.opacity, + backgroundOpacity: Float = LocalTransparencyScheme.current.surface, shape: Shape = MaterialTheme.shapes.medium, color: Color = MaterialTheme.colorScheme.surface.copy(alpha = backgroundOpacity.coerceIn(0f, 1f)), border: BorderStroke? = LocalCardStyle.current.borderWidth.takeIf { it > 0 } @@ -55,7 +56,7 @@ fun PartialLauncherCard( isTop: Boolean = false, isBottom: Boolean = false, elevation: Dp = 2.dp, - backgroundOpacity: Float = LocalCardStyle.current.opacity, + backgroundOpacity: Float = LocalTransparencyScheme.current.surface, content: @Composable () -> Unit ) { @@ -79,7 +80,7 @@ fun PartialLauncherCard( private fun CardMiddlePiece( modifier: Modifier, elevation: Dp, - backgroundOpacity: Float = LocalCardStyle.current.opacity, + backgroundOpacity: Float = LocalTransparencyScheme.current.surface, content: @Composable () -> Unit ) { val borderWidth = LocalCardStyle.current.borderWidth.dp diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt index 72b48f19..f1456400 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/SearchBar.kt @@ -47,6 +47,7 @@ import de.mm20.launcher2.preferences.SearchBarStyle import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.layout.BottomReversed import de.mm20.launcher2.ui.locals.LocalCardStyle +import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme @Composable fun SearchBar( @@ -102,7 +103,7 @@ fun SearchBar( } }) { when { - it == SearchBarLevel.Active -> LocalCardStyle.current.opacity + it == SearchBarLevel.Active -> LocalTransparencyScheme.current.surface style != SearchBarStyle.Transparent -> 1f it == SearchBarLevel.Resting -> 0f else -> 1f @@ -165,9 +166,6 @@ fun SearchBar( color = contentColor ) } - LaunchedEffect(level) { - if (level == SearchBarLevel.Resting) onUnfocus() - } BasicTextField( modifier = Modifier .onFocusChanged { @@ -207,7 +205,7 @@ fun SearchBar( } } -enum class SearchBarLevel { +enum class SearchBarLevel: Comparable { /** * The default, "hidden" state, when the launcher is in its initial state (scroll position is 0 * and search is closed) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/gestures/Gesture.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/gestures/Gesture.kt deleted file mode 100644 index dee5073b..00000000 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/gestures/Gesture.kt +++ /dev/null @@ -1,10 +0,0 @@ -package de.mm20.launcher2.ui.gestures - -enum class Gesture { - DoubleTap, - LongPress, - SwipeDown, - SwipeLeft, - SwipeRight, - HomeButton, -} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/gestures/GestureDetector.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/gestures/GestureDetector.kt deleted file mode 100644 index b06f01f4..00000000 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/gestures/GestureDetector.kt +++ /dev/null @@ -1,67 +0,0 @@ -package de.mm20.launcher2.ui.gestures - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.geometry.Offset - -class GestureDetector { - private var dragStart: Offset? = null - var currentDrag : Offset? = null - - var gestureListener: OnGestureListener? = null - - var shouldDetectDoubleTaps by mutableStateOf(false) - - fun dispatchTap(position: Offset) { - gestureListener?.onTap(position) - } - - fun dispatchDoubleTap(position: Offset) { - gestureListener?.onDoubleTap(position) - } - - fun dispatchLongPress(position: Offset) { - gestureListener?.onLongPress(position) - } - - private var hasDragEnded = false - fun dispatchDrag(offset: Offset) { - if (hasDragEnded) return - val totalDrag = currentDrag?.plus(offset) ?: offset - currentDrag = totalDrag - if (gestureListener?.onDrag(totalDrag) == true) hasDragEnded = true - } - - fun dispatchDragEnd() { - dragStart = null - currentDrag = null - hasDragEnded = false - gestureListener?.onDragEnd() - } - - fun dispatchHomeButtonPress() { - gestureListener?.onHomeButtonPress() - } - - interface OnGestureListener { - fun onTap(position: Offset) {} - fun onDoubleTap(position: Offset) {} - fun onLongPress(position: Offset) {} - - /** - * @return true if the drag gesture has been handled. - * The gesture detector will no longer track the drag gesture in this case. - */ - fun onDrag(offset: Offset): Boolean = false - - fun onDragEnd() {} - - fun onHomeButtonPress() {} - } -} - -val LocalGestureDetector = staticCompositionLocalOf { - GestureDetector() -} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/gestures/GestureHandler.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/gestures/GestureHandler.kt deleted file mode 100644 index b34ce185..00000000 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/gestures/GestureHandler.kt +++ /dev/null @@ -1,47 +0,0 @@ -package de.mm20.launcher2.ui.gestures - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.geometry.Offset - -@Composable -fun GestureHandler( - detector: GestureDetector, - onTap: (Offset) -> Unit = {}, - onLongPress: (Offset) -> Unit = {}, - onDoubleTap: (Offset) -> Unit = {}, - onDrag: (Offset) -> Boolean = { false }, - onDragEnd: () -> Unit = {}, - onHomeButtonPress: () -> Unit = {}, -) { - DisposableEffect(detector) { - detector.gestureListener = object : GestureDetector.OnGestureListener { - override fun onTap(position: Offset) { - onTap(position) - } - - override fun onLongPress(position: Offset) { - onLongPress(position) - } - - override fun onDoubleTap(position: Offset) { - onDoubleTap(position) - } - - override fun onDrag(offset: Offset): Boolean { - return onDrag(offset) - } - - override fun onDragEnd() { - onDragEnd() - } - - override fun onHomeButtonPress() { - onHomeButtonPress() - } - } - onDispose { - detector.gestureListener = null - } - } -} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivity.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivity.kt index 7600314d..d2529a3b 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivity.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivity.kt @@ -7,29 +7,14 @@ import com.android.launcher3.GestureNavContract class LauncherActivity: SharedLauncherActivity(LauncherActivityMode.Launcher) { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - val navContract = intent?.let { GestureNavContract.fromIntent(it) } + val navContract = intent.let { GestureNavContract.fromIntent(it) } if (navContract != null) { enterHomeTransitionManager.resolve(navContract, window) - } else if (System.currentTimeMillis() - pausedAt < 50) { - // If the onPause was called less than 50ms ago, we assume that the app was already - // in the foreground when the user pressed the home button. In this case, we dispatch - // the home button press event to the gesture detector. - gestureDetector.dispatchHomeButtonPress() } } - private var pausedAt: Long = 0 - override fun onPause() { super.onPause() enterHomeTransitionManager.clear() - pausedAt = System.currentTimeMillis() - } - - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - if (onBackPressedDispatcher.hasEnabledCallbacks()) { - onBackPressedDispatcher.onBackPressed() - } } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt index 0053db3a..48bba307 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt @@ -1,18 +1,9 @@ package de.mm20.launcher2.ui.launcher -import android.app.Activity -import android.content.Context -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.core.app.ActivityOptionsCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.mm20.launcher2.searchable.SavableSearchableRepository -import de.mm20.launcher2.globalactions.GlobalActionsService -import de.mm20.launcher2.permissions.PermissionGroup -import de.mm20.launcher2.permissions.PermissionsManager -import de.mm20.launcher2.preferences.BaseLayout import de.mm20.launcher2.preferences.ColorScheme import de.mm20.launcher2.preferences.GestureAction import de.mm20.launcher2.preferences.ScreenOrientation @@ -21,7 +12,6 @@ import de.mm20.launcher2.preferences.SearchBarStyle import de.mm20.launcher2.preferences.ui.GestureSettings import de.mm20.launcher2.preferences.ui.UiSettings import de.mm20.launcher2.search.SavableSearchable -import de.mm20.launcher2.ui.gestures.Gesture import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -29,7 +19,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -37,8 +26,6 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent { private val uiSettings: UiSettings by inject() private val gestureSettings: GestureSettings by inject() - private val globalActionsService: GlobalActionsService by inject() - private val permissionsManager: PermissionsManager by inject() private val searchableRepository: SavableSearchableRepository by inject() private var isSystemInDarkMode = MutableStateFlow(false) @@ -69,8 +56,6 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent { isSystemInDarkMode.value = darkMode } - val baseLayout = uiSettings.baseLayout - .stateIn(viewModelScope, SharingStarted.Eagerly, null) val bottomSearchBar = uiSettings.bottomSearchBar .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) val reverseSearchResults = uiSettings.reverseSearchResults @@ -81,49 +66,11 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent { .map { it != ScreenOrientation.Auto } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) - val isSearchOpen = mutableStateOf(false) - val isWidgetEditMode = mutableStateOf(false) - - val searchBarFocused = mutableStateOf(false) + val widgetsOnHomeScreen = uiSettings.homeScreenWidgets + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) val autoFocusSearch = uiSettings.openKeyboardOnSearch - fun setSearchbarFocus(focused: Boolean) { - if (searchBarFocused.value != focused) searchBarFocused.value = focused - } - - fun openSearch() { - if (isSearchOpen.value == true) return - isSearchOpen.value = true - viewModelScope.launch { - if (autoFocusSearch.first()) setSearchbarFocus(true) - } - } - - fun closeSearch() { - if (!isSearchOpen.value) return - isSearchOpen.value = false - setSearchbarFocus(false) - } - - var skipNextSearchAnimation = false - fun closeSearchWithoutAnimation() { - if (!isSearchOpen.value) return - skipNextSearchAnimation = true - isSearchOpen.value = false - setSearchbarFocus(false) - } - - fun toggleSearch() { - if (isSearchOpen.value == true) closeSearch() - else openSearch() - } - - fun setWidgetEditMode(editMode: Boolean) { - isSearchOpen.value = false - isWidgetEditMode.value = editMode - } - val wallpaperBlur = uiSettings.blurWallpaper .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), true) val wallpaperBlurRadius = uiSettings.wallpaperBlurRadius @@ -137,34 +84,27 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent { val searchBarStyle = uiSettings.searchBarStyle .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), SearchBarStyle.Transparent) - val gestureState: StateFlow = gestureSettings - .combine(baseLayout) { settings, layout -> - val swipeLeftAction = - settings.swipeLeft.takeIf { layout != BaseLayout.Pager } ?: GestureAction.NoAction - val swipeRightAction = settings.swipeRight.takeIf { layout != BaseLayout.PagerReversed } - ?: GestureAction.NoAction - val swipeDownAction = - settings.swipeDown.takeIf { layout != BaseLayout.PullDown } ?: GestureAction.NoAction + val gestureState: StateFlow = gestureSettings.map { settings -> + val swipeLeftAction = settings.swipeLeft + val swipeRightAction = settings.swipeRight + val swipeDownAction = settings.swipeDown + val swipeUpAction = settings.swipeUp val longPressAction = settings.longPress val doubleTapAction = settings.doubleTap val homeButtonAction = settings.homeButton - val swipeLeftAppKey = - if (swipeLeftAction is GestureAction.Launch) swipeLeftAction.key else null - val swipeRightAppKey = - if (swipeRightAction is GestureAction.Launch) swipeRightAction.key else null - val swipeDownAppKey = - if (swipeDownAction is GestureAction.Launch) swipeDownAction.key else null - val longPressAppKey = - if (longPressAction is GestureAction.Launch) longPressAction.key else null - val doubleTapAppKey = - if (doubleTapAction is GestureAction.Launch) doubleTapAction.key else null - val homeButtonAppKey = - if (homeButtonAction is GestureAction.Launch) homeButtonAction.key else null + val swipeLeftAppKey = (swipeLeftAction as? GestureAction.Launch)?.key + val swipeRightAppKey = (swipeRightAction as? GestureAction.Launch)?.key + val swipeDownAppKey = (swipeDownAction as? GestureAction.Launch)?.key + val swipeUpAppKey = (swipeUpAction as? GestureAction.Launch)?.key + val longPressAppKey = (longPressAction as? GestureAction.Launch)?.key + val doubleTapAppKey = (doubleTapAction as? GestureAction.Launch)?.key + val homeButtonAppKey = (homeButtonAction as? GestureAction.Launch)?.key val apps = listOfNotNull( swipeLeftAppKey, swipeRightAppKey, swipeDownAppKey, + swipeUpAppKey, longPressAppKey, doubleTapAppKey, homeButtonAppKey, @@ -174,117 +114,35 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent { swipeLeftAction = swipeLeftAction, swipeRightAction = swipeRightAction, swipeDownAction = swipeDownAction, + swipeUpAction = swipeUpAction, longPressAction = longPressAction, doubleTapAction = doubleTapAction, homeButtonAction = homeButtonAction, - swipeLeftApp = apps.firstOrNull { it.key == swipeLeftAppKey }, - swipeRightApp = apps.firstOrNull { it.key == swipeRightAppKey }, - swipeDownApp = apps.firstOrNull { it.key == swipeDownAppKey }, - longPressApp = apps.firstOrNull { it.key == longPressAppKey }, - doubleTapApp = apps.firstOrNull { it.key == doubleTapAppKey }, - homeButtonApp = apps.firstOrNull { it.key == homeButtonAppKey }, + swipeLeftApp = apps.find { it.key == swipeLeftAppKey }, + swipeRightApp = apps.find { it.key == swipeRightAppKey }, + swipeDownApp = apps.find { it.key == swipeDownAppKey }, + swipeUpApp = apps.find { it.key == swipeUpAppKey }, + longPressApp = apps.find { it.key == longPressAppKey }, + doubleTapApp = apps.find { it.key == doubleTapAppKey }, + homeButtonApp = apps.find { it.key == homeButtonAppKey }, ) - }.stateIn(viewModelScope, SharingStarted.Eagerly, GestureState()) - - var failedGestureState by mutableStateOf(null) - fun handleGesture(context: Context, gesture: Gesture): Boolean { - val action = when (gesture) { - Gesture.DoubleTap -> gestureState.value.doubleTapAction - Gesture.LongPress -> gestureState.value.longPressAction - Gesture.SwipeDown -> gestureState.value.swipeDownAction.takeIf { baseLayout.value != BaseLayout.PullDown } - Gesture.SwipeLeft -> gestureState.value.swipeLeftAction.takeIf { baseLayout.value != BaseLayout.Pager } - Gesture.SwipeRight -> gestureState.value.swipeRightAction.takeIf { baseLayout.value != BaseLayout.PagerReversed } - Gesture.HomeButton -> gestureState.value.homeButtonAction - } - val requiresAccessibilityService = - action is GestureAction.Recents - || action is GestureAction.PowerMenu - || action is GestureAction.QuickSettings - || action is GestureAction.Notifications - || action is GestureAction.ScreenLock - - if (action != null && requiresAccessibilityService && !permissionsManager.checkPermissionOnce( - PermissionGroup.Accessibility - ) - ) { - failedGestureState = FailedGesture(gesture, action) - return true - } - - - return when (action) { - is GestureAction.Search -> { - openSearch() - true - } - - is GestureAction.Notifications -> { - globalActionsService.openNotificationDrawer() - true - } - - is GestureAction.QuickSettings -> { - globalActionsService.openQuickSettings() - true - } - - is GestureAction.ScreenLock -> { - globalActionsService.lockScreen() - true - } - - is GestureAction.PowerMenu -> { - globalActionsService.openPowerDialog() - true - } - - is GestureAction.Recents -> { - globalActionsService.openRecents() - true - } - - is GestureAction.Launch -> { - val view = (context as Activity).window.decorView - val options = ActivityOptionsCompat.makeScaleUpAnimation( - view, - 0, - 0, - view.width, - view.height - ) - when (gesture) { - Gesture.SwipeLeft -> gestureState.value.swipeLeftApp - Gesture.SwipeRight -> gestureState.value.swipeRightApp - Gesture.SwipeDown -> gestureState.value.swipeDownApp - Gesture.LongPress -> gestureState.value.longPressApp - Gesture.DoubleTap -> gestureState.value.doubleTapApp - Gesture.HomeButton -> gestureState.value.homeButtonApp - }?.launch(context, options.toBundle()) - true - } - - else -> false - } - } - - fun dismissGestureFailedSheet() { - failedGestureState = null - } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) } data class GestureState( val swipeLeftAction: GestureAction = GestureAction.NoAction, val swipeRightAction: GestureAction = GestureAction.NoAction, val swipeDownAction: GestureAction = GestureAction.NoAction, + val swipeUpAction: GestureAction = GestureAction.NoAction, val longPressAction: GestureAction = GestureAction.NoAction, val doubleTapAction: GestureAction = GestureAction.NoAction, val homeButtonAction: GestureAction = GestureAction.NoAction, val swipeLeftApp: SavableSearchable? = null, val swipeRightApp: SavableSearchable? = null, val swipeDownApp: SavableSearchable? = null, + val swipeUpApp: SavableSearchable? = null, val longPressApp: SavableSearchable? = null, val doubleTapApp: SavableSearchable? = null, val homeButtonApp: SavableSearchable? = null, ) -data class FailedGesture(val gesture: Gesture, val action: GestureAction) \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt deleted file mode 100644 index 99fa329e..00000000 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PagerScaffold.kt +++ /dev/null @@ -1,785 +0,0 @@ -package de.mm20.launcher2.ui.launcher - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.slideIn -import androidx.compose.animation.slideOut -import androidx.compose.foundation.LocalOverscrollConfiguration -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.gestures.ScrollableState -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.drag -import androidx.compose.foundation.gestures.scrollBy -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imeAnimationTarget -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredWidth -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerDefaults -import androidx.compose.foundation.pager.PagerSnapDistance -import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Done -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.input.pointer.positionChange -import androidx.compose.ui.input.pointer.util.VelocityTracker -import androidx.compose.ui.input.pointer.util.addPointerInputChange -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.LocalViewConfiguration -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.Velocity -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.accompanist.systemuicontroller.rememberSystemUiController -import de.mm20.launcher2.preferences.SearchBarColors -import de.mm20.launcher2.searchactions.actions.SearchAction -import de.mm20.launcher2.ui.R -import de.mm20.launcher2.ui.component.SearchBarLevel -import de.mm20.launcher2.ui.gestures.LocalGestureDetector -import de.mm20.launcher2.ui.ktx.animateTo -import de.mm20.launcher2.ui.ktx.toPixels -import de.mm20.launcher2.ui.launcher.gestures.LauncherGestureHandler -import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur -import de.mm20.launcher2.ui.launcher.search.SearchColumn -import de.mm20.launcher2.ui.launcher.search.SearchVM -import de.mm20.launcher2.ui.launcher.searchbar.LauncherSearchBar -import de.mm20.launcher2.ui.launcher.widgets.WidgetColumn -import de.mm20.launcher2.ui.launcher.widgets.clock.ClockWidget -import de.mm20.launcher2.ui.locals.LocalCardStyle -import de.mm20.launcher2.ui.locals.LocalDarkTheme -import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper -import kotlinx.coroutines.launch -import kotlin.math.absoluteValue -import kotlin.math.pow -import kotlin.math.roundToInt - -@Composable -fun PagerScaffold( - modifier: Modifier = Modifier, - darkStatusBarIcons: Boolean = false, - darkNavBarIcons: Boolean = false, - reverse: Boolean = false, - bottomSearchBar: Boolean = true, - reverseSearchResults: Boolean = true, - fixedSearchBar: Boolean = false, -) { - val viewModel: LauncherScaffoldVM = viewModel() - val searchVM: SearchVM = viewModel() - - val context = LocalContext.current - - val hapticFeedback = LocalHapticFeedback.current - - val isSearchOpen by viewModel.isSearchOpen - val isWidgetEditMode by viewModel.isWidgetEditMode - - val actions = searchVM.searchActionResults - - val widgetsScrollState = rememberScrollState() - val searchState = rememberLazyListState() - - val pagerState = rememberPagerState { 2 } - - val filterBar by searchVM.filterBar.collectAsState(false) - - val keyboardFilterBarPadding by animateDpAsState( - if (WindowInsets.imeAnimationTarget.getBottom(LocalDensity.current) > 0 && !searchVM.showFilters.value && filterBar) 50.dp else 0.dp - ) - - val isSearchAtBottom by remember { - derivedStateOf { - if (reverseSearchResults) { - searchState.firstVisibleItemIndex == 0 && searchState.firstVisibleItemScrollOffset == 0 - } else { - val lastItem = - searchState.layoutInfo.visibleItemsInfo.lastOrNull() - ?: return@derivedStateOf true - lastItem.offset + lastItem.size <= searchState.layoutInfo.viewportEndOffset - searchState.layoutInfo.afterContentPadding - } - } - } - - val isSearchAtTop by remember { - derivedStateOf { - if (reverseSearchResults) { - val lastItem = - searchState.layoutInfo.visibleItemsInfo.lastOrNull() - ?: return@derivedStateOf true - lastItem.offset + lastItem.size <= searchState.layoutInfo.viewportEndOffset - searchState.layoutInfo.afterContentPadding - } else { - searchState.firstVisibleItemIndex == 0 && searchState.firstVisibleItemScrollOffset == 0 - } - } - } - - val showStatusBarScrim by remember { - derivedStateOf { - if (isSearchOpen) { - !isSearchAtTop - } else { - widgetsScrollState.value > 0 - } - } - } - - val fillClockHeight by viewModel.fillClockHeight.collectAsState() - - val showNavBarScrim by remember { - derivedStateOf { - if (isSearchOpen) { - !isSearchAtBottom - } else { - (widgetsScrollState.value > 0 || !fillClockHeight) && widgetsScrollState.value < widgetsScrollState.maxValue - } - } - } - - val isWidgetsScrollZero by remember { - derivedStateOf { - widgetsScrollState.value == 0 - } - } - - val systemUiController = rememberSystemUiController() - - val colorSurface = MaterialTheme.colorScheme.surface - val isDarkTheme = LocalDarkTheme.current - LaunchedEffect( - isWidgetEditMode, - darkStatusBarIcons, - colorSurface, - showStatusBarScrim, - isSearchOpen - ) { - if (isWidgetEditMode) { - systemUiController.setStatusBarColor( - colorSurface - ) - } else if (showStatusBarScrim) { - systemUiController.setStatusBarColor( - colorSurface.copy(0.7f), - ) - } else if (isSearchOpen) { - systemUiController.setStatusBarColor( - Color.Transparent, - darkIcons = !isDarkTheme, - ) - } else { - systemUiController.setStatusBarColor( - Color.Transparent, - darkIcons = darkStatusBarIcons - ) - } - } - - LaunchedEffect(darkNavBarIcons, showNavBarScrim) { - if (showNavBarScrim) { - systemUiController.setNavigationBarColor( - colorSurface.copy(0.7f), - ) - } else { - systemUiController.setNavigationBarColor( - Color.Transparent, - darkIcons = darkNavBarIcons, - navigationBarContrastEnforced = false - ) - } - } - - val blurEnabled by viewModel.wallpaperBlur.collectAsState() - val blurRadius by viewModel.wallpaperBlurRadius.collectAsState() - - val blurWallpaper by remember { - derivedStateOf { - blurEnabled && (isSearchOpen || !isWidgetsScrollZero) - } - } - - WallpaperBlur { - if (blurWallpaper) blurRadius else 0 - } - - val currentPage = pagerState.currentPage - LaunchedEffect(currentPage) { - if (currentPage == 1) viewModel.openSearch() - else viewModel.closeSearch() - if (pagerState.currentPageOffsetFraction != 0f) { - hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) - } - } - - LaunchedEffect(isSearchOpen) { - if (isSearchOpen) pagerState.animateScrollToPage(1) - else { - if (viewModel.skipNextSearchAnimation) { - pagerState.scrollToPage(0) - viewModel.skipNextSearchAnimation = false - } else { - pagerState.animateScrollToPage(0) - } - searchVM.reset() - } - } - - val searchBarOffset = remember { mutableFloatStateOf(0f) } - - val scope = rememberCoroutineScope() - - val handleBackOrHomeEvent = { - when { - isSearchOpen -> { - viewModel.closeSearch() - searchVM.reset() - true - } - - isWidgetEditMode -> { - viewModel.setWidgetEditMode(false) - true - } - - widgetsScrollState.value != 0 -> { - scope.launch { - widgetsScrollState.animateScrollTo(0) - } - scope.launch { - searchBarOffset.animateTo(0f) - } - true - } - - else -> false - } - } - - BackHandler { - handleBackOrHomeEvent() - } - - val gestureManager = LocalGestureDetector.current - - val density = LocalDensity.current - val maxSearchBarOffset = with(density) { 128.dp.toPx() } - - val pagerNestedScrollConnection = remember(reverse) { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val drag = gestureManager.currentDrag - if (drag != null && drag.y > 0 && (reverse && drag.x < 0 || !reverse && drag.x > 0)) { - gestureManager.dispatchDrag(available) - return available - } - if (drag != null && drag.y > 0) { - gestureManager.dispatchDrag(available.copy(x = 0f)) - return available.copy(x = 0f) - } - if (drag != null && (reverse && drag.x < 0 || !reverse && drag.x > 0)) { - gestureManager.dispatchDrag(available.copy(y = 0f)) - return available.copy(y = 0f) - } - return super.onPreScroll(available, source) - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - if (source == NestedScrollSource.UserInput && !isWidgetEditMode && available != Offset.Zero) { - gestureManager.dispatchDrag(available) - } - val deltaSearchBarOffset = - consumed.y * if (isSearchOpen && reverseSearchResults) 1 else -1 - searchBarOffset.floatValue = - (searchBarOffset.floatValue + deltaSearchBarOffset).coerceIn(0f, maxSearchBarOffset) - return super.onPostScroll(consumed, available, source) - } - - override suspend fun onPreFling(available: Velocity): Velocity { - val drag = gestureManager.currentDrag - if (drag != null && drag.y > 0 && (reverse && drag.x < 0 || !reverse && drag.x > 0)) { - gestureManager.dispatchDragEnd() - return available - } - if (drag != null && drag.y > 0) { - gestureManager.dispatchDragEnd() - return available.copy(x = 0f) - } - if (drag != null && (reverse && drag.x < 0 || !reverse && drag.x > 0)) { - gestureManager.dispatchDragEnd() - return available.copy(y = 0f) - } - gestureManager.dispatchDragEnd() - return super.onPreFling(available) - } - } - } - - val innerNestedScrollConnection = remember { - object : NestedScrollConnection {} - } - - LaunchedEffect(pagerState.currentPage) { - searchBarOffset.animateTo(0f) - } - - val searchNestedScrollConnection = remember { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - if (source == NestedScrollSource.UserInput && available.y.absoluteValue > available.x.absoluteValue * 2) { - viewModel.setSearchbarFocus(false) - searchVM.bestMatch.value = null - } - return super.onPreScroll(available, source) - } - } - } - - val insets = WindowInsets.safeDrawing.asPaddingValues() - - Box( - modifier = modifier - ) { - - BoxWithConstraints( - modifier = Modifier.fillMaxWidth() - ) { - val height by remember { - derivedStateOf { maxHeight } - } - val width by remember { - derivedStateOf { maxWidth } - } - - CompositionLocalProvider( - LocalOverscrollConfiguration provides null, - ) { - - val minFlingVelocity = 1000.dp.toPixels() - val colorSurfaceContainer = MaterialTheme.colorScheme.surfaceContainer - val cardStyle = LocalCardStyle.current - - HorizontalPager( - modifier = Modifier - .fillMaxSize() - .drawBehind { - drawRect( - color = colorSurfaceContainer.copy( - alpha = -pagerState.getOffsetDistanceInPages( - 0 - ) * 0.85f * cardStyle.opacity - ), - ) - } - .nestedScroll(pagerNestedScrollConnection), - beyondViewportPageCount = 1, - reverseLayout = reverse == (LocalLayoutDirection.current == LayoutDirection.Ltr), - state = pagerState, - userScrollEnabled = false,//!isWidgetEditMode, - flingBehavior = PagerDefaults.flingBehavior( - state = pagerState, - pagerSnapDistance = remember { - object : PagerSnapDistance { - override fun calculateTargetPage( - startPage: Int, - suggestedTargetPage: Int, - velocity: Float, - pageSize: Int, - pageSpacing: Int - ): Int { - if (velocity.absoluteValue < minFlingVelocity) { - return startPage - } - return suggestedTargetPage - } - } - } - ), - pageNestedScrollConnection = innerNestedScrollConnection, - ) { - when (it) { - 0 -> { - val editModePadding by animateDpAsState(if (isWidgetEditMode && bottomSearchBar) 56.dp else 0.dp) - - val clockPadding by animateDpAsState( - if (isWidgetsScrollZero && fillClockHeight) - insets.calculateBottomPadding() + if (bottomSearchBar) 64.dp else 0.dp - else 0.dp - ) - - val clockHeight by remember { - derivedStateOf { - if (fillClockHeight) { - height - (64.dp + insets.calculateTopPadding() + insets.calculateBottomPadding() - clockPadding) - } else { - null - } - } - } - - Column( - modifier = Modifier - .requiredWidth(width) - .fillMaxHeight() - .pointerInput(gestureManager.shouldDetectDoubleTaps) { - detectTapGestures( - onDoubleTap = if (gestureManager.shouldDetectDoubleTaps) { - { - if (!isWidgetEditMode) gestureManager.dispatchDoubleTap( - it - ) - } - } else null, - onLongPress = { - if (!isWidgetEditMode) gestureManager.dispatchLongPress( - it - ) - }, - onTap = { - if (!isWidgetEditMode) gestureManager.dispatchTap(it) - }, - ) - } - .pagerScaffoldScrollHandler( - pagerState, - widgetsScrollState, - reversePager = reverse, - disablePager = isWidgetEditMode, - ) - .verticalScroll(widgetsScrollState, enabled = false) - .windowInsetsPadding(WindowInsets.safeDrawing) - .graphicsLayer { - val pagerProgress = - pagerState.currentPage + pagerState.currentPageOffsetFraction - alpha = 1f - pagerProgress - } - .padding(8.dp) - .padding( - top = if (bottomSearchBar) 0.dp else 56.dp, - bottom = if (bottomSearchBar) 56.dp else 0.dp, - ) - .padding(top = editModePadding) - ) { - ClockWidget( - modifier = Modifier - .fillMaxWidth() - .then(clockHeight?.let { Modifier.height(it) } ?: Modifier) - .padding(bottom = clockPadding), - editMode = isWidgetEditMode, - fillScreenHeight = fillClockHeight, - ) - - WidgetColumn( - modifier = Modifier.fillMaxWidth(), - editMode = isWidgetEditMode, - onEditModeChange = { - viewModel.setWidgetEditMode(it) - } - ) - } - } - - 1 -> { - val webSearchPadding by animateDpAsState( - if (actions.isEmpty()) 0.dp else 48.dp - ) - val windowInsets = WindowInsets.safeDrawing.asPaddingValues() - val paddingValues = if (bottomSearchBar) { - PaddingValues( - top = 8.dp + windowInsets.calculateTopPadding(), - bottom = 64.dp + webSearchPadding + windowInsets.calculateBottomPadding() + keyboardFilterBarPadding - ) - } else { - PaddingValues( - bottom = 8.dp + windowInsets.calculateBottomPadding() + keyboardFilterBarPadding, - top = 64.dp + webSearchPadding + windowInsets.calculateTopPadding() - ) - } - SearchColumn( - modifier = Modifier - .requiredWidth(width) - .fillMaxHeight() - .graphicsLayer { - val pagerProgress = - pagerState.currentPage + pagerState.currentPageOffsetFraction - alpha = pagerProgress - } - .nestedScroll(searchNestedScrollConnection) - .pagerScaffoldScrollHandler( - pagerState, - searchState, - reversePager = reverse, - reverseScroll = reverseSearchResults - ) - .padding( - start = windowInsets.calculateStartPadding( - LocalLayoutDirection.current - ), - end = windowInsets.calculateStartPadding( - LocalLayoutDirection.current - ), - ), - reverse = reverseSearchResults, - state = searchState, - paddingValues = paddingValues, - userScrollEnabled = false, - ) - } - } - } - } - } - AnimatedVisibility(visible = isWidgetEditMode, - enter = slideIn { IntOffset(0, -it.height) }, - exit = slideOut { IntOffset(0, -it.height) } - ) { - CenterAlignedTopAppBar( - modifier = Modifier.systemBarsPadding(), - title = { - Text(stringResource(R.string.menu_edit_widgets)) - }, - navigationIcon = { - IconButton(onClick = { viewModel.setWidgetEditMode(false) }) { - Icon(imageVector = Icons.Rounded.Done, contentDescription = stringResource(R.string.action_done)) - } - }, - ) - } - - val searchBarLevel by remember { - derivedStateOf { - when { - pagerState.currentPageOffsetFraction != 0f -> SearchBarLevel.Raised - !isSearchOpen && isWidgetsScrollZero && (fillClockHeight || !bottomSearchBar) -> SearchBarLevel.Resting - isSearchOpen && isSearchAtTop && !bottomSearchBar -> SearchBarLevel.Active - isSearchOpen && isSearchAtBottom && bottomSearchBar -> SearchBarLevel.Active - else -> SearchBarLevel.Raised - } - } - } - - val focusSearchBar by viewModel.searchBarFocused - - val widgetEditModeOffset by animateDpAsState( - (if (isWidgetEditMode) 128.dp else 0.dp) * (if (bottomSearchBar) 1 else -1) - ) - - val value by searchVM.searchQuery - - val searchBarColor by viewModel.searchBarColor.collectAsState() - val searchBarStyle by viewModel.searchBarStyle.collectAsState() - - val launchOnEnter by searchVM.launchOnEnter.collectAsState(false) - - LauncherSearchBar( - modifier = Modifier - .fillMaxSize(), - style = searchBarStyle, - level = { searchBarLevel }, - value = { value }, - focused = focusSearchBar, - onFocusChange = { - if (it) viewModel.openSearch() - viewModel.setSearchbarFocus(it) - }, - actions = actions, - highlightedAction = searchVM.bestMatch.value as? SearchAction, - isSearchOpen = isSearchOpen, - darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == SearchBarColors.Auto || searchBarColor == SearchBarColors.Dark, - bottomSearchBar = bottomSearchBar, - searchBarOffset = { - (if (focusSearchBar || fixedSearchBar) 0 else searchBarOffset.value.toInt() * if (bottomSearchBar) 1 else -1) + - with(density) { - (widgetEditModeOffset - if (bottomSearchBar) keyboardFilterBarPadding else 0.dp) - .toPx() - .roundToInt() - } - }, - onKeyboardActionGo = if (launchOnEnter) { - { searchVM.launchBestMatchOrAction(context) } - } else null - ) - } - LauncherGestureHandler( - onHomeButtonPress = handleBackOrHomeEvent, - ) -} - -fun Modifier.pagerScaffoldScrollHandler( - pagerState: PagerState, - scrollableState: ScrollableState, - reversePager: Boolean = false, - reverseScroll: Boolean = false, - disablePager: Boolean = false, -) = composed { - val scope = rememberCoroutineScope() - val flingBehavior = ScrollableDefaults.flingBehavior() - val nestedScrollDispatcher = remember { NestedScrollDispatcher() } - val touchSlopSq = LocalViewConfiguration.current.touchSlop.pow(2) - this - .nestedScroll(DefaultNestedScrollConnection, nestedScrollDispatcher) - .pointerInput(scrollableState, pagerState, reversePager, reverseScroll, disablePager) { - val velocityTracker = VelocityTracker() - val lockScrollThreshold = 200.dp.toPx() - val pagerMultiplier = if (reversePager) 1f else -1f - val scrollMultiplier = if (reverseScroll) 1f else -1f - - - awaitEachGesture { - var overSlop = false - val initialDown = - awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) - val down = - if (scrollableState.isScrollInProgress || pagerState.isScrollInProgress) { - overSlop = true - scope.launch { - scrollableState.scrollBy(0f) - pagerState.scrollBy(0f) - } - initialDown - } else { - awaitFirstDown(requireUnconsumed = false) - } - velocityTracker.resetTracking() - velocityTracker.addPointerInputChange(down) - val canceled = !drag(down.id) { - if (it.isConsumed) return@drag - val totalDrag = down.position - it.position - if (!overSlop && totalDrag.getDistanceSquared() > touchSlopSq) { - overSlop = true - } - if (!overSlop) return@drag - val dragAmount = it - .positionChange() - .let { - if (it.x.absoluteValue > it.y.absoluteValue) it.copy(y = 0f) else it.copy( - x = 0f - ) - } - it.consume() - velocityTracker.addPointerInputChange(it) - scope.launch { - val preConsumed = nestedScrollDispatcher.dispatchPreScroll( - dragAmount, - NestedScrollSource.UserInput - ) - val available = dragAmount - preConsumed - val consumedY = - scrollableState.scrollBy(available.y * scrollMultiplier) * scrollMultiplier - val consumedX = - if (disablePager) 0f - else pagerState.scrollBy(available.x * pagerMultiplier) * pagerMultiplier - val totalConsumed = - Offset(preConsumed.x + consumedX, preConsumed.y + consumedY) - nestedScrollDispatcher.dispatchPostScroll( - totalConsumed, - dragAmount - totalConsumed, - NestedScrollSource.UserInput - ) - } - } - val velocity = velocityTracker - .calculateVelocity() - - if (canceled || velocity.x.absoluteValue > velocity.y.absoluteValue) { - scope.launch { - val preConsumed = nestedScrollDispatcher.dispatchPreFling(velocity) - val flingVelocity = (velocity - preConsumed).x - - if (!disablePager) { - if (flingVelocity.absoluteValue > 400.dp.toPx()) { - if (flingVelocity * pagerMultiplier < 0) { - pagerState.animateScrollToPage(pagerState.settledPage - 1) - } else { - pagerState.animateScrollToPage(pagerState.settledPage + 1) - } - } else { - pagerState.animateScrollToPage(pagerState.settledPage) - } - } - - nestedScrollDispatcher.dispatchPostFling( - velocity, - Velocity.Zero, - ) - } - } else { - scope.launch { - val preConsumed = nestedScrollDispatcher.dispatchPreFling(velocity) - val flingVelocity = (velocity - preConsumed).y - var consumed = 0f - launch { - with(flingBehavior) { - scrollableState.scroll { - consumed = - performFling(flingVelocity * scrollMultiplier) * scrollMultiplier - } - } - val totalConsumed = - Velocity(preConsumed.x, preConsumed.y + consumed) - nestedScrollDispatcher.dispatchPostFling( - totalConsumed, - velocity - totalConsumed - ) - } - launch { - pagerState.animateScrollToPage(pagerState.settledPage) - } - } - } - } - } -} - -internal object DefaultNestedScrollConnection : NestedScrollConnection {} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt deleted file mode 100644 index 7e765b1b..00000000 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt +++ /dev/null @@ -1,633 +0,0 @@ -package de.mm20.launcher2.ui.launcher - -import android.util.Log -import android.view.HapticFeedbackConstants -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.slideIn -import androidx.compose.animation.slideOut -import androidx.compose.foundation.LocalOverscrollConfiguration -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imeAnimationTarget -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.pager.VerticalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Done -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.Velocity -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.accompanist.systemuicontroller.rememberSystemUiController -import de.mm20.launcher2.preferences.SearchBarColors -import de.mm20.launcher2.searchactions.actions.SearchAction -import de.mm20.launcher2.ui.R -import de.mm20.launcher2.ui.component.SearchBarLevel -import de.mm20.launcher2.ui.gestures.LocalGestureDetector -import de.mm20.launcher2.ui.ktx.animateTo -import de.mm20.launcher2.ui.launcher.gestures.LauncherGestureHandler -import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur -import de.mm20.launcher2.ui.launcher.search.SearchColumn -import de.mm20.launcher2.ui.launcher.search.SearchVM -import de.mm20.launcher2.ui.launcher.searchbar.LauncherSearchBar -import de.mm20.launcher2.ui.launcher.widgets.WidgetColumn -import de.mm20.launcher2.ui.launcher.widgets.clock.ClockWidget -import de.mm20.launcher2.ui.locals.LocalCardStyle -import de.mm20.launcher2.ui.locals.LocalDarkTheme -import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper -import kotlinx.coroutines.launch -import kotlin.math.absoluteValue -import kotlin.math.min -import kotlin.math.roundToInt - -@Composable -fun PullDownScaffold( - modifier: Modifier = Modifier, - darkStatusBarIcons: Boolean = false, - darkNavBarIcons: Boolean = false, - bottomSearchBar: Boolean = false, - reverseSearchResults: Boolean = false, - fixedSearchBar: Boolean = false, -) { - val viewModel: LauncherScaffoldVM = viewModel() - val searchVM: SearchVM = viewModel() - - val density = LocalDensity.current - val context = LocalContext.current - - val actions = searchVM.searchActionResults - - val isSearchOpen by viewModel.isSearchOpen - val isWidgetEditMode by viewModel.isWidgetEditMode - - val widgetsScrollState = rememberScrollState() - val searchState = rememberLazyListState() - - val pagerState = rememberPagerState { 2 } - - val offsetY = remember { mutableFloatStateOf(0f) } - val maxOffset = with(density) { 64.dp.toPx() } - val toggleSearchThreshold = with(density) { 48.dp.toPx() } - - val filterBar by searchVM.filterBar.collectAsState(false) - val keyboardFilterBarPadding by animateDpAsState( - if (WindowInsets.imeAnimationTarget.getBottom(LocalDensity.current) > 0 && !searchVM.showFilters.value && filterBar) 50.dp else 0.dp - ) - - val isSearchAtTop by remember { - derivedStateOf { - if (reverseSearchResults) { - val lastItem = - searchState.layoutInfo.visibleItemsInfo.lastOrNull() - ?: return@derivedStateOf true - lastItem.offset + lastItem.size <= searchState.layoutInfo.viewportEndOffset - searchState.layoutInfo.afterContentPadding - } else { - searchState.firstVisibleItemIndex == 0 && searchState.firstVisibleItemScrollOffset == 0 - } - } - } - - val isSearchAtBottom by remember { - derivedStateOf { - if (reverseSearchResults) { - searchState.firstVisibleItemIndex == 0 && searchState.firstVisibleItemScrollOffset == 0 - } else { - val lastItem = - searchState.layoutInfo.visibleItemsInfo.lastOrNull() - ?: return@derivedStateOf true - lastItem.offset + lastItem.size <= searchState.layoutInfo.viewportEndOffset - searchState.layoutInfo.afterContentPadding - } - } - } - - val isOverThreshold by remember { - derivedStateOf { - offsetY.floatValue.absoluteValue > toggleSearchThreshold - } - } - - val dragProgress by remember { - derivedStateOf { - (offsetY.floatValue.absoluteValue / toggleSearchThreshold).coerceAtMost(1f) - } - } - - val systemUiController = rememberSystemUiController() - - val isWidgetsAtStart by remember { - derivedStateOf { - widgetsScrollState.value == 0 - } - } - - val isWidgetsAtEnd by remember { - derivedStateOf { - widgetsScrollState.value >= widgetsScrollState.maxValue - } - } - - val fillClockHeight by viewModel.fillClockHeight.collectAsState() - - val showStatusBarScrim by remember { - derivedStateOf { - if (isSearchOpen) { - !isSearchAtTop - } else { - widgetsScrollState.value > 0 - } - } - } - val showNavBarScrim by remember { - derivedStateOf { - if (isSearchOpen) { - !isSearchAtBottom - } else { - (widgetsScrollState.value > 0 || !fillClockHeight) && widgetsScrollState.value < widgetsScrollState.maxValue - } - } - } - - val colorSurface = MaterialTheme.colorScheme.surface - val isDarkTheme = LocalDarkTheme.current - LaunchedEffect(isWidgetEditMode, darkStatusBarIcons, colorSurface, showStatusBarScrim, isSearchOpen) { - if (isWidgetEditMode) { - systemUiController.setStatusBarColor( - colorSurface - ) - } else if (showStatusBarScrim) { - systemUiController.setStatusBarColor( - colorSurface.copy(0.7f), - ) - } else if (isSearchOpen) { - systemUiController.setStatusBarColor( - Color.Transparent, - darkIcons = !isDarkTheme, - ) - } else { - systemUiController.setStatusBarColor( - Color.Transparent, - darkIcons = darkStatusBarIcons - ) - } - } - - LaunchedEffect(darkNavBarIcons, showNavBarScrim) { - if (showNavBarScrim) { - systemUiController.setNavigationBarColor( - colorSurface.copy(0.7f), - ) - } else { - systemUiController.setNavigationBarColor( - Color.Transparent, - darkIcons = darkNavBarIcons, - navigationBarContrastEnforced = false - ) - } - } - - val searchBarOffset = remember { mutableFloatStateOf(0f) } - - val maxSearchBarOffset = with(density) { 128.dp.toPx() } - - val blurEnabled by viewModel.wallpaperBlur.collectAsState() - val blurRadius by viewModel.wallpaperBlurRadius.collectAsState() - - val blurWallpaper by remember { - derivedStateOf { - blurEnabled && (isSearchOpen || isOverThreshold || widgetsScrollState.value > 0) - } - } - - WallpaperBlur { - if (blurWallpaper) blurRadius else 0 - } - - - val scope = rememberCoroutineScope() - - LaunchedEffect(isSearchOpen) { - launch { - searchBarOffset.animateTo(0f) - } - if (isSearchOpen) { - searchState.scrollToItem(0) - pagerState.animateScrollToPage( - 1, - animationSpec = spring(stiffness = Spring.StiffnessMediumLow) - ) - } else { - searchVM.reset() - if (viewModel.skipNextSearchAnimation) { - pagerState.scrollToPage(0) - viewModel.skipNextSearchAnimation = false - } else { - pagerState.animateScrollToPage( - 0, - animationSpec = spring(stiffness = Spring.StiffnessMediumLow) - ) - } - } - } - - LaunchedEffect(isWidgetEditMode) { - if (!isWidgetEditMode) searchBarOffset.floatValue = 0f - } - - val handleBackOrHomeEvent = { - when { - isSearchOpen -> { - viewModel.closeSearch() - searchVM.reset() - true - } - - isWidgetEditMode -> { - viewModel.setWidgetEditMode(false) - true - } - - widgetsScrollState.value != 0 -> { - scope.launch { - widgetsScrollState.animateScrollTo(0) - } - scope.launch { - searchBarOffset.animateTo(0f) - } - true - } - - else -> false - } - } - - BackHandler { - handleBackOrHomeEvent() - } - - val gestureManager = LocalGestureDetector.current - val view = LocalView.current - - LaunchedEffect(isOverThreshold) { - if (isOverThreshold) { - view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - } else if (offsetY.floatValue != 0f) { - view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY_RELEASE) - } - } - - val nestedScrollConnection = remember { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - if (isWidgetEditMode || source != NestedScrollSource.Drag) return Offset.Zero - if (available.y.absoluteValue > available.x.absoluteValue * 2) { - viewModel.setSearchbarFocus(false) - searchVM.bestMatch.value = null - } - val canPullDown = if (isSearchOpen) { - isSearchAtTop - } else { - isWidgetsAtStart - } - val canPullUp = isSearchOpen && isSearchAtBottom - - val consumed = when { - canPullUp && available.y < 0 || offsetY.floatValue < 0 -> { - val consumed = available.y - offsetY.floatValue = (offsetY.floatValue + (consumed * 0.5f)).coerceIn(-maxOffset, 0f) - consumed - } - - canPullDown && available.y > 0 || offsetY.floatValue > 0 -> { - val consumed = available.y - offsetY.floatValue = (offsetY.floatValue + (consumed * 0.5f)).coerceIn(0f, maxOffset) - consumed - } - - else -> 0f - } - - return Offset(0f, consumed) - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - val deltaSearchBarOffset = - consumed.y * if (isSearchOpen && reverseSearchResults) 1 else -1 - searchBarOffset.floatValue = - (searchBarOffset.floatValue + deltaSearchBarOffset).coerceIn(0f, maxSearchBarOffset) - return super.onPostScroll(consumed, available, source) - } - - override suspend fun onPreFling(available: Velocity): Velocity { - if (offsetY.floatValue > toggleSearchThreshold || offsetY.floatValue < -toggleSearchThreshold) { - viewModel.toggleSearch() - } - if (!isWidgetEditMode) gestureManager.dispatchDragEnd() - if (offsetY.floatValue != 0f) { - offsetY.animateTo(0f) - return available - } - return Velocity.Zero - } - } - } - - val insets = WindowInsets.safeDrawing.asPaddingValues() - val colorSurfaceContainer = MaterialTheme.colorScheme.surfaceContainer - val cardStyle = LocalCardStyle.current - Box( - modifier = modifier - .drawBehind { - drawRect( - color = colorSurfaceContainer.copy(alpha = -pagerState.getOffsetDistanceInPages(0) * 0.85f * cardStyle.opacity), - ) - } - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragEnd = { - if (!isWidgetEditMode) gestureManager.dispatchDragEnd() - }, - onHorizontalDrag = { _, dragAmount -> - if (!isWidgetEditMode) gestureManager.dispatchDrag(Offset(dragAmount, 0f)) - } - ) - } - .offset { IntOffset(0, offsetY.floatValue.toInt()) }, - contentAlignment = Alignment.TopCenter - ) { - BoxWithConstraints( - modifier = modifier - .fillMaxSize() - ) { - val height by remember { - derivedStateOf { maxHeight } - } - CompositionLocalProvider( - LocalOverscrollConfiguration provides null - ) { - VerticalPager( - modifier = Modifier - .fillMaxSize(), - beyondViewportPageCount = 1, - state = pagerState, - reverseLayout = true, - userScrollEnabled = false, - pageNestedScrollConnection = nestedScrollConnection, - ) { - when (it) { - - 0 -> { - val clockPadding by animateDpAsState( - if (isWidgetsAtStart && fillClockHeight) - insets.calculateBottomPadding() + if (bottomSearchBar) 64.dp else 0.dp - else 0.dp - - ) - val editModePadding by animateDpAsState(if (isWidgetEditMode && bottomSearchBar) 56.dp else 0.dp) - - val clockHeight by remember { - derivedStateOf { - if (fillClockHeight) { - height - (insets.calculateTopPadding() + insets.calculateBottomPadding() - clockPadding + 56.dp) - } else { - null - } - } - } - Column( - modifier = Modifier - .graphicsLayer { - val progress = - pagerState.currentPage + pagerState.currentPageOffsetFraction - transformOrigin = TransformOrigin.Center - alpha = 1 - progress - scaleX = 1f - (dragProgress * 0.05f) - scaleY = 1f - (dragProgress * 0.05f) - } - .pointerInput(gestureManager.shouldDetectDoubleTaps) { - detectTapGestures( - onDoubleTap = if (gestureManager.shouldDetectDoubleTaps) { - { - if (!isWidgetEditMode) gestureManager.dispatchDoubleTap( - it - ) - } - } else null, - onLongPress = { - if (!isWidgetEditMode) gestureManager.dispatchLongPress( - it - ) - }, - onTap = { - if (!isWidgetEditMode) gestureManager.dispatchTap(it) - }, - ) - } - .fillMaxSize() - .verticalScroll(widgetsScrollState) - .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(8.dp) - .padding( - top = if (bottomSearchBar) 0.dp else 56.dp, - bottom = if (bottomSearchBar) 56.dp else 0.dp, - ) - .padding(top = editModePadding) - ) { - ClockWidget( - modifier = Modifier - .fillMaxWidth() - .then(clockHeight?.let { Modifier.height(it) } ?: Modifier) - .padding(bottom = clockPadding), - editMode = isWidgetEditMode, - fillScreenHeight = fillClockHeight, - ) - - WidgetColumn( - modifier = Modifier.fillMaxWidth(), - editMode = isWidgetEditMode, - onEditModeChange = { - viewModel.setWidgetEditMode(it) - } - ) - } - } - - 1 -> { - val webSearchPadding by animateDpAsState( - if (actions.isEmpty()) 0.dp else 48.dp - ) - val windowInsets = WindowInsets.safeDrawing.asPaddingValues() - SearchColumn( - modifier = Modifier - .graphicsLayer { - val progress = - pagerState.currentPage + pagerState.currentPageOffsetFraction - transformOrigin = TransformOrigin.Center - alpha = min(progress, 1f - dragProgress * 0.1f) - scaleX = min( - 1f - (dragProgress * 0.05f), - 1f - (1f - progress) * 0.1f - ) - scaleY = min( - 1f - (dragProgress * 0.05f), - 1f - (1f - progress) * 0.1f - ) - } - .fillMaxSize() - .padding( - start = windowInsets.calculateStartPadding( - LocalLayoutDirection.current - ), - end = windowInsets.calculateStartPadding( - LocalLayoutDirection.current - ), - ), - paddingValues = PaddingValues( - top = windowInsets.calculateTopPadding() + if (!bottomSearchBar) 64.dp + webSearchPadding else 8.dp, - bottom = windowInsets.calculateBottomPadding() + - keyboardFilterBarPadding + - if (bottomSearchBar) 64.dp + webSearchPadding else 8.dp - ), - state = searchState, - reverse = reverseSearchResults, - ) - } - } - } - - - } - - } - - - AnimatedVisibility(visible = isWidgetEditMode, - enter = slideIn { IntOffset(0, -it.height) }, - exit = slideOut { IntOffset(0, -it.height) } - ) { - CenterAlignedTopAppBar( - modifier = Modifier - .windowInsetsPadding(WindowInsets.safeDrawing), - title = { - Text(stringResource(R.string.menu_edit_widgets)) - }, - navigationIcon = { - IconButton(onClick = { viewModel.setWidgetEditMode(false) }) { - Icon(imageVector = Icons.Rounded.Done, contentDescription = stringResource(R.string.action_done)) - } - } - ) - } - - val searchBarLevel by remember { - derivedStateOf { - when { - offsetY.floatValue != 0f -> SearchBarLevel.Raised - !isSearchOpen && isWidgetsAtStart && (fillClockHeight || !bottomSearchBar) -> SearchBarLevel.Resting - isSearchOpen && isSearchAtTop && !bottomSearchBar -> SearchBarLevel.Active - isSearchOpen && isSearchAtBottom && bottomSearchBar -> SearchBarLevel.Active - else -> SearchBarLevel.Raised - } - } - } - val searchBarFocused by viewModel.searchBarFocused - val editModeSearchBarOffset by animateDpAsState( - (if (isWidgetEditMode) 128.dp else 0.dp) * (if (bottomSearchBar) 1 else -1) - ) - - val value by searchVM.searchQuery - - val searchBarColor by viewModel.searchBarColor.collectAsState() - val searchBarStyle by viewModel.searchBarStyle.collectAsState() - - val launchOnEnter by searchVM.launchOnEnter.collectAsState(false) - - LauncherSearchBar( - modifier = Modifier - .fillMaxSize(), - style = searchBarStyle, - level = { searchBarLevel }, - value = { value }, - focused = searchBarFocused, - onFocusChange = { - if (it) viewModel.openSearch() - viewModel.setSearchbarFocus(it) - }, - actions = actions, - highlightedAction = searchVM.bestMatch.value as? SearchAction, - isSearchOpen = isSearchOpen, - darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == SearchBarColors.Auto || searchBarColor == SearchBarColors.Dark, - bottomSearchBar = bottomSearchBar, - searchBarOffset = { - (if (searchBarFocused || fixedSearchBar) 0 else searchBarOffset.floatValue.toInt() * (if (bottomSearchBar) 1 else -1)) + - with(density) { - (editModeSearchBarOffset - if (bottomSearchBar) keyboardFilterBarPadding else 0.dp) - .toPx() - .roundToInt() - } - }, - onKeyboardActionGo = if (launchOnEnter) { - { searchVM.launchBestMatchOrAction(context) } - } else null - ) - - } - LauncherGestureHandler( - onHomeButtonPress = handleBackOrHomeEvent, - ) -} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/SharedLauncherActivity.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/SharedLauncherActivity.kt index 134da7ac..59abdc29 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/SharedLauncherActivity.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/SharedLauncherActivity.kt @@ -5,28 +5,27 @@ import android.content.pm.ActivityInfo import android.content.res.Configuration import android.content.res.Resources import android.os.Bundle +import android.view.WindowManager import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.IntOffset @@ -34,23 +33,41 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle -import com.google.accompanist.systemuicontroller.rememberSystemUiController -import de.mm20.launcher2.preferences.BaseLayout +import de.mm20.launcher2.ktx.isAtLeastApiLevel +import de.mm20.launcher2.preferences.GestureAction +import de.mm20.launcher2.preferences.SearchBarColors +import de.mm20.launcher2.preferences.SearchBarStyle import de.mm20.launcher2.preferences.SystemBarColors -import de.mm20.launcher2.ui.assistant.AssistantScaffold +import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.ui.base.BaseActivity import de.mm20.launcher2.ui.base.ProvideCompositionLocals import de.mm20.launcher2.ui.component.NavBarEffects -import de.mm20.launcher2.ui.gestures.GestureDetector -import de.mm20.launcher2.ui.gestures.LocalGestureDetector import de.mm20.launcher2.ui.ktx.animateTo -import de.mm20.launcher2.ui.launcher.search.SearchVM +import de.mm20.launcher2.ui.launcher.scaffold.ClockAndWidgetsHomeComponent +import de.mm20.launcher2.ui.launcher.scaffold.ClockHomeComponent +import de.mm20.launcher2.ui.launcher.scaffold.DismissComponent +import de.mm20.launcher2.ui.launcher.scaffold.Gesture +import de.mm20.launcher2.ui.launcher.scaffold.LaunchComponent +import de.mm20.launcher2.ui.launcher.scaffold.LauncherScaffold +import de.mm20.launcher2.ui.launcher.scaffold.NotificationsComponent +import de.mm20.launcher2.ui.launcher.scaffold.PowerMenuComponent +import de.mm20.launcher2.ui.launcher.scaffold.QuickSettingsComponent +import de.mm20.launcher2.ui.launcher.scaffold.RecentsComponent +import de.mm20.launcher2.ui.launcher.scaffold.ScaffoldAnimation +import de.mm20.launcher2.ui.launcher.scaffold.ScaffoldConfiguration +import de.mm20.launcher2.ui.launcher.scaffold.ScaffoldGesture +import de.mm20.launcher2.ui.launcher.scaffold.ScreenOffComponent +import de.mm20.launcher2.ui.launcher.scaffold.SearchBarPosition +import de.mm20.launcher2.ui.launcher.scaffold.SearchComponent +import de.mm20.launcher2.ui.launcher.scaffold.SecretComponent +import de.mm20.launcher2.ui.launcher.scaffold.WidgetsComponent import de.mm20.launcher2.ui.launcher.sheets.LauncherBottomSheetManager import de.mm20.launcher2.ui.launcher.sheets.LauncherBottomSheets import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager import de.mm20.launcher2.ui.launcher.transitions.EnterHomeTransition import de.mm20.launcher2.ui.launcher.transitions.EnterHomeTransitionManager import de.mm20.launcher2.ui.launcher.transitions.LocalEnterHomeTransitionManager +import de.mm20.launcher2.ui.locals.LocalDarkTheme import de.mm20.launcher2.ui.locals.LocalPreferDarkContentOverWallpaper import de.mm20.launcher2.ui.locals.LocalSnackbarHostState import de.mm20.launcher2.ui.locals.LocalWallpaperColors @@ -66,13 +83,15 @@ abstract class SharedLauncherActivity( ) : BaseActivity() { private val viewModel: LauncherScaffoldVM by viewModels() - private val searchVM: SearchVM by viewModels() internal val enterHomeTransitionManager = EnterHomeTransitionManager() - internal val gestureDetector = GestureDetector() - override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + if (isAtLeastApiLevel(29)) { + window.isNavigationBarContrastEnforced = false + window.isStatusBarContrastEnforced = false + } super.onCreate(savedInstanceState) val wallpaperManager = WallpaperManager.getInstance(this) @@ -98,7 +117,6 @@ abstract class SharedLauncherActivity( LocalWallpaperColors provides wallpaperColors, LocalPreferDarkContentOverWallpaper provides (!dimBackground && wallpaperColors.supportsDarkText), LocalBottomSheetManager provides bottomSheetManager, - LocalGestureDetector provides gestureDetector, ) { LauncherTheme { ProvideCompositionLocals { @@ -114,13 +132,20 @@ abstract class SharedLauncherActivity( val hideStatus by viewModel.hideStatusBar.collectAsState() val hideNav by viewModel.hideNavBar.collectAsState() - val layout by viewModel.baseLayout.collectAsState(null) val bottomSearchBar by viewModel.bottomSearchBar.collectAsState() val reverseSearchResults by viewModel.reverseSearchResults.collectAsState() val fixedSearchBar by viewModel.fixedSearchBar.collectAsState() + val gestures by viewModel.gestureState.collectAsState() + val searchBarStyle by viewModel.searchBarStyle.collectAsState() + val searchBarColor by viewModel.searchBarColor.collectAsState() + val widgetsOnHomeScreen by viewModel.widgetsOnHomeScreen.collectAsState() val fixedRotation by viewModel.fixedRotation.collectAsState() + val backgroundColor = MaterialTheme.colorScheme.surfaceContainer + + if (gestures == null || widgetsOnHomeScreen == null) return@ProvideCompositionLocals + LaunchedEffect(fixedRotation) { requestedOrientation = if (fixedRotation) { ActivityInfo.SCREEN_ORIENTATION_PORTRAIT @@ -129,8 +154,25 @@ abstract class SharedLauncherActivity( } } + val darkTheme = LocalDarkTheme.current + val darkSearchBar = LocalPreferDarkContentOverWallpaper.current + && searchBarColor == SearchBarColors.Auto || searchBarColor == SearchBarColors.Dark - val systemUiController = rememberSystemUiController() + LaunchedEffect(dimBackground && darkTheme) { + if (dimBackground && darkTheme) { + val windowAttributes = window.attributes + windowAttributes.flags = + windowAttributes.flags or WindowManager.LayoutParams.FLAG_DIM_BEHIND + window.attributes = windowAttributes + window.setDimAmount(0.3f) + } else { + val windowAttributes = window.attributes + windowAttributes.flags = + windowAttributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv() + window.attributes = windowAttributes + window.setDimAmount(0f) + } + } val enterTransitionProgress = remember { mutableStateOf(1f) } var enterTransition by remember { @@ -153,83 +195,191 @@ abstract class SharedLauncherActivity( } } - LaunchedEffect(hideStatus) { - systemUiController.isStatusBarVisible = !hideStatus - } - LaunchedEffect(hideNav) { - systemUiController.isNavigationBarVisible = !hideNav - } - OverlayHost( modifier = Modifier - .fillMaxSize() - .background(if (dimBackground) Color.Black.copy(alpha = 0.30f) else Color.Transparent), + .fillMaxSize(), contentAlignment = Alignment.BottomCenter ) { if (chargingAnimation == true) { NavBarEffects(modifier = Modifier.fillMaxSize()) } - if (mode == LauncherActivityMode.Assistant) { - key(bottomSearchBar, reverseSearchResults) { - AssistantScaffold( - modifier = Modifier - .fillMaxSize(), + + val config = remember( + mode, + reverseSearchResults, + bottomSearchBar, + fixedSearchBar, + gestures, + searchBarStyle, + darkSearchBar, + backgroundColor, + lightStatus, + lightNav, + hideStatus, + hideNav, + widgetsOnHomeScreen, + ) { + if (mode == LauncherActivityMode.Assistant) { + val searchComponent = SearchComponent( + reverse = reverseSearchResults, + ) + val dismissComponent = + DismissComponent(this@SharedLauncherActivity) + ScaffoldConfiguration( + homeComponent = searchComponent, + searchComponent = searchComponent, + swipeDown = ScaffoldGesture( + component = dismissComponent, + animation = ScaffoldAnimation.Push + ), + swipeUp = ScaffoldGesture( + component = dismissComponent, + animation = ScaffoldAnimation.Push + ), + fixedSearchBar = fixedSearchBar, + searchBarStyle = SearchBarStyle.Solid, + searchBarPosition = if (bottomSearchBar) SearchBarPosition.Bottom else SearchBarPosition.Top, + finishOnBack = true, + backgroundColor = backgroundColor, + ) + } else { + val searchComponent = SearchComponent( + reverse = reverseSearchResults, + ) + val widgetComponent by lazy { WidgetsComponent } + + fun getScaffoldGesture( + action: GestureAction?, + searchable: SavableSearchable?, + gesture: Gesture + ): ScaffoldGesture? { + return when (action) { + is GestureAction.Search -> ScaffoldGesture( + component = searchComponent, + animation = when (gesture) { + Gesture.SwipeDown -> ScaffoldAnimation.Rubberband + Gesture.LongPress -> ScaffoldAnimation.ZoomIn + Gesture.DoubleTap -> ScaffoldAnimation.ZoomIn + else -> ScaffoldAnimation.Push + }, + ) + + is GestureAction.Widgets -> ScaffoldGesture( + component = widgetComponent, + animation = if (gesture.orientation == null) ScaffoldAnimation.ZoomIn else ScaffoldAnimation.Push, + ) + + is GestureAction.Notifications -> ScaffoldGesture( + component = NotificationsComponent, + animation = if (gesture.orientation == null) ScaffoldAnimation.ZoomIn else ScaffoldAnimation.Push, + ) + + is GestureAction.QuickSettings -> ScaffoldGesture( + component = QuickSettingsComponent, + animation = if (gesture.orientation == null) ScaffoldAnimation.ZoomIn else ScaffoldAnimation.Push, + ) + + is GestureAction.Recents -> ScaffoldGesture( + component = RecentsComponent, + animation = if (gesture.orientation == null) ScaffoldAnimation.ZoomIn else ScaffoldAnimation.Push, + ) + + is GestureAction.PowerMenu -> ScaffoldGesture( + component = PowerMenuComponent, + animation = if (gesture.orientation == null) ScaffoldAnimation.ZoomIn else ScaffoldAnimation.Push, + ) + + is GestureAction.ScreenLock -> ScaffoldGesture( + component = ScreenOffComponent, + animation = if (gesture.orientation == null) ScaffoldAnimation.ZoomIn else ScaffoldAnimation.Push, + ) + + is GestureAction.Launch if (searchable != null) -> ScaffoldGesture( + component = LaunchComponent( + this@SharedLauncherActivity, + searchable + ), + animation = if (gesture.orientation == null) ScaffoldAnimation.ZoomIn else ScaffoldAnimation.Push, + ) + + else -> null + } + } + + val gestures = gestures!! + + val config = ScaffoldConfiguration( + homeComponent = if (widgetsOnHomeScreen == true) { + ClockAndWidgetsHomeComponent + } else { + ClockHomeComponent + }, + searchComponent = searchComponent, + swipeUp = getScaffoldGesture( + gestures.swipeUpAction, + gestures.swipeUpApp, + Gesture.SwipeUp, + ), + swipeDown = getScaffoldGesture( + gestures.swipeDownAction, + gestures.swipeDownApp, + Gesture.SwipeDown, + ), + swipeLeft = getScaffoldGesture( + gestures.swipeLeftAction, + gestures.swipeLeftApp, + Gesture.SwipeLeft, + ), + swipeRight = getScaffoldGesture( + gestures.swipeRightAction, + gestures.swipeRightApp, + Gesture.SwipeRight, + ), + doubleTap = getScaffoldGesture( + gestures.doubleTapAction, + gestures.doubleTapApp, + Gesture.DoubleTap, + ), + longPress = getScaffoldGesture( + gestures.longPressAction, + gestures.longPressApp, + Gesture.LongPress, + ), + homeButton = getScaffoldGesture( + gestures.homeButtonAction, + gestures.homeButtonApp, + Gesture.HomeButton, + ), + fixedSearchBar = fixedSearchBar, + searchBarStyle = searchBarStyle, + searchBarPosition = if (bottomSearchBar) SearchBarPosition.Bottom else SearchBarPosition.Top, darkStatusBarIcons = lightStatus, darkNavBarIcons = lightNav, - bottomSearchBar = bottomSearchBar, - reverseSearchResults = reverseSearchResults, - fixedSearchBar = fixedSearchBar, + backgroundColor = backgroundColor, + showStatusBar = !hideStatus, + showNavBar = !hideNav, + darkSearchBar = darkSearchBar, ) - } - } else { - when (layout) { - BaseLayout.PullDown -> { - key(bottomSearchBar, reverseSearchResults) { - PullDownScaffold( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - scaleX = - 0.5f + enterTransitionProgress.value * 0.5f - scaleY = - 0.5f + enterTransitionProgress.value * 0.5f - alpha = enterTransitionProgress.value - }, - darkStatusBarIcons = lightStatus, - darkNavBarIcons = lightNav, - bottomSearchBar = bottomSearchBar, - reverseSearchResults = reverseSearchResults, - fixedSearchBar = fixedSearchBar, - ) - } - } - BaseLayout.Pager, - BaseLayout.PagerReversed -> { - key(bottomSearchBar, reverseSearchResults) { - PagerScaffold( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - scaleX = - 0.5f + enterTransitionProgress.value * 0.5f - scaleY = - 0.5f + enterTransitionProgress.value * 0.5f - alpha = enterTransitionProgress.value - }, - darkStatusBarIcons = lightStatus, - darkNavBarIcons = lightNav, - reverse = layout == BaseLayout.PagerReversed, - bottomSearchBar = bottomSearchBar, - reverseSearchResults = reverseSearchResults, - fixedSearchBar = fixedSearchBar, - ) - } - } - - else -> {} + if (config.isUseless()) config.copy( + homeComponent = SecretComponent, + ) else config } } + + LauncherScaffold( + config = config, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = + 0.5f + enterTransitionProgress.value * 0.5f + scaleY = + 0.5f + enterTransitionProgress.value * 0.5f + alpha = enterTransitionProgress.value + } + ) + SnackbarHost( snackbarHostState, modifier = Modifier @@ -270,20 +420,6 @@ abstract class SharedLauncherActivity( } } - private var pauseTime = 0L - override fun onPause() { - super.onPause() - pauseTime = System.currentTimeMillis() - } - - override fun onResume() { - super.onResume() - if (System.currentTimeMillis() - pauseTime > 20000) { - viewModel.closeSearchWithoutAnimation() - searchVM.reset() - } - } - override fun onAttachedToWindow() { super.onAttachedToWindow() val windowController = WindowCompat.getInsetsController(window, window.decorView.rootView) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/gestures/LauncherGestureHandler.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/gestures/LauncherGestureHandler.kt deleted file mode 100644 index 05d50058..00000000 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/gestures/LauncherGestureHandler.kt +++ /dev/null @@ -1,240 +0,0 @@ -package de.mm20.launcher2.ui.launcher.gestures - -import android.app.WallpaperManager -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import de.mm20.launcher2.preferences.GestureAction -import de.mm20.launcher2.search.SavableSearchable -import de.mm20.launcher2.ui.component.FakeSplashScreen -import de.mm20.launcher2.ui.gestures.Gesture -import de.mm20.launcher2.ui.gestures.GestureHandler -import de.mm20.launcher2.ui.gestures.LocalGestureDetector -import de.mm20.launcher2.ui.ktx.animateTo -import de.mm20.launcher2.ui.ktx.toPixels -import de.mm20.launcher2.ui.launcher.GestureState -import de.mm20.launcher2.ui.launcher.LauncherScaffoldVM -import de.mm20.launcher2.ui.launcher.sheets.FailedGestureSheet -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlin.math.absoluteValue - -/** - * Handles gestures on the launcher according to the user's settings. - * @param onHomeButtonPress Called when the home button is pressed. Allows the caller to intercept the event. - * If the function returns true, the event is considered handled and the default action is not performed. - */ -@Composable -fun LauncherGestureHandler( - onHomeButtonPress: () -> Boolean = { false } -) { - val context = LocalContext.current - val wallpaperManager = remember { WallpaperManager.getInstance(context) } - val gestureDetector = LocalGestureDetector.current - - val viewModel: LauncherScaffoldVM = viewModel() - val scope = rememberCoroutineScope() - - val gestureState by viewModel.gestureState.collectAsState(GestureState()) - - val shouldDetectDoubleTapGesture = gestureState.doubleTapAction !is GestureAction.NoAction - - LaunchedEffect(shouldDetectDoubleTapGesture) { - gestureDetector.shouldDetectDoubleTaps = shouldDetectDoubleTapGesture - } - - val windowToken = LocalView.current.windowToken - - var launchingApp by remember { mutableStateOf(null) } - var swipeGestureProgress = remember { mutableStateOf(0f) } - var swipeGestureDirection by remember { mutableStateOf(null) } - - // Min swipe distance to trigger show the launch app preview - val swipeStartThreshold = 18.dp.toPixels() - // Min swipe distance to trigger the action - val swipeActionThreshold = 150.dp.toPixels() - GestureHandler( - detector = gestureDetector, - onDoubleTap = { - viewModel.handleGesture(context, Gesture.DoubleTap) - }, - onLongPress = { - viewModel.handleGesture(context, Gesture.LongPress) - }, - onHomeButtonPress = { - if (onHomeButtonPress()) { - return@GestureHandler - } - viewModel.handleGesture(context, Gesture.HomeButton) - }, - onDrag = { - when { - gestureState.swipeRightApp != null && it.x > swipeStartThreshold && ( - swipeGestureDirection == SwipeDirection.Right || (swipeGestureDirection == null && it.x.absoluteValue > it.y.absoluteValue * 2f) - ) -> { - - swipeGestureDirection = SwipeDirection.Right - swipeGestureProgress.value = - ((it.x - swipeStartThreshold) / (swipeActionThreshold - swipeStartThreshold)).coerceIn( - 0f, - 2f - ) - launchingApp = gestureState.swipeRightApp - return@GestureHandler false - } - - gestureState.swipeLeftApp != null && it.x < -swipeStartThreshold && ( - swipeGestureDirection == SwipeDirection.Left || (swipeGestureDirection == null && it.x.absoluteValue > it.y.absoluteValue * 2f) - ) -> { - - swipeGestureDirection = SwipeDirection.Left - swipeGestureProgress.value = - ((-it.x - swipeStartThreshold) / (swipeActionThreshold - swipeStartThreshold)).coerceIn( - 0f, - 2f - ) - launchingApp = gestureState.swipeLeftApp - return@GestureHandler false - } - - gestureState.swipeDownApp != null && it.y > swipeStartThreshold && ( - swipeGestureDirection == SwipeDirection.Down || (swipeGestureDirection == null && it.y.absoluteValue > it.x.absoluteValue * 2f) - ) -> { - - swipeGestureDirection = SwipeDirection.Down - swipeGestureProgress.value = - ((it.y - swipeStartThreshold) / (swipeActionThreshold - swipeStartThreshold)).coerceIn( - 0f, - 2f - ) - launchingApp = gestureState.swipeDownApp - return@GestureHandler false - } - - else -> { - swipeGestureDirection = null - swipeGestureProgress.value = 0f - launchingApp = null - } - } - - return@GestureHandler when { - it.x > swipeActionThreshold && it.x.absoluteValue > it.y.absoluteValue * 2f -> { - viewModel.handleGesture(context, Gesture.SwipeRight) - } - - it.x < -swipeActionThreshold && it.x.absoluteValue > it.y.absoluteValue * 2f -> { - viewModel.handleGesture(context, Gesture.SwipeLeft) - } - - it.y > swipeActionThreshold && it.y.absoluteValue > it.x.absoluteValue * 2f -> { - viewModel.handleGesture(context, Gesture.SwipeDown) - } - - else -> false - } - }, - onDragEnd = { - if (swipeGestureProgress.value > 0f) { - scope.launch { - val direction = swipeGestureDirection - if (swipeGestureProgress.value >= 1f - && direction != null - ) { - swipeGestureProgress.animateTo(2f) - viewModel.handleGesture( - context, - when (direction) { - SwipeDirection.Right -> Gesture.SwipeRight - SwipeDirection.Left -> Gesture.SwipeLeft - SwipeDirection.Down -> Gesture.SwipeDown - }, - ) - delay(500) - } else { - swipeGestureProgress.animateTo(0f) - } - swipeGestureDirection = null - swipeGestureProgress.value = 0f - launchingApp = null - } - } - }, - onTap = { - wallpaperManager.sendWallpaperCommand( - windowToken, - WallpaperManager.COMMAND_TAP, - it.x.toInt(), - it.y.toInt(), - 0, - null - ) - } - ) - - if (launchingApp != null) { - BoxWithConstraints( - modifier = Modifier.fillMaxSize() - ) { - FakeSplashScreen( - modifier = Modifier - .offset { - val p = swipeGestureProgress.value - when (swipeGestureDirection) { - SwipeDirection.Right -> IntOffset( - (-minWidth.toPx() * (1f - p * 0.5f)).toInt(), - 0 - ) - - SwipeDirection.Left -> IntOffset( - (minWidth.toPx() * (1f - p * 0.5f)).toInt(), - 0 - ) - - SwipeDirection.Down -> IntOffset( - 0, - (-minHeight.toPx() * (1f - p * 0.5f)).toInt() - ) - - else -> IntOffset.Zero - } - } - .graphicsLayer { - alpha = (swipeGestureProgress.value).coerceAtMost(1f) - }, - searchable = launchingApp, - ) - } - } - - - if (viewModel.failedGestureState != null) { - FailedGestureSheet( - failedGesture = viewModel.failedGestureState!!, - onDismiss = { - viewModel.dismissGestureFailedSheet() - } - ) - } -} - -internal enum class SwipeDirection { - Left, - Right, - Down -} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/ClockAndWidgetsHomeComponent.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/ClockAndWidgetsHomeComponent.kt new file mode 100644 index 00000000..c0cacac0 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/ClockAndWidgetsHomeComponent.kt @@ -0,0 +1,143 @@ +package de.mm20.launcher2.ui.launcher.scaffold + +import android.annotation.SuppressLint +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.launcher.widgets.WidgetColumn +import de.mm20.launcher2.ui.launcher.widgets.clock.ClockWidget +import kotlinx.coroutines.launch + +internal object ClockAndWidgetsHomeComponent: ScaffoldComponent() { + private var editMode by mutableStateOf(false) + private val scrollState = ScrollState(0) + + override val isAtTop: State = derivedStateOf { + !scrollState.canScrollBackward + } + + override val isAtBottom: State = derivedStateOf { + !scrollState.canScrollForward + } + + override val drawBackground: Boolean = false + + // In note widget + override val hasIme: Boolean = true + + override val showSearchBar: Boolean = false + + @Composable + override fun Component( + modifier: Modifier, + insets: PaddingValues, + state: LauncherScaffoldState + ) { + val scope = rememberCoroutineScope() + + val topPadding by animateDpAsState(if (editMode) 80.dp else 0.dp) + + val previousScroll = remember { mutableIntStateOf(scrollState.value) } + + LaunchedEffect(scrollState.value, scrollState.canScrollForward, scrollState.canScrollBackward) { + val delta = scrollState.value - previousScroll.intValue + previousScroll.intValue = scrollState.value + if (!editMode) { + state.onComponentScroll(delta.toFloat()) + } + } + + Column( + modifier = modifier + .verticalScroll(scrollState, enabled = !state.isDragged) + .padding(horizontal = 8.dp) + .padding(top = topPadding) + .padding(insets), + ) { + ClockWidget( + editMode = editMode, + fillScreenHeight = false, + ) + WidgetColumn( + modifier = Modifier + .padding(top = 16.dp), + editMode = editMode, + onEditModeChange = { + scope.launch { state.lock(hideSearchBar = true) } + editMode = it + }, + ) + } + if (editMode) { + BackHandler { + editMode = false + scope.launch { state.unlock() } + } + } + AnimatedVisibility( + editMode, + modifier = Modifier.zIndex(10f), + enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + CenterAlignedTopAppBar( + title = { Text(stringResource(R.string.menu_edit_widgets)) }, + navigationIcon = { + IconButton( + onClick = { + editMode = false + scope.launch { state.unlock() } + } + ) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.action_done)) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) + } + } + + override suspend fun onDismiss(state: LauncherScaffoldState) { + super.onDismiss(state) + scrollState.scrollTo(0) + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/ClockHomeComponent.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/ClockHomeComponent.kt new file mode 100644 index 00000000..359e5b75 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/ClockHomeComponent.kt @@ -0,0 +1,32 @@ +package de.mm20.launcher2.ui.launcher.scaffold + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import de.mm20.launcher2.ui.launcher.widgets.clock.ClockWidget + +internal object ClockHomeComponent : ScaffoldComponent() { + + override val drawBackground: Boolean = false + + override var isAtTop: State = mutableStateOf(true) + override var isAtBottom: State = mutableStateOf(true) + + @Composable + override fun Component( + modifier: Modifier, + insets: PaddingValues, + state: LauncherScaffoldState, + ) { + ClockWidget( + modifier = modifier + .padding(insets) + .pointerInput(Unit) {}, + fillScreenHeight = true, + ) + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/DismissComponent.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/DismissComponent.kt new file mode 100644 index 00000000..f67c2d16 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/DismissComponent.kt @@ -0,0 +1,54 @@ +package de.mm20.launcher2.ui.launcher.scaffold + +import android.annotation.SuppressLint +import android.app.Activity +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale + +/** + * A scaffold component that finishes the activity when activated. + */ +internal class DismissComponent(private val activity: Activity) : ScaffoldComponent() { + + override val drawBackground: Boolean = false + + override val isAtTop: State = mutableStateOf(true) + override val isAtBottom: State = mutableStateOf(true) + + @Composable + override fun Component( + modifier: Modifier, + insets: PaddingValues, + state: LauncherScaffoldState + ) { + } + + @SuppressLint("ModifierFactoryExtensionFunction") + override fun homePageModifier( + state: LauncherScaffoldState, + defaultModifier: Modifier + ): Modifier { + return Modifier.alpha(1f - state.currentProgress) then defaultModifier then Modifier.scale( + 1f - (state.currentProgress * 0.25f) + ) + } + + @SuppressLint("ModifierFactoryExtensionFunction") + override fun searchBarModifier( + state: LauncherScaffoldState, + defaultModifier: Modifier + ): Modifier { + return defaultModifier.alpha(1f - state.currentProgress) + } + + override suspend fun onActivate(state: LauncherScaffoldState) { + super.onActivate(state) + activity.finish() + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/LaunchComponent.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/LaunchComponent.kt new file mode 100644 index 00000000..4458455c --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/LaunchComponent.kt @@ -0,0 +1,70 @@ +package de.mm20.launcher2.ui.launcher.scaffold + +import android.annotation.SuppressLint +import android.app.Activity +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.zIndex +import androidx.core.app.ActivityOptionsCompat +import de.mm20.launcher2.search.SavableSearchable +import de.mm20.launcher2.ui.component.FakeSplashScreen +import de.mm20.launcher2.ui.ktx.toIntOffset + +internal class LaunchComponent( + private val activity: Activity, + private val searchable: SavableSearchable, +): ScaffoldComponent() { + override val permanent: Boolean = false + override val resetDelay: Long = 500L + override val showSearchBar = false + + override val isAtTop: State = mutableStateOf(true) + override val isAtBottom: State = mutableStateOf(true) + + @Composable + override fun Component( + modifier: Modifier, + insets: PaddingValues, + state: LauncherScaffoldState + ) { + FakeSplashScreen( + modifier = modifier.zIndex(10f).alpha((state.currentProgress * 2f).coerceAtMost(1f)), + searchable = searchable, + ) + } + + override suspend fun onActivate(state: LauncherScaffoldState) { + super.onActivate(state) + val view = activity.window.decorView + val options = ActivityOptionsCompat.makeClipRevealAnimation( + view, + 0, + 0, + view.width, + view.height + ) + + searchable.launch(activity, options.toBundle()) + } + + override fun homePageModifier( + state: LauncherScaffoldState, + defaultModifier: Modifier + ): Modifier { + return defaultModifier.alpha(1f - state.currentProgress) + } + + @SuppressLint("ModifierFactoryExtensionFunction") + override fun searchBarModifier( + state: LauncherScaffoldState, + defaultModifier: Modifier + ): Modifier { + return defaultModifier.offset{ state.currentOffset.toIntOffset() }.alpha(1f - state.currentProgress) + } + +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/LauncherScaffold.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/LauncherScaffold.kt new file mode 100644 index 00000000..2b310e68 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/LauncherScaffold.kt @@ -0,0 +1,1771 @@ +package de.mm20.launcher2.ui.launcher.scaffold + +import android.view.animation.PathInterpolator +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.PredictiveBackHandler +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.draggable2D +import androidx.compose.foundation.gestures.rememberDraggable2DState +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imeAnimationSource +import androidx.compose.foundation.layout.imeAnimationTarget +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.waterfall +import androidx.compose.foundation.shape.CutCornerShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.ktx.isAtLeastApiLevel +import de.mm20.launcher2.preferences.SearchBarStyle +import de.mm20.launcher2.searchactions.actions.SearchAction +import de.mm20.launcher2.ui.component.SearchBarLevel +import de.mm20.launcher2.ui.ktx.toPixels +import de.mm20.launcher2.ui.launcher.helper.WallpaperBlur +import de.mm20.launcher2.ui.launcher.search.SearchVM +import de.mm20.launcher2.ui.launcher.search.filters.KeyboardFilterBar +import de.mm20.launcher2.ui.launcher.searchbar.LauncherSearchBar +import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue +import kotlin.math.pow +import kotlin.math.roundToInt + +enum class ScaffoldAnimation { + Rubberband, + Push, + ZoomIn, +} + +internal data class ScaffoldGesture( + val component: ScaffoldComponent, + val animation: ScaffoldAnimation, +) + +enum class SearchBarPosition { + Top, + Bottom, +} + +internal data class ScaffoldConfiguration( + /** + * The main component + */ + val homeComponent: ScaffoldComponent, + /** + * Search component that is activated when the search bar is tapped. + */ + val searchComponent: SearchComponent, + val swipeUp: ScaffoldGesture? = null, + val swipeDown: ScaffoldGesture? = null, + val swipeLeft: ScaffoldGesture? = null, + val swipeRight: ScaffoldGesture? = null, + val doubleTap: ScaffoldGesture? = null, + val longPress: ScaffoldGesture? = null, + val homeButton: ScaffoldGesture? = null, + /** + * Position of the search bar + */ + val searchBarPosition: SearchBarPosition = SearchBarPosition.Top, + val searchBarStyle: SearchBarStyle = SearchBarStyle.Hidden, + /** + * If true, the search bar does not scroll out of view + */ + val fixedSearchBar: Boolean = false, + /** + * The color that is used as background for secondary pages. + */ + val backgroundColor: Color, + /** + * Wallpaper blur radius. 0 to disable. + */ + val wallpaperBlurRadius: Dp = 32.dp, + /** + * Show the navigation bar + */ + val showNavBar: Boolean = true, + val darkNavBarIcons: Boolean = false, + /** + * Show the status bar + */ + val showStatusBar: Boolean = true, + val darkStatusBarIcons: Boolean = false, + /** + * Finishes the activity when back is pressed while on home component. + * Used for assistant mode. + */ + val finishOnBack: Boolean = false, + val darkSearchBar: Boolean = false, +) { + val searchBarTap = ScaffoldGesture( + component = searchComponent, + animation = ScaffoldAnimation.ZoomIn, + ) + + /** + * Returns true if the given config prevents the user from accessing search or settings, + * so that the user is locked out and the launcher is soft-bricked + */ + fun isUseless(): Boolean { + return searchBarStyle == SearchBarStyle.Hidden && + listOfNotNull( + swipeUp, + swipeDown, + swipeLeft, + swipeRight, + doubleTap, + longPress, + //homeButton, + ).none { it.component.showSearchBar } + } +} + +private operator fun ScaffoldConfiguration.get(gesture: Gesture): ScaffoldGesture? { + return when (gesture) { + Gesture.SwipeUp -> swipeUp + Gesture.SwipeDown -> swipeDown + Gesture.SwipeLeft -> swipeLeft + Gesture.SwipeRight -> swipeRight + Gesture.DoubleTap -> doubleTap + Gesture.LongPress -> longPress + Gesture.HomeButton -> homeButton + Gesture.TapSearchBar -> searchBarTap + } +} + +enum class Gesture(val orientation: Orientation?) { + SwipeUp(Orientation.Vertical), + SwipeDown(Orientation.Vertical), + SwipeLeft(Orientation.Horizontal), + SwipeRight(Orientation.Horizontal), + DoubleTap(null), + LongPress(null), + TapSearchBar(null), + HomeButton(null), +} + +internal class LauncherScaffoldState( + private val config: ScaffoldConfiguration, + val size: Size, + private val touchSlop: Float, + /** + * The threshold (in px) where a rubberband gesture is considered to be over the threshold + * (releasing it would snap to the next page) + */ + private val rubberbandThreshold: Float, + /** + * The minimum velocity (in px/s) where a fling will snap to the next page, regardless of the + * current offset. + */ + private val velocityThreshold: Float, + private val maxSearchBarOffset: Float, + private val onHapticFeedback: (HapticFeedbackType) -> Unit, + initialGesture: Gesture? = null, + initialIsLocked: Boolean = false, + initialIsSearchBarHidden: Boolean = false, +) { + var currentOffset by mutableStateOf(when { + initialGesture == null || initialGesture.orientation == null || config[initialGesture]?.animation == ScaffoldAnimation.Rubberband -> Offset.Zero + initialGesture == Gesture.SwipeRight -> Offset(-size.width, 0f) + initialGesture == Gesture.SwipeLeft -> Offset(size.width, 0f) + initialGesture == Gesture.SwipeUp -> Offset(0f, -size.height) + initialGesture == Gesture.SwipeDown -> Offset(0f, size.height) + else -> Offset.Zero + }) + private set + var currentZOffset by mutableFloatStateOf( + if (initialGesture != null && initialGesture.orientation == null) 1f else 0f + ) + private set + var currentGesture by mutableStateOf(initialGesture) + private set + + /** + * True if the search is animating to open after tapping the search bar or performing a tap gesture. + */ + private var isOpeningSearch by mutableStateOf(false) + val currentSearchBarOffset by derivedStateOf { + val base = when { + currentComponent?.showSearchBar == false -> homePageSearchBarOffset + else -> homePageSearchBarOffset * (1 - currentProgress) + secondaryPageSearchBarOffset * currentProgress + } + base + if (currentAnimation == ScaffoldAnimation.Rubberband && !isOpeningSearch) currentOffset.y else 0f + } + private var homePageSearchBarOffset by mutableFloatStateOf(0f) + private var secondaryPageSearchBarOffset by mutableFloatStateOf(0f) + + var isSearchBarFocused by mutableStateOf(config.homeComponent is SearchComponent) + + val statusBarScrim by derivedStateOf { + !isAtTop + } + + val navBarScrim by derivedStateOf { + !isAtBottom + } + + val darkStatusBarIcons by derivedStateOf { + val isLightBackground = config.backgroundColor.luminance() > 0.5f + when { + statusBarScrim -> isLightBackground + currentProgress < 0.5f && !config.homeComponent.drawBackground -> { + config.darkStatusBarIcons + } + + currentProgress >= 0.5f && currentComponent?.drawBackground == false -> { + config.darkStatusBarIcons + } + + else -> { + isLightBackground + } + } + } + val darkNavBarIcons by derivedStateOf { + val isLightBackground = config.backgroundColor.luminance() > 0.5f + when { + navBarScrim -> isLightBackground + currentProgress < 0.5f && !config.homeComponent.drawBackground -> { + config.darkNavBarIcons + } + + currentProgress >= 0.5f && currentComponent?.drawBackground == false -> { + config.darkNavBarIcons + } + + else -> { + isLightBackground + } + } + } + + val isAtTop by derivedStateOf { + val component = if (!isSettledOnSecondaryPage) null else currentComponent + (component?.isAtTop?.value ?: config.homeComponent.isAtTop.value) != false + } + + val isAtBottom by derivedStateOf { + val component = if (!isSettledOnSecondaryPage) null else currentComponent + (component?.isAtBottom?.value ?: config.homeComponent.isAtBottom.value) != false + } + + val searchBarLevel by derivedStateOf { + val component = currentComponent + + val homeLevel = if (config.searchBarPosition == SearchBarPosition.Top) { + when (config.homeComponent.isAtTop.value) { + true if config.homeComponent.drawBackground -> SearchBarLevel.Active + true -> SearchBarLevel.Resting + false -> SearchBarLevel.Raised + null -> null + } + } else { + when (config.homeComponent.isAtBottom.value) { + true if config.homeComponent.drawBackground -> SearchBarLevel.Active + true -> SearchBarLevel.Resting + false -> SearchBarLevel.Raised + null -> null + } + } + + val secondaryLevel = if (config.searchBarPosition == SearchBarPosition.Top) { + when (component?.isAtTop?.value) { + true if (component.drawBackground) -> SearchBarLevel.Active + true -> SearchBarLevel.Resting + false -> SearchBarLevel.Raised + null -> null + } + } else { + when (component?.isAtBottom?.value) { + true if (component.drawBackground) -> SearchBarLevel.Active + true -> SearchBarLevel.Resting + false -> SearchBarLevel.Raised + null -> null + } + } + + when (currentProgress) { + 0f -> homeLevel ?: SearchBarLevel.Active + 1f -> secondaryLevel ?: homeLevel ?: SearchBarLevel.Active + else if homeLevel != null && secondaryLevel != null -> maxOf(homeLevel, secondaryLevel) + else if homeLevel != null -> homeLevel + else -> SearchBarLevel.Active + } + } + + /** + * True if any page is open, false if on home page. + */ + var isSettledOnSecondaryPage by mutableStateOf(initialGesture != null) + private set + + /** + * 0..1 current progress + * 0: home page + * 1: any other page + */ + val currentProgress by derivedStateOf { + val dir = currentGesture ?: return@derivedStateOf 0f + val gesture = config[dir] ?: return@derivedStateOf 0f + + if (dir.orientation == null) { + return@derivedStateOf currentZOffset + } + + if (gesture.animation == ScaffoldAnimation.Rubberband) { + val offset = + (currentOffset.x + currentOffset.y).absoluteValue.coerceAtMost(rubberbandThreshold) + if (isSettledOnSecondaryPage) { + 1f - offset / (rubberbandThreshold * 2f) + } else { + offset / (rubberbandThreshold * 2f) + } + } else { + if (dir.orientation == Orientation.Horizontal) { + (currentOffset.x.absoluteValue / size.width).coerceIn(0f, 1f) + } else { + (currentOffset.y.absoluteValue / size.height).coerceIn(0f, 1f) + } + } + } + + val currentAnimation by derivedStateOf { + val dir = currentGesture ?: return@derivedStateOf null + config[dir]?.animation + } + + val currentComponent by derivedStateOf { + val dir = currentGesture ?: return@derivedStateOf null + config[dir]?.component + } + + private val offsetAnimatable = + Animatable(Offset.Zero, Offset.VectorConverter) + + private val zAnimatable = + Animatable(0f, Float.VectorConverter) + + private val searchBarAnimatable = + Animatable(0f, Float.VectorConverter) + + var isDragged by mutableStateOf(false) + private set + + suspend fun onDragStarted() { + if (isLocked) return + isDragged = true + offsetAnimatable.stop() + zAnimatable.stop() + } + + + fun onDrag(offset: Offset) { + if (isLocked) return + if (currentGesture == null || (!isSettledOnSecondaryPage && currentOffset.x.absoluteValue <= touchSlop && currentOffset.y.absoluteValue <= touchSlop)) { + currentGesture = getSwipeDirection(config, offset) + } + + val direction = currentGesture ?: return + + val gesture = config[direction] ?: return + + if (gesture.animation == ScaffoldAnimation.Rubberband) { + performRubberbandDrag(direction, currentOffset, offset) + } else if (gesture.animation == ScaffoldAnimation.Push) { + performPushDrag(direction, currentOffset, offset) + } + } + + private fun performRubberbandDrag(direction: Gesture, offset: Offset, delta: Offset) { + + val delta = when { + !isAtTop && !isAtBottom -> delta.copy(y = 0f) + !isAtTop -> delta.copy(y = delta.y.coerceAtMost(-offset.y)) + !isAtBottom -> delta.copy(y = delta.y.coerceAtLeast(-offset.y)) + else -> delta + } + val wasOverThreshold = currentOffset.x.absoluteValue > rubberbandThreshold || + currentOffset.y.absoluteValue > rubberbandThreshold + + val threshold = rubberbandThreshold * 1.5f + currentOffset = when (direction) { + Gesture.SwipeUp -> Offset( + 0f, + (offset.y + delta.y).coerceIn(-threshold, threshold) + ) + + Gesture.SwipeDown -> Offset( + 0f, + (offset.y + delta.y).coerceIn(-threshold, threshold) + ) + + Gesture.SwipeLeft -> Offset( + (offset.x + delta.x).coerceIn(-threshold, threshold), + 0f + ) + + Gesture.SwipeRight -> Offset( + (offset.x + delta.x).coerceIn(-threshold, threshold), + 0f + ) + + else -> Offset.Zero + } + + val isOverThreshold = currentOffset.x.absoluteValue > rubberbandThreshold || + currentOffset.y.absoluteValue > rubberbandThreshold + + if (wasOverThreshold != isOverThreshold) { + onHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + } + } + + /** + * @param direction The direction of the drag (currentDragDirection) + * @param offset The total offset of the drag (currentOffset) + * @param delta The delta of the drag (offset) + */ + private fun performPushDrag(direction: Gesture, offset: Offset, delta: Offset) { + val wasOverThreshold = + currentOffset.x.absoluteValue > size.width * 0.5f || + currentOffset.y.absoluteValue > size.height * 0.5f + + currentOffset = when (direction) { + Gesture.SwipeUp -> Offset( + 0f, + (offset.y + delta.y).coerceIn(-size.height, 0f) + ) + + Gesture.SwipeDown -> Offset( + 0f, + (offset.y + delta.y).coerceIn(0f, size.height) + ) + + Gesture.SwipeLeft -> Offset( + (offset.x + delta.x).coerceIn(-size.width, 0f), + 0f + ) + + Gesture.SwipeRight -> Offset( + (offset.x + delta.x).coerceIn(0f, size.width), + 0f + ) + + else -> Offset.Zero + + } + + val isOverThreshold = + currentOffset.x.absoluteValue > size.width * 0.5f || + currentOffset.y.absoluteValue > size.height * 0.5f + + if (wasOverThreshold != isOverThreshold) { + onHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + } + } + + private fun getSwipeDirection(config: ScaffoldConfiguration, offset: Offset): Gesture? { + when { + (offset.x >= 0 && offset.y >= 0) -> { + return if (offset.x > offset.y && config.swipeRight != null) { + Gesture.SwipeRight + } else if (offset.x < offset.y && config.swipeDown != null && isAtTop) { + Gesture.SwipeDown + } else { + null + } + } + + (offset.x < 0 && offset.y < 0) -> { + return if (offset.x < offset.y && config.swipeLeft != null) { + Gesture.SwipeLeft + } else if (offset.x > offset.y && config.swipeUp != null && isAtBottom) { + Gesture.SwipeUp + } else { + null + } + } + + (offset.x >= 0 && offset.y < 0) -> { + return if (offset.x > -offset.y && config.swipeRight != null) { + Gesture.SwipeRight + } else if (offset.x < -offset.y && config.swipeUp != null && isAtBottom) { + Gesture.SwipeUp + } else { + null + } + } + + (offset.x < 0 && offset.y >= 0) -> { + return if (offset.x < -offset.y && config.swipeLeft != null) { + Gesture.SwipeLeft + } else if (offset.x > -offset.y && config.swipeDown != null && isAtTop) { + Gesture.SwipeDown + } else { + null + } + } + } + return null + } + + /** + * Called when the drag gesture is stopped. + * This will perform the fling animation and snap to the next page if needed. + * @param velocity The velocity at the end of the drag gesture. + * @param disallowPageChange If true, the page will not change even if the velocity is over the threshold. + */ + suspend fun onDragStopped(velocity: Velocity, disallowPageChange: Boolean = false) { + isDragged = false + if (isLocked) return + val velocity = when { + !isAtTop && !isAtBottom -> velocity.copy(y = 0f) + !isAtTop -> velocity.copy(y = velocity.y.coerceAtMost(0f)) + !isAtBottom -> velocity.copy(y = velocity.y.coerceAtLeast(0f)) + else -> velocity + } + if (currentGesture == null) { + currentOffset = Offset.Zero + } + val direction = currentGesture ?: return + val offset = currentOffset + val wasPageOpen = isSettledOnSecondaryPage + + val gesture = config[direction] ?: return + + if (gesture.animation == ScaffoldAnimation.Rubberband) { + performRubberbandFling(direction, offset, velocity, disallowPageChange) + } else if (gesture.animation == ScaffoldAnimation.Push) { + performPushFling(direction, offset, velocity, disallowPageChange) + } + + if (isSettledOnSecondaryPage != wasPageOpen) { + if (wasPageOpen) { + deactivateSecondaryPage(gesture.component) + } else { + activateSecondaryPage(gesture.component) + } + } + + if (!isSettledOnSecondaryPage) currentGesture = null + } + + private suspend fun performRubberbandFling( + direction: Gesture, + offset: Offset, + velocity: Velocity, + disallowPageChange: Boolean, + ) { + val wasSettledOnSecondaryPage = isSettledOnSecondaryPage + + if (!disallowPageChange) { + if (offset.x <= -rubberbandThreshold || offset.x < 0f && velocity.x < -velocityThreshold) { + isSettledOnSecondaryPage = !isSettledOnSecondaryPage + } else if (offset.x >= rubberbandThreshold || offset.x > 0f && velocity.x > velocityThreshold) { + isSettledOnSecondaryPage = !isSettledOnSecondaryPage + } else if (offset.y <= -rubberbandThreshold || offset.y < 0f && velocity.y < -velocityThreshold) { + isSettledOnSecondaryPage = !isSettledOnSecondaryPage + } else if (offset.y >= rubberbandThreshold || offset.y > 0f && velocity.y > velocityThreshold) { + isSettledOnSecondaryPage = !isSettledOnSecondaryPage + } + } + + if (wasSettledOnSecondaryPage != isSettledOnSecondaryPage) { + onHapticFeedback(HapticFeedbackType.SegmentFrequentTick) + if (isSettledOnSecondaryPage) { + prepareSecondaryPage() + } else { + prepareHomePage() + } + } + + offsetAnimatable.snapTo(currentOffset) + offsetAnimatable.animateTo( + Offset.Zero, + initialVelocity = Offset(velocity.x, velocity.y), + ) { + currentOffset = this.value + } + } + + private suspend fun performPushFling( + direction: Gesture, + offset: Offset, + velocity: Velocity, + disallowPageChange: Boolean, + ) { + val wasOverThreshold = + currentOffset.x.absoluteValue > size.width * 0.5f || + currentOffset.y.absoluteValue > size.height * 0.5f + + val wasSettledOnSecondaryPage = isSettledOnSecondaryPage + + val lowerPage = when (direction) { + Gesture.SwipeUp -> -size.height + Gesture.SwipeDown -> 0f + Gesture.SwipeLeft -> -size.width + Gesture.SwipeRight -> 0f + else -> return + } + + val upperPage = if (direction.orientation == Orientation.Vertical) { + lowerPage + size.height + } else { + lowerPage + size.width + } + + val threshold = (upperPage + lowerPage) / 2f + + val targetOffset = if (direction.orientation == Orientation.Vertical) { + if (!disallowPageChange) { + if (offset.y > threshold && velocity.y > -velocityThreshold || velocity.y > velocityThreshold) { + Offset(0f, upperPage) + } else { + Offset(0f, lowerPage) + } + } else { + if (offset.y > threshold) Offset(0f, upperPage) else Offset(0f, lowerPage) + } + } else { + if (!disallowPageChange) { + if (offset.x > threshold && velocity.x > -velocityThreshold || velocity.x > velocityThreshold) { + Offset(upperPage, 0f) + } else { + Offset(lowerPage, 0f) + } + } else { + if (offset.x > threshold) Offset(upperPage, 0f) else Offset(lowerPage, 0f) + } + } + + isSettledOnSecondaryPage = targetOffset != Offset.Zero + + val isOverThreshold = + targetOffset.x.absoluteValue > size.width * 0.5f || + targetOffset.y.absoluteValue > size.height * 0.5f + + if (wasOverThreshold != isOverThreshold) { + onHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + } + + if (wasSettledOnSecondaryPage != isSettledOnSecondaryPage) { + if (isSettledOnSecondaryPage) { + prepareSecondaryPage() + } else { + prepareHomePage() + } + } + + offsetAnimatable.snapTo(currentOffset) + offsetAnimatable.animateTo( + targetOffset, + initialVelocity = Offset(velocity.x, velocity.y), + ) { + currentOffset = this.value + } + } + + suspend fun onDoubleTap() { + performTapGesture(Gesture.DoubleTap) + } + + suspend fun onLongPress() { + performTapGesture(Gesture.LongPress) + } + + suspend fun onHomeButtonPress() { + performTapGesture(Gesture.HomeButton) + + } + + suspend fun onSearchBarTap() { + if (currentComponent is SearchComponent) return + openSearch() + } + + /** + * Called by components to notify the scaffold that a child component has been scrolled vertically. + * Used to update the search bar position and visibility. + * Note that we aren't interested in horizontal scroll events. + * + * @param delta The y delta of the scroll event, in pixels. + */ + fun onComponentScroll(delta: Float) { + if (isSearchBarHidden || config.fixedSearchBar) return + if (isSettledOnSecondaryPage) { + secondaryPageSearchBarOffset = if (config.searchBarPosition == SearchBarPosition.Top) { + (secondaryPageSearchBarOffset - delta).coerceIn(-maxSearchBarOffset, 0f) + } else { + (secondaryPageSearchBarOffset + delta).coerceIn(0f, maxSearchBarOffset) + } + } else { + homePageSearchBarOffset = if (config.searchBarPosition == SearchBarPosition.Top) { + (homePageSearchBarOffset - delta).coerceIn(-maxSearchBarOffset, 0f) + } else { + (homePageSearchBarOffset + delta).coerceIn(0f, maxSearchBarOffset) + } + } + } + + private val backInterpolation = PathInterpolator(0f, 0f, 0f, 1f) + fun onPredictiveBack(progress: Float) { + if (!isSettledOnSecondaryPage) return + val gesture = currentGesture ?: return + val anim = currentAnimation ?: return + + val progress = backInterpolation.getInterpolation(progress) + + when (gesture) { + Gesture.TapSearchBar, Gesture.DoubleTap, Gesture.LongPress -> { + currentZOffset = 1f - progress + } + + else -> { + val x = when (gesture) { + Gesture.SwipeLeft -> -1f + Gesture.SwipeRight -> 1f + else -> 0f + } + val y = when (gesture) { + Gesture.SwipeUp -> -1f + Gesture.SwipeDown -> 1f + else -> 0f + } + + currentOffset = if (anim == ScaffoldAnimation.Push) { + Offset( + x * size.width * (1f - progress * 0.1f), + y * size.height * (1f - progress * 0.1f) + ) + } else { + Offset( + x * rubberbandThreshold * progress, + y * rubberbandThreshold * progress + ) + } + + } + } + } + + suspend fun onPredictiveBackCancel() { + val gesture = currentGesture ?: return + val anim = currentAnimation ?: return + + if (gesture.orientation == null) { + zAnimatable.snapTo(currentZOffset) + zAnimatable.animateTo(1f, animationSpec = tween(100)) { + currentZOffset = this.value + } + } else { + val targetOffset = if (anim == ScaffoldAnimation.Rubberband) { + Offset.Zero + } else { + when (gesture) { + Gesture.SwipeLeft -> Offset(-size.width, 0f) + Gesture.SwipeRight -> Offset(size.width, 0f) + Gesture.SwipeUp -> Offset(0f, -size.height) + Gesture.SwipeDown -> Offset(0f, size.height) + else -> Offset.Zero + } + } + + offsetAnimatable.snapTo(currentOffset) + offsetAnimatable.animateTo(targetOffset) { + currentOffset = this.value + } + } + } + + /** + * End the predictive back gesture and return to the home page. + * @param fast use a faster animation + */ + suspend fun onPredictiveBackEnd() { + navigateBack() + } + + suspend fun navigateBack(fast: Boolean = false) { + val gesture = currentGesture ?: return + val component = currentComponent ?: return + + unlock() + isSettledOnSecondaryPage = false + + prepareHomePage() + + if (gesture.orientation == null) { + zAnimatable.snapTo(currentZOffset) + zAnimatable.animateTo(0f, animationSpec = tween(100)) { + currentZOffset = this.value + } + } else { + offsetAnimatable.snapTo(currentOffset) + offsetAnimatable.animateTo( + Offset.Zero, + animationSpec = if (fast) tween(150) else spring() + ) { + currentOffset = this.value + } + } + deactivateSecondaryPage(component) + } + + private suspend fun performTapGesture(gesture: Gesture) { + val component = config[gesture]?.component ?: return + + if (component.hapticFeedback) onHapticFeedback(HapticFeedbackType.LongPress) + + if (component is SearchComponent && gesture != Gesture.TapSearchBar) { + openSearch() + return + } + lock() + currentGesture = gesture + + zAnimatable.snapTo(0f) + zAnimatable.animateTo(1f, animationSpec = tween(300)) { + currentZOffset = this.value + } + + isSettledOnSecondaryPage = true + + prepareSecondaryPage() + activateSecondaryPage(component) + } + + private suspend fun prepareSecondaryPage() { + config.homeComponent.onPreDismiss(this) + currentComponent?.onPreActivate(this) + } + + private suspend fun prepareHomePage() { + currentComponent?.onPreDismiss(this) + config.homeComponent.onPreActivate(this) + } + + /** + * Unmounts the secondary page component and mounts the home page component. + * Must be called after the animation is completed. + */ + private suspend fun deactivateSecondaryPage(component: ScaffoldComponent) { + config.homeComponent.onActivate(this) + component.onDismiss(this) + currentGesture = null + secondaryPageSearchBarOffset = 0f + } + + /** + * Mount the secondary page component and dismiss it again if not permanent. + * Must be called after the animation is completed. + */ + private suspend fun activateSecondaryPage(component: ScaffoldComponent) { + component.onActivate(this) + + if (!component.permanent) { + delay(component.resetDelay) + isSettledOnSecondaryPage = false + currentOffset = Offset.Zero + component.onPreDismiss(this) + component.onDismiss(this) + currentGesture = null + unlock() + } else { + config.homeComponent.onDismiss(this) + homePageSearchBarOffset = 0f + } + } + + private suspend fun openSearch() { + if (currentComponent != null && currentComponent !is SearchComponent) { + navigateBack(fast = true) + } + + if (config.homeComponent is SearchComponent) { + return + } + + // If there is any swipe gesture that is a SearchComponent, this takes precedence over the tap + // This allows to close the search with a reversed swipe + val gestures = + listOf(Gesture.SwipeDown, Gesture.SwipeUp, Gesture.SwipeLeft, Gesture.SwipeRight) + val gesture = gestures.find { config[it]?.component is SearchComponent } + + if (gesture == null) { + performTapGesture(Gesture.TapSearchBar) + return + } + isOpeningSearch = true + navigateToPage(gesture) + isOpeningSearch = false + } + + suspend fun navigateToPage(gesture: Gesture) { + currentGesture = gesture + val anim = config[gesture]?.animation ?: return + + prepareSecondaryPage() + + if (anim == ScaffoldAnimation.Rubberband) { + val targetOffset = when (gesture) { + Gesture.SwipeLeft -> Offset(-rubberbandThreshold, 0f) + Gesture.SwipeRight -> Offset(rubberbandThreshold, 0f) + Gesture.SwipeUp -> Offset(0f, -rubberbandThreshold) + Gesture.SwipeDown -> Offset(0f, rubberbandThreshold) + else -> Offset.Zero + } + + offsetAnimatable.snapTo(currentOffset) + offsetAnimatable.animateTo(targetOffset) { + currentOffset = this.value + } + isSettledOnSecondaryPage = true + offsetAnimatable.animateTo(Offset.Zero) { + currentOffset = this.value + } + } else { + val targetOffset = when (gesture) { + Gesture.SwipeLeft -> Offset(-size.width, 0f) + Gesture.SwipeRight -> Offset(size.width, 0f) + Gesture.SwipeUp -> Offset(0f, -size.height) + Gesture.SwipeDown -> Offset(0f, size.height) + else -> Offset.Zero + } + offsetAnimatable.snapTo(currentOffset) + offsetAnimatable.animateTo(targetOffset) { + currentOffset = this.value + } + isSettledOnSecondaryPage = true + } + activateSecondaryPage(config[gesture]!!.component) + } + + suspend fun reset() { + isSearchBarFocused = false + currentOffset = Offset.Zero + unlock() + isSettledOnSecondaryPage = false + + currentComponent?.let { + it.onPreDismiss(this) + config.homeComponent.onPreActivate(this) + deactivateSecondaryPage(it) + } + } + + /** + * If true, all gestures are ignored. + */ + var isLocked by mutableStateOf(initialIsLocked) + private set + var isSearchBarHidden by mutableStateOf(initialIsSearchBarHidden) + private set + + /** + * Lock the scaffold to the current page, disable all gestures and animations. + * Optionally hide the search bar. + */ + suspend fun lock(hideSearchBar: Boolean = false) { + isLocked = true + if (hideSearchBar) { + isSearchBarHidden = true + searchBarAnimatable.snapTo(currentSearchBarOffset) + searchBarAnimatable.animateTo( + if (config.searchBarPosition == SearchBarPosition.Bottom) maxSearchBarOffset else -maxSearchBarOffset, + tween(500) + ) { + if (isSettledOnSecondaryPage) { + secondaryPageSearchBarOffset = this.value + } else { + homePageSearchBarOffset = this.value + } + } + } + } + + suspend fun unlock() { + isLocked = false + if (isSearchBarHidden) { + isSearchBarHidden = false + searchBarAnimatable.snapTo(currentSearchBarOffset) + searchBarAnimatable.animateTo( + 0f, + tween(500) + ) { + if (isSettledOnSecondaryPage) { + secondaryPageSearchBarOffset = this.value + } else { + homePageSearchBarOffset = this.value + } + } + } + } +} + +@Composable +internal fun LauncherScaffold( + modifier: Modifier, + config: ScaffoldConfiguration, +) { + val scope = rememberCoroutineScope() + val lifecycleOwner = LocalLifecycleOwner.current + val activity = LocalActivity.current as AppCompatActivity + val view = LocalView.current + + val density = LocalDensity.current + val systemBarInsets = WindowInsets.displayCutout + .union(WindowInsets.waterfall) + .let { if (config.showStatusBar) it.union(WindowInsets.statusBars) else it } + .let { if (config.showNavBar) it.union(WindowInsets.navigationBars) else it } + + val searchVM = viewModel() + val searchActions = searchVM.searchActionResults + val highlightedResult by searchVM.bestMatch + val filters by searchVM.filters + val filterBar by searchVM.filterBar.collectAsState(false) + val filterBarItems by searchVM.filterBarItems.collectAsState(emptyList()) + val launchOnEnter by searchVM.launchOnEnter.collectAsState(false) + + val hazeState = rememberHazeState(blurEnabled = isAtLeastApiLevel(33)) + + BoxWithConstraints( + modifier = modifier, + ) { + val width = this.maxWidth + val height = this.maxHeight + val widthPx = width.toPixels() + val heightPx = height.toPixels() + + val touchSlop = LocalViewConfiguration.current.touchSlop + val minFlingVelocity = 125.dp.toPixels() + val rubberbandThreshold = 64.dp.toPixels() + val maxSearchBarOffset = ( + if (config.searchBarPosition == SearchBarPosition.Top) systemBarInsets.getTop( + density + ) + else systemBarInsets.getBottom(density) + ) + 128.dp.toPixels() + + val hapticFeedback = LocalHapticFeedback.current + + val state = + rememberSaveable( + widthPx, heightPx, touchSlop, rubberbandThreshold, minFlingVelocity, config, + saver = listSaver( + save = { + listOf( + it.currentGesture, + it.isSearchBarHidden, + it.isLocked, + ) + }, + restore = { + LauncherScaffoldState( + config = config, + size = Size(widthPx, heightPx), + touchSlop = touchSlop, + rubberbandThreshold = rubberbandThreshold, + velocityThreshold = minFlingVelocity, + maxSearchBarOffset = maxSearchBarOffset, + onHapticFeedback = { + hapticFeedback.performHapticFeedback(it) + }, + initialGesture = it[0] as Gesture?, + initialIsSearchBarHidden = it[1] as Boolean, + initialIsLocked = it[2] as Boolean, + ) + } + ) + ) { + LauncherScaffoldState( + config = config, + size = Size(widthPx, heightPx), + touchSlop = touchSlop, + rubberbandThreshold = rubberbandThreshold, + velocityThreshold = minFlingVelocity, + maxSearchBarOffset = maxSearchBarOffset, + onHapticFeedback = { + hapticFeedback.performHapticFeedback(it) + } + ) + } + + val searchBarHeight by animateDpAsState( + if (state.isSearchBarHidden) 0.dp + else if (searchActions.isEmpty()) 56.dp + else 104.dp + ) + + val isFilterBarVisible = + (state.currentProgress == 1f && state.currentComponent is SearchComponent || + state.currentProgress == 0f && config.homeComponent is SearchComponent) && + filterBar && WindowInsets.isImeVisible + + + val imeCurrent = WindowInsets.ime.getBottom(LocalDensity.current).toFloat() + val imeSource = WindowInsets.imeAnimationSource.getBottom(LocalDensity.current).toFloat() + val imeTarget = WindowInsets.imeAnimationTarget.getBottom(LocalDensity.current).toFloat() + val imeProgress = (imeCurrent / + (imeSource - imeTarget).absoluteValue.coerceAtLeast(imeCurrent)) + .coerceIn(0f, 1f) + + val filterBarHeight by animateDpAsState(if (isFilterBarVisible) imeProgress * 50.dp else 0.dp) + + LaunchedEffect(state) { + config.homeComponent.onPreActivate(state) + config.homeComponent.onActivate(state) + + var pauseTime = 0L + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + try { + if (pauseTime > 0L && System.currentTimeMillis() - pauseTime < 50L) { + if (!state.isLocked) { + if (state.currentProgress > 0f) { + state.onPredictiveBackEnd() + } else { + state.onHomeButtonPress() + } + } else { + activity.onBackPressedDispatcher.onBackPressed() + } + } else if (pauseTime > 0L && System.currentTimeMillis() - pauseTime > 5000L) { + if (!state.isLocked) { + state.reset() + searchVM.reset() + } + } + awaitCancellation() + } finally { + pauseTime = System.currentTimeMillis() + } + } + } + + + LaunchedEffect( + config, activity, view, + state.darkStatusBarIcons, + state.darkNavBarIcons, + ) { + val insetsController = WindowInsetsControllerCompat(activity.window, view) + insetsController.isAppearanceLightStatusBars = state.darkStatusBarIcons + insetsController.isAppearanceLightNavigationBars = state.darkNavBarIcons + if (config.showStatusBar) { + insetsController.show(WindowInsetsCompat.Type.statusBars()) + } else { + insetsController.hide(WindowInsetsCompat.Type.statusBars()) + } + if (config.showNavBar) { + insetsController.show(WindowInsetsCompat.Type.navigationBars()) + } else { + insetsController.hide(WindowInsetsCompat.Type.navigationBars()) + } + } + + if (config.wallpaperBlurRadius > 0.dp) { + val wallpaperBlur by animateIntAsState( + if (state.currentProgress >= 0.5f && (state.currentComponent?.drawBackground ?: config.homeComponent.drawBackground) + || state.currentProgress < 0.5f && config.homeComponent.drawBackground) { + 8.dp.toPixels().toInt() + } else { + 0 + } + ) + WallpaperBlur { wallpaperBlur } + } + + if (!config.finishOnBack || state.currentProgress > 0) { + PredictiveBackHandler { + try { + state.lock() + it.collect { + state.onPredictiveBack(it.progress) + } + scope.launch { state.onPredictiveBackEnd() } + } catch (_: CancellationException) { + scope.launch { state.onPredictiveBackCancel() } + } finally { + state.unlock() + } + } + } + + val nestedScrollConnection = remember(state) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (source != NestedScrollSource.UserInput) return Offset.Zero + + if (state.currentProgress != 0f && state.currentProgress != 1f) { + state.onDrag(available) + return available + } + + return Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (source == NestedScrollSource.UserInput) { + state.onDrag(available) + return available + } + return Offset.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity { + return super.onPreFling(available) + } + + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity + ): Velocity { + + if (state.currentProgress > 0f && !state.isSettledOnSecondaryPage) { + state.onDragStopped(available) + return available + } else if (state.currentProgress < 1f && state.isSettledOnSecondaryPage) { + state.onDragStopped(available) + return available + } else { + state.onDragStopped(available, disallowPageChange = true) + return available + } + } + } + } + + var draggableOrientation by remember { + mutableStateOf(state.currentGesture?.orientation) + } + + LaunchedEffect(state.isSettledOnSecondaryPage) { + if (!state.isSettledOnSecondaryPage) { + draggableOrientation = null + } else { + draggableOrientation = state.currentGesture?.orientation + } + } + + + Box( + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState) + .nestedScroll(nestedScrollConnection) + .draggable2D( + state = rememberDraggable2DState { + state.onDrag(it) + }, + enabled = draggableOrientation == null, + onDragStarted = { + scope.launch { + state.onDragStarted() + } + }, + onDragStopped = { velocity -> + scope.launch { + state.onDragStopped(velocity) + } + } + ) + .draggable( + state = rememberDraggableState { + state.onDrag(Offset(0f, it)) + }, + orientation = Orientation.Vertical, + enabled = draggableOrientation == Orientation.Vertical, + onDragStarted = { + scope.launch { + state.onDragStarted() + } + }, + onDragStopped = { velocity -> + scope.launch { + state.onDragStopped(Velocity(0f, velocity)) + } + }, + ) + .draggable( + state = rememberDraggableState { + state.onDrag(Offset(it, 0f)) + }, + orientation = Orientation.Horizontal, + enabled = draggableOrientation == Orientation.Horizontal, + onDragStarted = { + scope.launch { + state.onDragStarted() + } + }, + onDragStopped = { velocity -> + scope.launch { + state.onDragStopped(Velocity(velocity, 0f)) + } + } + ) + ) { + val searchBarInsets = WindowInsets( + top = if (config.searchBarPosition == SearchBarPosition.Top) searchBarHeight + 8.dp else 8.dp, + bottom = (if (config.searchBarPosition == SearchBarPosition.Bottom) searchBarHeight + 8.dp else 8.dp) + + filterBarHeight + ) + + val filterBarInsets = WindowInsets( + bottom = filterBarHeight + ) + + val insets = systemBarInsets.add(searchBarInsets).add(filterBarInsets) + + config.homeComponent.Component( + Modifier + .fillMaxSize() + .combinedClickable( + enabled = config.longPress != null || config.doubleTap != null, + onClick = {}, + onLongClick = if (config.longPress != null) { + { scope.launch { state.onLongPress() } } + } else null, + onDoubleClick = if (config.doubleTap != null) { + { scope.launch { state.onDoubleTap() } } + } else null, + hapticFeedbackEnabled = false, + indication = null, + interactionSource = null, + ) + .homePageAnimation( + state, + if (config.homeComponent.drawBackground) { + config.backgroundColor.copy(alpha = LocalTransparencyScheme.current.background) + } else { + Color.Transparent + } + ), + insets = systemBarInsets + .let { + if (config.searchBarStyle == SearchBarStyle.Hidden) it else it.add( + searchBarInsets + ) + } + .let { if (config.homeComponent.hasIme) it.union(WindowInsets.ime) else it } + .asPaddingValues(), + state + ) + + SecondaryPage( + state = state, + config = config, + modifier = Modifier + .fillMaxSize(), + insets = insets + .let { if (state.currentComponent?.hasIme == true) it.union(WindowInsets.ime) else it } + .asPaddingValues(), + ) + + Box( + modifier = Modifier + .fillMaxSize() + .searchBarAnimation( + state, + config, + systemBarInsets + .union(WindowInsets.ime) + .add(filterBarInsets) + .asPaddingValues() + ) + ) { + LauncherSearchBar( + modifier = Modifier + .align( + if (config.searchBarPosition == SearchBarPosition.Top) Alignment.TopCenter + else Alignment.BottomCenter + ), + searchBarOffset = { state.currentSearchBarOffset.roundToInt() }, + style = config.searchBarStyle, + focused = state.isSearchBarFocused, + actions = searchActions, + level = { state.searchBarLevel }, + bottomSearchBar = config.searchBarPosition == SearchBarPosition.Bottom, + onFocusChange = { + if (it) { + scope.launch { state.onSearchBarTap() } + } + state.isSearchBarFocused = it + }, + onKeyboardActionGo = if (launchOnEnter) { + { searchVM.launchBestMatchOrAction(activity) } + } else null, + highlightedAction = highlightedResult as? SearchAction, + darkColors = config.darkSearchBar, + ) + } + if (isFilterBarVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .offset(y = (1f - imeProgress) * 50.dp) + .alpha(imeProgress), + contentAlignment = Alignment.BottomCenter, + ) { + KeyboardFilterBar( + filters = filters, + onFiltersChange = { searchVM.setFilters(it) }, + items = filterBarItems + ) + } + } + } + + + AnimatedVisibility( + state.statusBarScrim, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .hazeEffect(hazeState) { + blurRadius = 4.dp + } + .background( + MaterialTheme.colorScheme.surfaceContainer.copy(alpha = LocalTransparencyScheme.current.background) + ) + .statusBarsPadding() + ) + } + AnimatedVisibility( + state.navBarScrim, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .hazeEffect(hazeState) { + blurRadius = 4.dp + } + .background( + MaterialTheme.colorScheme.surfaceContainer.copy(alpha = LocalTransparencyScheme.current.background) + ) + .navigationBarsPadding() + ) + } + } +} + +@Composable +private fun SecondaryPage( + state: LauncherScaffoldState, + config: ScaffoldConfiguration, + modifier: Modifier = Modifier, + insets: PaddingValues, +) { + val components = remember(config) { + setOfNotNull( + config.swipeUp?.component, + config.swipeDown?.component, + config.swipeLeft?.component, + config.swipeRight?.component, + config.doubleTap?.component, + config.longPress?.component, + config.searchComponent, + ) + } + + val composables = remember(config) { + components.associateWith { + movableContentOf { modifier, insets, state -> + it.Component( + modifier = modifier, + insets = insets, + state = state + ) + } + } + } + + val component = state.currentComponent + + if (component != null) { + + val mod = modifier + .fillMaxSize() + .secondaryPageAnimation( + state, + config.backgroundColor.copy(alpha = LocalTransparencyScheme.current.background), + ) + val composable = composables[component] + + composable?.invoke(mod, insets, state) + } + + // Keep other components alive, but out of the viewport + Box( + modifier = Modifier + .fillMaxSize() + .offset { IntOffset(x = state.size.width.toInt(), y = 0) } + ) { + for ((k, v) in composables) { + if (k == component) continue + v.invoke(modifier, insets, state) + } + } + +} + +private fun Modifier.homePageAnimation( + state: LauncherScaffoldState, + backgroundColor: Color, +): Modifier { + val dir = state.currentGesture ?: return this.background(backgroundColor) + val component = state.currentComponent ?: return this.background(backgroundColor) + + if (state.currentAnimation == ScaffoldAnimation.Rubberband) { + return this then component.homePageModifier( + state, + Modifier + .background(backgroundColor) + .graphicsLayer { + translationX = + if (dir.orientation == Orientation.Horizontal) state.currentOffset.x else 0f + translationY = + if (dir.orientation == Orientation.Vertical) state.currentOffset.y else 0f + if (!state.isSettledOnSecondaryPage) { + alpha = (1f - state.currentProgress).coerceAtMost(1f) + scaleX = 1f - (state.currentProgress * 0.03f) + scaleY = 1f - (state.currentProgress * 0.03f) + } else { + alpha = ((1f - state.currentProgress) * 2f - 1f).coerceAtLeast(0f) + } + } + ) + } + + if (state.currentAnimation == ScaffoldAnimation.ZoomIn) { + return this then Modifier + .graphicsLayer { + scaleX = 1f - (state.currentProgress * 0.03f) + scaleY = 1f - (state.currentProgress * 0.03f) + alpha = (1f - state.currentProgress).coerceIn(0f, 1f) + } + .background(backgroundColor) + } + + return this then component.homePageModifier(state, Modifier.background(backgroundColor).absoluteOffset { + IntOffset( + x = if (dir.orientation == Orientation.Horizontal) state.currentOffset.x.toInt() else 0, + y = if (dir.orientation == Orientation.Vertical) state.currentOffset.y.toInt() else 0 + ) + }) +} + +private fun Modifier.secondaryPageAnimation( + state: LauncherScaffoldState, + backgroundColor: Color, +): Modifier { + val dir = state.currentGesture ?: return this + val component = state.currentComponent ?: return this + + val background = + if (component.drawBackground) backgroundColor.copy(alpha = backgroundColor.alpha * state.currentProgress) else Color.Transparent + + if (state.currentAnimation == ScaffoldAnimation.Rubberband) { + return this then Modifier + .background(background) + .graphicsLayer { + translationX = + if (dir.orientation == Orientation.Horizontal) state.currentOffset.x else 0f + translationY = + if (dir.orientation == Orientation.Vertical) state.currentOffset.y else 0f + if (state.isSettledOnSecondaryPage) { + alpha = (state.currentProgress).coerceAtMost(1f) + scaleX = 1f - ((1f - state.currentProgress) * 0.03f) + scaleY = 1f - ((1f - state.currentProgress) * 0.03f) + } else { + alpha = (state.currentProgress * 2f - 1f).coerceAtLeast(0f) + } + } + + } + if (state.currentAnimation == ScaffoldAnimation.ZoomIn) { + return this then Modifier + .background(background) + .graphicsLayer { + scaleX = 1f - ((1f - state.currentProgress) * 0.03f) + scaleY = 1f - ((1f - state.currentProgress) * 0.03f) + alpha = (state.currentProgress).coerceAtMost(1f) + } + } + + return this then Modifier + .absoluteOffset { + when (state.currentGesture) { + Gesture.SwipeUp -> IntOffset(0, state.size.height.toInt()) + Gesture.SwipeDown -> IntOffset(0, -state.size.height.toInt()) + Gesture.SwipeLeft -> IntOffset(state.size.width.toInt(), 0) + Gesture.SwipeRight -> IntOffset(-state.size.width.toInt(), 0) + else -> IntOffset.Zero + } + } + .absoluteOffset { + IntOffset( + x = if (state.currentGesture?.orientation == Orientation.Horizontal) state.currentOffset.x.toInt() else 0, + y = if (state.currentGesture?.orientation == Orientation.Vertical) state.currentOffset.y.toInt() else 0 + ) + } + .composed { + val shape = MaterialTheme.shapes.extraLarge.let { + if (state.currentProgress < 0.95f) { + it + } else { + val density = LocalDensity.current + val p = 1f - ((state.currentProgress - 0.95f) * 20f).coerceIn(0f, 1f) + if (it is CutCornerShape) { + CutCornerShape( + topStart = it.topStart.toPx(state.size, density) * p, + topEnd = it.topEnd.toPx(state.size, density) * p, + bottomEnd = it.bottomEnd.toPx(state.size, density) * p, + bottomStart = it.bottomStart.toPx(state.size, density) * p, + ) + } else { + RoundedCornerShape( + topStart = it.topStart.toPx(state.size, density) * p, + topEnd = it.topEnd.toPx(state.size, density) * p, + bottomEnd = it.bottomEnd.toPx(state.size, density) * p, + bottomStart = it.bottomStart.toPx(state.size, density) * p, + ) + } + } + } + + Modifier + .background(background, shape) + .clip(shape) + } +} + +private fun Modifier.searchBarAnimation( + state: LauncherScaffoldState, + config: ScaffoldConfiguration, + insets: PaddingValues, +): Modifier { + val offsetFactor = if (config.searchBarPosition == SearchBarPosition.Top) -1f else 1f + + val component = state.currentComponent + val anim = state.currentAnimation + + val progress = if (anim == ScaffoldAnimation.Rubberband) { + (state.currentProgress * 2f).coerceAtMost(1f) + } else { + state.currentProgress + } + + val systemBarInset = + if (config.searchBarPosition == SearchBarPosition.Top) insets.calculateTopPadding() else insets.calculateBottomPadding() + + val offset = if (config.searchBarStyle == SearchBarStyle.Hidden) { + offsetFactor * (1 - progress).pow(2) * (128.dp + systemBarInset) + } else { + 0.dp + } + + val modifier = + if (component?.showSearchBar == false && config.searchBarStyle == SearchBarStyle.Hidden) { + Modifier.alpha(0f) + } else { + Modifier.offset(y = offset) + } + + return this then (component?.searchBarModifier(state, modifier) + ?: modifier) then Modifier.padding(insets) +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/NotificationsComponent.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/NotificationsComponent.kt new file mode 100644 index 00000000..36b3b921 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/NotificationsComponent.kt @@ -0,0 +1,138 @@ +package de.mm20.launcher2.ui.launcher.scaffold + +import android.annotation.SuppressLint +import android.view.animation.PathInterpolator +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import de.mm20.launcher2.globalactions.GlobalActionsService +import de.mm20.launcher2.permissions.PermissionGroup +import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.preferences.GestureAction +import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +internal object NotificationsComponent : ScaffoldComponent(), KoinComponent { + + private val permissionsManager: PermissionsManager by inject() + private val globalActionService: GlobalActionsService by inject() + + override val permanent: Boolean + get() = !permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility) + + override val showSearchBar: Boolean = false + + override val drawBackground: Boolean = false + + private val interpolator = PathInterpolator(0f, 0f, 0f, 1f) + + @Composable + override fun Component( + modifier: Modifier, + insets: PaddingValues, + state: LauncherScaffoldState + ) { + if (isActive) { + val bottomSheetManager = LocalBottomSheetManager.current + LaunchedEffect(Unit) { + val gesture = state.currentGesture ?: return@LaunchedEffect + if (!permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility)) { + bottomSheetManager.showFailedGestureSheet( + gesture = gesture, + action = GestureAction.Notifications, + ) + } + } + } + + val scale by animateFloatAsState( + if (state.currentProgress >= 0.5f) 1.2f else 1f + ) + + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(99f) + .pointerInput(Unit) {}, + contentAlignment = Alignment.TopCenter + ) { + Box( + modifier = Modifier + .systemBarsPadding() + .padding(16.dp) + .size(64.dp) + .offset( + y = -134.dp * interpolator.getInterpolation(1f - state.currentProgress * 2f) + .coerceAtLeast(0f) + ) + .scale(scale) + .shadow(4.dp, CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Rounded.Notifications, null, + modifier = Modifier.padding(16.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + + override suspend fun onActivate(state: LauncherScaffoldState) { + super.onActivate(state) + if (permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility)) { + globalActionService.openNotificationDrawer() + } else { + state.navigateBack(true) + } + } + + @SuppressLint("ModifierFactoryExtensionFunction") + override fun homePageModifier( + state: LauncherScaffoldState, + defaultModifier: Modifier + ): Modifier { + return Modifier + } + + @SuppressLint("ModifierFactoryExtensionFunction") + override fun searchBarModifier( + state: LauncherScaffoldState, + defaultModifier: Modifier + ): Modifier { + return defaultModifier.composed { + val color = MaterialTheme.colorScheme.scrim + Modifier.drawWithContent { + drawContent() + drawRect( + color = color.copy(alpha = 0.5f * (state.currentProgress * 2f).coerceAtMost(1f)) + ) + } + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/PowerMenuComponent.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/PowerMenuComponent.kt new file mode 100644 index 00000000..251c39bb --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/PowerMenuComponent.kt @@ -0,0 +1,200 @@ +package de.mm20.launcher2.ui.launcher.scaffold + +import android.annotation.SuppressLint +import android.view.Surface +import android.view.animation.PathInterpolator +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PowerSettingsNew +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.AbsoluteAlignment +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import androidx.compose.ui.zIndex +import de.mm20.launcher2.globalactions.GlobalActionsService +import de.mm20.launcher2.permissions.PermissionGroup +import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.preferences.GestureAction +import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +internal object PowerMenuComponent : ScaffoldComponent(), KoinComponent { + + private val permissionsManager: PermissionsManager by inject() + private val globalActionService: GlobalActionsService by inject() + + override val permanent: Boolean + get() = !permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility) + + override val showSearchBar: Boolean = false + + override val drawBackground: Boolean = false + + private val interpolator = PathInterpolator(0f, 0f, 0f, 1f) + + @Composable + override fun Component( + modifier: Modifier, + insets: PaddingValues, + state: LauncherScaffoldState + ) { + val context = LocalContext.current + val view = LocalView.current + val rotation = view.display.rotation + + val powerButtonY = remember { + val resources = + context.packageManager.getResourcesForApplication("com.android.systemui") + val resId = resources.getIdentifier( + "physical_power_button_center_screen_location_y", + "dimen", + "com.android.systemui" + ) + + if (resId != 0) { + resources.getDimensionPixelSize(resId) + } else { + (view.height * 0.33f).toInt() + } + } + + if (isActive) { + val bottomSheetManager = LocalBottomSheetManager.current + LaunchedEffect(Unit) { + val gesture = state.currentGesture ?: return@LaunchedEffect + if (!permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility)) { + bottomSheetManager.showFailedGestureSheet( + gesture = gesture, + action = GestureAction.PowerMenu, + ) + } + } + } + + val scale by animateFloatAsState( + if (state.currentProgress >= 0.5f) 1.2f else 1f + ) + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(99f) + .pointerInput(Unit) {}, + contentAlignment = when (rotation) { + Surface.ROTATION_0 -> AbsoluteAlignment.TopRight + Surface.ROTATION_90 -> AbsoluteAlignment.TopLeft + Surface.ROTATION_180 -> AbsoluteAlignment.BottomLeft + Surface.ROTATION_270 -> AbsoluteAlignment.BottomRight + else -> AbsoluteAlignment.TopRight + } + ) { + Box( + modifier = Modifier + .offset { + when (rotation) { + Surface.ROTATION_0 -> IntOffset(0, powerButtonY) + Surface.ROTATION_90 -> IntOffset(powerButtonY, 0) + Surface.ROTATION_180 -> IntOffset(0, -powerButtonY) + Surface.ROTATION_270 -> IntOffset(-powerButtonY, 0) + else -> IntOffset(0, powerButtonY) + } + } + .offset( + x = when (rotation) { + Surface.ROTATION_90 -> -48.dp + Surface.ROTATION_270 -> 48.dp + else -> 0.dp + }, + y = + when (rotation) { + Surface.ROTATION_0 -> -48.dp + Surface.ROTATION_180 -> 48.dp + else -> 0.dp + }, + ) + .padding(16.dp) + .size(64.dp) + .offset( + x = when (rotation) { + Surface.ROTATION_0 -> 1 + Surface.ROTATION_180 -> -1 + else -> 0 + } * 128.dp * interpolator.getInterpolation(1f - state.currentProgress * 2f) + .coerceAtLeast(0f), + y = when (rotation) { + Surface.ROTATION_90 -> -1 + Surface.ROTATION_270 -> 1 + else -> 0 + } * 128.dp * interpolator.getInterpolation(1f - state.currentProgress * 2f) + .coerceAtLeast(0f), + ) + .scale(scale) + .shadow(4.dp, CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Rounded.PowerSettingsNew, null, + modifier = Modifier.padding(16.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + + override suspend fun onActivate(state: LauncherScaffoldState) { + super.onActivate(state) + if (permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility)) { + globalActionService.openPowerDialog() + } else { + state.navigateBack(true) + } + } + + @SuppressLint("ModifierFactoryExtensionFunction") + override fun homePageModifier( + state: LauncherScaffoldState, + defaultModifier: Modifier + ): Modifier { + return Modifier + } + + @SuppressLint("ModifierFactoryExtensionFunction") + override fun searchBarModifier( + state: LauncherScaffoldState, + defaultModifier: Modifier + ): Modifier { + return defaultModifier.composed { + val color = MaterialTheme.colorScheme.scrim + Modifier.drawWithContent { + drawContent() + drawRect( + color = color.copy(alpha = 0.5f * (state.currentProgress * 2f).coerceAtMost(1f)) + ) + } + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/QuickSettingsComponent.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/QuickSettingsComponent.kt new file mode 100644 index 00000000..c6ca7b7d --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/QuickSettingsComponent.kt @@ -0,0 +1,135 @@ +package de.mm20.launcher2.ui.launcher.scaffold + +import android.annotation.SuppressLint +import android.view.animation.PathInterpolator +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import de.mm20.launcher2.globalactions.GlobalActionsService +import de.mm20.launcher2.permissions.PermissionGroup +import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.preferences.GestureAction +import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +internal object QuickSettingsComponent : ScaffoldComponent(), KoinComponent { + + private val permissionsManager: PermissionsManager by inject() + private val globalActionService: GlobalActionsService by inject() + + override val permanent: Boolean + get() = !permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility) + + override val showSearchBar: Boolean = false + + override val drawBackground: Boolean = false + + private val interpolator = PathInterpolator(0f, 0f, 0f, 1f) + + @Composable + override fun Component( + modifier: Modifier, + insets: PaddingValues, + state: LauncherScaffoldState + ) { + if (isActive) { + val bottomSheetManager = LocalBottomSheetManager.current + LaunchedEffect(Unit) { + val gesture = state.currentGesture ?: return@LaunchedEffect + if (!permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility)) { + bottomSheetManager.showFailedGestureSheet( + gesture = gesture, + action = GestureAction.QuickSettings, + ) + } + } + } + + val scale by animateFloatAsState( + if (state.currentProgress >= 0.5f) 1.2f else 1f + ) + + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(99f) + .pointerInput(Unit) {}, + contentAlignment = Alignment.TopCenter + ) { + Box( + modifier = Modifier + .systemBarsPadding() + .padding(16.dp) + .size(64.dp) + .offset(y = -134.dp * interpolator.getInterpolation(1f - state.currentProgress * 2f).coerceAtLeast(0f)) + .scale(scale) + .shadow(4.dp, CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Rounded.Settings, null, + modifier = Modifier.padding(16.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + + override suspend fun onActivate(state: LauncherScaffoldState) { + super.onActivate(state) + if (permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility)) { + globalActionService.openQuickSettings() + } else { + state.navigateBack(true) + } + } + + @SuppressLint("ModifierFactoryExtensionFunction") + override fun homePageModifier( + state: LauncherScaffoldState, + defaultModifier: Modifier + ): Modifier { + return Modifier + } + + @SuppressLint("ModifierFactoryExtensionFunction") + override fun searchBarModifier( + state: LauncherScaffoldState, + defaultModifier: Modifier + ): Modifier { + return defaultModifier.composed { + val color = MaterialTheme.colorScheme.scrim + Modifier.drawWithContent { + drawContent() + drawRect( + color = color.copy(alpha = 0.5f * (state.currentProgress * 2f).coerceAtMost(1f)) + ) + } + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/RecentsComponent.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/RecentsComponent.kt new file mode 100644 index 00000000..d3bbf987 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/RecentsComponent.kt @@ -0,0 +1,132 @@ +package de.mm20.launcher2.ui.launcher.scaffold + +import android.annotation.SuppressLint +import android.view.RoundedCorner +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import de.mm20.launcher2.globalactions.GlobalActionsService +import de.mm20.launcher2.ktx.isAtLeastApiLevel +import de.mm20.launcher2.permissions.PermissionGroup +import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.preferences.GestureAction +import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager +import kotlinx.coroutines.delay +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +internal object RecentsComponent : ScaffoldComponent(), KoinComponent { + + private val permissionsManager: PermissionsManager by inject() + private val globalActionService: GlobalActionsService by inject() + + override val permanent: Boolean + get() = !permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility) + + override val showSearchBar: Boolean = false + + override val drawBackground: Boolean = false + + override val isAtTop: State = mutableStateOf(true) + override val isAtBottom: State = mutableStateOf(true) + + @Composable + override fun Component( + modifier: Modifier, + insets: PaddingValues, + state: LauncherScaffoldState + ) { + if (isActive) { + val bottomSheetManager = LocalBottomSheetManager.current + LaunchedEffect(Unit) { + val gesture = state.currentGesture ?: return@LaunchedEffect + if (!permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility)) { + bottomSheetManager.showFailedGestureSheet( + gesture = gesture, + action = GestureAction.Recents, + ) + } + } + } + } + + override suspend fun onActivate(state: LauncherScaffoldState) { + super.onActivate(state) + if (permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility)) { + globalActionService.openRecents() + delay(50L) + state.navigateBack(true) + } else { + state.navigateBack(true) + } + } + + @SuppressLint("ModifierFactoryExtensionFunction") + override fun homePageModifier( + state: LauncherScaffoldState, + defaultModifier: Modifier + ): Modifier = Modifier.composed { + val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl + val insets = LocalView.current.rootWindowInsets + val shape = if (isAtLeastApiLevel(31) && insets != null) { + RoundedCornerShape( + topStart = insets.getRoundedCorner(if (rtl) RoundedCorner.POSITION_TOP_RIGHT else RoundedCorner.POSITION_TOP_LEFT)?.radius?.toFloat() + ?: 0f, + topEnd = insets.getRoundedCorner(if (rtl) RoundedCorner.POSITION_TOP_LEFT else RoundedCorner.POSITION_TOP_RIGHT)?.radius?.toFloat() + ?: 0f, + bottomStart = insets.getRoundedCorner(if (rtl) RoundedCorner.POSITION_BOTTOM_RIGHT else RoundedCorner.POSITION_BOTTOM_LEFT)?.radius?.toFloat() + ?: 0f, + bottomEnd = insets.getRoundedCorner(if (rtl) RoundedCorner.POSITION_BOTTOM_LEFT else RoundedCorner.POSITION_BOTTOM_RIGHT)?.radius?.toFloat() + ?: 0f, + ) + } else { + RectangleShape + } + + val surfaceColor = MaterialTheme.colorScheme.surfaceContainer + + Modifier + .drawWithContent() { + drawRect(color = surfaceColor.copy(alpha = state.currentProgress * 0.5f)) + drawIntoCanvas { + withTransform({ + this.scale(1f - state.currentProgress * 0.3f) + }) { + drawOutline( + shape.createOutline(size, layoutDirection, Density(density, fontScale)), + color = Color.Black, + blendMode = BlendMode.Clear + ) + this@drawWithContent.drawContent() + } + } + } + } + + @SuppressLint("ModifierFactoryExtensionFunction") + override fun searchBarModifier( + state: LauncherScaffoldState, + defaultModifier: Modifier + ): Modifier { + return defaultModifier.scale(1f - state.currentProgress * 0.3f) + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/ScaffoldComponent.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/ScaffoldComponent.kt new file mode 100644 index 00000000..1807c456 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/ScaffoldComponent.kt @@ -0,0 +1,103 @@ +package de.mm20.launcher2.ui.launcher.scaffold + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier + + +internal abstract class ScaffoldComponent { + /** + * If true, the component stays open. I.e. widgets, search. + * If false, the component is immediately dismissed after running its onMount function, + * returning to the home screen. I.e. turn off screen, launch app. + */ + open val permanent: Boolean = true + + /** + * For non-permanent components, this is the delay before the component is dismissed. + */ + open val resetDelay: Long = 0L + + /** + * If true, a semi-transparent background is drawn behind the page. + */ + open val drawBackground: Boolean = true + + /** + * Show the search bar on this component, if search bar style is hidden. + * For other styles, the search bar is always shown. + */ + open val showSearchBar: Boolean = true + + /** + * Whether haptic feedback should be used when the component is activated / dismissed. + */ + open val hapticFeedback: Boolean = true + + /** + * Whether this component can be interacted with the IME. + * If true, IME insets will be passed to the component. + * If false, IME insets will be ignored, to avoid unnecessary animations. + */ + open val hasIme: Boolean = false + + /** + * Whether the component is scrolled all the way up + * null, if the component does not provide content + */ + open val isAtTop: State = mutableStateOf(null) + + /** + * Whether the component is scrolled all the way down + * null, if the component does not provide content + */ + open val isAtBottom: State = mutableStateOf(null) + + @Composable abstract fun Component( + modifier: Modifier, + insets: PaddingValues, + state: LauncherScaffoldState, + ) + + @SuppressLint("ModifierFactoryExtensionFunction") + open fun homePageModifier(state: LauncherScaffoldState, defaultModifier: Modifier): Modifier = defaultModifier + + @SuppressLint("ModifierFactoryExtensionFunction") + open fun searchBarModifier(state: LauncherScaffoldState, defaultModifier: Modifier): Modifier = defaultModifier + + protected var isActive by mutableStateOf(false) + + /** + * Called when the component is about to be activated, but before the animation is completed. + */ + open suspend fun onPreActivate(state: LauncherScaffoldState) { + + } + + /** + * Called when the component is activated, after the animation is completed. + */ + open suspend fun onActivate(state: LauncherScaffoldState) { + isActive = true + } + + + /** + * Called when the component is about to be dismissed, but before the animation is completed. + */ + open suspend fun onPreDismiss(state: LauncherScaffoldState) { + + } + + /** + * Called when the component is dismissed, after the animation is completed, when the component is no longer visible. + */ + open suspend fun onDismiss(state: LauncherScaffoldState) { + isActive = false + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/ScreenOffComponent.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/ScreenOffComponent.kt new file mode 100644 index 00000000..2e5a99a0 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/ScreenOffComponent.kt @@ -0,0 +1,106 @@ +package de.mm20.launcher2.ui.launcher.scaffold + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import de.mm20.launcher2.globalactions.GlobalActionsService +import de.mm20.launcher2.permissions.PermissionGroup +import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.preferences.GestureAction +import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +internal object ScreenOffComponent : ScaffoldComponent(), KoinComponent { + + private val permissionsManager: PermissionsManager by inject() + private val globalActionService: GlobalActionsService by inject() + + override val permanent: Boolean + get() = !permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility) + + override val showSearchBar: Boolean = false + + override val resetDelay: Long = 1000L + + override val drawBackground: Boolean = false + + override val isAtTop: State = mutableStateOf(true) + override val isAtBottom: State = mutableStateOf(true) + + @Composable + override fun Component( + modifier: Modifier, + insets: PaddingValues, + state: LauncherScaffoldState + ) { + if (isActive) { + val bottomSheetManager = LocalBottomSheetManager.current + LaunchedEffect(Unit) { + val gesture = state.currentGesture ?: return@LaunchedEffect + if (!permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility)) { + bottomSheetManager.showFailedGestureSheet( + gesture = gesture, + action = GestureAction.ScreenLock, + ) + } + } + } + Box( + modifier = modifier + .zIndex(10f) + .pointerInput(Unit) {}, + contentAlignment = Alignment.Center + ) { + } + } + + @SuppressLint("ModifierFactoryExtensionFunction") + override fun homePageModifier( + state: LauncherScaffoldState, + defaultModifier: Modifier + ): Modifier { + return Modifier + .scale(1f - (state.currentProgress * 0.1f)) + .blur(12.dp * state.currentProgress) + .alpha(1f - (state.currentProgress * 0.1f)) + } + + @SuppressLint("ModifierFactoryExtensionFunction") + override fun searchBarModifier( + state: LauncherScaffoldState, + defaultModifier: Modifier + ): Modifier { + return Modifier + .drawWithContent { + drawContent() + drawRect(Color.Black, alpha = state.currentProgress) + } + .scale(1f - (state.currentProgress * 0.1f)) + .blur(12.dp * state.currentProgress) + .alpha(1f - (state.currentProgress * 0.1f)) then defaultModifier + } + + override suspend fun onActivate(state: LauncherScaffoldState) { + super.onActivate(state) + if (permissionsManager.checkPermissionOnce(PermissionGroup.Accessibility)) { + globalActionService.lockScreen() + } else { + state.onPredictiveBackEnd() + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/SearchComponent.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/SearchComponent.kt new file mode 100644 index 00000000..e8414b52 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/SearchComponent.kt @@ -0,0 +1,94 @@ +package de.mm20.launcher2.ui.launcher.scaffold + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.ui.launcher.search.SearchColumn +import de.mm20.launcher2.ui.launcher.search.SearchVM + +internal class SearchComponent( + private val reverse: Boolean = false, +) : ScaffoldComponent() { + + override val isAtTop: MutableState = mutableStateOf(true) + + override val isAtBottom: MutableState = mutableStateOf(true) + + override val hasIme: Boolean = true + + + @Composable + override fun Component( + modifier: Modifier, + insets: PaddingValues, + state: LauncherScaffoldState + ) { + val searchVM = viewModel() + val lazyListState = rememberLazyListState() + + LaunchedEffect(isActive) { + if (!isActive) { + searchVM.reset() + lazyListState.scrollToItem(0, 0) + } + } + + LaunchedEffect(lazyListState.canScrollForward, lazyListState.canScrollBackward) { + isAtBottom.value = !lazyListState.canScrollForward && !reverse || !lazyListState.canScrollBackward && reverse + isAtTop.value = !lazyListState.canScrollForward && reverse || !lazyListState.canScrollBackward && !reverse + } + + + val scrollConnection = remember(state) { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + searchVM.bestMatch.value = null + state.isSearchBarFocused = false + state.onComponentScroll( + if (reverse) consumed.y else -consumed.y, + ) + return super.onPostScroll(consumed, available, source) + } + } + } + + SearchColumn( + modifier.nestedScroll(scrollConnection), + paddingValues = insets, + state = lazyListState, + reverse = reverse, + userScrollEnabled = !state.isDragged, + ) + } + + override suspend fun onDismiss(state: LauncherScaffoldState) { + super.onDismiss(state) + } + + override suspend fun onPreActivate(state: LauncherScaffoldState) { + super.onPreActivate(state) + state.isSearchBarFocused = true + } + + override suspend fun onPreDismiss(state: LauncherScaffoldState) { + super.onPreDismiss(state) + state.isSearchBarFocused = false + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/SecretComponent.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/SecretComponent.kt new file mode 100644 index 00000000..fcada05d --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/SecretComponent.kt @@ -0,0 +1,82 @@ +package de.mm20.launcher2.ui.launcher.scaffold + +import android.content.Intent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.settings.SettingsActivity + +internal object SecretComponent : ScaffoldComponent() { + override var isAtTop: State = mutableStateOf(true) + override var isAtBottom: State = mutableStateOf(true) + + @Composable + override fun Component( + modifier: Modifier, + insets: PaddingValues, + state: LauncherScaffoldState + ) { + val context = LocalContext.current + Column( + modifier = modifier + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val color = MaterialTheme.colorScheme.onSurface + + Text( + "\uD83E\uDEF5\uD83D\uDE06", + style = MaterialTheme.typography.displayLarge, + textAlign = TextAlign.Center + ) + + Text( + stringResource(R.string.bad_configuration_title), + color = color, + modifier = Modifier.padding(vertical = 16.dp), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + + Text( + stringResource(R.string.bad_configuration_summary), + color = color, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + Button( + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + onClick = { + context.startActivity(Intent(context, SettingsActivity::class.java)) + }, modifier = Modifier.padding(top = 24.dp)) { + Icon( + Icons.Rounded.Settings, + contentDescription = null, + modifier = Modifier.padding(ButtonDefaults.IconSpacing).size(ButtonDefaults.IconSize) + ) + Text(stringResource(R.string.settings)) + } + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/WidgetsComponent.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/WidgetsComponent.kt new file mode 100644 index 00000000..f8e0f3b1 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/scaffold/WidgetsComponent.kt @@ -0,0 +1,134 @@ +package de.mm20.launcher2.ui.launcher.scaffold + +import android.annotation.SuppressLint +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.launcher.widgets.WidgetColumn +import kotlinx.coroutines.launch + +internal object WidgetsComponent : ScaffoldComponent() { + + private val scrollState = ScrollState(0) + + override val isAtTop: State = derivedStateOf { + !scrollState.canScrollBackward + } + + override val isAtBottom: State = derivedStateOf { + !scrollState.canScrollForward + } + + // In note widget + override val hasIme: Boolean = true + + @Composable + override fun Component( + modifier: Modifier, + insets: PaddingValues, + state: LauncherScaffoldState + ) { + var editMode by rememberSaveable { mutableStateOf(false) } + + val scope = rememberCoroutineScope() + val topPadding by animateDpAsState(if (editMode) 64.dp else 0.dp) + + val previousScroll = remember { mutableIntStateOf(scrollState.value) } + + LaunchedEffect(scrollState.value, scrollState.canScrollForward, scrollState.canScrollBackward) { + val delta = scrollState.value - previousScroll.intValue + previousScroll.intValue = scrollState.value + if (!editMode) { + state.onComponentScroll(delta.toFloat()) + } + } + + Column( + modifier = modifier + .verticalScroll(scrollState) + .padding(horizontal = 8.dp) + .padding(top = topPadding) + .padding(insets), + ) { + WidgetColumn( + modifier = Modifier, + editMode = editMode, + onEditModeChange = { + scope.launch { state.lock(hideSearchBar = true) } + editMode = it + }, + ) + } + if (editMode) { + BackHandler { + editMode = false + scope.launch { state.unlock() } + } + } + AnimatedVisibility( + editMode, + modifier = Modifier.zIndex(10f), + enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + CenterAlignedTopAppBar( + title = { Text(stringResource(R.string.menu_edit_widgets)) }, + navigationIcon = { + IconButton( + onClick = { + editMode = false + scope.launch { state.unlock() } + } + ) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.action_done)) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) + } + } + + override suspend fun onDismiss(state: LauncherScaffoldState) { + super.onDismiss(state) + scrollState.scrollTo(0) + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt index 1c9ed1dc..2530b607 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt @@ -53,6 +53,7 @@ import de.mm20.launcher2.ui.launcher.sheets.HiddenItemsSheet import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager import de.mm20.launcher2.ui.locals.LocalCardStyle import de.mm20.launcher2.ui.locals.LocalGridSettings +import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme @Composable fun SearchColumn( @@ -369,7 +370,7 @@ fun LazyListScope.SingleResult( vertical = 4.dp, ), color = if (highlight) MaterialTheme.colorScheme.secondaryContainer - else MaterialTheme.colorScheme.surface.copy(LocalCardStyle.current.opacity) + else MaterialTheme.colorScheme.surface.copy(LocalTransparencyScheme.current.surface) ) { content() } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridResults.kt index acd159aa..dfd44d30 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridResults.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/grid/GridResults.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.ui.ktx.withCorners -import de.mm20.launcher2.ui.locals.LocalCardStyle +import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme import kotlin.math.ceil fun LazyListScope.GridResults( @@ -39,7 +39,7 @@ fun LazyListScope.GridResults( bottom = if (!reverse && isBottom) 8.dp else 0.dp, ) .background( - MaterialTheme.colorScheme.surface.copy(alpha = LocalCardStyle.current.opacity), + MaterialTheme.colorScheme.surface.copy(alpha = LocalTransparencyScheme.current.surface), MaterialTheme.shapes.medium.withCorners( topStart = isTop, topEnd = isTop, @@ -72,7 +72,7 @@ fun LazyListScope.GridResults( bottom = if (!reverse && isLast) 8.dp else 0.dp, ) .background( - MaterialTheme.colorScheme.surface.copy(alpha = LocalCardStyle.current.opacity), + MaterialTheme.colorScheme.surface.copy(alpha = LocalTransparencyScheme.current.surface), MaterialTheme.shapes.medium.withCorners( topStart = isFirst && !reverse || isLast && reverse, topEnd = isFirst && !reverse || isLast && reverse, @@ -120,7 +120,7 @@ fun LazyListScope.GridResults( bottom = if (!reverse) 8.dp else 0.dp, ) .background( - MaterialTheme.colorScheme.surface.copy(alpha = LocalCardStyle.current.opacity), + MaterialTheme.colorScheme.surface.copy(alpha = LocalTransparencyScheme.current.surface), MaterialTheme.shapes.medium.withCorners( topStart = isTop, topEnd = isTop, diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListResults.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListResults.kt index dcd142ac..66fc7b9c 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListResults.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/list/ListResults.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.unit.dp import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.ui.ktx.animateCorners import de.mm20.launcher2.ui.layout.BottomReversed -import de.mm20.launcher2.ui.locals.LocalCardStyle +import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme fun LazyListScope.ListResults( key: String, @@ -104,7 +104,7 @@ fun LazyItemScope.ListItemSurface( if (it) 2.dp else 0.dp } val backgroundAlpha by transition.animateFloat { - if (it) 1f else LocalCardStyle.current.opacity + if (it) 1f else LocalTransparencyScheme.current.surface } val padding by transition.animateDp { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/favorites/SearchFavorites.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/favorites/SearchFavorites.kt index 46a8ca91..e4de3036 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/favorites/SearchFavorites.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/favorites/SearchFavorites.kt @@ -22,7 +22,7 @@ import de.mm20.launcher2.ui.common.FavoritesTagSelector import de.mm20.launcher2.ui.component.Banner import de.mm20.launcher2.ui.launcher.search.common.grid.SearchResultGrid import de.mm20.launcher2.ui.layout.BottomReversed -import de.mm20.launcher2.ui.locals.LocalCardStyle +import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme fun LazyListScope.SearchFavorites( favorites: List, @@ -47,7 +47,7 @@ fun LazyListScope.SearchFavorites( ) .background( MaterialTheme.colorScheme.surface.copy( - LocalCardStyle.current.opacity + LocalTransparencyScheme.current.surface ), MaterialTheme.shapes.medium ) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/filters/KeyboardFilterBar.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/filters/KeyboardFilterBar.kt index 6b483d4e..0f6ae88c 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/filters/KeyboardFilterBar.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/filters/KeyboardFilterBar.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import de.mm20.launcher2.preferences.KeyboardFilterBarItem import de.mm20.launcher2.search.SearchFilters +import de.mm20.launcher2.ui.modifier.consumeAllScrolling @Composable fun KeyboardFilterBar( @@ -43,6 +44,7 @@ fun KeyboardFilterBar( HorizontalDivider() Row( modifier = Modifier + .consumeAllScrolling() .horizontalScroll(rememberScrollState()) .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/LauncherSearchBar.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/LauncherSearchBar.kt index 864728a0..4109e851 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/LauncherSearchBar.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/LauncherSearchBar.kt @@ -18,10 +18,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.FilterAlt import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material3.Badge -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FilledTonalIconToggleButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -52,11 +49,10 @@ fun LauncherSearchBar( modifier: Modifier = Modifier, style: SearchBarStyle, level: () -> SearchBarLevel, - value: () -> String, focused: Boolean, onFocusChange: (Boolean) -> Unit, actions: List, - highlightedAction: SearchAction?, + highlightedAction: SearchAction? = null, isSearchOpen: Boolean = false, darkColors: Boolean = false, bottomSearchBar: Boolean = false, @@ -77,18 +73,15 @@ fun LauncherSearchBar( else focusManager.clearFocus() } - val filterBar by searchVM.filterBar.collectAsState(false) - - val _value = value() + val value by searchVM.searchQuery Box(modifier = modifier) { SearchBar( modifier = Modifier .align(if (bottomSearchBar) Alignment.BottomCenter else Alignment.TopCenter) - .windowInsetsPadding(WindowInsets.safeDrawing) .padding(8.dp) .offset { IntOffset(0, searchBarOffset()) }, - style = style, level = level(), value = _value, onValueChange = { + style = style, level = level(), value = value, onValueChange = { searchVM.search(it) }, reverse = bottomSearchBar, @@ -136,7 +129,7 @@ fun LauncherSearchBar( } } } - SearchBarMenu(searchBarValue = _value, onInputClear = { + SearchBarMenu(searchBarValue = value, onInputClear = { searchVM.reset() }) }, @@ -152,22 +145,5 @@ fun LauncherSearchBar( onUnfocus = { onFocusChange(false) }, onKeyboardActionGo = onKeyboardActionGo ) - - AnimatedVisibility (filterBar && isSearchOpen && !searchVM.showFilters.value - // Use imeAnimationTarget instead of isImeVisible to animate the filter bar at the same time as the keyboard - && WindowInsets.imeAnimationTarget.getBottom(LocalDensity.current) > 0, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier.align(Alignment.BottomCenter) - ) { - val items by searchVM.filterBarItems.collectAsState(emptyList()) - KeyboardFilterBar( - filters = searchVM.filters.value, - onFiltersChange = { - searchVM.setFilters(it) - }, - items = items - ) - } } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarActions.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarActions.kt index 0b241e32..408e17c3 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarActions.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarActions.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import de.mm20.launcher2.searchactions.actions.SearchAction import de.mm20.launcher2.ui.component.SearchActionIcon +import de.mm20.launcher2.ui.modifier.consumeAllScrolling import de.mm20.launcher2.ui.settings.SettingsActivity @Composable @@ -37,6 +38,7 @@ fun ColumnScope.SearchBarActions( AnimatedVisibility(actions.isNotEmpty()) { LazyRow( modifier = Modifier + .consumeAllScrolling() .height(48.dp) .padding(bottom = if (reverse) 0.dp else 8.dp, top = if (reverse) 8.dp else 0.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarMenu.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarMenu.kt index e197cbd3..06fd92c7 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarMenu.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/searchbar/SearchBarMenu.kt @@ -77,22 +77,6 @@ fun RowScope.SearchBarMenu( Icon(imageVector = Icons.Rounded.Wallpaper, contentDescription = null) } ) - val editButton by widgetsVM.editButton.collectAsState() - val searchOpen by launcherVM.isSearchOpen - if (!searchOpen && editButton == false) { - DropdownMenuItem( - onClick = { - launcherVM.setWidgetEditMode(editMode = true) - showOverflowMenu = false - }, - text = { - Text(stringResource(R.string.menu_edit_widgets)) - }, - leadingIcon = { - Icon(imageVector = Icons.Rounded.Edit, contentDescription = null) - } - ) - } DropdownMenuItem( onClick = { context.startActivity(Intent(context, SettingsActivity::class.java)) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/FailedGestureSheet.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/FailedGestureSheet.kt index 9aa9270e..545c7326 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/FailedGestureSheet.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/FailedGestureSheet.kt @@ -18,8 +18,9 @@ import de.mm20.launcher2.preferences.GestureAction import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.component.BottomSheetDialog import de.mm20.launcher2.ui.component.MissingPermissionBanner -import de.mm20.launcher2.ui.gestures.Gesture -import de.mm20.launcher2.ui.launcher.FailedGesture +import de.mm20.launcher2.ui.launcher.scaffold.Gesture + +data class FailedGesture(val gesture: Gesture, val action: GestureAction) @Composable fun FailedGestureSheet( @@ -43,7 +44,9 @@ fun FailedGestureSheet( Gesture.SwipeDown -> R.string.preference_gesture_swipe_down Gesture.SwipeLeft -> R.string.preference_gesture_swipe_left Gesture.SwipeRight -> R.string.preference_gesture_swipe_right - Gesture.HomeButton -> R.string.preference_gesture_home_button + Gesture.SwipeUp -> R.string.preference_gesture_swipe_up + else -> throw IllegalArgumentException("Unknown gesture: ${failedGesture.gesture}") + //Gesture.HomeButton -> R.string.preference_gesture_home_button }) BottomSheetDialog( diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/FailedGestureSheetVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/FailedGestureSheetVM.kt index 6cf11829..4086fae1 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/FailedGestureSheetVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/FailedGestureSheetVM.kt @@ -2,13 +2,11 @@ package de.mm20.launcher2.ui.launcher.sheets import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.preferences.GestureAction import de.mm20.launcher2.preferences.ui.GestureSettings -import de.mm20.launcher2.ui.gestures.Gesture -import kotlinx.coroutines.launch +import de.mm20.launcher2.ui.launcher.scaffold.Gesture import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -27,7 +25,8 @@ class FailedGestureSheetVM : ViewModel(), KoinComponent { Gesture.SwipeDown -> gestureSettings.setSwipeDown(GestureAction.NoAction) Gesture.SwipeLeft -> gestureSettings.setSwipeLeft(GestureAction.NoAction) Gesture.SwipeRight -> gestureSettings.setSwipeRight(GestureAction.NoAction) - Gesture.HomeButton -> gestureSettings.setHomeButton(GestureAction.NoAction) + //Gesture.HomeButton -> gestureSettings.setHomeButton(GestureAction.NoAction) + else -> {} } } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/LauncherBottomSheetManager.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/LauncherBottomSheetManager.kt index 13bc5375..03eaa14f 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/LauncherBottomSheetManager.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/LauncherBottomSheetManager.kt @@ -8,7 +8,9 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryOwner +import de.mm20.launcher2.preferences.GestureAction import de.mm20.launcher2.search.SavableSearchable +import de.mm20.launcher2.ui.launcher.scaffold.Gesture class LauncherBottomSheetManager(registryOwner: SavedStateRegistryOwner) : SavedStateRegistry.SavedStateProvider { @@ -16,6 +18,7 @@ class LauncherBottomSheetManager(registryOwner: SavedStateRegistryOwner) : val editFavoritesSheetShown = mutableStateOf(false) val hiddenItemsSheetShown = mutableStateOf(false) val editTagSheetShown = mutableStateOf(null) + val failedGestureSheetShown = mutableStateOf(null) init { registryOwner.lifecycle.addObserver(LifecycleEventObserver { _, event -> @@ -73,6 +76,13 @@ class LauncherBottomSheetManager(registryOwner: SavedStateRegistryOwner) : editTagSheetShown.value = null } + fun showFailedGestureSheet(gesture: Gesture, action: GestureAction) { + failedGestureSheetShown.value = FailedGesture(gesture, action) + } + fun dismissFailedGestureSheet() { + failedGestureSheetShown.value = null + } + companion object { private const val PROVIDER = "bottom_sheet_manager" private const val FAVORITES = "favorites" diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/LauncherBottomSheets.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/LauncherBottomSheets.kt index 5133cff8..d2ef8ac8 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/LauncherBottomSheets.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/LauncherBottomSheets.kt @@ -16,4 +16,7 @@ fun LauncherBottomSheets() { bottomSheetManager.editTagSheetShown.value?.let { EditTagSheet(tag = it, onDismiss = { bottomSheetManager.dismissEditTagSheet() }) } + bottomSheetManager.failedGestureSheetShown.value?.let { + FailedGestureSheet(it, onDismiss = { bottomSheetManager.dismissFailedGestureSheet() }) + } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetColumn.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetColumn.kt index abfc6681..b08b3ba6 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetColumn.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetColumn.kt @@ -8,7 +8,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarResult @@ -114,7 +118,7 @@ fun WidgetColumn( swapThresholds[i][0] = it.positionInParent().y swapThresholds[i][1] = it.positionInParent().y + it.size.height } - .padding(top = 8.dp) + .padding(top = if (i > 0) 8.dp else 0.dp) .offset { IntOffset(0, offsetY.value.toInt()) }, @@ -156,31 +160,28 @@ fun WidgetColumn( ) val icon = AnimatedImageVector.animatedVectorResource(R.drawable.anim_ic_edit_add) - ExtendedFloatingActionButton( - modifier = Modifier - .semantics { - role = Role.Button - contentDescription = title - } - .padding(16.dp) - .align(Alignment.CenterHorizontally), - icon = { - Icon( - painter = rememberAnimatedVectorPainter( - animatedImageVector = icon, - atEnd = !editMode - ), contentDescription = null - ) - }, - text = { - Text(title) - }, onClick = { + + Button( + modifier = Modifier.align(Alignment.CenterHorizontally).padding(vertical = 8.dp), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + onClick = { if (!editMode) { onEditModeChange(true) } else { addNewWidget = true } - }) + } + ) { + Icon( + modifier = Modifier.padding(end = ButtonDefaults.IconSpacing) + .size(ButtonDefaults.IconSize), + painter = rememberAnimatedVectorPainter( + animatedImageVector = icon, + atEnd = !editMode + ), contentDescription = null + ) + Text(title) + } } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetItem.kt index c3d09f63..4bcf1037 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetItem.kt @@ -10,18 +10,14 @@ import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.DragIndicator import androidx.compose.material.icons.rounded.Tune -import androidx.compose.material.icons.rounded.Warning -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -37,17 +33,15 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import de.mm20.launcher2.ui.R -import de.mm20.launcher2.ui.component.Banner import de.mm20.launcher2.ui.component.LauncherCard import de.mm20.launcher2.ui.launcher.sheets.ConfigureWidgetSheet -import de.mm20.launcher2.ui.launcher.sheets.WidgetPickerSheet import de.mm20.launcher2.ui.launcher.widgets.calendar.CalendarWidget import de.mm20.launcher2.ui.launcher.widgets.external.AppWidget import de.mm20.launcher2.ui.launcher.widgets.favorites.FavoritesWidget import de.mm20.launcher2.ui.launcher.widgets.music.MusicWidget import de.mm20.launcher2.ui.launcher.widgets.notes.NotesWidget import de.mm20.launcher2.ui.launcher.widgets.weather.WeatherWidget -import de.mm20.launcher2.ui.locals.LocalCardStyle +import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme import de.mm20.launcher2.widgets.AppWidget import de.mm20.launcher2.widgets.CalendarWidget import de.mm20.launcher2.widgets.FavoritesWidget @@ -72,14 +66,14 @@ fun WidgetItem( var configure by rememberSaveable { mutableStateOf(false) } var isDragged by remember { mutableStateOf(false) } - val elevation by animateDpAsState(if (isDragged) 8.dp else 2.dp) + val elevation by animateDpAsState(if (isDragged) 8.dp else 0.dp) val appWidget = if (widget is AppWidget) remember(widget.config.widgetId) { AppWidgetManager.getInstance(context).getAppWidgetInfo(widget.config.widgetId) } else null val backgroundOpacity by animateFloatAsState( - if (widget is AppWidget && !widget.config.background && !editMode) 0f else LocalCardStyle.current.opacity, + if (widget is AppWidget && !widget.config.background && !editMode) 0f else LocalTransparencyScheme.current.surface, label = "widgetCardBackgroundOpacity", ) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt index d6a18a2c..8fddf522 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt @@ -27,7 +27,6 @@ import androidx.compose.material.icons.rounded.AutoAwesome import androidx.compose.material.icons.rounded.BatteryFull import androidx.compose.material.icons.rounded.ColorLens import androidx.compose.material.icons.rounded.DarkMode -import androidx.compose.material.icons.rounded.Height import androidx.compose.material.icons.rounded.HorizontalSplit import androidx.compose.material.icons.rounded.LightMode import androidx.compose.material.icons.rounded.MusicNote @@ -563,21 +562,13 @@ fun ConfigureClockWidgetSheet( } } } - OutlinedCard( - modifier = Modifier.padding(top = 16.dp), - ) { - Column( - modifier = Modifier.fillMaxWidth() + if (fillHeight == true) { + OutlinedCard( + modifier = Modifier.padding(top = 16.dp), ) { - SwitchPreference( - title = stringResource(R.string.preference_clock_widget_fill_height), - icon = Icons.Rounded.Height, - value = fillHeight == true, - onValueChanged = { - viewModel.setFillHeight(it) - } - ) - AnimatedVisibility(fillHeight == true) { + Column( + modifier = Modifier.fillMaxWidth() + ) { var showDropdown by remember { mutableStateOf(false) } Preference( title = stringResource(R.string.preference_clock_widget_alignment), diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/music/MusicWidget.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/music/MusicWidget.kt index 28e2cb3a..f29067c3 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/music/MusicWidget.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/music/MusicWidget.kt @@ -86,8 +86,8 @@ import de.mm20.launcher2.ui.component.Tooltip import de.mm20.launcher2.ui.ktx.conditional import de.mm20.launcher2.ui.launcher.transitions.EnterHomeTransitionParams import de.mm20.launcher2.ui.launcher.transitions.HandleEnterHomeTransition -import de.mm20.launcher2.ui.locals.LocalCardStyle import de.mm20.launcher2.ui.locals.LocalWindowSize +import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme import de.mm20.launcher2.widgets.MusicWidget import kotlin.math.min @@ -352,7 +352,7 @@ fun MusicWidget(widget: MusicWidget) { ) { FilledTonalIconButton( colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = LocalCardStyle.current.opacity), + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = LocalTransparencyScheme.current.surface), ), onClick = { viewModel.togglePause() }, ) { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidget.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidget.kt index 6462d18b..f4854938 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidget.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidget.kt @@ -5,13 +5,11 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.text.format.DateUtils -import android.util.Log import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -81,8 +79,7 @@ import de.mm20.launcher2.ui.component.Tooltip import de.mm20.launcher2.ui.component.weather.AnimatedWeatherIcon import de.mm20.launcher2.ui.component.weather.WeatherIcon import de.mm20.launcher2.ui.ktx.blendIntoViewScale -import de.mm20.launcher2.ui.locals.LocalCardStyle -import de.mm20.launcher2.ui.modifier.consumeAllScrolling +import de.mm20.launcher2.ui.theme.transparency.LocalTransparencyScheme import de.mm20.launcher2.weather.DailyForecast import de.mm20.launcher2.weather.Forecast import de.mm20.launcher2.widgets.WeatherWidget @@ -121,7 +118,9 @@ fun WeatherWidget(widget: WeatherWidget) { Column { if (!isProviderAvailable) { Banner( - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), text = stringResource(R.string.weather_widget_no_provider), icon = Icons.Rounded.ErrorOutline, primaryAction = { @@ -134,7 +133,9 @@ fun WeatherWidget(widget: WeatherWidget) { Icon( Icons.AutoMirrored.Rounded.OpenInNew, null, - modifier = Modifier.padding(end = ButtonDefaults.IconSpacing).size(ButtonDefaults.IconSize) + modifier = Modifier + .padding(end = ButtonDefaults.IconSpacing) + .size(ButtonDefaults.IconSize) ) Text(stringResource(R.string.settings)) } @@ -179,7 +180,7 @@ fun WeatherWidget(widget: WeatherWidget) { val currentDayForecasts by viewModel.currentDayForecasts Surface( - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = LocalCardStyle.current.opacity), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = LocalTransparencyScheme.current.surface), modifier = Modifier.fillMaxWidth() ) { Column( @@ -240,26 +241,26 @@ fun CurrentWeather(forecast: Forecast, imperialUnits: Boolean) { ) } .clickable( - enabled = weatherApp != null, - onClick = { - context.tryStartActivity( - Intent().also { - it.component = weatherApp?.activityInfo?.let { - ComponentName(it.packageName, it.name) - } - }, - ActivityOptionsCompat.makeClipRevealAnimation( - view, - bounds.left.toInt(), - bounds.top.toInt(), - bounds.width.toInt(), - bounds.height.toInt() - ).toBundle() - ) - }, - interactionSource = remember { MutableInteractionSource() }, - indication = LocalIndication.current, - ) + enabled = weatherApp != null, + onClick = { + context.tryStartActivity( + Intent().also { + it.component = weatherApp?.activityInfo?.let { + ComponentName(it.packageName, it.name) + } + }, + ActivityOptionsCompat.makeClipRevealAnimation( + view, + bounds.left.toInt(), + bounds.top.toInt(), + bounds.width.toInt(), + bounds.height.toInt() + ).toBundle() + ) + }, + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current, + ) ) { Column( @@ -296,7 +297,7 @@ fun CurrentWeather(forecast: Forecast, imperialUnits: Boolean) { topEnd = CornerSize(0), bottomEnd = CornerSize(0) ), - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = LocalCardStyle.current.opacity), + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = LocalTransparencyScheme.current.surface), ) { Text( text = "${forecast.provider} (${ @@ -451,8 +452,7 @@ fun WeatherTimeSelector( LazyRow( state = listState, modifier = modifier - .fillMaxWidth() - .consumeAllScrolling(), + .fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(start = 16.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically @@ -476,7 +476,8 @@ fun WeatherTimeSelector( verticalArrangement = Arrangement.SpaceEvenly ) { WeatherIcon( - modifier = Modifier.align(Alignment.CenterHorizontally) + modifier = Modifier + .align(Alignment.CenterHorizontally) .semantics { contentDescription = fc.condition }, @@ -515,8 +516,7 @@ fun WeatherDaySelector( LazyRow( state = listState, modifier = modifier - .fillMaxWidth() - .consumeAllScrolling(), + .fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, contentPadding = PaddingValues(start = 16.dp, end = 16.dp), diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt index e123b7f4..68397e8d 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/clockwidget/ClockWidgetSettingsScreenVM.kt @@ -9,7 +9,6 @@ import de.mm20.launcher2.preferences.TimeFormat import de.mm20.launcher2.preferences.ui.ClockWidgetSettings import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -71,9 +70,6 @@ class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent { val fillHeight = settings.fillHeight .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) - fun setFillHeight(fillHeight: Boolean) { - settings.setFillHeight(fillHeight) - } val parts = settings.parts .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreen.kt index 7ba487dc..50c32e03 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreen.kt @@ -6,6 +6,7 @@ import android.widget.Toast import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -17,10 +18,12 @@ import androidx.compose.ui.res.stringResource import androidx.core.content.FileProvider import androidx.lifecycle.viewmodel.compose.viewModel import de.mm20.launcher2.ktx.tryStartActivity +import de.mm20.launcher2.ui.BuildConfig 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.component.preferences.SwitchPreference import de.mm20.launcher2.ui.locals.LocalNavController import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreenVM.kt index 43365fc8..57477f63 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreenVM.kt @@ -3,7 +3,10 @@ package de.mm20.launcher2.ui.settings.debug import androidx.lifecycle.ViewModel import de.mm20.launcher2.data.customattrs.CustomAttributesRepository import de.mm20.launcher2.icons.IconService +import de.mm20.launcher2.preferences.BaseLayout +import de.mm20.launcher2.preferences.ui.UiSettings import de.mm20.launcher2.searchable.SavableSearchableRepository +import kotlinx.coroutines.flow.map import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -12,6 +15,9 @@ class DebugSettingsScreenVM: ViewModel(), KoinComponent { private val searchableRepository: SavableSearchableRepository by inject() private val customAttributesRepository: CustomAttributesRepository by inject() private val iconService: IconService by inject() + + private val uiSettings: UiSettings by inject() + suspend fun cleanUpDatabase(): Int { var removed = searchableRepository.cleanupDatabase() removed += customAttributesRepository.cleanupDatabase() diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/gestures/GestureSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/gestures/GestureSettingsScreen.kt index 653064de..946ea060 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/gestures/GestureSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/gestures/GestureSettingsScreen.kt @@ -9,8 +9,15 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Adjust +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.SwipeDownAlt +import androidx.compose.material.icons.rounded.SwipeLeftAlt +import androidx.compose.material.icons.rounded.SwipeRightAlt +import androidx.compose.material.icons.rounded.SwipeUpAlt import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -20,6 +27,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -27,7 +35,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import de.mm20.launcher2.icons.LauncherIcon import de.mm20.launcher2.ktx.isAtLeastApiLevel -import de.mm20.launcher2.preferences.BaseLayout import de.mm20.launcher2.preferences.GestureAction import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.ui.R @@ -43,7 +50,6 @@ import de.mm20.launcher2.ui.ktx.toPixels fun GestureSettingsScreen() { val viewModel: GestureSettingsScreenVM = viewModel() - val layout by viewModel.baseLayout.collectAsStateWithLifecycle(null) val hasPermission by viewModel.hasPermission.collectAsStateWithLifecycle(null) val options = buildList { @@ -54,30 +60,109 @@ fun GestureSettingsScreen() { add(stringResource(R.string.gesture_action_recents) to GestureAction.Recents) add(stringResource(R.string.gesture_action_power_menu) to GestureAction.PowerMenu) add(stringResource(R.string.gesture_action_open_search) to GestureAction.Search) + add(stringResource(R.string.gesture_action_widgets) to GestureAction.Widgets) add(stringResource(R.string.gesture_action_launch_app) to GestureAction.Launch(null)) } val context = LocalContext.current PreferenceScreen(title = stringResource(R.string.preference_screen_gestures)) { - item { - PreferenceCategory { - val baseLayout by viewModel.baseLayout.collectAsStateWithLifecycle(null) - ListPreference(title = stringResource(R.string.preference_layout_open_search), - items = listOf( - stringResource(R.string.open_search_pull_down) to BaseLayout.PullDown, - stringResource(R.string.open_search_swipe_left) to BaseLayout.Pager, - stringResource(R.string.open_search_swipe_right) to BaseLayout.PagerReversed, - ), - value = baseLayout, - onValueChanged = { - if (it != null) viewModel.setBaseLayout(it) - }, - ) - } - } item { val appIconSize = 32.dp.toPixels() PreferenceCategory { + + + val swipeDown by viewModel.swipeDown.collectAsStateWithLifecycle(null) + AnimatedVisibility(hasPermission == false && requiresAccessibilityService(swipeDown)) { + MissingPermissionBanner( + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.missing_permission_accessibility_gesture_settings), + onClick = { viewModel.requestPermission(context as AppCompatActivity) } + ) + } + val swipeDownApp by viewModel.swipeDownApp.collectAsState(null) + val swipeDownAppIcon by remember(swipeDownApp?.key) { + viewModel.getIcon(swipeDownApp, appIconSize.toInt()) + }.collectAsState(null) + GesturePreference( + title = stringResource(R.string.preference_gesture_swipe_down), + icon = Icons.Rounded.SwipeDownAlt, + value = swipeDown, + onValueChanged = { viewModel.setSwipeDown(it) }, + options = options, + app = swipeDownApp, + appIcon = swipeDownAppIcon, + onAppChanged = { viewModel.setSwipeDownApp(it) } + ) + + val swipeLeft by viewModel.swipeLeft.collectAsStateWithLifecycle(null) + AnimatedVisibility(hasPermission == false && requiresAccessibilityService(swipeLeft)) { + MissingPermissionBanner( + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.missing_permission_accessibility_gesture_settings), + onClick = { viewModel.requestPermission(context as AppCompatActivity) } + ) + } + val swipeLeftApp by viewModel.swipeLeftApp.collectAsState(null) + val swipeLeftAppIcon by remember(swipeLeftApp?.key) { + viewModel.getIcon(swipeLeftApp, appIconSize.toInt()) + }.collectAsState(null) + GesturePreference( + title = stringResource(R.string.preference_gesture_swipe_left), + icon = Icons.Rounded.SwipeLeftAlt, + value = swipeLeft, + onValueChanged = { viewModel.setSwipeLeft(it) }, + options = options, + app = swipeLeftApp, + appIcon = swipeLeftAppIcon, + onAppChanged = { viewModel.setSwipeLeftApp(it) } + ) + + val swipeRight by viewModel.swipeRight.collectAsStateWithLifecycle(null) + AnimatedVisibility(hasPermission == false && requiresAccessibilityService(swipeRight)) { + MissingPermissionBanner( + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.missing_permission_accessibility_gesture_settings), + onClick = { viewModel.requestPermission(context as AppCompatActivity) } + ) + } + val swipeRightApp by viewModel.swipeRightApp.collectAsState(null) + val swipeRightAppIcon by remember(swipeRightApp?.key) { + viewModel.getIcon(swipeRightApp, appIconSize.toInt()) + }.collectAsState(null) + GesturePreference( + title = stringResource(R.string.preference_gesture_swipe_right), + icon = Icons.Rounded.SwipeRightAlt, + value = swipeRight, + onValueChanged = { viewModel.setSwipeRight(it) }, + options = options, + app = swipeRightApp, + appIcon = swipeRightAppIcon, + onAppChanged = { viewModel.setSwipeRightApp(it) } + ) + + val swipeUp by viewModel.swipeUp.collectAsStateWithLifecycle(null) + AnimatedVisibility(hasPermission == false && requiresAccessibilityService(swipeUp)) { + MissingPermissionBanner( + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.missing_permission_accessibility_gesture_settings), + onClick = { viewModel.requestPermission(context as AppCompatActivity) } + ) + } + val swipeUpApp by viewModel.swipeUpApp.collectAsState(null) + val swipeUpAppIcon by remember(swipeUpApp?.key) { + viewModel.getIcon(swipeUpApp, appIconSize.toInt()) + }.collectAsState(null) + GesturePreference( + title = stringResource(R.string.preference_gesture_swipe_up), + icon = Icons.Rounded.SwipeUpAlt, + value = swipeUp, + onValueChanged = { viewModel.setSwipeUp(it) }, + options = options, + app = swipeUpApp, + appIcon = swipeUpAppIcon, + onAppChanged = { viewModel.setSwipeUpApp(it) } + ) + val doubleTap by viewModel.doubleTap.collectAsStateWithLifecycle(null) AnimatedVisibility(hasPermission == false && requiresAccessibilityService(doubleTap)) { MissingPermissionBanner( @@ -92,9 +177,9 @@ fun GestureSettingsScreen() { }.collectAsState(null) GesturePreference( title = stringResource(R.string.preference_gesture_double_tap), + icon = Icons.Rounded.Adjust, value = doubleTap, onValueChanged = { viewModel.setDoubleTap(it) }, - isOpenSearch = false, options = options, app = doubleTapApp, appIcon = doubleTapAppIcon, @@ -115,87 +200,15 @@ fun GestureSettingsScreen() { }.collectAsState(null) GesturePreference( title = stringResource(R.string.preference_gesture_long_press), + icon = Icons.Rounded.Circle, value = longPress, onValueChanged = { viewModel.setLongPress(it) }, - isOpenSearch = false, options = options, app = longPressApp, appIcon = longPressAppIcon, onAppChanged = { viewModel.setLongPressApp(it) } ) - val swipeDown by viewModel.swipeDown.collectAsStateWithLifecycle(null) - val swipeDownIsSearch = layout == BaseLayout.PullDown - AnimatedVisibility(hasPermission == false && requiresAccessibilityService(swipeDown) && !swipeDownIsSearch) { - MissingPermissionBanner( - modifier = Modifier.padding(16.dp), - text = stringResource(R.string.missing_permission_accessibility_gesture_settings), - onClick = { viewModel.requestPermission(context as AppCompatActivity) } - ) - } - val swipeDownApp by viewModel.swipeDownApp.collectAsState(null) - val swipeDownAppIcon by remember(swipeDownApp?.key) { - viewModel.getIcon(swipeDownApp, appIconSize.toInt()) - }.collectAsState(null) - GesturePreference( - title = stringResource(R.string.preference_gesture_swipe_down), - value = swipeDown, - onValueChanged = { viewModel.setSwipeDown(it) }, - isOpenSearch = swipeDownIsSearch, - options = options, - app = swipeDownApp, - appIcon = swipeDownAppIcon, - onAppChanged = { viewModel.setSwipeDownApp(it) } - ) - - val swipeLeft by viewModel.swipeLeft.collectAsStateWithLifecycle(null) - val swipeLeftIsSearch = layout == BaseLayout.Pager - AnimatedVisibility(hasPermission == false && requiresAccessibilityService(swipeLeft) && !swipeLeftIsSearch) { - MissingPermissionBanner( - modifier = Modifier.padding(16.dp), - text = stringResource(R.string.missing_permission_accessibility_gesture_settings), - onClick = { viewModel.requestPermission(context as AppCompatActivity) } - ) - } - val swipeLeftApp by viewModel.swipeLeftApp.collectAsState(null) - val swipeLeftAppIcon by remember(swipeLeftApp?.key) { - viewModel.getIcon(swipeLeftApp, appIconSize.toInt()) - }.collectAsState(null) - GesturePreference( - title = stringResource(R.string.preference_gesture_swipe_left), - value = swipeLeft, - onValueChanged = { viewModel.setSwipeLeft(it) }, - isOpenSearch = swipeLeftIsSearch, - options = options, - app = swipeLeftApp, - appIcon = swipeLeftAppIcon, - onAppChanged = { viewModel.setSwipeLeftApp(it) } - ) - - val swipeRight by viewModel.swipeRight.collectAsStateWithLifecycle(null) - val swipeRightIsSearch = layout == BaseLayout.PagerReversed - AnimatedVisibility(hasPermission == false && requiresAccessibilityService(swipeRight) && !swipeRightIsSearch) { - MissingPermissionBanner( - modifier = Modifier.padding(16.dp), - text = stringResource(R.string.missing_permission_accessibility_gesture_settings), - onClick = { viewModel.requestPermission(context as AppCompatActivity) } - ) - } - val swipeRightApp by viewModel.swipeRightApp.collectAsState(null) - val swipeRightAppIcon by remember(swipeRightApp?.key) { - viewModel.getIcon(swipeRightApp, appIconSize.toInt()) - }.collectAsState(null) - GesturePreference( - title = stringResource(R.string.preference_gesture_swipe_right), - value = swipeRight, - onValueChanged = { viewModel.setSwipeRight(it) }, - isOpenSearch = swipeRightIsSearch, - options = options, - app = swipeRightApp, - appIcon = swipeRightAppIcon, - onAppChanged = { viewModel.setSwipeRightApp(it) } - ) - val homeButton by viewModel.homeButton.collectAsStateWithLifecycle(null) AnimatedVisibility(hasPermission == false && requiresAccessibilityService(homeButton)) { MissingPermissionBanner( @@ -210,9 +223,9 @@ fun GestureSettingsScreen() { }.collectAsState(null) GesturePreference( title = stringResource(R.string.preference_gesture_home_button), + icon = Icons.Rounded.Home, value = homeButton, onValueChanged = { viewModel.setHomeButton(it) }, - isOpenSearch = false, options = options, app = homeButtonApp, appIcon = homeButtonAppIcon, @@ -230,6 +243,7 @@ fun requiresAccessibilityService(action: GestureAction?): Boolean { is GestureAction.QuickSettings, is GestureAction.Recents, is GestureAction.PowerMenu -> true + else -> false } } @@ -237,9 +251,9 @@ fun requiresAccessibilityService(action: GestureAction?): Boolean { @Composable fun GesturePreference( title: String, + icon: ImageVector, value: GestureAction?, onValueChanged: (GestureAction) -> Unit, - isOpenSearch: Boolean, options: List>, app: SavableSearchable?, appIcon: LauncherIcon?, @@ -254,14 +268,15 @@ fun GesturePreference( ) { ListPreference( title = title, - enabled = !isOpenSearch, + icon = icon, items = options, - value = if (isOpenSearch) GestureAction.Search else value, + value = value, + summary = options.find { value?.javaClass == it.second.javaClass }?.first, onValueChanged = { if (it != null) onValueChanged(it) } ) } - if (value is GestureAction.Launch && !isOpenSearch) { + if (value is GestureAction.Launch) { Box( modifier = Modifier .height(36.dp) @@ -269,15 +284,16 @@ fun GesturePreference( .alpha(0.38f) .background(LocalContentColor.current) ) - Box(modifier = Modifier - .clickable { showAppPicker = true } - .padding(12.dp)) { + Box( + modifier = Modifier + .clickable { showAppPicker = true } + .padding(12.dp)) { ShapedLauncherIcon(size = 32.dp, icon = { appIcon }) } } } - if (!isOpenSearch && value is GestureAction.Launch && (showAppPicker || app == null)) { + if (value is GestureAction.Launch && (showAppPicker || app == null)) { SearchablePicker( onDismissRequest = { showAppPicker = false diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/gestures/GestureSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/gestures/GestureSettingsScreenVM.kt index a53c4c0a..1471af46 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/gestures/GestureSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/gestures/GestureSettingsScreenVM.kt @@ -34,20 +34,14 @@ class GestureSettingsScreenVM : ViewModel(), KoinComponent { val hasPermission = permissionsManager.hasPermission(PermissionGroup.Accessibility) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) - val baseLayout = uiSettings.baseLayout - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) - - fun setBaseLayout(baseLayout: BaseLayout) { - uiSettings.setBaseLayout(baseLayout) - } - - val swipeDown = gestureSettings.swipeDown .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) val swipeLeft = gestureSettings.swipeLeft .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) val swipeRight = gestureSettings.swipeRight .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + val swipeUp = gestureSettings.swipeUp + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) val doubleTap = gestureSettings.doubleTap .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) val longPress = gestureSettings.longPress @@ -67,6 +61,10 @@ class GestureSettingsScreenVM : ViewModel(), KoinComponent { gestureSettings.setSwipeRight(action) } + fun setSwipeUp(action: GestureAction) { + gestureSettings.setSwipeUp(action) + } + fun setDoubleTap(action: GestureAction) { gestureSettings.setDoubleTap(action) } @@ -121,6 +119,20 @@ class GestureSettingsScreenVM : ViewModel(), KoinComponent { setSwipeDown(GestureAction.Launch(searchable.key)) } + val swipeUpApp: Flow = swipeUp + .flatMapLatest { + if (it !is GestureAction.Launch || it.key == null) flowOf(null) + else searchableRepository.getByKeys(listOf(it.key!!)).map { + it.firstOrNull() + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 10000), null) + + fun setSwipeUpApp(searchable: SavableSearchable?) { + searchable?.let { searchableRepository.insert(it) } ?: return + setSwipeUp(GestureAction.Launch(searchable.key)) + } + val longPressApp: Flow = longPress .flatMapLatest { if (it !is GestureAction.Launch || it.key == null) flowOf(null) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/homescreen/HomescreenSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/homescreen/HomescreenSettingsScreen.kt index c8500323..b1497c23 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/homescreen/HomescreenSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/homescreen/HomescreenSettingsScreen.kt @@ -54,6 +54,7 @@ fun HomescreenSettingsScreen() { val dock by viewModel.dock.collectAsStateWithLifecycle(null) val fixedRotation by viewModel.fixedRotation.collectAsStateWithLifecycle(null) + val widgetsOnHomeScreen by viewModel.widgetsOnHomeScreen.collectAsStateWithLifecycle(null) val editButton by viewModel.widgetEditButton.collectAsStateWithLifecycle(null) val searchBarStyle by viewModel.searchBarStyle.collectAsStateWithLifecycle(null) val searchBarColor by viewModel.searchBarColor.collectAsStateWithLifecycle(null) @@ -81,18 +82,6 @@ fun HomescreenSettingsScreen() { ) } } - item { - PreferenceCategory(stringResource(R.string.preference_clockwidget_favorites_part)) { - SwitchPreference( - title = stringResource(R.string.preference_clockwidget_favorites_part), - summary = stringResource(R.string.preference_clockwidget_favorites_part_summary), - value = dock == true, - onValueChanged = { - viewModel.setDock(it) - }, - ) - } - } item { PreferenceCategory( title = stringResource(id = R.string.preference_category_widgets) @@ -104,6 +93,21 @@ fun HomescreenSettingsScreen() { viewModel.showClockWidgetSheet = true } ) + SwitchPreference( + title = stringResource(R.string.preference_clockwidget_favorites_part), + summary = stringResource(R.string.preference_clockwidget_favorites_part_summary), + value = dock == true, + onValueChanged = { + viewModel.setDock(it) + }, + ) + SwitchPreference( + title = stringResource(R.string.preference_widgets_on_home_screen), + summary = stringResource(R.string.preference_widgets_on_home_screen_summary), + value = widgetsOnHomeScreen == true, + onValueChanged = { + viewModel.setWidgetsOnHomeScreen(it) + }) SwitchPreference( title = stringResource(id = R.string.preference_edit_button), summary = stringResource(id = R.string.preference_widgets_edit_button_summary), diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/homescreen/HomescreenSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/homescreen/HomescreenSettingsScreenVM.kt index c5f8a03b..7867b26f 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/homescreen/HomescreenSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/homescreen/HomescreenSettingsScreenVM.kt @@ -13,13 +13,16 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import de.mm20.launcher2.ktx.isAtLeastApiLevel +import de.mm20.launcher2.preferences.GestureAction import de.mm20.launcher2.preferences.ScreenOrientation import de.mm20.launcher2.preferences.SearchBarColors import de.mm20.launcher2.preferences.SearchBarStyle import de.mm20.launcher2.preferences.SystemBarColors import de.mm20.launcher2.preferences.ui.ClockWidgetSettings +import de.mm20.launcher2.preferences.ui.GestureSettings import de.mm20.launcher2.preferences.ui.UiSettings import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -29,6 +32,7 @@ import org.koin.core.component.get class HomescreenSettingsScreenVM( private val uiSettings: UiSettings, private val clockWidgetSettings: ClockWidgetSettings, + private val gestureSettings: GestureSettings, ) : ViewModel() { var showClockWidgetSheet by mutableStateOf(false) @@ -120,11 +124,11 @@ class HomescreenSettingsScreenVM( uiSettings.setBottomSearchBar(bottomSearchBar) } - val dock = clockWidgetSettings.dock + val dock = uiSettings.dock .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) fun setDock(dock: Boolean) { - clockWidgetSettings.setDock(dock) + uiSettings.setDock(dock) } val fixedRotation = uiSettings.orientation.map { it != ScreenOrientation.Auto } @@ -148,12 +152,52 @@ class HomescreenSettingsScreenVM( uiSettings.setChargingAnimation(chargingAnimation) } + val widgetsOnHomeScreen = uiSettings.homeScreenWidgets + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + + fun setWidgetsOnHomeScreen(widgetsOnHomeScreen: Boolean) { + uiSettings.setHomeScreenWidgets(widgetsOnHomeScreen) + viewModelScope.launch { + val gestures = gestureSettings.first() + if (widgetsOnHomeScreen) { + if (gestures.swipeUp is GestureAction.Widgets) { + gestureSettings.setSwipeUp(GestureAction.NoAction) + } else if (gestures.swipeRight is GestureAction.Widgets) { + gestureSettings.setSwipeUp(GestureAction.NoAction) + } else if (gestures.swipeLeft is GestureAction.Widgets) { + gestureSettings.setSwipeUp(GestureAction.NoAction) + } else if (gestures.swipeDown is GestureAction.Widgets) { + gestureSettings.setSwipeUp(GestureAction.NoAction) + } else if (gestures.longPress is GestureAction.Widgets) { + gestureSettings.setLongPress(GestureAction.NoAction) + } else if (gestures.doubleTap is GestureAction.Widgets) { + gestureSettings.setDoubleTap(GestureAction.NoAction) + } + } else { + if (gestures.swipeUp is GestureAction.NoAction || gestures.swipeUp is GestureAction.Widgets) { + gestureSettings.setSwipeUp(GestureAction.Widgets) + } else if (gestures.swipeRight is GestureAction.NoAction || gestures.swipeRight is GestureAction.Widgets) { + gestureSettings.setSwipeRight(GestureAction.Widgets) + } else if (gestures.swipeLeft is GestureAction.NoAction || gestures.swipeLeft is GestureAction.Widgets) { + gestureSettings.setSwipeLeft(GestureAction.Widgets) + } else if (gestures.swipeDown is GestureAction.NoAction || gestures.swipeDown is GestureAction.Widgets) { + gestureSettings.setSwipeDown(GestureAction.Widgets) + } else if (gestures.longPress is GestureAction.NoAction || gestures.longPress is GestureAction.Widgets) { + gestureSettings.setLongPress(GestureAction.Widgets) + } else if (gestures.doubleTap is GestureAction.NoAction || gestures.doubleTap is GestureAction.Widgets) { + gestureSettings.setDoubleTap(GestureAction.Widgets) + } + } + } + } + companion object : KoinComponent { val Factory = viewModelFactory { initializer { HomescreenSettingsScreenVM( uiSettings = get(), clockWidgetSettings = get(), + gestureSettings = get(), ) } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/main/MainSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/main/MainSettingsScreen.kt index 6d67fea4..f6c2e928 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/main/MainSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/main/MainSettingsScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.Power +import androidx.compose.material.icons.rounded.Science import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.SettingsBackupRestore import androidx.compose.runtime.Composable diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/theme/WallpaperColors.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/theme/WallpaperColors.kt index bf457e43..ba166902 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/theme/WallpaperColors.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/theme/WallpaperColors.kt @@ -48,8 +48,8 @@ fun wallpaperColorsAsState(): State { if (isAtLeastApiLevel(27)) { DisposableEffect(null) { val wallpaperManager = WallpaperManager.getInstance(context) - val callback = callback@{ colors: android.app.WallpaperColors?, which: Int -> - if (which and WallpaperManager.FLAG_SYSTEM == 0) return@callback + val callback = WallpaperManager.OnColorsChangedListener { colors, which -> + if (which and WallpaperManager.FLAG_SYSTEM == 0) return@OnColorsChangedListener if (colors != null) { state.value = WallpaperColors.fromPlatformType(colors) } else { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/theme/transparency/TransparencyScheme.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/theme/transparency/TransparencyScheme.kt new file mode 100644 index 00000000..77ed6480 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/theme/transparency/TransparencyScheme.kt @@ -0,0 +1,10 @@ +package de.mm20.launcher2.ui.theme.transparency + +import androidx.compose.runtime.compositionLocalOf + +data class TransparencyScheme( + val background: Float, + val surface: Float, +) + +val LocalTransparencyScheme = compositionLocalOf { TransparencyScheme(0.85f, 1f) } \ No newline at end of file diff --git a/core/base/src/main/res/values/themes.xml b/core/base/src/main/res/values/themes.xml index 8ee9ccce..38b6b1f0 100644 --- a/core/base/src/main/res/values/themes.xml +++ b/core/base/src/main/res/values/themes.xml @@ -19,6 +19,14 @@ true + +