Refactor widgets

This commit is contained in:
MM20 2022-03-29 18:13:31 +02:00
parent 2e43b871c5
commit 956af82f79
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
21 changed files with 804 additions and 846 deletions

View File

@ -8,36 +8,51 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:name="path"
android:pathData="M 3 17.46 L 3 20.5 C 3 20.78 3.22 21 3.5 21 L 6.54 21 C 6.67 21 6.8 20.95 6.89 20.85 L 17.81 9.94 L 14.06 6.19 L 3.15 17.1 C 3.05 17.2 3 17.32 3 17.46 Z"
android:fillColor="#000"
android:strokeWidth="1"/>
<path
android:name="path_1"
android:pathData="M 20.71 7.04 C 21.1 6.65 21.1 6.02 20.71 5.63 L 18.37 3.29 C 17.98 2.9 17.35 2.9 16.96 3.29 L 15.13 5.12 L 18.88 8.87 L 20.71 7.04 Z"
android:fillColor="#000000"
android:strokeWidth="1"/>
<group
android:name="group"
android:pivotX="12"
android:pivotY="12">
<path
android:name="path"
android:pathData="M 3 17.46 L 3 20.5 C 3 20.78 3.22 21 3.5 21 L 6.54 21 C 6.67 21 6.8 20.95 6.89 20.85 L 17.81 9.94 L 14.06 6.19 L 3.15 17.1 C 3.05 17.2 3 17.32 3 17.46 Z M 20.71 7.04 C 21.1 6.65 21.1 6.02 20.71 5.63 L 18.37 3.29 C 17.98 2.9 17.35 2.9 16.96 3.29 L 15.13 5.12 L 18.88 8.87 L 20.71 7.04 Z"
android:fillColor="#000"
android:strokeWidth="1"/>
<path
android:name="path_1"
android:pathData=""
android:fillColor="#000000"/>
</group>
</vector>
</aapt:attr>
<target android:name="path">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="pathData"
android:duration="200"
android:valueFrom="M 15.894 8.024 C 15.283 7.413 14.671 6.801 14.06 6.19 L 3.15 17.1 C 3.05 17.2 3 17.32 3 17.46 L 3 17.46 C 3 18.473 3 19.487 3 20.5 C 3 20.78 3.22 21 3.5 21 C 4.513 21 5.527 21 6.54 21 C 6.67 21 6.8 20.95 6.89 20.85 C 10.53 17.213 14.17 13.577 17.81 9.94 C 17.171 9.301 16.533 8.663 15.894 8.024"
android:valueTo="M 19 12 C 19 11.45 18.55 11 18 11 L 6.014 11 C 6.014 11 6.014 11 6.014 11 L 6 11 C 5.456 11 5.01 11.44 5 11.982 C 5 11.988 5 11.994 5 12 C 5 12.55 5.45 13 6 13 C 6 13 6 13 6 13 C 10 13 14 13 18 13 C 18.55 13 19 12.55 19 12"
android:duration="100"
android:valueFrom="M 11 18 C 11 14.007 11 9.578 11 6 C 11 5.45 11.45 5 12 5 C 12.55 5 13 5.45 13 6 L 13 18 C 13 18.298 12.868 18.567 12.659 18.751 C 12.482 18.906 12.252 19 12 19 C 12 19 12 19 12 19 C 11.706 19 11.441 18.872 11.257 18.668 C 11.098 18.491 11 18.256 11 18 C 11 18 11 18 11 18 M 12.001 3.13 L 12.001 3.13 L 12.001 3.13 L 12.001 3.13 C 12.001 3.13 12.001 3.13 12.001 3.13 L 12.001 3.13 C 12.001 3.13 12.001 3.13 12.001 3.13"
android:valueTo="M 9.35 21.865 C 9.35 16.722 9.35 11.579 9.35 6.436 C 10.233 6.436 11.117 6.436 12.001 6.436 C 12.885 6.436 13.769 6.436 14.653 6.436 L 14.646 21.872 C 14.653 22.006 14.596 22.133 14.504 22.225 C 13.788 22.942 13.071 23.658 12.355 24.375 C 12.157 24.573 11.846 24.573 11.648 24.375 C 10.931 23.658 10.215 22.942 9.498 22.225 C 9.498 22.225 9.498 22.225 9.498 22.225 C 9.399 22.126 9.35 22.006 9.35 21.865 M 14.653 2.334 L 14.653 4.922 L 9.35 4.922 L 9.35 2.334 C 9.35 1.783 9.795 1.337 10.347 1.337 L 13.656 1.337 C 14.207 1.337 14.653 1.783 14.653 2.334"
android:valueType="pathType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
<target android:name="group">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="rotation"
android:duration="200"
android:valueFrom="0"
android:valueTo="45"
android:valueType="floatType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
<target android:name="path_1">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="pathData"
android:duration="200"
android:valueFrom="M 18.88 8.87 C 19.49 8.26 20.1 7.65 20.71 7.04 C 20.71 7.04 20.71 7.04 20.71 7.04 C 21.1 6.65 21.1 6.02 20.71 5.63 C 19.93 4.85 19.15 4.07 18.37 3.29 C 17.98 2.9 17.35 2.9 16.96 3.29 C 16.35 3.9 15.74 4.51 15.13 5.12 L 18.88 8.87"
android:valueTo="M 11 18 C 11 18.55 11.45 19 12 19 C 12.029 19 12.058 18.999 12.086 18.996 C 12.596 18.952 13 18.521 13 18 C 13 14 13 10 13 6 C 13 5.45 12.55 5 12 5 C 11.45 5 11 5.45 11 6 L 11 18"
android:valueFrom="M 18 13 C 14.007 13 9.578 13 6 13 C 5.45 13 5 12.55 5 12 C 5 11.45 5.45 11 6 11 L 18 11 C 18.55 11 19 11.45 19 12 C 19 12.55 18.55 13 18 13 Z"
android:valueTo="M 12 13 C 12 13 12 13 12 13 C 11.45 13 11 12.55 11 12 C 11 11.45 11.45 11 12 11 L 12 11 C 12.55 11 13 11.45 13 12 C 13 12.55 12.55 13 12 13 Z"
android:valueType="pathType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>

