Migrate scaffold to Jetpack Compose
This commit is contained in:
parent
0aed749d31
commit
b346c69a65
@ -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
|
||||
|
||||
@ -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?) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user