Migrate scaffold to Jetpack Compose

This commit is contained in:
MM20 2022-04-22 22:50:22 +02:00
parent 0aed749d31
commit b346c69a65
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
16 changed files with 859 additions and 1156 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Float?> { 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)
)
}
}
}
}
}
}
}

View File

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

View File

@ -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<Intent>
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<Float?> { 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
}
}

View File

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/rootView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false">
<de.mm20.launcher2.ui.legacy.view.BatteryChargingView
android:id="@+id/batteryAnimationView"
android:layout_width="48dp"
android:layout_height="96dp"
android:layout_gravity="bottom|center_horizontal" />
<de.mm20.launcher2.ui.launcher.LauncherScaffoldView
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<View
android:id="@+id/activityStartOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:background="@color/cardview_background"
android:visibility="invisible" />
</FrameLayout>

View File

@ -1,60 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:layout_height="match_parent"
tools:layout_width="match_parent"
tools:parentTag="android.widget.FrameLayout">
<androidx.core.widget.NestedScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false"
android:scrollbars="none"
tools:context=".activity.LauncherActivity">
<LinearLayout
android:id="@+id/scrollContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical">
<de.mm20.launcher2.ui.launcher.search.SearchView
android:id="@+id/searchContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="gone" />
<de.mm20.launcher2.ui.launcher.widgets.WidgetsView
android:id="@+id/widgetContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<de.mm20.launcher2.ui.launcher.search.SearchBarView
android:id="@+id/searchBar"
android:layout_width="match_parent"
android:layout_height="112dp"
android:descendantFocusability="beforeDescendants"
android:focusableInTouchMode="true" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/editWidgetToolbar"
style="@style/Widget.Material3.Toolbar.Surface"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="4dp"
android:translationY="-56dp"
android:visibility="gone"
app:title="@string/menu_edit_widgets" />
</merge>