View File

@ -1,7 +1,9 @@
package de.mm20.launcher2.database
import androidx.lifecycle.LiveData
import androidx.room.*
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import de.mm20.launcher2.database.entities.WidgetEntity
import kotlinx.coroutines.flow.Flow
@ -19,6 +21,16 @@ interface WidgetDao {
@Insert
fun insertAll(widgets: List<WidgetEntity>)
@Insert
fun insert(widget: WidgetEntity)
@Query("DELETE FROM Widget")
fun deleteAll()
@Query("DELETE FROM Widget WHERE data = :data AND type = :type")
fun deleteWidget(type: String, data: String)
@Query("UPDATE Widget SET height = :newHeight WHERE data = :data AND type = :type")
fun updateHeight(type: String, data: String, newHeight: Int)
}

View File

@ -173,7 +173,7 @@ class LauncherScaffoldView @JvmOverloads constructor(
}
widgetsViewModel.isEditMode.observe(context) {
OneShotLayoutTransition.run(binding.scrollContainer)
//OneShotLayoutTransition.run(binding.scrollContainer)
if (it) {
binding.scrollView.setOnTouchListener(null)
binding.searchBar.visibility = View.INVISIBLE

View File

@ -0,0 +1,164 @@
package de.mm20.launcher2.ui.launcher.widgets
import android.app.Activity
import android.appwidget.AppWidgetHost
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.launcher.widgets.calendar.CalendarWidget
import de.mm20.launcher2.ui.launcher.widgets.external.ExternalWidget
import de.mm20.launcher2.ui.launcher.widgets.music.MusicWidget
import de.mm20.launcher2.ui.launcher.widgets.weather.WeatherWidget
import de.mm20.launcher2.widgets.*
import java.lang.Integer.max
import kotlin.math.roundToInt
@Composable
fun WidgetItem(
widget: Widget,
appWidgetHost: AppWidgetHost,
modifier: Modifier = Modifier,
editMode: Boolean = false,
onWidgetResize: (newHeight: Int) -> Unit = {},
onWidgetRemove: () -> Unit = {},
draggableState: DraggableState = rememberDraggableState {},
onDragStopped: () -> Unit = {}
) {
val context = LocalContext.current
var resizeMode by remember(editMode) { mutableStateOf(false) }
var isDragged by remember { mutableStateOf(false) }
val elevation by animateDpAsState(if (isDragged) 8.dp else 2.dp)
LauncherCard(
modifier = modifier.zIndex(if (isDragged) 1f else 0f),
elevation = elevation
) {
Column {
AnimatedVisibility(editMode) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Rounded.DragIndicator,
contentDescription = null,
modifier = Modifier.draggable(
state = draggableState,
orientation = Orientation.Vertical,
startDragImmediately = true,
onDragStarted = {
isDragged = true
},
onDragStopped = {
isDragged = false
onDragStopped()
}
)
)
Text(
text = remember(widget) { widget.loadLabel(context) },
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
if (widget is ExternalWidget) {
IconButton(onClick = { resizeMode = !resizeMode }) {
Icon(
imageVector = Icons.Rounded.Edit,
contentDescription = stringResource(R.string.widget_action_adjust_height)
)
}
}
if (widget.isConfigurable) {
IconButton({
widget.configure(context as Activity, appWidgetHost)
}) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings)
)
}
}
IconButton(onClick = { onWidgetRemove() }) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = stringResource(R.string.widget_action_remove)
)
}
}
}
AnimatedVisibility(!editMode || resizeMode) {
when (widget) {
is WeatherWidget -> {
WeatherWidget()
}
is MusicWidget -> {
MusicWidget()
}
is CalendarWidget -> {
CalendarWidget()
}
is ExternalWidget -> {
var height by remember(widget) { mutableStateOf(widget.height) }
Column {
ExternalWidget(
appWidgetHost = appWidgetHost,
widgetId = widget.widgetId,
modifier = Modifier.fillMaxWidth(),
height = height,
)
if (resizeMode) {
val density = LocalDensity.current
val drgStt = rememberDraggableState {
height = max(
height + (it / density.density).roundToInt(),
widget.widgetProviderInfo.minResizeHeight
)
}
Icon(
imageVector = Icons.Rounded.DragHandle,
contentDescription = null,
modifier = Modifier
.padding(top = 12.dp)
.requiredHeight(24.dp)
.fillMaxWidth()
.draggable(
state = drgStt,
orientation = Orientation.Vertical,
startDragImmediately = true,
onDragStopped = {
onWidgetResize(height)
}
)
)
}
}
}
}
}
}
}
}

