Make the clock agenda's calendars and row count configurable

The agenda part filtered calendars through the search settings and always
collapsed after four rows. It now has its own selection: an exclusion set
(`clockWidgetCalendarPartExcludedCalendars`, empty = all) that is added to the
search settings' exclusions, because the picker only offers calendars those
settings leave enabled.

The row cap is `clockWidgetCalendarPartMaxRows` (1..8, default 4); the agenda
shows that many events and then a "+N more events" row if more are left, so
with the default five events now render as four rows plus "+1 more event"
instead of three plus "+2".

Both are configured in `ConfigureCalendarPart`, shown by the clock sheet while
the Events part is enabled: a slider, a Calendars row that expands into a
colour-tinted checkbox per calendar, and a button to Settings -> Search ->
Calendar (new `ROUTE_SEARCH_CALENDARS`) for calendars the search settings
disable. A missing calendar permission shows a banner with a Grant button.

New setting fields default to the previous behaviour, so no datastore
migration is needed.
This commit is contained in:
2026-09-17 08:56:55 +02:00
parent c56ea58fc5
commit c824b100b6
9 changed files with 321 additions and 9 deletions

View File

@@ -709,6 +709,14 @@ fun ConfigureClockWidgetSheet(
}
}
}
if (smartspacer == false) {
AnimatedVisibility(visible = parts?.calendar == true) {
ConfigureCalendarPart(
modifier = Modifier.padding(top = 16.dp),
viewModel = viewModel,
)
}
}
}
}
}

View File

@@ -0,0 +1,181 @@
package de.mm20.launcher2.ui.launcher.widgets.clock
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.ui.MaxCalendarPartRows
import de.mm20.launcher2.preferences.ui.MinCalendarPartRows
import de.mm20.launcher2.themes.colors.atTone
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.CheckboxPreference
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.SliderPreference
import de.mm20.launcher2.ui.locals.LocalDarkTheme
import de.mm20.launcher2.ui.settings.SettingsActivity
import de.mm20.launcher2.ui.settings.clockwidget.ClockWidgetSettingsScreenVM
import org.koin.compose.koinInject
/**
* Settings for the agenda in the clock widget's dynamic zone: how many events are shown
* before they collapse into a "+N more events" row, and which calendars they come from.
*/
@Composable
internal fun ConfigureCalendarPart(
viewModel: ClockWidgetSettingsScreenVM,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val activity = LocalLifecycleOwner.current as? AppCompatActivity
val permissionsManager: PermissionsManager = koinInject()
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val excludedCalendars by viewModel.calendarPartExcludedCalendars.collectAsStateWithLifecycle()
val maxRows by viewModel.calendarPartMaxRows.collectAsStateWithLifecycle()
val hasPermission by viewModel.hasCalendarPermission.collectAsStateWithLifecycle()
var showCalendars by remember { mutableStateOf(false) }
val shownCalendars = calendars.count { it.id !in excludedCalendars }
Column(modifier = modifier) {
if (!hasPermission) {
MissingPermissionBanner(
modifier = Modifier.padding(bottom = 8.dp),
text = stringResource(R.string.missing_permission_calendar_widget_settings),
onClick = {
activity?.let {
permissionsManager.requestPermission(it, PermissionGroup.Calendar)
}
},
)
}
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
SliderPreference(
title = stringResource(R.string.preference_clockwidget_calendar_part_events),
icon = R.drawable.event_24px,
value = maxRows,
min = MinCalendarPartRows,
max = MaxCalendarPartRows,
step = 1,
onValueChanged = {
viewModel.setCalendarPartMaxRows(it)
}
)
HorizontalDivider()
Preference(
title = stringResource(R.string.preference_clockwidget_calendar_part_calendars),
summary = when {
calendars.isEmpty() -> stringResource(R.string.preference_clockwidget_calendar_part_no_calendars)
shownCalendars == calendars.size -> stringResource(R.string.preference_clockwidget_calendar_part_all_calendars)
else -> stringResource(
R.string.preference_clockwidget_calendar_part_calendars_summary,
shownCalendars,
calendars.size,
)
},
icon = R.drawable.calendar_today_24px,
onClick = { showCalendars = !showCalendars },
enabled = calendars.isNotEmpty(),
controls = {
Icon(
painter = painterResource(
if (showCalendars) R.drawable.keyboard_arrow_up_24px
else R.drawable.keyboard_arrow_down_24px
),
contentDescription = null,
)
},
)
AnimatedVisibility(
visible = showCalendars && calendars.isNotEmpty(),
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
HorizontalDivider()
for (calendar in calendars) {
CheckboxPreference(
title = calendar.name,
summary = calendar.owner,
iconPadding = false,
value = calendar.id !in excludedCalendars,
onValueChanged = {
viewModel.setCalendarExcluded(calendar.id, !it)
},
checkboxColors = CheckboxDefaults.colors(
checkedColor = if (calendar.color == 0) MaterialTheme.colorScheme.primary
else Color(
calendar.color.atTone(if (LocalDarkTheme.current) 80 else 40)
),
checkmarkColor = if (calendar.color == 0) MaterialTheme.colorScheme.onPrimary
else Color(
calendar.color.atTone(if (LocalDarkTheme.current) 20 else 100)
)
)
)
}
}
}
}
}
TextButton(
modifier = Modifier
.padding(top = 8.dp)
.align(Alignment.CenterHorizontally),
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
onClick = {
context.startActivity(
Intent(context, SettingsActivity::class.java).apply {
putExtra(
SettingsActivity.EXTRA_ROUTE,
SettingsActivity.ROUTE_SEARCH_CALENDARS,
)
}
)
},
) {
Text(stringResource(R.string.preference_clockwidget_calendar_part_manage_calendars))
Icon(
modifier = Modifier
.padding(start = ButtonDefaults.IconSpacing)
.requiredSize(ButtonDefaults.IconSize),
painter = painterResource(R.drawable.open_in_new_20px),
contentDescription = null,
)
}
}
}

