diff --git a/ui/src/main/java/de/mm20/launcher2/ui/component/NavBarEffects.kt b/ui/src/main/java/de/mm20/launcher2/ui/component/NavBarEffects.kt index 12269b0a..8a8b1bdd 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/component/NavBarEffects.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/component/NavBarEffects.kt @@ -23,7 +23,7 @@ import kotlin.math.roundToInt @Composable fun NavBarEffects( - modifier: Modifier + modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -91,7 +91,7 @@ fun NavBarEffects( val bubble = newBubbles[i] val oldBubble = bubbles[i] if (oldBubble.lifetime <= 0f) { - bubble.posX = (Math.random() * 64 - 32).toFloat() * dp + bubble.posX = (Math.random() * 48 - 24).toFloat() * dp bubble.posY = 0f bubble.deltaX = (Math.random() - 0.5).toFloat() * 1f * dp bubble.deltaY = Math.random().toFloat() * 3f * dp diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivity.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivity.kt index a2e9b71d..8d87c9b9 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivity.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivity.kt @@ -1,25 +1,36 @@ package de.mm20.launcher2.ui.launcher -import android.app.WallpaperManager import android.content.Intent import android.content.res.Configuration import android.os.Bundle -import android.view.LayoutInflater -import android.view.View +import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.core.view.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat import com.afollestad.materialdialogs.LayoutMode import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.bottomsheets.BottomSheet import com.afollestad.materialdialogs.callbacks.onDismiss import com.afollestad.materialdialogs.customview.customView +import com.google.accompanist.systemuicontroller.rememberSystemUiController import de.mm20.launcher2.icons.DynamicIconController -import de.mm20.launcher2.icons.IconRepository import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.base.BaseActivity -import de.mm20.launcher2.ui.databinding.ActivityLauncherBinding +import de.mm20.launcher2.ui.base.ProvideSettings +import de.mm20.launcher2.ui.component.NavBarEffects import de.mm20.launcher2.ui.launcher.modals.EditFavoritesView -import de.mm20.launcher2.ui.launcher.modals.HiddenItemsView +import de.mm20.launcher2.ui.launcher.modals.HiddenItemsSheet +import de.mm20.launcher2.ui.theme.LauncherTheme import org.koin.android.ext.android.inject @@ -27,8 +38,6 @@ class LauncherActivity : BaseActivity() { private val viewModel: LauncherActivityVM by viewModels() - private lateinit var binding: ActivityLauncherBinding - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -36,46 +45,49 @@ class LauncherActivity : BaseActivity() { viewModel.setDarkMode(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) - binding = ActivityLauncherBinding.inflate(LayoutInflater.from(this)) - setContentView(binding.root) + setContent { + LauncherTheme { + ProvideSettings { + val lightStatus by viewModel.lightStatusBar.observeAsState(false) + val lightNav by viewModel.lightNavBar.observeAsState(false) + val hideStatus by viewModel.hideStatusBar.observeAsState(false) + val hideNav by viewModel.hideNavBar.observeAsState(false) + val dimBackground by viewModel.dimBackground.observeAsState(false) - viewModel.dimBackground.observe(this) { dim -> - window.attributes = window.attributes.also { - if (dim) { - binding.rootView.setBackgroundColor(0x4C000000) - } else { - binding.rootView.setBackgroundColor(0) + val systemUiController = rememberSystemUiController() + + LaunchedEffect(hideStatus) { + systemUiController.isStatusBarVisible = !hideStatus + } + LaunchedEffect(hideNav) { + systemUiController.isNavigationBarVisible = !hideNav + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(if (dimBackground) Color.Black.copy(alpha = 0.30f) else Color.Transparent), + contentAlignment = Alignment.BottomCenter + ) { + NavBarEffects(modifier = Modifier.fillMaxSize()) + PullDownScaffold( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding(), + darkStatusBarIcons = lightStatus, + darkNavBarIcons = lightNav, + ) + } + val showHiddenItems by viewModel.isHiddenItemsShown.observeAsState(false) + if (showHiddenItems) { + HiddenItemsSheet(onDismiss = { + viewModel.hideHiddenItems() + }) + } } } } - viewModel.lightStatusBar.observe(this) { - val windowController = WindowCompat.getInsetsController(window, binding.rootView) - windowController.isAppearanceLightStatusBars = it - } - - viewModel.lightNavBar.observe(this) { - val windowController = WindowCompat.getInsetsController(window, binding.rootView) - windowController.isAppearanceLightNavigationBars = it - } - - viewModel.hideStatusBar.observe(this) { - val windowController = WindowCompat.getInsetsController(window, binding.rootView) - if (it) { - windowController.hide(WindowInsetsCompat.Type.statusBars()) - } else { - windowController.show(WindowInsetsCompat.Type.statusBars()) - } - } - viewModel.hideNavBar.observe(this) { - val windowController = WindowCompat.getInsetsController(window, binding.rootView) - if (it) { - windowController.hide(WindowInsetsCompat.Type.navigationBars()) - } else { - windowController.show(WindowInsetsCompat.Type.navigationBars()) - } - } - var editFavoritesDialog: MaterialDialog? = null viewModel.isEditFavoritesShown.observe(this) { if (it) { @@ -99,23 +111,6 @@ class LauncherActivity : BaseActivity() { } } - var hiddenItemsView: HiddenItemsView? = null - viewModel.isHiddenItemsShown.observe(this) { - if (it) { - if (hiddenItemsView != null) return@observe - hiddenItemsView = HiddenItemsView(this).apply { - onDismiss = { - viewModel.hideHiddenItems() - } - } - binding.rootView.addView(hiddenItemsView) - } else { - if (hiddenItemsView == null) return@observe - binding.rootView.removeView(hiddenItemsView) - hiddenItemsView = null - } - } - val dynamicIconController: DynamicIconController by inject() lifecycle.addObserver(dynamicIconController) @@ -123,18 +118,17 @@ class LauncherActivity : BaseActivity() { override fun onAttachedToWindow() { super.onAttachedToWindow() - val windowController = WindowCompat.getInsetsController(window, binding.rootView) + val windowController = WindowCompat.getInsetsController(window, window.decorView.rootView) windowController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } override fun onResume() { super.onResume() - binding.activityStartOverlay.visibility = View.INVISIBLE - binding.container.doOnNextLayout { + /*binding.container.doOnNextLayout { WallpaperManager.getInstance(this).setWallpaperOffsets(it.windowToken, 0.5f, 0.5f) - } + }*/ } override fun onNewIntent(intent: Intent?) { diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt index 97a3f373..30810d3b 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldVM.kt @@ -2,63 +2,40 @@ package de.mm20.launcher2.ui.launcher import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.asLiveData -import de.mm20.launcher2.ktx.isBrightColor +import androidx.lifecycle.viewModelScope import de.mm20.launcher2.preferences.LauncherDataStore -import de.mm20.launcher2.ui.launcher.search.SearchBarLevel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject class LauncherScaffoldVM : ViewModel(), KoinComponent { val isSearchOpen = MutableLiveData(false) - val blurBackground = MutableLiveData(false) + val isWidgetEditMode = MutableLiveData(false) - val statusBarColor = MutableLiveData(0) - val darkStatusBarIcons = MutableLiveData(false) - - val searchBarLevel = MutableLiveData(SearchBarLevel.Resting) + val searchBarFocused = MutableLiveData(false) val dataStore: LauncherDataStore by inject() - val hideStatusBar = dataStore.data.map { it.systemBars.hideStatusBar }.asLiveData() - val hideNavBar = dataStore.data.map { it.systemBars.hideNavBar }.asLiveData() + private val autoFocusSearch = dataStore.data.map { it.searchBar.autoFocus } - val autoFocus = dataStore.data.map { it.searchBar.autoFocus }.asLiveData() - - var scrollY = 0 - set(value) { - if (value == 0 && field != 0) { - if (isSearchOpen.value == true) { - searchBarLevel.value = SearchBarLevel.Active - blurBackground.value = true - } else { - searchBarLevel.value = SearchBarLevel.Resting - blurBackground.value = false - } - } else if (value > 0 && field == 0) { - searchBarLevel.value = SearchBarLevel.Raised - blurBackground.value = true - } - field = value - } + fun setSearchbarFocus(focused: Boolean) { + if (searchBarFocused.value != focused) searchBarFocused.value = focused + } fun openSearch() { if (isSearchOpen.value == true) return isSearchOpen.value = true - if (scrollY == 0) { - searchBarLevel.value = SearchBarLevel.Active - blurBackground.value = true + viewModelScope.launch { + if (autoFocusSearch.first()) setSearchbarFocus(true) } } fun closeSearch() { if (isSearchOpen.value == false) return isSearchOpen.value = false - if (scrollY == 0) { - searchBarLevel.value = SearchBarLevel.Resting - blurBackground.value = false - } + setSearchbarFocus(false) } fun toggleSearch() { @@ -66,8 +43,8 @@ class LauncherScaffoldVM : ViewModel(), KoinComponent { else openSearch() } - fun setStatusBarColor(color: Int) { - statusBarColor.value = color - darkStatusBarIcons.value = color.isBrightColor() + fun setWidgetEditMode(editMode: Boolean) { + isSearchOpen.value = false + isWidgetEditMode.value = editMode } } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldView.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldView.kt deleted file mode 100644 index 26100c77..00000000 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherScaffoldView.kt +++ /dev/null @@ -1,351 +0,0 @@ -package de.mm20.launcher2.ui.launcher - -import android.animation.AnimatorSet -import android.animation.ObjectAnimator -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.util.Log -import android.util.TypedValue -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.WindowManager -import android.view.inputmethod.InputMethodManager -import android.widget.FrameLayout -import androidx.activity.addCallback -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.animation.doOnEnd -import androidx.core.content.ContextCompat -import androidx.core.content.getSystemService -import androidx.core.view.* -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import de.mm20.launcher2.ktx.dp -import de.mm20.launcher2.ktx.isAtLeastApiLevel -import de.mm20.launcher2.transition.OneShotLayoutTransition -import de.mm20.launcher2.ui.R -import de.mm20.launcher2.ui.databinding.ViewLauncherScaffoldBinding -import de.mm20.launcher2.ui.launcher.search.SearchBarVM -import de.mm20.launcher2.ui.launcher.search.SearchVM -import de.mm20.launcher2.ui.launcher.widgets.WidgetsVM -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.launch - -@SuppressLint("ClickableViewAccessibility") -class LauncherScaffoldView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : FrameLayout(context, attrs) { - - private val binding = ViewLauncherScaffoldBinding.inflate(LayoutInflater.from(context), this) - - private val viewModel: LauncherScaffoldVM by (context as AppCompatActivity).viewModels() - private val widgetsViewModel: WidgetsVM by (context as AppCompatActivity).viewModels() - private val searchViewModel: SearchVM by (context as AppCompatActivity).viewModels() - private val searchBarViewModel: SearchBarVM by (context as AppCompatActivity).viewModels() - - private var autoFocus = false - - private val scrollViewOnTouchListener = object : OnTouchListener { - @SuppressLint("ClickableViewAccessibility") - override fun onTouch(v: View, event: MotionEvent): Boolean { - when (event.action) { - MotionEvent.ACTION_DOWN -> return true - MotionEvent.ACTION_MOVE -> { - when { - binding.scrollView.scrollY == 0 -> { - if (event.historySize > 0) { - val dY = event.y - event.getHistoricalY(0) - val newTransY = 0.4f * dY + translationY - if (newTransY > 0 && newTransY < 48 * dp) { - translationY = newTransY - } else if (newTransY <= 0) { - translationY = 0f - } else { - translationY = 48 * dp - - } - - if (translationY == 0f) return false - } - } - binding.scrollView.scrollY == binding.scrollContainer.height - binding.scrollView.height && viewModel.isSearchOpen.value == true -> { - if (event.historySize > 0) { - val dY = event.y - event.getHistoricalY(0) - val newTransY = 0.4f * dY + translationY - - if (newTransY <= 0 && newTransY > -48 * dp) { - translationY = newTransY - } else if (newTransY > 0) { - translationY = 0f - } else { - translationY = -48 * dp - } - - if (translationY == 0f) return false - } - } - else -> return false - } - return true - } - MotionEvent.ACTION_UP -> { - if (translationY >= 48 * dp * 0.6) viewModel.toggleSearch() - if (translationY <= -48 * dp) viewModel.closeSearch() - animate().translationY(0f).setDuration(200).start() - return false - } - else -> return false - } - } - - } - - init { - context as AppCompatActivity - - context.onBackPressedDispatcher.addCallback { - viewModel.closeSearch() - widgetsViewModel.setEditMode(false) - ObjectAnimator.ofInt(binding.scrollView, "scrollY", 0).setDuration(200).start() - } - - context.lifecycleScope.launch { - context.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { - if (viewModel.isSearchOpen.value == true && autoFocus) { - searchBarViewModel.setFocused(true) - } - try { - awaitCancellation() - } finally { - searchBarViewModel.setFocused(false) - } - } - } - - binding.scrollView.scrollY = viewModel.scrollY - binding.scrollView.setOnTouchListener(scrollViewOnTouchListener) - - binding.scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY: Int -> - viewModel.scrollY = scrollY - when { - /* Hide searchbar*/ - scrollY > oldScrollY && ((scrollY > 48 * dp)) -> { - var newTransY = binding.searchBar.translationY - scrollY + oldScrollY - if (newTransY < -112 * dp) { - newTransY = -112 * dp - } - binding.searchBar.translationY = newTransY - } - /* Show searchbar*/ - scrollY < oldScrollY -> { - var newTransY = binding.searchBar.translationY - scrollY + oldScrollY - if (newTransY > 0f) { - newTransY = 0f - } - binding.searchBar.translationY = newTransY - } - } - } - - viewModel.isSearchOpen.observe(context) { - if (it) showSearch() - else hideSearch() - } - - viewModel.searchBarLevel.observe(context) { - binding.searchBar.level = it - } - - searchViewModel.websearchResults.observe(context) { - binding.searchContainer.setPadding( - 0, - (if (it.isEmpty()) 48 * dp else 96 * dp).toInt(), - 0, - 0 - ) - } - - viewModel.autoFocus.observe(context) { - autoFocus = it == true - } - - widgetsViewModel.isEditMode.observe(context) { - //OneShotLayoutTransition.run(binding.scrollContainer) - if (it) { - binding.scrollView.setOnTouchListener(null) - binding.searchBar.visibility = View.INVISIBLE - binding.editWidgetToolbar - .animate() - .translationY(0f) - .alpha(1f) - .withStartAction { - binding.editWidgetToolbar.visibility = View.VISIBLE - } - .start() - binding.widgetContainer.setPadding(0, (56 * dp).toInt(), 0, 0) - val colorSurface = TypedValue() - context.theme.resolveAttribute(R.attr.colorSurface, colorSurface, true) - context.window.statusBarColor = colorSurface.data - viewModel.setStatusBarColor(colorSurface.data) - } else { - binding.scrollView.setOnTouchListener(scrollViewOnTouchListener) - - binding.searchBar.visibility = View.VISIBLE - binding.editWidgetToolbar - .animate() - .translationY(-binding.editWidgetToolbar.height.toFloat()) - .alpha(0f) - .withEndAction { - binding.editWidgetToolbar.visibility = View.GONE - } - .start() - binding.widgetContainer.setPadding(0) - viewModel.setStatusBarColor(0) - } - } - binding.editWidgetToolbar.apply { - navigationIcon = - ContextCompat.getDrawable(context, R.drawable.ic_done)?.apply { - setTint(ContextCompat.getColor(context, R.color.icon_color)) - } - setNavigationOnClickListener { - widgetsViewModel.setEditMode(false) - } - } - - searchBarViewModel.focused.observe(context) { - if (it) { - viewModel.openSearch() - } - } - - viewModel.blurBackground.observe(context) { blur -> - if (!isAtLeastApiLevel(31)) return@observe - context.window.attributes = context.window.attributes.also { - if (blur) { - it.blurBehindRadius = (32 * dp).toInt() - it.flags = it.flags or WindowManager.LayoutParams.FLAG_BLUR_BEHIND - } else { - it.blurBehindRadius = 0 - it.flags = it.flags and WindowManager.LayoutParams.FLAG_BLUR_BEHIND.inv() - } - } - } - - viewModel.statusBarColor.observe(context) { - context.window.statusBarColor = it - } - viewModel.darkStatusBarIcons.observe(context) { - WindowCompat.getInsetsController(context.window, this).isAppearanceLightStatusBars = it - } - - viewModel.hideNavBar.observe(context) { - updateInsetPaddings() - } - viewModel.hideStatusBar.observe(context) { - updateInsetPaddings() - } - - ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> - updateInsetPaddings() - insets - } - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - updateInsetPaddings() - } - - private fun updateInsetPaddings() { - val windowInsets = ViewCompat.getRootWindowInsets(this) ?: return - val hideStatusBar = viewModel.hideStatusBar.value == true - val hideNavBar = viewModel.hideNavBar.value == true - - var topPadding = 0 - var leftPadding = 0 - var rightPadding = 0 - var bottomPadding = 0 - - if (!hideStatusBar) { - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()) - topPadding += insets.top - leftPadding += insets.left - rightPadding += insets.right - bottomPadding += insets.bottom - } - - if (!hideNavBar) { - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) - topPadding += insets.top - leftPadding += insets.left - rightPadding += insets.right - bottomPadding += insets.bottom - } - - setPadding(leftPadding, topPadding, rightPadding, bottomPadding) - binding.widgetContainer.setClockWidgetHeight(height - paddingTop - paddingBottom) - } - - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - if (changed) { - binding.widgetContainer.setClockWidgetHeight(bottom - top - paddingTop - paddingBottom) - } - super.onLayout(changed, left, top, right, bottom) - } - - private fun hideSearch() { - val set = AnimatorSet() - set.duration = 300 - set.doOnEnd { - binding.searchContainer.visibility = View.GONE - binding.widgetContainer.animate().alpha(1f).setDuration(500).start() - binding.widgetContainer.visibility = View.VISIBLE - } - set.playTogether( - ObjectAnimator.ofFloat(binding.widgetContainer, "translationY", 0f), - ObjectAnimator.ofInt(binding.scrollView, "scrollY", 0), - ObjectAnimator.ofFloat( - binding.searchContainer, "translationY", 0f, - if (binding.scrollView.scrollY > binding.searchContainer.height / 2f) -binding.searchContainer.height.toFloat() else binding.scrollView.height.toFloat() - ) - ) - set.doOnEnd { - searchViewModel.search("") - } - set.start() - binding.scrollView.scrollTo(0, 0) - context.getSystemService()?.hideSoftInputFromWindow( - binding.searchBar.windowToken, - 0 - ) - } - - private fun showSearch() { - OneShotLayoutTransition.run(binding.widgetContainer) - binding.searchContainer.visibility = View.VISIBLE - binding.widgetContainer.animate().alpha(0f).setDuration(500).start() - binding.widgetContainer.visibility = View.GONE - val set = AnimatorSet() - set.duration = 300 - set.playTogether( - ObjectAnimator.ofFloat( - binding.widgetContainer, - "translationY", - binding.scrollView.height.toFloat() - ), - ObjectAnimator.ofInt(binding.scrollView, "scrollY", 0), - ObjectAnimator.ofFloat( - binding.searchContainer, - "translationY", - binding.scrollView.height.toFloat(), - 0f - ) - ) - set.start() - if (autoFocus) searchBarViewModel.setFocused(true) - } -} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt new file mode 100644 index 00000000..72c8038e --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/PullDownScaffold.kt @@ -0,0 +1,300 @@ +package de.mm20.launcher2.ui.launcher + +import android.app.Activity +import android.view.WindowManager +import androidx.activity.compose.BackHandler +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.slideIn +import androidx.compose.animation.slideOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +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.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +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.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.* +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.accompanist.pager.ExperimentalPagerApi +import com.google.accompanist.pager.VerticalPager +import com.google.accompanist.pager.calculateCurrentOffsetForPage +import com.google.accompanist.pager.rememberPagerState +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import de.mm20.launcher2.ktx.isAtLeastApiLevel +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.ktx.toDp +import de.mm20.launcher2.ui.launcher.search.SearchBar +import de.mm20.launcher2.ui.launcher.search.SearchBarLevel +import de.mm20.launcher2.ui.launcher.search.SearchColumn +import de.mm20.launcher2.ui.launcher.search.SearchVM +import de.mm20.launcher2.ui.launcher.widgets.WidgetColumn +import de.mm20.launcher2.ui.modifier.scale +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue +import kotlin.ranges.coerceIn + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun PullDownScaffold( + modifier: Modifier = Modifier, + darkStatusBarIcons: Boolean = false, + darkNavBarIcons: Boolean = false, +) { + val viewModel: LauncherScaffoldVM = viewModel() + val searchVM: SearchVM = viewModel() + val context = LocalContext.current + + val isSearchOpen by viewModel.isSearchOpen.observeAsState(false) + val isWidgetEditMode by viewModel.isWidgetEditMode.observeAsState(false) + + val pagerState = rememberPagerState() + val widgetsScrollState = rememberScrollState() + val searchScrollState = rememberScrollState() + + val systemUiController = rememberSystemUiController() + + val colorSurface = MaterialTheme.colorScheme.surface + LaunchedEffect(isWidgetEditMode, darkStatusBarIcons, colorSurface) { + if (isWidgetEditMode) { + systemUiController.setStatusBarColor( + colorSurface + ) + } else { + systemUiController.setStatusBarColor( + Color.Transparent, + darkIcons = darkStatusBarIcons + ) + } + } + + LaunchedEffect(darkNavBarIcons) { + systemUiController.setNavigationBarColor( + Color.Transparent, + darkIcons = darkNavBarIcons, + navigationBarContrastEnforced = false + ) + } + + val dp = LocalDensity.current.density + + val offsetY = remember { Animatable(0.dp, Dp.VectorConverter) } + var searchBarOffset by remember { mutableStateOf(0.dp) } + + val blurWallpaper = isSearchOpen || offsetY.value > 48.dp || widgetsScrollState.value > 0 + + LaunchedEffect(blurWallpaper) { + if (!isAtLeastApiLevel(31)) return@LaunchedEffect + (context as Activity).window.attributes = context.window.attributes.also { + if (blurWallpaper) { + it.blurBehindRadius = (32 * dp).toInt() + it.flags = it.flags or WindowManager.LayoutParams.FLAG_BLUR_BEHIND + } else { + it.blurBehindRadius = 0 + it.flags = it.flags and WindowManager.LayoutParams.FLAG_BLUR_BEHIND.inv() + } + } + } + + + val nestedScrollDispatcher = remember { NestedScrollDispatcher() } + val scope = rememberCoroutineScope() + + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val consumed = when { + isSearchOpen && (offsetY.value > 0.dp || source == NestedScrollSource.Drag && searchScrollState.value - available.y < 0) -> { + val consumed = available.y - searchScrollState.value + scope.launch { + offsetY.snapTo((offsetY.value + (consumed * 0.5f / dp).dp).coerceIn(0.dp..64.dp)) + } + consumed + } + isSearchOpen && (offsetY.value < 0.dp || source == NestedScrollSource.Drag && searchScrollState.value - available.y > searchScrollState.maxValue) -> { + val consumed = + available.y - (searchScrollState.maxValue - searchScrollState.value) + scope.launch { + offsetY.snapTo((offsetY.value + (consumed * 0.5f / dp).dp).coerceIn(-64.dp..0.dp)) + } + consumed + } + !isSearchOpen && (offsetY.value > 0.dp || source == NestedScrollSource.Drag && widgetsScrollState.value - available.y < 0) -> { + val consumed = available.y - widgetsScrollState.value + scope.launch { + offsetY.snapTo((offsetY.value + (consumed * 0.5f / dp).dp).coerceIn(0.dp..64.dp)) + } + consumed + } + else -> { + 0f + } + } + + searchBarOffset = + ((searchBarOffset) + ((available.y - consumed) / dp).dp).coerceIn(-128.dp, 0.dp) + + return Offset(0f, consumed) + } + + override suspend fun onPreFling(available: Velocity): Velocity { + if (offsetY.value > 48.dp || offsetY.value < -48.dp) { + viewModel.toggleSearch() + } + if (offsetY.value != 0.dp) { + offsetY.animateTo(0.dp) + return available + } + return Velocity.Zero + } + } + } + + LaunchedEffect(isSearchOpen) { + searchScrollState.scrollTo(0) + if (!isSearchOpen) searchVM.search("") + searchBarOffset = 0.dp + pagerState.animateScrollToPage(if (isSearchOpen) 1 else 0) + } + + LaunchedEffect(isWidgetEditMode) { + if (!isWidgetEditMode) searchBarOffset = 0.dp + } + + BackHandler { + when { + isSearchOpen -> { + viewModel.closeSearch() + searchVM.search("") + } + isWidgetEditMode -> { + viewModel.setWidgetEditMode(false) + } + widgetsScrollState.value != 0 -> { + scope.launch { + widgetsScrollState.animateScrollTo(0) + } + } + } + } + + var size by remember { mutableStateOf(IntSize.Zero) } + + Box( + modifier = modifier + .fillMaxSize() + .clipToBounds() + .onSizeChanged { + size = it + } + .offset(y = offsetY.value), + contentAlignment = Alignment.TopCenter + ) { + VerticalPager( + state = pagerState, + modifier = Modifier + .nestedScroll(nestedScrollConnection, nestedScrollDispatcher), + count = 2, + reverseLayout = true, + userScrollEnabled = false, + ) { + if (it == 1) { + val websearches by searchVM.websearchResults.observeAsState(emptyList()) + val webSearchPadding by animateDpAsState( + if (websearches.isEmpty()) 0.dp else 48.dp + ) + val offset = calculateCurrentOffsetForPage(1).absoluteValue + SearchColumn( + modifier = Modifier + .alpha(1 - offset) + .scale(1 - offset, TransformOrigin.Center) + .fillMaxSize() + .verticalScroll(searchScrollState) + .padding(8.dp) + .padding(top = 56.dp) + .padding(top = webSearchPadding) + ) + } + + if (it == 0) { + val offset = calculateCurrentOffsetForPage(0).absoluteValue + val editModePadding by animateDpAsState(if (isWidgetEditMode) 56.dp else 0.dp) + WidgetColumn( + modifier = + Modifier + .alpha(1 - offset) + .scale(1 - offset, TransformOrigin.Center) + .fillMaxSize() + .verticalScroll(widgetsScrollState) + .padding(8.dp) + .padding(top = editModePadding), + clockHeight = size.height.toDp(), + editMode = isWidgetEditMode, + onEditModeChange = { + viewModel.setWidgetEditMode(it) + } + ) + } + } + + val searchBarLevel = when { + offsetY.value != 0.dp -> SearchBarLevel.Raised + isSearchOpen && searchScrollState.value == 0 -> SearchBarLevel.Active + isSearchOpen && searchScrollState.value > 0 -> SearchBarLevel.Raised + widgetsScrollState.value > 0 -> SearchBarLevel.Raised + else -> SearchBarLevel.Resting + } + val searchBarFocused by viewModel.searchBarFocused.observeAsState(false) + + + val editModeSearchBarOffset by animateDpAsState( + if (isWidgetEditMode) -128.dp else 0.dp + ) + AnimatedVisibility(visible = isWidgetEditMode, + enter = slideIn { IntOffset(0, -it.height) }, + exit = slideOut { IntOffset(0, -it.height) } + ) { + CenterAlignedTopAppBar( + title = { + Text(stringResource(R.string.menu_edit_widgets)) + }, + navigationIcon = { + IconButton(onClick = { viewModel.setWidgetEditMode(false) }) { + Icon(imageVector = Icons.Rounded.Done, contentDescription = null) + } + } + ) + } + SearchBar( + level = searchBarLevel, + modifier = Modifier.offset(y = searchBarOffset + editModeSearchBarOffset), + focused = searchBarFocused, + onFocusChange = { + if (it) viewModel.openSearch() + viewModel.setSearchbarFocus(it) + } + ) + + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/HiddenItemsSheet.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/HiddenItemsSheet.kt new file mode 100644 index 00000000..6a7df559 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/HiddenItemsSheet.kt @@ -0,0 +1,116 @@ +package de.mm20.launcher2.ui.launcher.modals + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.slideIn +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FractionalThreshold +import androidx.compose.material.rememberSwipeableState +import androidx.compose.material.swipeable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.ktx.toPixels +import de.mm20.launcher2.ui.launcher.search.common.grid.SearchResultGrid +import kotlin.math.roundToInt + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterialApi::class) +@Composable +fun HiddenItemsSheet( + onDismiss: () -> Unit +) { + val viewModel: HiddenItemsVM = viewModel() + + Dialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { onDismiss() }) { + val animationState = remember { + MutableTransitionState(false).apply { + targetState = true + } + } + + AnimatedVisibility( + animationState, + enter = slideIn { IntOffset(0, it.height) } + ) { + val swipeState = + rememberSwipeableState(initialValue = SwipeState.Default) { + if (it == SwipeState.Dismiss) onDismiss() + return@rememberSwipeableState true + } + Surface( + modifier = Modifier + .fillMaxSize() + .swipeable( + swipeState, + mapOf( + 0f to SwipeState.Default, + 600.dp.toPixels() to SwipeState.Dismiss + ), + orientation = Orientation.Vertical, + thresholds = { _, _ -> FractionalThreshold(0.5f) }, + ) + .offset { IntOffset(0, swipeState.offset.value.roundToInt()) } + + ) { + Column( + modifier = Modifier + .fillMaxSize() + ) { + Text( + stringResource(R.string.menu_hidden_items), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(24.dp) + + ) + val items by viewModel.hiddenItems.collectAsState(emptyList()) + SearchResultGrid( + items, + modifier = Modifier + .weight(1f) + .padding(8.dp) + .verticalScroll(rememberScrollState()) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + contentAlignment = Alignment.CenterEnd + ) { + TextButton(onClick = { onDismiss() }) { + Text( + stringResource(id = R.string.close), + ) + } + } + } + } + } + + } + +} + +private enum class SwipeState { + Default, Dismiss +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/HiddenItemsView.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/HiddenItemsView.kt deleted file mode 100644 index 12fa71cc..00000000 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/modals/HiddenItemsView.kt +++ /dev/null @@ -1,133 +0,0 @@ -package de.mm20.launcher2.ui.launcher.modals - -import android.content.Context -import android.util.AttributeSet -import android.widget.FrameLayout -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.slideIn -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.FractionalThreshold -import androidx.compose.material.rememberSwipeableState -import androidx.compose.material.swipeable -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import de.mm20.launcher2.ui.theme.LauncherTheme -import de.mm20.launcher2.ui.R -import de.mm20.launcher2.ui.base.ProvideSettings -import de.mm20.launcher2.ui.ktx.toPixels -import de.mm20.launcher2.ui.launcher.search.common.grid.SearchResultGrid -import kotlin.math.roundToInt - -@OptIn(ExperimentalComposeUiApi::class, androidx.compose.material.ExperimentalMaterialApi::class) -class HiddenItemsView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : FrameLayout(context, attrs) { - private val viewModel: HiddenItemsVM by (context as AppCompatActivity).viewModels() - - init { - val composeView = ComposeView(context) - - composeView.setContent { - LauncherTheme { - ProvideSettings { - Dialog( - properties = DialogProperties(usePlatformDefaultWidth = false), - onDismissRequest = { onDismiss() }) { - val animationState = remember { - MutableTransitionState(false).apply { - targetState = true - } - } - - AnimatedVisibility( - animationState, - enter = slideIn { IntOffset(0, it.height) } - ) { - val swipeState = - rememberSwipeableState(initialValue = SwipeState.Default) { - if (it == SwipeState.Dismiss) onDismiss() - return@rememberSwipeableState true - } - Surface( - modifier = Modifier - .fillMaxSize() - .swipeable( - swipeState, - mapOf( - 0f to SwipeState.Default, - 600.dp.toPixels() to SwipeState.Dismiss - ), - orientation = Orientation.Vertical, - thresholds = { _, _ -> FractionalThreshold(0.5f) }, - ) - .offset { IntOffset(0, swipeState.offset.value.roundToInt()) } - - ) { - Column( - modifier = Modifier - .fillMaxSize() - ) { - Text( - stringResource(R.string.menu_hidden_items), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(24.dp) - - ) - val items by viewModel.hiddenItems.collectAsState(emptyList()) - SearchResultGrid( - items, - modifier = Modifier - .weight(1f) - .padding(8.dp) - .verticalScroll(rememberScrollState()) - ) - Box( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - contentAlignment = Alignment.CenterEnd - ) { - TextButton(onClick = { onDismiss() }) { - Text( - stringResource(id = R.string.close), - ) - } - } - } - } - } - - } - } - } - } - - addView(composeView) - } - - var onDismiss: () -> Unit = {} -} - -private enum class SwipeState { - Default, Dismiss -} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBar.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBar.kt index 426cea19..f5ca96b9 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBar.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBar.kt @@ -55,34 +55,43 @@ import java.io.File @Composable fun SearchBar( - level: SearchBarLevel + modifier: Modifier = Modifier, + level: SearchBarLevel, + focused: Boolean, + onFocusChange: (Boolean) -> Unit ) { val searchViewModel: SearchVM = viewModel() val activityViewModel: LauncherActivityVM = viewModel() - val viewModel: SearchBarVM = viewModel() val dataStore: LauncherDataStore by inject() val style by remember { dataStore.data.map { it.searchBar.searchBarStyle } } .collectAsState(SearchBarSettings.SearchBarStyle.Hidden) - val focused by viewModel.focused.observeAsState(false) - val focusManager = LocalFocusManager.current val focusRequester = remember { FocusRequester() } - LaunchedEffect(focused) { - if (focused) focusRequester.requestFocus() - else focusManager.clearFocus() - } - val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(focused) { + val f = focused + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + try { + if (f) focusRequester.requestFocus() + awaitCancellation() + } finally { + focusManager.clearFocus() + } + } + } val query by searchViewModel.searchQuery.observeAsState("") val websearches by searchViewModel.websearchResults.observeAsState(emptyList()) SearchBar( + modifier, level, websearches, value = query, @@ -137,10 +146,10 @@ fun SearchBar( }, focusRequester = focusRequester, onFocus = { - viewModel.setFocused(true) + onFocusChange(true) }, onUnfocus = { - viewModel.setFocused(false) + onFocusChange(false) } ) } @@ -148,6 +157,7 @@ fun SearchBar( @OptIn(ExperimentalAnimationGraphicsApi::class) @Composable fun SearchBar( + modifier: Modifier = Modifier, level: SearchBarLevel, websearches: List, overflowMenu: @Composable (show: Boolean, onDismissRequest: () -> Unit) -> Unit = { _, _ -> }, @@ -230,7 +240,7 @@ fun SearchBar( val rightIcon = AnimatedImageVector.animatedVectorResource(R.drawable.anim_ic_menu_clear) LauncherCard( - modifier = Modifier + modifier = modifier .fillMaxWidth() .wrapContentHeight() .alpha(opacity) @@ -259,7 +269,6 @@ fun SearchBar( color = contentColor ) } - val focusManager = LocalFocusManager.current LaunchedEffect(level) { if (level == SearchBarLevel.Resting) onUnfocus() } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBarView.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBarView.kt deleted file mode 100644 index ba830067..00000000 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchBarView.kt +++ /dev/null @@ -1,62 +0,0 @@ -package de.mm20.launcher2.ui.launcher.search - -import android.content.Context -import android.util.AttributeSet -import android.widget.FrameLayout -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.platform.ComposeView -import androidx.lifecycle.MutableLiveData -import de.mm20.launcher2.preferences.LauncherDataStore -import de.mm20.launcher2.preferences.Settings -import de.mm20.launcher2.ui.theme.LauncherTheme -import de.mm20.launcher2.ui.locals.LocalCardStyle -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -class SearchBarView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : FrameLayout(context, attrs, defStyleAttr), KoinComponent { - - var level: SearchBarLevel = SearchBarLevel.Resting - set(value) { - levelState.value = value - field = value - } - - private val dataStore: LauncherDataStore by inject() - private val levelState = MutableLiveData(level) - - init { - val view = ComposeView(context) - view.setContent { - val level by levelState.observeAsState(SearchBarLevel.Resting) - val cardStyle by remember { - dataStore.data.map { it.cards }.distinctUntilChanged() - }.collectAsState( - Settings.CardSettings.getDefaultInstance() - ) - CompositionLocalProvider( - LocalCardStyle provides cardStyle - ) { - LauncherTheme { - Box(contentAlignment = Alignment.TopCenter) { - SearchBar( - level - ) - } - } - } - } - addView(view) - } -} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt new file mode 100644 index 00000000..e8ffb0a5 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchColumn.kt @@ -0,0 +1,39 @@ +package de.mm20.launcher2.ui.launcher.search + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import de.mm20.launcher2.ui.launcher.search.apps.AppResults +import de.mm20.launcher2.ui.launcher.search.appshortcuts.AppShortcutResults +import de.mm20.launcher2.ui.launcher.search.calculator.CalculatorResults +import de.mm20.launcher2.ui.launcher.search.calendar.CalendarResults +import de.mm20.launcher2.ui.launcher.search.contacts.ContactResults +import de.mm20.launcher2.ui.launcher.search.favorites.FavoritesResults +import de.mm20.launcher2.ui.launcher.search.files.FileResults +import de.mm20.launcher2.ui.launcher.search.unitconverter.UnitConverterResults +import de.mm20.launcher2.ui.launcher.search.website.WebsiteResults +import de.mm20.launcher2.ui.launcher.search.wikipedia.WikipediaResults + +@Composable +fun SearchColumn( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + ) { + FavoritesResults() + AppResults() + AppShortcutResults() + UnitConverterResults() + CalculatorResults() + CalendarResults() + ContactResults() + WikipediaResults() + WebsiteResults() + FileResults() + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchView.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchView.kt deleted file mode 100644 index 533b4500..00000000 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchView.kt +++ /dev/null @@ -1,62 +0,0 @@ -package de.mm20.launcher2.ui.launcher.search - -import android.content.Context -import android.util.AttributeSet -import android.widget.FrameLayout -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.unit.dp -import de.mm20.launcher2.ui.theme.LauncherTheme -import de.mm20.launcher2.ui.base.ProvideSettings -import de.mm20.launcher2.ui.launcher.search.apps.AppResults -import de.mm20.launcher2.ui.launcher.search.appshortcuts.AppShortcutResults -import de.mm20.launcher2.ui.launcher.search.calculator.CalculatorResults -import de.mm20.launcher2.ui.launcher.search.calendar.CalendarResults -import de.mm20.launcher2.ui.launcher.search.contacts.ContactResults -import de.mm20.launcher2.ui.launcher.search.favorites.FavoritesResults -import de.mm20.launcher2.ui.launcher.search.files.FileResults -import de.mm20.launcher2.ui.launcher.search.unitconverter.UnitConverterResults -import de.mm20.launcher2.ui.launcher.search.website.WebsiteResults -import de.mm20.launcher2.ui.launcher.search.wikipedia.WikipediaResults -import org.koin.core.component.KoinComponent - -class SearchView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : FrameLayout(context, attrs), KoinComponent { - - init { - val view = ComposeView(context) - view.layoutParams = LayoutParams( - LayoutParams.MATCH_PARENT, - LayoutParams.WRAP_CONTENT - ) - view.setContent { - LauncherTheme { - ProvideSettings { - Column( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(8.dp) - ) { - FavoritesResults() - AppResults() - AppShortcutResults() - UnitConverterResults() - CalculatorResults() - CalendarResults() - ContactResults() - WikipediaResults() - WebsiteResults() - FileResults() - } - } - } - } - addView(view) - } -} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetColumn.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetColumn.kt new file mode 100644 index 00000000..2734148e --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetColumn.kt @@ -0,0 +1,306 @@ +package de.mm20.launcher2.ui.launcher.widgets + +import android.app.Activity +import android.appwidget.AppWidgetHost +import android.appwidget.AppWidgetManager +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.ui.ClockWidget +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.launcher.widgets.picker.PickAppWidgetActivity +import de.mm20.launcher2.widgets.ExternalWidget +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch + +@OptIn(ExperimentalAnimationGraphicsApi::class) +@Composable +fun WidgetColumn( + modifier: Modifier = Modifier, + clockHeight: Dp = 0.dp, + editMode: Boolean = false, + onEditModeChange: (Boolean) -> Unit, +) { + + val viewModel: WidgetsVM = viewModel() + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val widgetHost = remember { AppWidgetHost(context.applicationContext, 44203) } + + LaunchedEffect(null) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + widgetHost.startListening() + try { + awaitCancellation() + } finally { + widgetHost.stopListening() + } + } + } + + val pickWidgetLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { + val data = it.data ?: return@rememberLauncherForActivityResult + val widgetId = data.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@rememberLauncherForActivityResult + if (it.resultCode == Activity.RESULT_OK) { + viewModel.addAppWidget(context, widgetId) + } + } + + Column( + modifier = modifier + ) { + val scope = rememberCoroutineScope() + var showAddDialog by remember { mutableStateOf(false) } + + AnimatedVisibility(!editMode) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(clockHeight), + contentAlignment = Alignment.BottomCenter + ) { + ClockWidget( + modifier = Modifier.fillMaxWidth() + ) + } + } + val widgets by viewModel.widgets.observeAsState(emptyList()) + Column { + val swapThresholds = remember(widgets) { + Array(widgets.size) { floatArrayOf(0f, 0f) } + } + for ((i, widget) in widgets.withIndex()) { + key(if (widget is ExternalWidget) widget.widgetId else widget) { + var dragOffsetAfterSwap = remember { null } + val offsetY = remember(widgets) { Animatable(dragOffsetAfterSwap ?: 0f) } + + LaunchedEffect(widgets) { + dragOffsetAfterSwap = null + } + + WidgetItem( + widget = widget, + appWidgetHost = widgetHost, + editMode = editMode, + onWidgetRemove = { + if (widget is ExternalWidget) { + widgetHost.deleteAppWidgetId(widget.widgetId) + } + viewModel.removeWidget(widget) + }, + onWidgetResize = { + viewModel.setWidgetHeight(widget, it) + }, + modifier = Modifier + .fillMaxWidth() + .onPlaced { + swapThresholds[i][0] = it.positionInParent().y + swapThresholds[i][1] = it.positionInParent().y + it.size.height + } + .padding(top = 8.dp) + .offset { + IntOffset(0, offsetY.value.toInt()) + }, + draggableState = rememberDraggableState { + scope.launch { + val newOffset = offsetY.value + it + offsetY.snapTo(newOffset) + if (i > 0 && newOffset < (swapThresholds[i - 1][0] - swapThresholds[i - 1][1])) { + if (dragOffsetAfterSwap == null) { + dragOffsetAfterSwap = + swapThresholds[i - 1][1] - swapThresholds[i - 1][0] + newOffset + viewModel.moveUp(i) + } + } + if (i < widgets.lastIndex && newOffset > (swapThresholds[i + 1][1] - swapThresholds[i + 1][0])) { + if (dragOffsetAfterSwap == null) { + dragOffsetAfterSwap = + swapThresholds[i + 1][0] - swapThresholds[i + 1][1] + newOffset + viewModel.moveDown(i) + } + } + } + }, + onDragStopped = { + scope.launch { + offsetY.animateTo(0f) + } + } + ) + } + } + } + + val icon = + AnimatedImageVector.animatedVectorResource(R.drawable.anim_ic_edit_add) + ExtendedFloatingActionButton( + modifier = Modifier + .padding(16.dp) + .align(Alignment.CenterHorizontally), + icon = { + Icon( + painter = rememberAnimatedVectorPainter( + animatedImageVector = icon, + atEnd = !editMode + ), contentDescription = null + ) + }, + text = { + Text( + stringResource( + if (editMode) R.string.widget_add_widget + else R.string.menu_edit_widgets + ) + ) + }, onClick = { + if (!editMode) { + onEditModeChange(true) + } else { + if (viewModel.getAvailableBuiltInWidgets().isEmpty()) { + pickWidgetLauncher.launch( + Intent( + context, + PickAppWidgetActivity::class.java + ) + ) + } else { + showAddDialog = true + } + } + }) + + if (showAddDialog) { + val availableBuiltInWidgets = + remember { viewModel.getAvailableBuiltInWidgets() } + Dialog(onDismissRequest = { showAddDialog = false }) { + Surface( + tonalElevation = 16.dp, + shadowElevation = 16.dp, + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.widget_add_widget), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding( + start = 24.dp, + end = 24.dp, + top = 24.dp, + bottom = 8.dp + ) + ) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding( + top = 16.dp + ) + ) { + items(availableBuiltInWidgets) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + viewModel.addWidget(it) + showAddDialog = false + } + .padding( + horizontal = 24.dp, + vertical = 16.dp + ) + ) { + Text( + text = it.loadLabel(LocalContext.current), + style = MaterialTheme.typography.bodyLarge + ) + } + } + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .clickable { + pickWidgetLauncher.launch( + Intent( + context, + PickAppWidgetActivity::class.java + ) + ) + showAddDialog = false + } + .padding( + horizontal = 24.dp, + vertical = 16.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text( + text = stringResource(R.string.widget_add_external), + style = MaterialTheme.typography.titleMedium + ) + } + } + } + + TextButton( + onClick = { showAddDialog = false }, + modifier = Modifier + .align(Alignment.End) + .padding(bottom = 16.dp, end = 24.dp) + ) { + Text( + stringResource(android.R.string.cancel) + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetsVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetsVM.kt index 59b1c4f2..bbfb3105 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetsVM.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetsVM.kt @@ -18,14 +18,8 @@ import org.koin.core.component.inject class WidgetsVM : ViewModel(), KoinComponent { private val widgetRepository: WidgetRepository by inject() - val isEditMode = MutableLiveData(false) - val widgets = widgetRepository.getWidgets().asLiveData() - fun setEditMode(editMode: Boolean) { - isEditMode.value = editMode - } - fun addWidget(widget: Widget) { widgetRepository.addWidget(widget, widgets.value?.size ?: 0) } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetsView.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetsView.kt deleted file mode 100644 index 94f0993d..00000000 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetsView.kt +++ /dev/null @@ -1,337 +0,0 @@ -package de.mm20.launcher2.ui.launcher.widgets - -import android.app.Activity -import android.appwidget.AppWidgetHost -import android.appwidget.AppWidgetManager -import android.content.Context -import android.content.Intent -import android.util.AttributeSet -import android.widget.FrameLayout -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi -import androidx.compose.animation.graphics.res.animatedVectorResource -import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter -import androidx.compose.animation.graphics.vector.AnimatedImageVector -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onPlaced -import androidx.compose.ui.layout.positionInParent -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import de.mm20.launcher2.ui.ClockWidget -import de.mm20.launcher2.ui.theme.LauncherTheme -import de.mm20.launcher2.ui.R -import de.mm20.launcher2.ui.base.ProvideSettings -import de.mm20.launcher2.ui.ktx.toDp -import de.mm20.launcher2.ui.launcher.widgets.picker.PickAppWidgetActivity -import de.mm20.launcher2.widgets.ExternalWidget -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.launch - -@OptIn(ExperimentalAnimationGraphicsApi::class) -class WidgetsView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : FrameLayout(context, attrs) { - - private val widgetHost: AppWidgetHost = AppWidgetHost(context.applicationContext, 44203) - - private val viewModel: WidgetsVM by (context as AppCompatActivity).viewModels() - - private val pickWidgetLauncher: ActivityResultLauncher - - private val clockWidgetHeight = MutableLiveData(0) - - init { - context as AppCompatActivity - - pickWidgetLauncher = context.registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - val data = it.data ?: return@registerForActivityResult - val widgetId = data.getIntExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID - ) - if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@registerForActivityResult - if (it.resultCode == Activity.RESULT_OK) { - viewModel.addAppWidget(context, widgetId) - } - } - - context.lifecycleScope.launch { - context.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - widgetHost.startListening() - try { - awaitCancellation() - } finally { - widgetHost.stopListening() - } - } - } - - - val composeView = ComposeView(context) - composeView.setContent { - LauncherTheme { - ProvideSettings { - Column( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(8.dp) - ) { - val editMode by viewModel.isEditMode.observeAsState(false) - val clockHeight by clockWidgetHeight.observeAsState(0) - val scope = rememberCoroutineScope() - var showAddDialog by remember { mutableStateOf(false) } - - AnimatedVisibility(!editMode) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(clockHeight.toDp()), - contentAlignment = Alignment.BottomCenter - ) { - ClockWidget( - modifier = Modifier.fillMaxWidth() - ) - } - } - val widgets by viewModel.widgets.observeAsState(emptyList()) - Column { - val swapThresholds = remember(widgets) { - Array(widgets.size) { floatArrayOf(0f, 0f) } - } - for ((i, widget) in widgets.withIndex()) { - key(if (widget is ExternalWidget) widget.widgetId else widget) { - var dragOffsetAfterSwap = remember { null } - val offsetY = remember(widgets) { Animatable(dragOffsetAfterSwap ?: 0f) } - - LaunchedEffect(widgets) { - dragOffsetAfterSwap = null - } - - WidgetItem( - widget = widget, - appWidgetHost = widgetHost, - editMode = editMode, - onWidgetRemove = { - if (widget is ExternalWidget) { - widgetHost.deleteAppWidgetId(widget.widgetId) - } - viewModel.removeWidget(widget) - }, - onWidgetResize = { - viewModel.setWidgetHeight(widget, it) - }, - modifier = Modifier - .fillMaxWidth() - .onPlaced { - swapThresholds[i][0] = it.positionInParent().y - swapThresholds[i][1] = it.positionInParent().y + it.size.height - } - .padding(top = 8.dp) - .offset { - IntOffset(0, offsetY.value.toInt()) - }, - draggableState = rememberDraggableState { - scope.launch { - val newOffset = offsetY.value + it - offsetY.snapTo(newOffset) - if (i > 0 && newOffset < (swapThresholds[i - 1][0] - swapThresholds[i - 1][1])) { - if (dragOffsetAfterSwap == null) { - dragOffsetAfterSwap = swapThresholds[i - 1][1] - swapThresholds[i - 1][0] + newOffset - viewModel.moveUp(i) - } - } - if (i < widgets.lastIndex && newOffset > (swapThresholds[i + 1][1] - swapThresholds[i + 1][0])) { - if (dragOffsetAfterSwap == null) { - dragOffsetAfterSwap = swapThresholds[i + 1][0] - swapThresholds[i + 1][1] + newOffset - viewModel.moveDown(i) - } - } - } - }, - onDragStopped = { - scope.launch { - offsetY.animateTo(0f) - } - } - ) - } - } - } - - val icon = - AnimatedImageVector.animatedVectorResource(R.drawable.anim_ic_edit_add) - ExtendedFloatingActionButton( - modifier = Modifier - .padding(16.dp) - .align(Alignment.CenterHorizontally), - icon = { - Icon( - painter = rememberAnimatedVectorPainter( - animatedImageVector = icon, - atEnd = !editMode - ), contentDescription = null - ) - }, - text = { - Text( - stringResource( - if (editMode) R.string.widget_add_widget - else R.string.menu_edit_widgets - ) - ) - }, onClick = { - if (!editMode) { - viewModel.setEditMode(true) - } else { - if (viewModel.getAvailableBuiltInWidgets().isEmpty()) { - pickWidgetLauncher.launch( - Intent( - context, - PickAppWidgetActivity::class.java - ) - ) - } else { - showAddDialog = true - } - } - }) - - if (showAddDialog) { - val availableBuiltInWidgets = - remember { viewModel.getAvailableBuiltInWidgets() } - Dialog(onDismissRequest = { showAddDialog = false }) { - Surface( - tonalElevation = 16.dp, - shadowElevation = 16.dp, - shape = RoundedCornerShape(16.dp), - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - ) { - Column( - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.widget_add_widget), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding( - start = 24.dp, - end = 24.dp, - top = 24.dp, - bottom = 8.dp - ) - ) - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding( - top = 16.dp - ) - ) { - items(availableBuiltInWidgets) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - viewModel.addWidget(it) - showAddDialog = false - } - .padding( - horizontal = 24.dp, - vertical = 16.dp - ) - ) { - Text( - text = it.loadLabel(context), - style = MaterialTheme.typography.bodyLarge - ) - } - } - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp) - .clickable { - pickWidgetLauncher.launch( - Intent( - context, - PickAppWidgetActivity::class.java - ) - ) - showAddDialog = false - } - .padding( - horizontal = 24.dp, - vertical = 16.dp - ), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp) - ) - Text( - text = stringResource(R.string.widget_add_external), - style = MaterialTheme.typography.titleMedium - ) - } - } - } - - TextButton( - onClick = { showAddDialog = false }, - modifier = Modifier - .align(Alignment.End) - .padding(bottom = 16.dp, end = 24.dp) - ) { - Text( - stringResource(android.R.string.cancel) - ) - } - } - } - } - } - } - - - } - } - } - addView(composeView) - } - - fun setClockWidgetHeight(height: Int) { - clockWidgetHeight.value = height - } - -} \ No newline at end of file diff --git a/ui/src/main/res/layout/activity_launcher.xml b/ui/src/main/res/layout/activity_launcher.xml deleted file mode 100644 index 0a39faca..00000000 --- a/ui/src/main/res/layout/activity_launcher.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/ui/src/main/res/layout/view_launcher_scaffold.xml b/ui/src/main/res/layout/view_launcher_scaffold.xml deleted file mode 100644 index 021aa003..00000000 --- a/ui/src/main/res/layout/view_launcher_scaffold.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file