View File

@ -1,11 +1,17 @@
package de.mm20.launcher2.ui.launcher.widgets
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData
import de.mm20.launcher2.widgets.ExternalWidget
import de.mm20.launcher2.widgets.Widget
import de.mm20.launcher2.widgets.WidgetRepository
import de.mm20.launcher2.widgets.WidgetType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@ -20,11 +26,47 @@ class WidgetsVM : ViewModel(), KoinComponent {
isEditMode.value = editMode
}
fun saveWidgets(widgets: List<Widget>) {
fun addWidget(widget: Widget) {
widgetRepository.addWidget(widget, widgets.value?.size ?: 0)
}
fun addAppWidget(context: Context, widgetId: Int) {
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return
val appWidget = AppWidgetManager.getInstance(context)
.getAppWidgetInfo(widgetId) ?: return
val widget = ExternalWidget(
widgetProviderInfo = appWidget,
height = appWidget.minHeight,
widgetId = widgetId,
)
addWidget(widget)
}
fun removeWidget(widget: Widget) {
widgetRepository.removeWidget(widget)
}
fun setWidgetHeight(widget: Widget, newHeight: Int) {
widgetRepository.setWidgetHeight(widget, newHeight)
}
fun getAvailableBuiltInWidgets(): List<Widget> {
return widgetRepository.getInternalWidgets().filter {
widgets.value?.contains(it)?.not() ?: false
}
}
fun moveUp(index: Int) {
val widgets = widgets.value?.toMutableList() ?: return
val widget = widgets.removeAt(index)
widgets.add(index - 1, widget)
widgetRepository.saveWidgets(widgets)
}
fun getInternalWidgets(): List<Widget> {
return widgetRepository.getInternalWidgets()
fun moveDown(index: Int) {
val widgets = widgets.value?.toMutableList() ?: return
val widget = widgets.removeAt(index)
widgets.add(index + 1, widget)
widgetRepository.saveWidgets(widgets)
}
}

View File

@ -1,7 +1,5 @@
package de.mm20.launcher2.ui.launcher.widgets
import android.animation.LayoutTransition
import android.annotation.SuppressLint
import android.app.Activity
import android.appwidget.AppWidgetHost
import android.appwidget.AppWidgetManager
@ -9,259 +7,317 @@ import android.content.Context
import android.content.Intent
import android.util.AttributeSet
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
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.core.view.iterator
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItems
import com.balsikandar.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.dp
import de.mm20.launcher2.transition.ChangingLayoutTransition
import de.mm20.launcher2.transition.OneShotLayoutTransition
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
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.MutableLiveData
import de.mm20.launcher2.ui.ClockWidget
import de.mm20.launcher2.ui.MdcLauncherTheme
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.databinding.ViewWidgetsBinding
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.ui.legacy.component.WidgetView
import de.mm20.launcher2.widgets.Widget
import de.mm20.launcher2.widgets.WidgetType
import kotlinx.coroutines.awaitCancellation
import de.mm20.launcher2.widgets.ExternalWidget
import kotlinx.coroutines.launch
import java.util.*
import kotlin.math.roundToInt
@OptIn(ExperimentalAnimationGraphicsApi::class)
class WidgetsView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : LinearLayout(context, attrs) {
private val binding = ViewWidgetsBinding.inflate(LayoutInflater.from(context), this)
) : FrameLayout(context, attrs) {
private val widgetHost: AppWidgetHost = AppWidgetHost(context.applicationContext, 44203)
private val viewModel: WidgetsVM by (context as AppCompatActivity).viewModels()
private lateinit var widgets: MutableList<Widget>
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)
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) {
bindAppWidget(widgetId)
viewModel.addAppWidget(context, widgetId)
}
}
viewModel.widgets.observe(context) {
if (it != null && !::widgets.isInitialized) {
widgets = it.toMutableList()
initWidgets()
}
}
viewModel.isEditMode.observe(context) {
if (it) {
OneShotLayoutTransition.run(binding.widgetList)
binding.clockWidget.visibility = View.GONE
val composeView = ComposeView(context)
composeView.setContent {
MdcLauncherTheme {
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) }
for (v in binding.widgetList.iterator()) {
if (v is WidgetView) {
v.editMode = true
v.onResizeModeChange = {
OneShotLayoutTransition.run(binding.widgetList)
OneShotLayoutTransition.run(this)
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
)
)
}
.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)
)
}
}
}
}
}
}
}
binding.fabEditWidget.apply {
setIconResource(R.drawable.ic_add)
setText(R.string.widget_add_widget)
setOnClickListener {
addWidget()
}
}
} else {
if (::widgets.isInitialized) {
viewModel.saveWidgets(widgets)
OneShotLayoutTransition.run(binding.widgetList)
}
binding.clockWidget.visibility = View.VISIBLE
for (v in binding.widgetList.iterator()) {
if (v is WidgetView) {
v.editMode = false
}
}
binding.fabEditWidget.apply {
setIconResource(R.drawable.ic_edit)
setText(R.string.menu_edit_widgets)
setOnClickListener {
viewModel.setEditMode(true)
}
}
}
}
context.lifecycleScope.launch {
context.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
widgetHost.startListening()
try {
awaitCancellation()
} finally {
// TODO: find out why there is a NPE thrown sometimes
try {
widgetHost.stopListening()
} catch (e: NullPointerException) {
CrashReporter.logException(e)
}
}
}
}
binding.fabEditWidget.setOnClickListener {
viewModel.setEditMode(true)
}
addView(composeView)
}
fun setClockWidgetHeight(height: Int) {
val params = binding.clockWidget.layoutParams
params.height = height
binding.clockWidget.layoutParams = params
}
private fun initWidgets() {
binding.widgetList.removeAllViews()
val params = LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
params.topMargin = (8 * dp).roundToInt()
for (w in widgets) {
val view = WidgetView(context)
view.layoutParams = params
if (view.setWidget(w, widgetHost)) {
binding.widgetList.addDragView(view, view.getDragHandle())
view.onRemove = {
OneShotLayoutTransition.run(binding.widgetList)
binding.widgetList.removeDragView(view)
removeWidget(view.widget)
}
view.onResizeModeChange = {
OneShotLayoutTransition.run(binding.widgetList)
}
}
}
binding.widgetList.setOnViewSwapListener { _, firstPosition, _, secondPosition ->
Collections.swap(widgets, firstPosition, secondPosition)
}
}
@SuppressLint("CheckResult")
private fun addWidget() {
val usedWidgets = widgets.filter { it.type == WidgetType.INTERNAL }.map { it.data }
val internalWidgets =
viewModel.getInternalWidgets().filter { !usedWidgets.contains(it.data) }
if (internalWidgets.isNotEmpty()) {
MaterialDialog(context).show {
title(R.string.widget_add_widget)
listItems(items = internalWidgets.map { it.label }) { dialog, index, _ ->
val widget = internalWidgets[index]
val view = WidgetView(this@WidgetsView.context)
val params = LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
params.topMargin = (8 * dp).roundToInt()
view.layoutParams = params
if (view.setWidget(widget, widgetHost)) {
view.editMode = true
binding.widgetList.addDragView(view, view.getDragHandle())
view.onRemove = {
OneShotLayoutTransition.run(binding.widgetList)
OneShotLayoutTransition.run(this@WidgetsView)
binding.widgetList.removeDragView(view)
removeWidget(view.widget)
}
view.onResizeModeChange = {
OneShotLayoutTransition.run(binding.widgetList)
OneShotLayoutTransition.run(this@WidgetsView)
}
widgets.add(widget)
}
dialog.dismiss()
}
@Suppress("DEPRECATION") // I don't care that neutral buttons are discouraged.
neutralButton(R.string.widget_add_external) {
pickAppWidget()
it.dismiss()
}
}
} else {
pickAppWidget()
}
}
private fun removeWidget(widget: Widget?) {
widget ?: return
widgets.remove(widget)
val id = widget.data.toIntOrNull() ?: return
widgetHost.deleteAppWidgetId(id)
}
private fun pickAppWidget() {
val pickIntent = Intent(context, PickAppWidgetActivity::class.java)
pickWidgetLauncher.launch(pickIntent)
}
private fun bindAppWidget(widgetId: Int) {
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return
val appWidget = AppWidgetManager.getInstance(context)
.getAppWidgetInfo(widgetId) ?: return
val widget = Widget(
type = WidgetType.THIRD_PARTY,
data = widgetId.toString(),
height = appWidget.minHeight
)
val params = LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
params.topMargin = (8 * dp).roundToInt()
val view = WidgetView(context)
view.layoutParams = params
if (view.setWidget(widget, widgetHost)) {
view.editMode = true
binding.widgetList.addDragView(view, view.getDragHandle())
view.onRemove = {
OneShotLayoutTransition.run(binding.widgetList)
OneShotLayoutTransition.run(this)
binding.widgetList.removeDragView(view)
removeWidget(view.widget)
}
view.onResizeModeChange = {
OneShotLayoutTransition.run(binding.widgetList)
OneShotLayoutTransition.run(this)
}
widgets.add(widget)
}
clockWidgetHeight.value = height
}
}