View File

@@ -39,6 +39,7 @@ import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.TimeFormat
import de.mm20.launcher2.preferences.search.CalendarSearchSettings
import de.mm20.launcher2.preferences.ui.ClockWidgetSettings
import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.locals.LocalTimeFormat
@@ -65,10 +66,10 @@ import java.time.OffsetDateTime
import java.util.Date
/**
* Highest number of rows the agenda shows. If there are more events than this, the last
* row is a "+N more events" hint.
* Number of rows the agenda shows before it collapses into a "+N more events" row, until the
* setting has been read.
*/
private const val MaxRows = 4
private const val DefaultMaxRows = 4
/** Re-query interval, so the agenda survives calendar changes that don't notify. */
private const val RefreshInterval = 15 * 60 * 1000L
@@ -96,13 +97,17 @@ class CalendarPartProvider : PartProvider, KoinComponent {
private val calendarRepository: CalendarRepository by inject()
private val permissionsManager: PermissionsManager by inject()
private val searchSettings: CalendarSearchSettings by inject()
private val clockWidgetSettings: ClockWidgetSettings by inject()
private val agenda = MutableStateFlow<List<CalendarEvent>>(emptyList())
private val maxRows = MutableStateFlow(DefaultMaxRows)
override fun getRanking(context: Context): Flow<Int> = channelFlow {
// Load the agenda in the background; the ranking itself only depends on the
// already known agenda, so it can be emitted right away.
launch { observeAgenda(context) }
launch { clockWidgetSettings.calendarPartMaxRows.collect { maxRows.value = it } }
agenda.map { ranking(it) }
.distinctUntilChanged()
.collect { send(it) }
@@ -112,8 +117,17 @@ class CalendarPartProvider : PartProvider, KoinComponent {
combine(
dayChanges(context),
permissionsManager.hasPermission(PermissionGroup.Calendar),
clockWidgetSettings.calendarPartExcludedCalendars,
searchSettings.excludedCalendars,
) { now, hasPermission, excludedCalendars -> Triple(now, hasPermission, excludedCalendars) }
) { now, hasPermission, hiddenInWidget, excludedInSearch ->
AgendaInputs(
now = now,
hasCalendarPermission = hasPermission,
// Calendars the user disabled in the search settings cannot be picked in the
// widget either, so the two sets just add up.
excludedCalendars = if (hasPermission) hiddenInWidget + excludedInSearch else emptySet(),
)
}
.collectLatest { (now, hasPermission, excludedCalendars) ->
agenda.value = if (!hasPermission) {
emptyList()
@@ -176,13 +190,15 @@ class CalendarPartProvider : PartProvider, KoinComponent {
@Composable
override fun Component(compactLayout: Boolean) {
val events by agenda.collectAsState()
val maxRows by this.maxRows.collectAsState()
val context = LocalContext.current
if (events.isEmpty()) return
val timeFormat = LocalTimeFormat.current
val contentColor = LocalContentColor.current
val visibleCount = if (events.size > MaxRows) MaxRows - 1 else events.size
// The configured number of events, then a "+N more events" row if there are more.
val visibleCount = minOf(events.size, maxRows)
val hiddenCount = events.size - visibleCount
Column(
@@ -216,6 +232,13 @@ class CalendarPartProvider : PartProvider, KoinComponent {
}
}
/** The three inputs the agenda query depends on. */
private data class AgendaInputs(
val now: Long,
val hasCalendarPermission: Boolean,
val excludedCalendars: Set<String>,
)
/**
* The time of an event, or "all-day" for all-day events. Never a date.
*/

View File

@@ -422,6 +422,7 @@ class SettingsActivity : BaseActivity() {
ROUTE_MEDIA_INTEGRATION -> MediaIntegrationSettingsRoute
ROUTE_SEARCH_ACTIONS -> SearchActionsSettingsRoute
ROUTE_HIDDEN_ITEMS -> HiddenItemsSettingsRoute
ROUTE_SEARCH_CALENDARS -> CalendarSearchSettingsRoute
ROUTE_CRASH_REPORT if (intent.hasExtra(EXTRA_CRASH_REPORT_PATH)) -> {
CrashReportRoute(intent.getStringExtra(EXTRA_CRASH_REPORT_PATH)!!)
}
@@ -435,6 +436,7 @@ class SettingsActivity : BaseActivity() {
const val ROUTE_MEDIA_INTEGRATION = "settings/integrations/media"
const val ROUTE_SEARCH_ACTIONS = "settings/search/searchactions"
const val ROUTE_HIDDEN_ITEMS = "settings/search/hiddenitems"
const val ROUTE_SEARCH_CALENDARS = "settings/search/calendar"
const val ROUTE_CRASH_REPORT = "settings/debug/crashreport"
const val EXTRA_CRASH_REPORT_PATH = "crash_report_path"
}

View File

@@ -2,11 +2,16 @@ package de.mm20.launcher2.ui.settings.clockwidget
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.calendar.CalendarRepository
import de.mm20.launcher2.calendar.providers.CalendarList
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.BatteryStatusVisibility
import de.mm20.launcher2.preferences.ClockWidgetAlignment
import de.mm20.launcher2.preferences.ClockWidgetColors
import de.mm20.launcher2.preferences.ClockWidgetStyle
import de.mm20.launcher2.preferences.TimeFormat
import de.mm20.launcher2.preferences.search.CalendarSearchSettings
import de.mm20.launcher2.preferences.ui.ClockWidgetSettings
import de.mm20.launcher2.preferences.ui.UiSettings
import kotlinx.coroutines.flow.SharingStarted
@@ -19,6 +24,9 @@ import org.koin.core.component.inject
class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent {
private val settings: ClockWidgetSettings by inject()
private val uiSettings: UiSettings by inject()
private val calendarRepository: CalendarRepository by inject()
private val calendarSearchSettings: CalendarSearchSettings by inject()
private val permissionsManager: PermissionsManager by inject()
val compact = settings.compact
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
@@ -106,6 +114,40 @@ class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent {
settings.setCalendarPart(calendarPart)
}
/**
* Number of event rows in the agenda before it collapses into a "+N more events" row.
*/
val calendarPartMaxRows = settings.calendarPartMaxRows
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 4)
fun setCalendarPartMaxRows(rows: Int) {
settings.setCalendarPartMaxRows(rows)
}
val calendarPartExcludedCalendars = settings.calendarPartExcludedCalendars
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptySet<String>())
fun setCalendarExcluded(calendarId: String, excluded: Boolean) {
settings.setCalendarExcluded(calendarId, excluded)
}
/**
* Calendars the agenda can show, i.e. every calendar the search settings make available.
* Calendars disabled there are missing here on purpose.
*/
val calendars = combine(
calendarRepository.getCalendars(),
calendarSearchSettings.excludedCalendars,
) { calendars, excluded ->
calendars
.filter { it.id !in excluded }
.sortedWith(compareBy({ it.owner ?: "" }, { it.name }))
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList<CalendarList>())
/** Whether the agenda can show anything at all. */
val hasCalendarPermission = permissionsManager.hasPermission(PermissionGroup.Calendar)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), true)
val slots = settings.slots
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 1)