Move widget specific settings to widgets

This commit is contained in:
MM20
2023-04-16 14:07:08 +02:00
parent fb87ab20f9
commit 5b2ad94065
39 changed files with 958 additions and 645 deletions
@@ -26,10 +26,13 @@ import androidx.compose.material.FractionalThreshold
import androidx.compose.material.SwipeableState
import androidx.compose.material.swipeable
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.LocalAbsoluteTonalElevation
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocal
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -133,143 +136,147 @@ fun BottomSheetDialog(
}
}
Dialog(
properties = DialogProperties(
dismissOnBackPress = dismissOnBackPress(),
dismissOnClickOutside = swipeToDismiss(),
usePlatformDefaultWidth = false,
),
onDismissRequest = onDismissRequest,
CompositionLocalProvider(
LocalAbsoluteTonalElevation provides 0.dp,
) {
BoxWithConstraints(
modifier = Modifier.fillMaxSize(),
propagateMinConstraints = true,
contentAlignment = Alignment.BottomCenter
Dialog(
properties = DialogProperties(
dismissOnBackPress = dismissOnBackPress(),
dismissOnClickOutside = swipeToDismiss(),
usePlatformDefaultWidth = false,
),
onDismissRequest = onDismissRequest,
) {
val maxHeightPx = maxHeight.toPixels()
val scrimAlpha by animateFloatAsState(
if (swipeState.targetValue == SwipeState.Dismiss) 0f else 0.32f,
label = "Scrim alpha"
)
BoxWithConstraints(
modifier = Modifier.fillMaxSize(),
propagateMinConstraints = true,
contentAlignment = Alignment.BottomCenter
) {
val maxHeightPx = maxHeight.toPixels()
val scrimAlpha by animateFloatAsState(
if (swipeState.targetValue == SwipeState.Dismiss) 0f else 0.32f,
label = "Scrim alpha"
)
Box(modifier = Modifier
.background(MaterialTheme.colorScheme.scrim.copy(alpha = scrimAlpha))
.fillMaxSize()
.pointerInput(onDismissRequest, swipeToDismiss) {
detectTapGestures {
if (swipeToDismiss()) {
scope.launch {
swipeState.animateTo(SwipeState.Dismiss)
onDismissRequest()
Box(modifier = Modifier
.background(MaterialTheme.colorScheme.scrim.copy(alpha = scrimAlpha))
.fillMaxSize()
.pointerInput(onDismissRequest, swipeToDismiss) {
detectTapGestures {
if (swipeToDismiss()) {
scope.launch {
swipeState.animateTo(SwipeState.Dismiss)
onDismissRequest()
}
}
}
}
}
)
)
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(Alignment.Bottom)
.clipToBounds(),
verticalArrangement = Arrangement.Bottom
) {
var height by remember {
mutableStateOf(maxHeightPx)
}
LaunchedEffect(null) {
swipeState.animateTo(SwipeState.Peek)
}
val heightDp = height.toDp()
val peekHeight = (height - maxHeightPx / 2).coerceAtLeast(0f)
val anchors = mutableMapOf(
peekHeight to SwipeState.Peek,
height to SwipeState.Dismiss,
).also {
if (peekHeight > 0f) {
it[0f] = SwipeState.Full
}
}
Surface(
Column(
modifier = Modifier
.nestedScroll(nestedScrollConnection)
.animateContentSize()
.onSizeChanged {
height = it.height.toFloat()
}
.offset { IntOffset(0, swipeState.offset.value.roundToInt()) }
.swipeable(
swipeState,
anchors = anchors,
orientation = Orientation.Vertical,
thresholds = { _, to ->
if (to == SwipeState.Dismiss) {
FixedThreshold(heightDp - 48.dp)
} else {
FractionalThreshold(0.5f)
}
},
resistance = null
)
.fillMaxWidth()
.weight(1f, false),
shape = MaterialTheme.shapes.extraLarge.copy(
bottomStart = CornerSize(0),
bottomEnd = CornerSize(0),
),
shadowElevation = 16.dp,
.wrapContentHeight(Alignment.Bottom)
.clipToBounds(),
verticalArrangement = Arrangement.Bottom
) {
Column {
CenterAlignedTopAppBar(
title = title,
actions = actions,
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
),
)
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
propagateMinConstraints = true,
contentAlignment = Alignment.Center
) {
content(PaddingValues(horizontal = 24.dp, vertical = 8.dp))
}
var height by remember {
mutableStateOf(maxHeightPx)
}
}
if (confirmButton != null || dismissButton != null) {
val elevation by animateDpAsState(if (swipeState.offset.value == 0f) 0.dp else 1.dp)
val alpha by animateFloatAsState(if (swipeState.targetValue == SwipeState.Dismiss) 0f else 1f)
LaunchedEffect(null) {
swipeState.animateTo(SwipeState.Peek)
}
val heightDp = height.toDp()
val peekHeight = (height - maxHeightPx / 2).coerceAtLeast(0f)
val anchors = mutableMapOf(
peekHeight to SwipeState.Peek,
height to SwipeState.Dismiss,
).also {
if (peekHeight > 0f) {
it[0f] = SwipeState.Full
}
}
Surface(
modifier = Modifier
.wrapContentHeight()
.fillMaxWidth(),
tonalElevation = elevation,
.nestedScroll(nestedScrollConnection)
.animateContentSize()
.onSizeChanged {
height = it.height.toFloat()
}
.offset { IntOffset(0, swipeState.offset.value.roundToInt()) }
.swipeable(
swipeState,
anchors = anchors,
orientation = Orientation.Vertical,
thresholds = { _, to ->
if (to == SwipeState.Dismiss) {
FixedThreshold(heightDp - 48.dp)
} else {
FractionalThreshold(0.5f)
}
},
resistance = null
)
.fillMaxWidth()
.weight(1f, false),
shape = MaterialTheme.shapes.extraLarge.copy(
bottomStart = CornerSize(0),
bottomEnd = CornerSize(0),
),
shadowElevation = 16.dp,
) {
Row(
Column {
CenterAlignedTopAppBar(
title = title,
actions = actions,
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
),
)
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
propagateMinConstraints = true,
contentAlignment = Alignment.Center
) {
content(PaddingValues(horizontal = 24.dp, vertical = 8.dp))
}
}
}
if (confirmButton != null || dismissButton != null) {
val elevation by animateDpAsState(if (swipeState.offset.value == 0f) 0.dp else 1.dp)
val alpha by animateFloatAsState(if (swipeState.targetValue == SwipeState.Dismiss) 0f else 1f)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.End
.wrapContentHeight()
.fillMaxWidth(),
tonalElevation = elevation,
) {
if (dismissButton != null) {
dismissButton()
}
if (confirmButton != null && dismissButton != null) {
Spacer(modifier = Modifier.width(16.dp))
}
if (confirmButton != null) {
confirmButton()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.End
) {
if (dismissButton != null) {
dismissButton()
}
if (confirmButton != null && dismissButton != null) {
Spacer(modifier = Modifier.width(16.dp))
}
if (confirmButton != null) {
confirmButton()
}
}
}
}
}
}
}
@@ -0,0 +1,32 @@
package de.mm20.launcher2.ui.component.preferences
import androidx.compose.material3.Checkbox
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun CheckboxPreference(
title: String,
icon: ImageVector? = null,
iconPadding: Boolean = true,
summary: String? = null,
value: Boolean,
onValueChanged: (Boolean) -> Unit,
enabled: Boolean = true
) {
Preference(
title = title,
icon = icon,
iconPadding = iconPadding,
summary = summary,
enabled = enabled,
onClick = {
onValueChanged(!value)
},
controls = {
Checkbox(
enabled = enabled, checked = value, onCheckedChange = onValueChanged,
)
}
)
}
@@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
fun SwitchPreference(
title: String,
icon: ImageVector? = null,
iconPadding: Boolean = true,
summary: String? = null,
value: Boolean,
onValueChanged: (Boolean) -> Unit,
@@ -16,6 +17,7 @@ fun SwitchPreference(
Preference(
title = title,
icon = icon,
iconPadding = iconPadding,
summary = summary,
enabled = enabled,
onClick = {
@@ -0,0 +1,536 @@
package de.mm20.launcher2.ui.launcher.sheets
import android.app.Activity
import android.appwidget.AppWidgetHost
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Build
import androidx.compose.material.icons.rounded.Error
import androidx.compose.material.icons.rounded.OpenInNew
import androidx.compose.material.icons.rounded.UnfoldMore
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.calendar.CalendarRepository
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.search.data.UserCalendar
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.component.LargeMessage
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.CheckboxPreference
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
import de.mm20.launcher2.ui.launcher.widgets.external.ExternalWidget
import de.mm20.launcher2.ui.settings.SettingsActivity
import de.mm20.launcher2.widgets.AppWidget
import de.mm20.launcher2.widgets.CalendarWidget
import de.mm20.launcher2.widgets.FavoritesWidget
import de.mm20.launcher2.widgets.MusicWidget
import de.mm20.launcher2.widgets.WeatherWidget
import de.mm20.launcher2.widgets.Widget
import org.koin.androidx.compose.get
import kotlin.math.roundToInt
@Composable
fun ConfigureWidgetSheet(
appWidgetHost: AppWidgetHost,
widget: Widget,
onWidgetUpdated: (Widget) -> Unit,
onDismiss: () -> Unit,
) {
BottomSheetDialog(onDismissRequest = onDismiss,
title = {
Box(
modifier = Modifier
.width(32.dp)
.height(4.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
)
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = if (widget is AppWidget) 8.dp else 16.dp)
.verticalScroll(rememberScrollState())
.padding(bottom = 8.dp)
) {
when (widget) {
is WeatherWidget -> ConfigureWeatherWidget(widget, onWidgetUpdated)
is AppWidget -> ConfigureAppWidget(appWidgetHost, widget, onWidgetUpdated)
is CalendarWidget -> ConfigureCalendarWidget(widget, onWidgetUpdated)
is FavoritesWidget -> ConfigureFavoritesWidget(widget, onWidgetUpdated)
is MusicWidget -> ConfigureMusicWidget()
}
}
}
}
@Composable
fun ColumnScope.ConfigureWeatherWidget(
widget: WeatherWidget,
onWidgetUpdated: (WeatherWidget) -> Unit,
) {
val context = LocalContext.current
OutlinedCard {
Column(
modifier = Modifier.fillMaxWidth()
) {
SwitchPreference(
title = stringResource(R.string.widget_config_weather_compact),
iconPadding = false,
value = !widget.config.showForecast,
onValueChanged = {
onWidgetUpdated(widget.copy(config = widget.config.copy(showForecast = !it)))
}
)
}
}
TextButton(
modifier = Modifier
.padding(top = 8.dp)
.align(Alignment.End),
contentPadding = PaddingValues(
end = 16.dp,
top = 8.dp,
start = 24.dp,
bottom = 8.dp,
),
onClick = {
context.startActivity(Intent(
context,
SettingsActivity::class.java
).apply {
putExtra(
SettingsActivity.EXTRA_ROUTE,
SettingsActivity.ROUTE_WEATHER_INTEGRATION
)
})
}) {
Text(stringResource(R.string.widget_config_weather_integration_settings))
Icon(
modifier = Modifier
.padding(start = ButtonDefaults.IconSpacing)
.size(ButtonDefaults.IconSize),
imageVector = Icons.Rounded.OpenInNew, contentDescription = null
)
}
}
@Composable
fun ColumnScope.ConfigureFavoritesWidget(
widget: FavoritesWidget,
onWidgetUpdated: (FavoritesWidget) -> Unit,
) {
val bottomSheetManager = LocalBottomSheetManager.current
OutlinedCard {
Column(
modifier = Modifier.fillMaxWidth()
) {
SwitchPreference(
title = stringResource(R.string.preference_edit_button),
iconPadding = false,
value = widget.config.editButton,
onValueChanged = {
onWidgetUpdated(widget.copy(config = widget.config.copy(editButton = it)))
}
)
}
}
TextButton(
modifier = Modifier
.padding(top = 8.dp)
.align(Alignment.End),
contentPadding = PaddingValues(
end = 16.dp,
top = 8.dp,
start = 24.dp,
bottom = 8.dp,
),
onClick = {
bottomSheetManager.showEditFavoritesSheet()
}) {
Text(stringResource(R.string.menu_item_edit_favs))
Icon(
modifier = Modifier
.padding(start = ButtonDefaults.IconSpacing)
.size(ButtonDefaults.IconSize),
imageVector = Icons.Rounded.OpenInNew, contentDescription = null
)
}
}
@Composable
fun ColumnScope.ConfigureMusicWidget(
) {
val context = LocalContext.current
TextButton(
modifier = Modifier
.align(Alignment.CenterHorizontally),
contentPadding = PaddingValues(
end = 16.dp,
top = 8.dp,
start = 24.dp,
bottom = 8.dp,
),
onClick = {
context.startActivity(Intent(
context,
SettingsActivity::class.java
).apply {
putExtra(
SettingsActivity.EXTRA_ROUTE,
SettingsActivity.ROUTE_MEDIA_INTEGRATION,
)
})
}) {
Text(stringResource(R.string.widget_config_music_integration_settings))
Icon(
modifier = Modifier
.padding(start = ButtonDefaults.IconSpacing)
.size(ButtonDefaults.IconSize),
imageVector = Icons.Rounded.OpenInNew, contentDescription = null
)
}
}
@Composable
fun ColumnScope.ConfigureAppWidget(
appWidgetHost: AppWidgetHost,
widget: AppWidget,
onWidgetUpdated: (Widget) -> Unit,
) {
val context = LocalContext.current
val widgetInfo = remember(widget.config.widgetId) {
AppWidgetManager.getInstance(context).getAppWidgetInfo(widget.config.widgetId)
}
if (widgetInfo == null) {
var replaceWidget by rememberSaveable {
mutableStateOf(false)
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
contentAlignment = Alignment.Center
) {
LargeMessage(
icon = Icons.Rounded.Error,
text = stringResource(id = R.string.app_widget_loading_failed)
)
}
OutlinedButton(
modifier = Modifier
.padding(vertical = 24.dp)
.align(Alignment.CenterHorizontally),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
onClick = { replaceWidget = true }) {
Icon(
Icons.Rounded.Build,
null,
modifier = Modifier
.padding(end = ButtonDefaults.IconSpacing)
.size(ButtonDefaults.IconSize)
)
Text(stringResource(R.string.widget_action_replace))
}
if (replaceWidget) {
WidgetPickerSheet(
onDismiss = { replaceWidget = false },
onWidgetSelected = {
val updatedWidget = when (it) {
is AppWidget -> widget.copy(
config = widget.config.copy(
widgetId = it.config.widgetId
)
)
is WeatherWidget -> it.copy(id = widget.id)
is MusicWidget -> it.copy(id = widget.id)
is CalendarWidget -> it.copy(id = widget.id)
is FavoritesWidget -> it.copy(id = widget.id)
}
onWidgetUpdated(updatedWidget)
replaceWidget = false
}
)
}
return
}
var dragDelta by remember { mutableStateOf(0) }
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
ExternalWidget(
appWidgetHost = appWidgetHost,
widgetInfo = widgetInfo,
widgetId = widget.config.widgetId,
modifier = Modifier.fillMaxWidth(),
height = widget.config.height + dragDelta,
borderless = widget.config.borderless,
)
}
val density = LocalDensity.current
val draggableState = rememberDraggableState {
dragDelta = (dragDelta + it / density.density).roundToInt()
.coerceIn(
-widget.config.height + 1,
500 - widget.config.height
)
}
Row(
modifier = Modifier
.padding(top = 8.dp, bottom = 16.dp)
.padding(horizontal = 8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.padding(end = 16.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.primaryContainer)
.height(36.dp)
.width(48.dp)
.draggable(
state = draggableState,
orientation = Orientation.Vertical,
startDragImmediately = true,
onDragStopped = {
onWidgetUpdated(
widget.copy(
config = widget.config.copy(
height = widget.config.height + dragDelta
)
)
)
dragDelta = 0
}
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Rounded.UnfoldMore,
contentDescription = null,
)
}
var textFieldValue by remember(widget.config.height) { mutableStateOf(widget.config.height.toString()) }
OutlinedTextField(
modifier = Modifier
.weight(1f)
.padding(bottom = 8.dp),
value = (widget.config.height + dragDelta).toString(),
onValueChange = {
val intValue = it.toIntOrNull()
if (intValue == null) textFieldValue = ""
else if (intValue in 1..500) {
onWidgetUpdated(
widget.copy(
config = widget.config.copy(
height = intValue
)
)
)
textFieldValue = intValue.toString()
}
},
label = { Text(stringResource(R.string.widget_config_appwidget_height)) },
suffix = { Text("dp") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
)
}
}
Column(
modifier = Modifier.padding(horizontal = 8.dp)
) {
OutlinedCard {
Column(
modifier = Modifier.fillMaxWidth()
) {
SwitchPreference(
title = stringResource(R.string.widget_config_appwidget_borderless),
iconPadding = false,
value = widget.config.borderless,
onValueChanged = {
onWidgetUpdated(widget.copy(config = widget.config.copy(borderless = it)))
}
)
}
}
if (isAtLeastApiLevel(28) && widgetInfo.widgetFeatures and AppWidgetProviderInfo.WIDGET_FEATURE_RECONFIGURABLE != 0) {
TextButton(
modifier = Modifier
.padding(top = 8.dp)
.align(Alignment.End),
contentPadding = PaddingValues(
end = 16.dp,
top = 8.dp,
start = 24.dp,
bottom = 8.dp,
),
onClick = {
appWidgetHost.startAppWidgetConfigureActivityForResult(
context as Activity,
widget.config.widgetId,
0,
0,
null
)
}) {
Text(stringResource(id = R.string.widget_config_appwidget_configure))
Icon(
modifier = Modifier
.padding(start = ButtonDefaults.IconSpacing)
.size(ButtonDefaults.IconSize),
imageVector = Icons.Rounded.OpenInNew, contentDescription = null
)
}
}
}
}
@Composable
fun ColumnScope.ConfigureCalendarWidget(
widget: CalendarWidget,
onWidgetUpdated: (CalendarWidget) -> Unit
) {
val calendarRepository: CalendarRepository = get()
val permissionsManager: PermissionsManager = get()
var calendars by remember { mutableStateOf(emptyList<UserCalendar>()) }
var ready by remember { mutableStateOf(false) }
val hasPermission by remember {
permissionsManager.hasPermission(PermissionGroup.Calendar)
}.collectAsState(true)
LaunchedEffect(hasPermission) {
calendars = calendarRepository.getCalendars().sortedBy { it.name }
ready = true
}
OutlinedCard {
Column(
modifier = Modifier.fillMaxWidth()
) {
SwitchPreference(
title = stringResource(R.string.preference_calendar_hide_allday),
iconPadding = false,
value = !widget.config.allDayEvents,
onValueChanged = {
onWidgetUpdated(widget.copy(config = widget.config.copy(allDayEvents = !it)))
}
)
}
}
Text(
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary,
text = stringResource(R.string.preference_calendar_calendars)
)
val context = LocalLifecycleOwner.current as AppCompatActivity
if (calendars.isNotEmpty()) {
OutlinedCard {
Column(
modifier = Modifier.fillMaxWidth()
) {
for ((i, calendar) in calendars.withIndex()) {
if (i > 0) Divider()
CheckboxPreference(
title = calendar.name,
summary = calendar.owner,
iconPadding = false,
value = !widget.config.excludedCalendarIds.contains(calendar.id),
onValueChanged = {
onWidgetUpdated(
widget.copy(
config = widget.config.copy(
excludedCalendarIds = if (it) {
widget.config.excludedCalendarIds - calendar.id
} else {
widget.config.excludedCalendarIds + calendar.id
}
)
)
)
}
)
}
}
}
} else if (!hasPermission) {
MissingPermissionBanner(
modifier = Modifier.padding(8.dp),
text = stringResource(R.string.missing_permission_calendar_widget_settings),
onClick = { permissionsManager.requestPermission(context, PermissionGroup.Calendar) },
)
} else if (ready) {
Text(
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = "No calendars found"
)
}
}
@@ -15,7 +15,6 @@ class LauncherBottomSheetManager(registryOwner: SavedStateRegistryOwner) :
val customizeSearchableSheetShown = mutableStateOf<SavableSearchable?>(null)
val editFavoritesSheetShown = mutableStateOf(false)
val hiddenItemsSheetShown = mutableStateOf(false)
val widgetPickerSheetShown = mutableStateOf(false)
init {
registryOwner.lifecycle.addObserver(LifecycleEventObserver { _, event ->
@@ -28,7 +27,6 @@ class LauncherBottomSheetManager(registryOwner: SavedStateRegistryOwner) :
editFavoritesSheetShown.value = state?.getBoolean(FAVORITES) ?: false
hiddenItemsSheetShown.value = state?.getBoolean(HIDDEN) ?: false
widgetPickerSheetShown.value = state?.getBoolean(WIDGETS) ?: false
}
})
}
@@ -37,7 +35,6 @@ class LauncherBottomSheetManager(registryOwner: SavedStateRegistryOwner) :
return bundleOf(
FAVORITES to editFavoritesSheetShown.value,
HIDDEN to hiddenItemsSheetShown.value,
WIDGETS to widgetPickerSheetShown.value,
)
}
@@ -65,14 +62,6 @@ class LauncherBottomSheetManager(registryOwner: SavedStateRegistryOwner) :
hiddenItemsSheetShown.value = false
}
fun showWidgetPickerSheet() {
widgetPickerSheetShown.value = true
}
fun dismissWidgetPickerSheet() {
widgetPickerSheetShown.value = false
}
companion object {
private const val PROVIDER = "bottom_sheet_manager"
private const val FAVORITES = "favorites"
@@ -13,9 +13,4 @@ fun LauncherBottomSheets() {
if (bottomSheetManager.editFavoritesSheetShown.value) {
EditFavoritesSheet(onDismiss = { bottomSheetManager.dismissEditFavoritesSheet() })
}
if (bottomSheetManager.widgetPickerSheetShown.value) {
WidgetPickerSheet(
onDismiss = { bottomSheetManager.dismissWidgetPickerSheet() }
)
}
}
@@ -199,7 +199,6 @@ private class BindAndConfigureAppWidgetContract(
height = widgetProviderInfo.minHeight,
widgetId = widgetId,
),
widgetProviderInfo = widgetProviderInfo,
)
}
}
@@ -210,6 +209,7 @@ private class BindAndConfigureAppWidgetContract(
@Composable
fun WidgetPickerSheet(
onWidgetSelected: (Widget) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
@@ -219,7 +219,7 @@ fun WidgetPickerSheet(
val bindAppWidgetStarter =
rememberLauncherForActivityResult(BindAndConfigureAppWidgetContract()) {
if (it != null) {
viewModel.pickWidget(it)
onWidgetSelected(it)
onDismiss()
}
}
@@ -290,7 +290,7 @@ fun WidgetPickerSheet(
FavoritesWidget.Type -> FavoritesWidget(id)
else -> return@OutlinedCard
}
viewModel.pickWidget(widget)
onWidgetSelected(widget)
onDismiss()
}) {
Row(
@@ -110,11 +110,6 @@ class WidgetPickerSheetVM(
val expandedGroup = mutableStateOf<String?>(null)
fun pickWidget(widget: Widget) {
val position = enabledWidgets.value.size
widgetsService.addWidget(widget, position)
}
fun toggleGroup(group: String) {
expandedGroup.value = if (expandedGroup.value == group) null else group
}
@@ -24,6 +24,8 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onPlaced
@@ -39,6 +41,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.ktx.animateTo
import de.mm20.launcher2.ui.launcher.sheets.LocalBottomSheetManager
import de.mm20.launcher2.ui.launcher.sheets.WidgetPickerSheet
import de.mm20.launcher2.widgets.AppWidget
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
@@ -56,6 +59,8 @@ fun WidgetColumn(
val lifecycleOwner = LocalLifecycleOwner.current
val widgetHost = remember { AppWidgetHost(context.applicationContext, 44203) }
var addNewWidget by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(null) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
widgetHost.startListening()
@@ -166,10 +171,20 @@ fun WidgetColumn(
if (!editMode) {
onEditModeChange(true)
} else {
bottomSheetManager.showWidgetPickerSheet()
addNewWidget = true
}
})
}
}
if (addNewWidget) {
WidgetPickerSheet(
onDismiss = { addNewWidget = false },
onWidgetSelected = {
viewModel.addWidget(it)
addNewWidget = false
}
)
}
}
@@ -1,36 +1,57 @@
package de.mm20.launcher2.ui.launcher.widgets
import android.app.Activity
import android.appwidget.AppWidgetHost
import android.util.Log
import android.appwidget.AppWidgetManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.DragIndicator
import androidx.compose.material.icons.rounded.Tune
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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.Banner
import de.mm20.launcher2.ui.component.LauncherCard
import de.mm20.launcher2.ui.launcher.sheets.ConfigureWidgetSheet
import de.mm20.launcher2.ui.launcher.sheets.WidgetPickerSheet
import de.mm20.launcher2.ui.launcher.widgets.calendar.CalendarWidget
import de.mm20.launcher2.ui.launcher.widgets.external.ExternalWidget
import de.mm20.launcher2.ui.launcher.widgets.favorites.FavoritesWidget
import de.mm20.launcher2.ui.launcher.widgets.music.MusicWidget
import de.mm20.launcher2.ui.launcher.widgets.weather.WeatherWidget
import de.mm20.launcher2.widgets.*
import kotlin.math.roundToInt
import de.mm20.launcher2.widgets.AppWidget
import de.mm20.launcher2.widgets.CalendarWidget
import de.mm20.launcher2.widgets.FavoritesWidget
import de.mm20.launcher2.widgets.MusicWidget
import de.mm20.launcher2.widgets.WeatherWidget
import de.mm20.launcher2.widgets.Widget
@Composable
fun WidgetItem(
@@ -44,11 +65,16 @@ fun WidgetItem(
onDragStopped: () -> Unit = {}
) {
val context = LocalContext.current
var resizeMode by remember(editMode) { mutableStateOf(false) }
var configure by rememberSaveable { mutableStateOf(false) }
var isDragged by remember { mutableStateOf(false) }
val elevation by animateDpAsState(if (isDragged) 8.dp else 2.dp)
val appWidget = if (widget is AppWidget) remember(widget.config.widgetId) {
AppWidgetManager.getInstance(context).getAppWidgetInfo(widget.config.widgetId)
} else null
LauncherCard(
modifier = modifier.zIndex(if (isDragged) 1f else 0f),
elevation = elevation
@@ -76,7 +102,18 @@ fun WidgetItem(
)
)
Text(
text = remember(widget) { widget.loadLabel(context) },
text = when (widget) {
is WeatherWidget -> stringResource(R.string.widget_name_weather)
is MusicWidget -> stringResource(R.string.widget_name_music)
is CalendarWidget -> stringResource(R.string.widget_name_calendar)
is FavoritesWidget -> stringResource(R.string.widget_name_favorites)
is AppWidget -> remember(widget.config.widgetId) {
appWidget?.loadLabel(
context.packageManager
)
}
?: stringResource(R.string.widget_name_unknown)
},
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.weight(1f)
@@ -84,23 +121,13 @@ fun WidgetItem(
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
if (widget is AppWidget) {
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 = {
configure = true
}) {
Icon(
imageVector = Icons.Rounded.Tune,
contentDescription = stringResource(R.string.settings)
)
}
IconButton(onClick = { onWidgetRemove() }) {
Icon(
@@ -110,61 +137,89 @@ fun WidgetItem(
}
}
}
AnimatedVisibility(!editMode || resizeMode) {
AnimatedVisibility(!editMode) {
when (widget) {
is WeatherWidget -> {
WeatherWidget()
WeatherWidget(widget)
}
is MusicWidget -> {
MusicWidget()
}
is CalendarWidget -> {
CalendarWidget()
CalendarWidget(widget)
}
is FavoritesWidget -> {
FavoritesWidget()
FavoritesWidget(widget)
}
is AppWidget -> {
var dragDelta by remember { mutableStateOf(0) }
Column {
val widgetInfo = remember(widget.config.widgetId) {
AppWidgetManager.getInstance(context)
.getAppWidgetInfo(widget.config.widgetId)
}
if (widgetInfo == null) {
var replaceWidget by rememberSaveable {
mutableStateOf(false)
}
Banner(
modifier = Modifier.padding(16.dp),
text = stringResource(R.string.app_widget_loading_failed),
icon = Icons.Rounded.Warning,
secondaryAction = {
OutlinedButton(onClick = onWidgetRemove) {
Text(stringResource(R.string.widget_action_remove))
}
},
primaryAction = {
Button(onClick = { replaceWidget = true }) {
Text(stringResource(R.string.widget_action_replace))
}
}
)
if (replaceWidget) {
WidgetPickerSheet(
onDismiss = { replaceWidget = false },
onWidgetSelected = {
val updatedWidget = when (it) {
is AppWidget -> widget.copy(
config = widget.config.copy(
widgetId = it.config.widgetId
)
)
is WeatherWidget -> it.copy(id = widget.id)
is MusicWidget -> it.copy(id = widget.id)
is CalendarWidget -> it.copy(id = widget.id)
is FavoritesWidget -> it.copy(id = widget.id)
}
onWidgetUpdate(updatedWidget)
replaceWidget = false
}
)
}
} else {
ExternalWidget(
appWidgetHost = appWidgetHost,
widgetId = widget.config.widgetId,
widgetInfo = widgetInfo,
modifier = Modifier.fillMaxWidth(),
height = widget.config.height + dragDelta,
height = widget.config.height,
borderless = widget.config.borderless,
)
if (resizeMode) {
val density = LocalDensity.current
val drgStt = rememberDraggableState {
dragDelta += (it / density.density).roundToInt()
}
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 = {
onWidgetUpdate(widget.copy(
config = widget.config.copy(
height = widget.config.height + dragDelta
)
))
dragDelta = 0
}
)
)
}
}
}
}
}
}
}
}
if (configure) {
ConfigureWidgetSheet(
appWidgetHost = appWidgetHost,
widget = widget,
onWidgetUpdated = onWidgetUpdate,
onDismiss = { configure = false },
)
}
}
@@ -19,6 +19,11 @@ class WidgetsVM : ViewModel(), KoinComponent {
val widgets = widgetRepository.get().asLiveData()
fun addWidget(widget: Widget) {
val widgets = widgets.value?.toMutableList() ?: return
widgets.add(widget)
widgetRepository.set(widgets)
}
fun removeWidget(widget: Widget) {
widgetRepository.delete(widget)
@@ -27,11 +27,14 @@ import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.InnerCard
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.launcher.search.common.list.SearchResultList
import de.mm20.launcher2.widgets.CalendarWidget
import java.time.LocalDate
import java.time.ZoneId
@Composable
fun CalendarWidget() {
fun CalendarWidget(
widget: CalendarWidget,
) {
val viewModel: CalendarWidgetVM = viewModel()
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
@@ -42,6 +45,10 @@ fun CalendarWidget() {
}
}
LaunchedEffect(widget) {
viewModel.updateWidget(widget)
}
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -17,6 +17,9 @@ import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.services.favorites.FavoritesService
import de.mm20.launcher2.widgets.CalendarWidget
import de.mm20.launcher2.widgets.CalendarWidgetConfig
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import org.koin.core.component.KoinComponent
@@ -30,11 +33,12 @@ import kotlin.math.max
class CalendarWidgetVM : ViewModel(), KoinComponent {
private val dataStore: LauncherDataStore by inject()
private val calendarRepository: CalendarRepository by inject()
private val favoritesService: FavoritesService by inject()
private val searchableRepository: SearchableRepository by inject()
private val widgetConfig = MutableStateFlow(CalendarWidgetConfig())
val calendarEvents = MutableLiveData<List<CalendarEvent>>(emptyList())
val pinnedCalendarEvents =
favoritesService.getFavorites(
@@ -53,6 +57,10 @@ class CalendarWidgetVM : ViewModel(), KoinComponent {
val selectedDate = MutableLiveData(LocalDate.now())
fun updateWidget(widget: CalendarWidget) {
widgetConfig.value = widget.config
}
private var upcomingEvents: List<CalendarEvent> = emptyList()
set(value) {
field = value
@@ -155,10 +163,10 @@ class CalendarWidgetVM : ViewModel(), KoinComponent {
suspend fun onActive() {
selectDate(LocalDate.now())
dataStore.data.map { it.calendarWidget }.collectLatest { settings ->
widgetConfig.collectLatest { config ->
calendarRepository.getUpcomingEvents(
excludeAllDayEvents = settings.hideAlldayEvents,
excludeCalendars = settings.excludeCalendarsList
excludeAllDayEvents = !config.allDayEvents,
excludeCalendars = config.excludedCalendarIds,
).collectLatest { events ->
searchableRepository.getKeys(
includeTypes = listOf(CalendarEvent.Domain),
@@ -2,6 +2,7 @@ package de.mm20.launcher2.ui.launcher.widgets.external
import android.appwidget.AppWidgetHost
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo
import android.os.Bundle
import android.util.Log
import android.view.View
@@ -22,20 +23,20 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.doOnNextLayout
import androidx.core.view.iterator
import androidx.core.view.setPadding
import de.mm20.launcher2.ui.ktx.toPixels
import kotlin.math.roundToInt
@Composable
fun ExternalWidget(
appWidgetHost: AppWidgetHost,
widgetInfo: AppWidgetProviderInfo,
widgetId: Int,
height: Int,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
borderless: Boolean = false,
) {
val context = LocalContext.current
val widgetInfo = remember(widgetId) {
AppWidgetManager.getInstance(context).getAppWidgetInfo(widgetId)
}
val padding = if (borderless) 0 else 8.dp.toPixels().roundToInt()
BoxWithConstraints {
val maxWidth = maxWidth
key(widgetId) {
@@ -56,6 +57,7 @@ fun ExternalWidget(
maxWidth.value.roundToInt(),
height,
)
it.setPadding(padding)
}
)
}
@@ -18,14 +18,15 @@ import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.common.FavoritesTagSelector
import de.mm20.launcher2.ui.component.Banner
import de.mm20.launcher2.ui.launcher.search.common.grid.SearchResultGrid
import de.mm20.launcher2.widgets.FavoritesWidget
@Composable
fun FavoritesWidget() {
fun FavoritesWidget(widget: FavoritesWidget) {
val viewModel: FavoritesWidgetVM = viewModel()
val favorites by remember { viewModel.favorites }.collectAsState(emptyList())
val pinnedTags by viewModel.pinnedTags.collectAsState(emptyList())
val selectedTag by viewModel.selectedTag.collectAsState(null)
val favoritesEditButton by viewModel.showEditButton.collectAsState(false)
val favoritesEditButton = widget.config.editButton
val tagsExpanded by viewModel.tagsExpanded.collectAsState(false)
@@ -33,6 +33,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -44,6 +45,7 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.booleanResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -62,13 +64,14 @@ import de.mm20.launcher2.ui.ktx.blendIntoViewScale
import de.mm20.launcher2.ui.locals.LocalCardStyle
import de.mm20.launcher2.weather.DailyForecast
import de.mm20.launcher2.weather.Forecast
import de.mm20.launcher2.widgets.WeatherWidget
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.SimpleDateFormat
import kotlin.math.roundToInt
@Composable
fun WeatherWidget() {
fun WeatherWidget(widget: WeatherWidget) {
val viewModel: WeatherWidgetWM = viewModel()
val context = LocalContext.current
@@ -82,9 +85,8 @@ fun WeatherWidget() {
val selectedForecast by viewModel.currentForecast.observeAsState()
val imperialUnits by viewModel.imperialUnits.observeAsState(false)
val compactMode by viewModel.compactMode.observeAsState(false)
val imperialUnits by viewModel.imperialUnits.collectAsState(false)
val compactMode = !widget.config.showForecast
var showLocationDialog by remember { mutableStateOf(false) }
@@ -8,8 +8,10 @@ import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.weather.DailyForecast
import de.mm20.launcher2.weather.Forecast
import de.mm20.launcher2.weather.WeatherRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -104,9 +106,7 @@ class WeatherWidgetWM : ViewModel(), KoinComponent {
}
val autoLocation = weatherRepository.autoLocation.asLiveData()
val imperialUnits = dataStore.data.map { it.weather.imperialUnits }.asLiveData()
val compactMode = dataStore.data.map { it.weather.compactMode }.asLiveData()
val imperialUnits = dataStore.data.map { it.weather.imperialUnits }
fun selectDay(index: Int) {
selectedDayIndex = min(index, forecasts.lastIndex)
@@ -27,7 +27,6 @@ import de.mm20.launcher2.ui.settings.integrations.IntegrationsSettingsScreen
import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen
import de.mm20.launcher2.ui.settings.backup.BackupSettingsScreen
import de.mm20.launcher2.ui.settings.buildinfo.BuildInfoSettingsScreen
import de.mm20.launcher2.ui.settings.calendarwidget.CalendarWidgetSettingsScreen
import de.mm20.launcher2.ui.settings.cards.CardsSettingsScreen
import de.mm20.launcher2.ui.settings.clockwidget.ClockWidgetSettingsScreen
import de.mm20.launcher2.ui.settings.colorscheme.ColorSchemeSettingsScreen
@@ -139,15 +138,12 @@ class SettingsActivity : BaseActivity() {
composable("settings/search/tags") {
TagsSettingsScreen()
}
composable("settings/integrations/weather") {
composable(ROUTE_WEATHER_INTEGRATION) {
WeatherIntegrationSettingsScreen()
}
composable("settings/integrations/media") {
composable(ROUTE_MEDIA_INTEGRATION) {
MediaIntegrationSettingsScreen()
}
composable("settings/widgets/calendar") {
CalendarWidgetSettingsScreen()
}
composable("settings/homescreen/clock") {
ClockWidgetSettingsScreen()
}
@@ -214,5 +210,7 @@ class SettingsActivity : BaseActivity() {
companion object {
const val EXTRA_ROUTE = "de.mm20.launcher2.settings.ROUTE"
const val ROUTE_WEATHER_INTEGRATION = "settings/integrations/weather"
const val ROUTE_MEDIA_INTEGRATION = "settings/integrations/media"
}
}
@@ -1,173 +0,0 @@
package de.mm20.launcher2.ui.settings.calendarwidget
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
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.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.search.data.UserCalendar
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
@Composable
fun CalendarWidgetSettingsScreen() {
val viewModel: CalendarWidgetSettingsScreenVM = viewModel()
val context = LocalContext.current
PreferenceScreen(
title = stringResource(R.string.preference_screen_calendarwidget),
helpUrl = "https://kvaesitso.mm20.de/docs/user-guide/widgets/calendar-widget"
) {
item {
val excludeAllDayEvents by viewModel.excludeAllDayEvents.observeAsState()
PreferenceCategory {
val hasPermission by viewModel.hasCalendarPermission.observeAsState()
AnimatedVisibility(hasPermission == false) {
MissingPermissionBanner(
modifier = Modifier.padding(16.dp),
text = stringResource(R.string.missing_permission_calendar_widget_settings),
onClick = {
viewModel.requestPermission(context as AppCompatActivity)
}
)
}
SwitchPreference(
title = stringResource(R.string.preference_calendar_hide_allday),
value = excludeAllDayEvents == true,
onValueChanged = {
viewModel.setExcludeAllDayEvents(it)
}
)
val calendars by viewModel.calendars.observeAsState(emptyList())
val unselectedCalendars by viewModel.unselectedCalendars.observeAsState(emptyList())
ExcludedCalendarsPreference(
calendars = calendars,
value = unselectedCalendars,
onValueChanged = {
viewModel.setUnselectedCalendars(it)
}
)
}
}
}
}
@Composable
fun ExcludedCalendarsPreference(
calendars: List<UserCalendar>,
value: List<Long>,
onValueChanged: (List<Long>) -> Unit
) {
var showDialog by remember { mutableStateOf(false) }
Preference(
title = stringResource(R.string.preference_calendar_calendars),
summary = pluralStringResource(
R.plurals.preference_calendar_calendars_summary,
count = calendars.size - value.size,
calendars.size - value.size
),
onClick = {
showDialog = true
}
)
if (showDialog) {
Dialog(
onDismissRequest = { showDialog = false },
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 400.dp),
shape = MaterialTheme.shapes.extraLarge,
tonalElevation = 16.dp,
shadowElevation = 16.dp,
) {
Column {
Text(
text = stringResource(R.string.preference_calendar_calendars),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.fillMaxWidth()
.padding(
start = 24.dp, end = 24.dp, top = 16.dp, bottom = 8.dp
)
)
LazyColumn(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp)
) {
items(calendars) { c ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
if (value.contains(c.id)) {
onValueChanged(
value.filter { it != c.id }
)
} else {
onValueChanged(
value + c.id
)
}
}
) {
Checkbox(
checked = !value.contains(c.id),
onCheckedChange = {
if (it) {
onValueChanged(
value.filter { it != c.id }
)
} else {
onValueChanged(
value + c.id
)
}
},
colors = CheckboxDefaults.colors(
checkedColor = Color(c.color)
)
)
Text(text = c.name)
}
}
}
TextButton(
onClick = {
onValueChanged(value.toList())
showDialog = false
},
modifier = Modifier
.align(Alignment.End)
.padding(vertical = 12.dp, horizontal = 16.dp)
) {
Text(
text = stringResource(android.R.string.ok),
)
}
}
}
}
}
}
@@ -1,62 +0,0 @@
package de.mm20.launcher2.ui.settings.calendarwidget
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.calendar.CalendarRepository
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class CalendarWidgetSettingsScreenVM : ViewModel(), KoinComponent {
private val dataStore: LauncherDataStore by inject()
private val calendarRepository: CalendarRepository by inject()
private val permissionsManager: PermissionsManager by inject()
val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar).asLiveData()
val excludeAllDayEvents = dataStore.data.map { it.calendarWidget.hideAlldayEvents }.asLiveData()
fun setExcludeAllDayEvents(excludeAllDayEvents: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setCalendarWidget(
it.calendarWidget
.toBuilder()
.setHideAlldayEvents(excludeAllDayEvents)
)
.build()
}
}
}
val calendars = liveData {
emit(calendarRepository.getCalendars())
}
val unselectedCalendars =
dataStore.data.map { it.calendarWidget.excludeCalendarsList }.asLiveData()
fun setUnselectedCalendars(unselectedCalendars: List<Long>) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setCalendarWidget(
it.calendarWidget.toBuilder()
.clearExcludeCalendars()
.addAllExcludeCalendars(unselectedCalendars)
)
.build()
}
}
}
fun requestPermission(activity: AppCompatActivity) {
permissionsManager.requestPermission(activity, PermissionGroup.Calendar)
}
}
@@ -46,24 +46,17 @@ fun WeatherIntegrationSettingsScreen() {
},
value = weatherProvider
)
val imperialUnits by viewModel.imperialUnits.observeAsState(false)
SwitchPreference(
title = stringResource(R.string.preference_imperial_units),
summary = stringResource(R.string.preference_imperial_units_summary),
value = imperialUnits,
onValueChanged = {
viewModel.setImperialUnits(it)
}
)
val compactMode by viewModel.compactMode.observeAsState(false)
SwitchPreference(
title = stringResource(R.string.preference_compact_mode),
summary = stringResource(R.string.preference_compact_mode_summary),
value = compactMode,
onValueChanged = {
viewModel.setCompactMode(it)
})
}
val imperialUnits by viewModel.imperialUnits.collectAsState(false)
SwitchPreference(
title = stringResource(R.string.preference_imperial_units),
summary = stringResource(R.string.preference_imperial_units_summary),
value = imperialUnits,
onValueChanged = {
viewModel.setImperialUnits(it)
}
)
}
item {
PreferenceCategory(title = stringResource(R.string.preference_category_location)) {
@@ -20,12 +20,17 @@ import org.koin.core.component.inject
class WeatherIntegrationSettingsScreenVM : ViewModel(), KoinComponent {
private val repository: WeatherRepository by inject()
private val dataStore: LauncherDataStore by inject()
private val permissionsManager: PermissionsManager by inject()
private val dataStore: LauncherDataStore by inject()
val availableProviders = repository.getAvailableProviders()
val imperialUnits = dataStore.data.map { it.weather.imperialUnits }.asLiveData()
val weatherProvider = repository.selectedProvider.asLiveData()
fun setWeatherProvider(provider: WeatherSettings.WeatherProvider) {
repository.selectProvider(provider)
}
val imperialUnits = dataStore.data.map { it.weather.imperialUnits }
fun setImperialUnits(imperialUnits: Boolean) {
viewModelScope.launch {
dataStore.updateData {
@@ -36,22 +41,6 @@ class WeatherIntegrationSettingsScreenVM : ViewModel(), KoinComponent {
}
}
val compactMode = dataStore.data.map { it.weather.compactMode }.asLiveData()
fun setCompactMode(compactMode: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setWeather(it.weather.toBuilder().setCompactMode(compactMode))
.build()
}
}
}
val weatherProvider = repository.selectedProvider.asLiveData()
fun setWeatherProvider(provider: WeatherSettings.WeatherProvider) {
repository.selectProvider(provider)
}
val autoLocation = repository.autoLocation.asLiveData()
fun setAutoLocation(autoLocation: Boolean) {
repository.setAutoLocation(autoLocation)