View File

@ -0,0 +1,58 @@
package de.mm20.launcher2.ui.launcher.widgets.external
import android.appwidget.AppWidgetHost
import android.appwidget.AppWidgetManager
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.ListView
import android.widget.ScrollView
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.doOnNextLayout
import androidx.core.view.iterator
import de.mm20.launcher2.ui.ktx.toPixels
@Composable
fun ExternalWidget(
appWidgetHost: AppWidgetHost,
widgetId: Int,
height: Int,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val widgetInfo = remember(widgetId) {
AppWidgetManager.getInstance(context).getAppWidgetInfo(widgetId)
}
val viewHeightPx = height.dp.toPixels()
key(widgetId) {
AndroidView(
modifier = modifier.fillMaxWidth().height(height.dp),
factory = {
val view = appWidgetHost.createView(it.applicationContext, widgetId, widgetInfo)
enableNestedScroll(view)
return@AndroidView view
},
update = {
it.updateAppWidgetSize(null, 0, 0, it.width, height)
}
)
}
}
private fun enableNestedScroll(view: View) {
if (view is ViewGroup) {
for (child in view.iterator()) {
enableNestedScroll(child)
}
}
if (view is ListView || view is ScrollView) view.isNestedScrollingEnabled = true
}

View File

@ -1,120 +0,0 @@
package de.mm20.launcher2.ui.legacy.component
import android.appwidget.AppWidgetHost
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import androidx.appcompat.widget.TooltipCompat
import androidx.core.view.get
import de.mm20.launcher2.ktx.dp
import de.mm20.launcher2.transition.OneShotLayoutTransition
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.databinding.ViewWidgetBinding
import de.mm20.launcher2.ui.legacy.view.LauncherCardView
import de.mm20.launcher2.ui.legacy.widget.*
import de.mm20.launcher2.widgets.Widget
import de.mm20.launcher2.widgets.WidgetType
class WidgetView : LauncherCardView {
var onRemove: (() -> Unit)? = null
var widget: Widget? = null
var widgetView: LauncherWidget? = null
var editMode = false
set(value) {
OneShotLayoutTransition.run(this)
if (value) {
binding.widgetControlPanel.visibility = View.VISIBLE
val widget = binding.widgetWrapper[2]
widget.visibility = View.GONE
binding.widgetName.visibility = View.VISIBLE
visibility = View.VISIBLE
} else {
resizeMode = false
binding.widgetControlPanel.visibility = View.GONE
val widget = binding.widgetWrapper[2] as LauncherWidget
widget.visibility = View.VISIBLE
binding.widgetName.visibility = View.GONE
}
field = value
}
private var resizeMode = false
set(value) {
if (value == field) return
onResizeModeChange?.invoke(value)
OneShotLayoutTransition.run(this)
OneShotLayoutTransition.run(binding.widgetWrapper)
if (value) {
binding.widgetResizeDragHandle.visibility = View.VISIBLE
val widget = binding.widgetWrapper[2]
widget.visibility = View.VISIBLE
binding.widgetName.visibility = View.GONE
} else {
binding.widgetResizeDragHandle.visibility = View.GONE
if (editMode) {
val widget = binding.widgetWrapper[2]
widget.visibility = View.GONE
binding.widgetName.visibility = View.VISIBLE
}
}
field = value
}
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleRes: Int) : super(context, attrs, defStyleRes)
private val binding = ViewWidgetBinding.inflate(LayoutInflater.from(context), this)
init {
binding.widgetActionResize.setOnClickListener {
resizeMode = !resizeMode
}
binding.widgetActionRemove.setOnClickListener {
onRemove?.invoke()
}
TooltipCompat.setTooltipText(binding.widgetActionResize, context.getString(R.string.widget_action_adjust_height))
TooltipCompat.setTooltipText(binding.widgetActionRemove, context.getString(R.string.widget_action_remove))
}
var onResizeModeChange: ((Boolean) -> Unit)? = null
fun setWidget(widget: Widget, widgetHost: AppWidgetHost): Boolean {
if (widget.type == WidgetType.INTERNAL) {
widgetView = when (widget.data) {
CalendarWidget.ID -> CalendarWidget(context)
WeatherWidget.ID -> WeatherWidget(context)
MusicWidget.ID -> MusicWidget(context)
else -> return false
}
binding.widgetActionResize.visibility = View.GONE
binding.widgetResizeDragHandle.resizeView = widgetView
binding.widgetWrapper.addView(widgetView, 2)
binding.widgetName.text = widgetView?.name
} else {
widgetView = ExternalWidget(context, widget, widgetHost)
binding.widgetResizeDragHandle.resizeView = widgetView
binding.widgetResizeDragHandle.onResize = {
widget.height = (it / dp).toInt()
}
binding.widgetWrapper.addView(widgetView, 2)
binding.widgetName.text = widgetView?.name
binding.widgetActionResize.visibility = View.VISIBLE
}
this.widget = widget
return true
}
fun getDragHandle(): View {
return binding.widgetDragHandle
}
}

View File

@ -1,43 +0,0 @@
package de.mm20.launcher2.ui.legacy.view
import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.MotionEvent.INVALID_POINTER_ID
import android.view.View
import android.widget.ImageView
class WidgetResizeDragView : ImageView {
var resizeView: View? = null
private var lastY = 0f
var onResize: ((Int) -> Unit)? = null
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleRes: Int) : super(context, attrs, defStyleRes)
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
parent.requestDisallowInterceptTouchEvent(true)
val y = event.y
lastY = y
}
MotionEvent.ACTION_MOVE -> {
val y = event.y
val dY = y - lastY
val view = resizeView ?: return false
val params = view.layoutParams
val newHeight = (view.height + dY).toInt()
params.height = newHeight
onResize?.invoke(newHeight)
view.layoutParams = params
}
}
return true
}
}

View File

@ -1,59 +0,0 @@
package de.mm20.launcher2.ui.legacy.widget
import android.content.Context
import android.util.AttributeSet
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.LocalAbsoluteTonalElevation
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.ui.MdcLauncherTheme
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.base.ProvideSettings
import de.mm20.launcher2.ui.launcher.widgets.calendar.CalendarWidget
class CalendarWidget : LauncherWidget {
override val canResize: Boolean
get() = false
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleRes: Int) : super(
context,
attrs,
defStyleRes
)
init {
val composeView = ComposeView(context)
composeView.setContent {
MdcLauncherTheme {
ProvideSettings {
// TODO: Temporary solution until parent widget card is rewritten in Compose
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.onSurface,
LocalAbsoluteTonalElevation provides 1.dp
) {
Column {
CalendarWidget()
}
}
}
}
}
addView(composeView)
}
override val name: String
get() = resources.getString(R.string.widget_name_calendar)
companion object {
const val ID = "calendar"
}
}

View File

@ -1,43 +0,0 @@
package de.mm20.launcher2.ui.legacy.widget
import android.animation.LayoutTransition
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.compose.ui.platform.ComposeView
import de.mm20.launcher2.ui.ClockWidget
import de.mm20.launcher2.ui.MdcLauncherTheme
class ClockWidget : FrameLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleRes: Int) : super(
context,
attrs,
defStyleRes
)
val view = ComposeView(context)
init {
clipToPadding = false
clipChildren = false
layoutTransition = LayoutTransition()
val composeView = ComposeView(context)
addView(composeView)
composeView.layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
composeView.setContent {
MdcLauncherTheme {
ClockWidget()
}
}
}
}

View File

@ -1,75 +0,0 @@
package de.mm20.launcher2.ui.legacy.widget
import android.annotation.SuppressLint
import android.appwidget.AppWidgetHost
import android.appwidget.AppWidgetHostView
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.ListView
import android.widget.ScrollView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.doOnLayout
import androidx.core.view.doOnNextLayout
import androidx.core.view.get
import androidx.core.view.iterator
import de.mm20.launcher2.ktx.dp
import de.mm20.launcher2.widgets.Widget
@SuppressLint("ViewConstructor")
class ExternalWidget(
context: Context,
val widget: Widget,
host: AppWidgetHost
) : LauncherWidget(context) {
val widgetInfo: AppWidgetProviderInfo?
val widgetView: View
init {
val id = widget.data.toInt()
widgetInfo = AppWidgetManager.getInstance(context.applicationContext).getAppWidgetInfo(id)
widgetView = host.createView(context.applicationContext, id, widgetInfo)
?: View(context)
if (widgetView is AppWidgetHostView && widgetView.childCount > 0) {
enableNestedScroll(widgetView[0])
}
val h = widget.height * dp
val params = ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, h.toInt())
val p = ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
layoutParams = params
widgetView.layoutParams = p
addView(widgetView)
}
override fun setLayoutParams(params: ViewGroup.LayoutParams?) {
super.setLayoutParams(params)
params ?: return
doOnNextLayout {
val width = if (params.width > 0) params.width else it.width
val height = if (params.height > 0) params.height else widgetInfo?.minHeight ?: it.height
if (widgetView is AppWidgetHostView) {
widgetView.updateAppWidgetSize(Bundle(), 0, 0, width, height)
}
}
}
private fun enableNestedScroll(view: View) {
if (view is ViewGroup) {
for (child in view.iterator()) {
enableNestedScroll(child)
}
}
if (view is ListView || view is ScrollView) view.isNestedScrollingEnabled = true
}
override val canResize: Boolean
get() = true
override val name: String
get() = widgetInfo?.loadLabel(context.packageManager) ?: ""
}

View File

@ -1,17 +0,0 @@
package de.mm20.launcher2.ui.legacy.widget
import android.animation.LayoutTransition
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
abstract class LauncherWidget : FrameLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleRes: Int) : super(context, attrs, defStyleRes)
abstract val canResize: Boolean
abstract val name: String
}

View File

@ -1,47 +0,0 @@
package de.mm20.launcher2.ui.legacy.widget
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.ComposeView
import de.mm20.launcher2.ui.MdcLauncherTheme
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.launcher.widgets.music.MusicWidget
class MusicWidget : LauncherWidget {
override val canResize: Boolean
get() = false
override val name: String
get() = context.getString(R.string.widget_name_music)
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleRes: Int) : super(context, attrs, defStyleRes)
init {
val composeView = ComposeView(context)
composeView.id = FrameLayout.generateViewId()
composeView.setContent {
MdcLauncherTheme {
// TODO: Temporary solution until parent widget card is rewritten in Compose
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Column {
MusicWidget()
}
}
}
}
addView(composeView)
}
companion object {
const val ID = "music"
}
}

View File

@ -1,50 +0,0 @@
package de.mm20.launcher2.ui.legacy.widget
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.ComposeView
import de.mm20.launcher2.ui.MdcLauncherTheme
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.launcher.widgets.weather.WeatherWidget
class WeatherWidget : LauncherWidget {
override val canResize: Boolean
get() = false
override val name: String
get() = resources.getString(R.string.widget_name_weather)
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleRes: Int) : super(context, attrs, defStyleRes)
init {
val composeView = ComposeView(context)
composeView.id = FrameLayout.generateViewId()
composeView.setContent {
MdcLauncherTheme {
// TODO: Temporary solution until parent widget card is rewritten in Compose
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Column {
WeatherWidget()
}
}
}
}
addView(composeView)
}
companion object {
const val ID = "weather"
}
}

View File

@ -37,8 +37,7 @@
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical"
android:layout_margin="8dp" />
android:orientation="vertical" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -1,80 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/widgetWrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<FrameLayout
android:id="@+id/widgetControlPanel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:visibility="gone">
<ImageView
android:id="@+id/widgetDragHandle"
style="?iconStyle"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:focusable="true"
android:foreground="?selectableItemBackgroundBorderless"
android:padding="12dp"
android:src="@drawable/ic_drag_handle"/>
<ImageView
android:id="@+id/widgetActionResize"
style="?iconStyle"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="end"
android:layout_marginStart="4dp"
android:layout_marginEnd="56dp"
android:clickable="true"
android:focusable="true"
android:foreground="?selectableItemBackgroundBorderless"
android:padding="12dp"
android:src="@drawable/ic_widget_resize"/>
<ImageView
android:id="@+id/widgetActionRemove"
style="?iconStyle"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="end"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:clickable="true"
android:focusable="true"
android:foreground="?selectableItemBackgroundBorderless"
android:padding="12dp"
android:src="@drawable/ic_delete"/>
</FrameLayout>
<TextView
android:id="@+id/widgetName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?textAppearanceTitleLarge"
android:visibility="gone"/>
<de.mm20.launcher2.ui.legacy.view.WidgetResizeDragView
android:id="@+id/widgetResizeDragHandle"
style="?iconStyle"
android:layout_width="match_parent"
android:layout_height="24dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:src="@drawable/ic_resize_drag_handle"
android:visibility="gone"/>
</LinearLayout>
</merge>

View File

@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:layout_height="wrap_content"
tools:layout_width="match_parent">
<de.mm20.launcher2.ui.legacy.widget.ClockWidget
android:id="@+id/clockWidget"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.jmedeisis.draglinearlayout.DragLinearLayout
android:id="@+id/widgetList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical">
<!-- Widgets will be added here -->
</com.jmedeisis.draglinearlayout.DragLinearLayout>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fabEditWidget"
app:icon="@drawable/ic_edit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
android:text="@string/menu_edit_widgets" />
</merge>

View File

@ -5,5 +5,5 @@ import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val widgetsModule = module {
single { WidgetRepository(androidContext()) }
single<WidgetRepository> { WidgetRepositoryImpl(androidContext(), get()) }
}

View File

@ -1,32 +1,173 @@
package de.mm20.launcher2.widgets
import android.app.Activity
import android.appwidget.AppWidgetHost
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import de.mm20.launcher2.database.entities.WidgetEntity
import de.mm20.launcher2.ktx.tryStartActivity
data class Widget(
val type: WidgetType,
var data: String,
var height: Int,
val label: String = ""
) {
constructor(entity: WidgetEntity) : this(
type = if (entity.type == "internal") WidgetType.INTERNAL else WidgetType. THIRD_PARTY,
data = entity.data,
height = entity.height,
label = entity.label
)
sealed class Widget {
abstract fun loadLabel(context: Context): String
abstract fun toDatabaseEntity(position: Int = -1): WidgetEntity
open val isConfigurable: Boolean = false
open fun configure(context: Activity, appWidgetHost: AppWidgetHost) {}
fun toDatabaseEntity(position: Int): WidgetEntity {
companion object {
fun fromDatabaseEntity(context: Context, entity: WidgetEntity): Widget? {
if (entity.type == WidgetType.INTERNAL.value) {
return when (entity.data) {
"weather" -> WeatherWidget
"music" -> MusicWidget
"calendar" -> CalendarWidget
else -> null
}
} else {
val widgetId = entity.data.toIntOrNull() ?: return null
val widgetInfo =
AppWidgetManager.getInstance(context).getAppWidgetInfo(widgetId) ?: return null
return ExternalWidget(
height = entity.height,
widgetId = widgetId,
widgetProviderInfo = widgetInfo
)
}
}
}
}
object WeatherWidget : Widget() {
override fun loadLabel(context: Context): String {
return context.getString(R.string.widget_name_weather)
}
override fun toDatabaseEntity(position: Int): WidgetEntity {
return WidgetEntity(
type = if (type == WidgetType.INTERNAL) "internal" else "3rdparty",
label = label,
position = position,
height = height,
data = data
type = WidgetType.INTERNAL.value,
data = "weather",
height = -1,
position = position
)
}
override val isConfigurable: Boolean = true
override fun configure(context: Activity, appWidgetHost: AppWidgetHost) {
val intent = Intent()
intent.component = ComponentName(
context.getPackageName(),
"de.mm20.launcher2.ui.settings.SettingsActivity"
)
intent.putExtra(
"de.mm20.launcher2.settings.ROUTE",
"settings/widgets/weather"
)
context.tryStartActivity(intent)
}
}
object MusicWidget : Widget() {
override fun loadLabel(context: Context): String {
return context.getString(R.string.widget_name_music)
}
override fun toDatabaseEntity(position: Int): WidgetEntity {
return WidgetEntity(
type = WidgetType.INTERNAL.value,
data = "music",
height = -1,
position = position
)
}
override val isConfigurable: Boolean = true
override fun configure(context: Activity, appWidgetHost: AppWidgetHost) {
val intent = Intent()
intent.component = ComponentName(
context.getPackageName(),
"de.mm20.launcher2.ui.settings.SettingsActivity"
)
intent.putExtra(
"de.mm20.launcher2.settings.ROUTE",
"settings/widgets/music"
)
context.tryStartActivity(intent)
}
}
object CalendarWidget : Widget() {
override fun loadLabel(context: Context): String {
return context.getString(R.string.widget_name_calendar)
}
override fun toDatabaseEntity(position: Int): WidgetEntity {
return WidgetEntity(
type = WidgetType.INTERNAL.value,
data = "calendar",
height = -1,
position = position
)
}
override val isConfigurable: Boolean = true
override fun configure(context: Activity, appWidgetHost: AppWidgetHost) {
val intent = Intent()
intent.component = ComponentName(
context.getPackageName(),
"de.mm20.launcher2.ui.settings.SettingsActivity"
)
intent.putExtra(
"de.mm20.launcher2.settings.ROUTE",
"settings/widgets/calendar"
)
context.tryStartActivity(intent)
}
}
class ExternalWidget(
var height: Int,
val widgetId: Int,
val widgetProviderInfo: AppWidgetProviderInfo
) : Widget() {
override fun loadLabel(context: Context): String {
return widgetProviderInfo.loadLabel(context.packageManager)
}
override fun toDatabaseEntity(position: Int): WidgetEntity {
return WidgetEntity(
type = WidgetType.THIRD_PARTY.value,
data = widgetId.toString(),
height = height,
position = position
)
}
override val isConfigurable: Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
widgetProviderInfo.widgetFeatures and AppWidgetProviderInfo.WIDGET_FEATURE_RECONFIGURABLE != 0
} else {
false
}
override fun configure(context: Activity, appWidgetHost: AppWidgetHost) {
appWidgetHost.startAppWidgetConfigureActivityForResult(
context,
widgetId,
0,
0,
null
)
}
}
enum class WidgetType {
INTERNAL,
THIRD_PARTY
enum class WidgetType(val value: String) {
INTERNAL("internal"),
THIRD_PARTY("3rdparty")
}

View File

@ -1,40 +1,79 @@
package de.mm20.launcher2.widgets
import android.content.Context
import de.mm20.launcher2.widgets.R
import de.mm20.launcher2.database.AppDatabase
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import java.util.concurrent.Executors
class WidgetRepository(
val context: Context
) {
interface WidgetRepository {
fun getWidgets(): Flow<List<Widget>>
fun getInternalWidgets(): List<Widget>
fun saveWidgets(widgets: List<Widget>)
fun addWidget(widget: Widget, position: Int)
fun removeWidget(widget: Widget)
fun setWidgetHeight(widget: Widget, newHeight: Int)
}
internal class WidgetRepositoryImpl(
private val context: Context,
private val database: AppDatabase,
) : WidgetRepository {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
fun getWidgets(): Flow<List<Widget>> {
return AppDatabase.getInstance(context).widgetDao()
override fun getWidgets(): Flow<List<Widget>> {
return database.widgetDao()
.getWidgets()
.map { it.map { Widget(it) } }
.map { it.mapNotNull { Widget.fromDatabaseEntity(context, it) } }
}
fun getInternalWidgets(): List<Widget> {
return listOf(
Widget(WidgetType.INTERNAL, "weather", -1, context.getString(R.string.widget_name_weather)),
Widget(WidgetType.INTERNAL, "music", -1, context.getString(R.string.widget_name_music)),
Widget(WidgetType.INTERNAL, "calendar", -1, context.getString(R.string.widget_name_calendar)),
)
override fun getInternalWidgets(): List<Widget> {
return listOf(WeatherWidget, MusicWidget, CalendarWidget)
}
fun saveWidgets(widgets: List<Widget>) {
override fun saveWidgets(widgets: List<Widget>) {
scope.launch {
withContext(Dispatchers.IO) {
AppDatabase.getInstance(context).widgetDao().updateWidgets(widgets.mapIndexed { i, widget -> widget.toDatabaseEntity(i) })
database.widgetDao()
.updateWidgets(widgets.mapIndexed { i, widget -> widget.toDatabaseEntity(i) })
}
}
}
override fun addWidget(widget: Widget, position: Int) {
scope.launch {
withContext(Dispatchers.IO) {
database.widgetDao()
.insert(widget.toDatabaseEntity(position))
}
}
}
override fun removeWidget(widget: Widget) {
scope.launch {
withContext(Dispatchers.IO) {
val ent = widget.toDatabaseEntity()
database.widgetDao().deleteWidget(
ent.type,
ent.data
)
}
}
}
override fun setWidgetHeight(widget: Widget, newHeight: Int) {
scope.launch {
withContext(Dispatchers.IO) {
val ent = widget.toDatabaseEntity()
database.widgetDao().updateHeight(
ent.type,
ent.data,
newHeight
)
}
}
}
}